Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ This project is pre-1.0. Breaking changes may appear in minor or patch releases

## 0.0.17

### Editor, import, and publishing

- Added a condition to data-row loops so a list can show a subset of a table rather than always its newest rows — pick one of the table's own fields and require it to be checked, unchecked, equal to a value, or to have any value at all. A relation field offers its rows by name instead of asking for an id. The condition applies on the canvas, on published pages, and in the "load more" endpoint, and the item count follows it so pagination never advertises rows the page drops.
- Added the table's own fields to a data-row loop's "Order by" list, so a list can follow a real date, title, or rank stored in the row instead of only the row's built-in columns. Values compare as text, which sorts ISO dates chronologically.

## 0.0.16 - 2026-08-11

### Media and integrations
Expand Down
16 changes: 12 additions & 4 deletions docs/features/loops.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,15 @@ Sources are **stateless** — they receive everything they need via the `ctx` ar

### `data.rows`

Iterates rows in any `data_table`. The user picks the table in the Properties panel; filters narrow by status, author, category-like fields, date.
Iterates rows in any `data_table`. The user picks the table in the Properties panel, and optionally one condition on a row's own cell — the difference between "the newest three" and "the three marked featured".

**Filtering by a cell.** Three `filters` keys carry the condition: `cellField` (a field id from the selected table), `cellOperator`, and `cellValue`. The operator set is closed — `is`, `isNot`, `isTrue`, `isFalse`, `isSet`, `isEmpty` — and `parseCellFilter` in `src/core/loops/cellFilter.ts` returns `null` for anything absent or half-configured, so a loop mid-edit keeps listing everything rather than silently emptying.

**Sorting by a cell.** `orderBy` also accepts `cell:<fieldId>`, read by `parseCellOrder`. Riding on `orderBy` rather than a separate prop means every caller that already threads it — the publisher, the canvas preview endpoint, imported `data-order-by` attributes — supports cell sorting without further plumbing. Values compare as text in both dialects: ISO dates sort chronologically, numbers sort lexicographically (`'10' < '9'`).

Both read `cells_json`, the one place the two dialects genuinely differ (`#>> array[$n]` on Postgres, `json_extract` on SQLite). **The field name binds as a parameter, never as SQL text.** `cellFilterSql` and `cellOrderSql` own that switch; `loop-source-sql-safety.test.ts` scans the whole `src/core/loops/` tree for Postgres-isms.

Only fields a single condition can address are offered in either picker — `isCellComparableField` excludes `multiSelect`, `media`, `repeater`, `pageTree`, `fieldSchema`, and multi-value `relation` fields, whose cells hold collections that read back as JSON array text and could never equal one picked value. Changing the loop's table clears the cell filter and any `cell:` order, since those field ids name columns the new table does not have.

```ts
fetch({ db, filter, orderBy, limit }) {
Expand Down Expand Up @@ -310,7 +318,7 @@ In the editor, `useLoopPreviewItems` (`src/admin/pages/site/canvas/useLoopPrevie

| Source | Canvas path |
|---|---|
| `data.rows` | GETs `/data/tables/:id/loop-preview` — same projection as the publisher. Falls back to synthetic items from the table's field definitions when no published rows exist yet. |
| `data.rows` | GETs `/data/tables/:id/loop-preview` — same projection as the publisher, and takes `cellField` / `cellOperator` / `cellValue` plus a `cell:<fieldId>` `orderBy` so the canvas shows the rows the published page will emit. Falls back to synthetic items from the table's field definitions when no published rows exist yet. |
| `site.pages` | Reads pages from the in-memory site document via `selectSitePagesLoopItems`. Applies `filterPagesForLoop` + `pageToLoopItem` imported from `@core/loops` — identical to the publisher path. |
| `site.media` | Fetches via `listCmsMediaAssets()`, filters by MIME prefix, sorts + slices client-side. |
| Plugin sources | Calls `source.preview(ctx)` synchronously. |
Expand All @@ -327,8 +335,8 @@ Subscription granularity: the hook never subscribes to the whole `site` document

1. Insert a `base.loop` node into the page.
2. In the Properties panel, set `sourceId = 'data.rows'`, pick the `data_table` (e.g. "Posts").
3. Set filters (`status: published`, `category: 'tech'`).
4. Set order (`publishedAt:desc`).
3. Optionally set a condition — *Filter by* a field, then *Condition* (and *Value* where the operator needs one).
4. Set order — one of the row's built-in columns, or `Field: <name>` to sort by a cell.
5. Configure variants:
- Drop a `base.container` as the loop's first child — this is variant A.
- Add nodes inside: a heading bound to `currentEntry.title`, content bound to `currentEntry.body`, an image bound to `currentEntry.featuredMedia`.
Expand Down
10 changes: 10 additions & 0 deletions server/handlers/cms/data/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { normalizeDataTableFields } from '@core/data/fields'
import { slugForTable } from '@core/data/cells'
import { slugFromTitle } from '@core/utils/slug'
import { fetchPublishedDataRowItems } from '@core/loops/sources/dataRows'
import { parseCellFilter } from '@core/loops/cellFilter'
import { badRequest, jsonResponse, methodNotAllowed, readValidatedBody } from '../../../http'
import { CMS_API_PREFIX, requestAuditContext } from '../shared'
import {
Expand Down Expand Up @@ -382,12 +383,21 @@ async function handleTableLoopPreview(
const rawOffset = Number.parseInt(url.searchParams.get('offset') ?? '0', 10)
const offset = Math.max(Number.isFinite(rawOffset) ? rawOffset : 0, 0)

// The canvas preview must apply the loop's cell condition too, or the
// editor shows rows the published page will not.
const cellFilter = parseCellFilter({
cellField: url.searchParams.get('cellField') ?? '',
cellOperator: url.searchParams.get('cellOperator') ?? '',
cellValue: url.searchParams.get('cellValue') ?? '',
})

const result = await fetchPublishedDataRowItems(db, {
tableId,
orderBy,
direction,
limit,
offset,
cellFilter,
})
return jsonResponse(result)
}
Expand Down
13 changes: 9 additions & 4 deletions src/__tests__/architecture/loop-source-sql-safety.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
/**
* Architecture gate — loop sources under `src/core/loops/sources/` issue
* SQL via the LoopSourceDb tagged-template surface, so they must obey
* the same dialect-neutral rules as `server/cms/*` repositories.
* Architecture gate — loop code under `src/core/loops/` issues SQL via the
* LoopSourceDb tagged-template surface, so it must obey the same
* dialect-neutral rules as `server/cms/*` repositories.
*
* The scan covers the whole `loops/` tree, not just `sources/`: the cell
* filter's dialect switch lives in `cellFilter.ts` one level up, and a gate
* that stops at `sources/` would be blind to exactly the file that renders
* the most engine-specific SQL in the subsystem.
*
* Mirrors `db-postgres-isms.test.ts` for a different scan root.
*
Expand All @@ -15,7 +20,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
import { extname, join, relative } from 'path'

const PROJECT_ROOT = join(import.meta.dir, '../../../')
const LOOP_SOURCES_ROOT = join(PROJECT_ROOT, 'src/core/loops/sources')
const LOOP_SOURCES_ROOT = join(PROJECT_ROOT, 'src/core/loops')

function walk(dir: string, out: string[] = []): string[] {
if (!existsSync(dir)) return out
Expand Down
206 changes: 206 additions & 0 deletions src/__tests__/loops/cellFilter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/**
* Unit tests for the loop cell filter — the pure half.
*
* Two properties matter and both are easy to get wrong:
* - a half-configured filter must never silently empty a list, and
* - the SQL must bind BOTH the field name and the value, so a field id
* can never reach the statement text.
*
* Which fields a condition may address is checked here too: the SQL reads a
* cell as text, so a field holding a collection can never match a single
* value and must not reach the picker at all.
*/
import { describe, expect, test } from 'bun:test'
import {
cellFilterSql,
cellOrderSql,
parseCellFilter,
parseCellOrder,
CELL_FILTER_OPERATORS,
isCellComparableField,
withoutCellFilter,
type CellFilter,
} from '@core/loops/cellFilter'

describe('parseCellFilter', () => {
test('returns null when no field is chosen', () => {
expect(parseCellFilter({})).toBeNull()
expect(parseCellFilter({ cellField: ' ' })).toBeNull()
})

test('a comparison without a value is treated as not-yet-configured', () => {
expect(parseCellFilter({ cellField: 'featured', cellOperator: 'is' })).toBeNull()
expect(parseCellFilter({ cellField: 'featured', cellOperator: 'isNot', cellValue: '' })).toBeNull()
})

test('valueless operators need no value', () => {
expect(parseCellFilter({ cellField: 'featured', cellOperator: 'isTrue' }))
.toEqual({ field: 'featured', operator: 'isTrue', value: '' })
})

test('defaults to `is` and coerces non-string values', () => {
expect(parseCellFilter({ cellField: 'rank', cellValue: 3 }))
.toEqual({ field: 'rank', operator: 'is', value: '3' })
expect(parseCellFilter({ cellField: 'live', cellValue: true }))
.toEqual({ field: 'live', operator: 'is', value: 'true' })
})

test('an unknown operator falls back to `is` rather than breaking the query', () => {
expect(parseCellFilter({ cellField: 'a', cellOperator: 'DROP TABLE', cellValue: 'x' }))
.toEqual({ field: 'a', operator: 'is', value: 'x' })
})
})

describe('cellFilterSql', () => {
const filter: CellFilter = { field: 'team-on-about-page', operator: 'isTrue', value: '' }

test('binds the field name as a parameter — never as SQL text', () => {
for (const dialect of ['postgres', 'sqlite'] as const) {
const { sql, params } = cellFilterSql({ filter, dialect, column: 'data_rows.cells_json', nextParamIndex: 4 })
expect(sql).not.toContain('team-on-about-page')
expect(params[0]).toBe('team-on-about-page')
}
})

test('a hostile field id cannot escape into the statement', () => {
const hostile: CellFilter = { field: "x'); drop table data_rows; --", operator: 'is', value: 'y' }
const { sql, params } = cellFilterSql({ filter: hostile, dialect: 'sqlite', column: 'c', nextParamIndex: 2 })
expect(sql).not.toContain('drop table')
expect(params).toEqual([hostile.field, 'y'])
})

test('placeholders follow the dialect and start at the given index', () => {
const pg = cellFilterSql({ filter: { field: 'f', operator: 'is', value: 'v' }, dialect: 'postgres', column: 'c', nextParamIndex: 4 })
expect(pg.sql).toContain('$4')
expect(pg.sql).toContain('$5')
const sqlite = cellFilterSql({ filter: { field: 'f', operator: 'is', value: 'v' }, dialect: 'sqlite', column: 'c', nextParamIndex: 4 })
expect(sqlite.sql).toContain('?')
expect(sqlite.sql).not.toContain('$4')
})

test('every operator produces a fragment with the right parameter count', () => {
const cases: Array<[CellFilter['operator'], number]> = [
['is', 2], ['isNot', 2], ['isTrue', 1], ['isFalse', 1], ['isSet', 1], ['isEmpty', 1],
]
for (const [operator, paramCount] of cases) {
const { sql, params } = cellFilterSql({
filter: { field: 'f', operator, value: 'v' },
dialect: 'sqlite',
column: 'c',
nextParamIndex: 1,
})
expect(sql.length).toBeGreaterThan(0)
expect(params).toHaveLength(paramCount)
}
})

test('the JSON read appears once per fragment, so the field binds once', () => {
// Repeating the expression would repeat its placeholder while the caller
// binds the field name a single time — the bug that made SQLite reject
// the statement with "expected 3 values, received 2".
for (const operator of CELL_FILTER_OPERATORS) {
const { sql, params } = cellFilterSql({
filter: { field: 'f', operator, value: 'v' },
dialect: 'sqlite',
column: 'c',
nextParamIndex: 1,
})
expect(sql.match(/json_extract/g) ?? []).toHaveLength(1)
expect(sql.match(/\?/g) ?? []).toHaveLength(params.length)
}
})

test('missing cells fold into the comparison instead of vanishing', () => {
// `coalesce(…, '')` is what keeps a row that never set the field inside
// "is not X" and "is unchecked".
for (const operator of ['isNot', 'isFalse', 'isEmpty'] as const) {
const { sql } = cellFilterSql({
filter: { field: 'f', operator, value: 'v' },
dialect: 'sqlite',
column: 'c',
nextParamIndex: 1,
})
expect(sql).toContain('coalesce')
}
})

test('SQLite casts the JSON read so boolean cells compare as text', () => {
const { sql } = cellFilterSql({ filter: { field: 'f', operator: 'isTrue', value: '' }, dialect: 'sqlite', column: 'c', nextParamIndex: 1 })
// Without the cast, json_extract returns INTEGER 1 and `1 = '1'` is false.
expect(sql).toContain('cast(')
expect(sql).toContain("'1'")
})
})

describe('parseCellOrder', () => {
test('only `cell:` values mean a cell sort', () => {
expect(parseCellOrder('publishedAt')).toBeNull()
expect(parseCellOrder('')).toBeNull()
expect(parseCellOrder('cell:published-on')).toEqual({ field: 'published-on' })
})

test('a prefix with no field is not a sort', () => {
expect(parseCellOrder('cell:')).toBeNull()
expect(parseCellOrder('cell: ')).toBeNull()
})
})

describe('cellOrderSql', () => {
test('binds the field name and never writes it into the SQL', () => {
for (const dialect of ['postgres', 'sqlite'] as const) {
const { sql, params } = cellOrderSql({ field: 'published-on', dialect, column: 'c', paramIndex: 2 })
expect(sql).not.toContain('published-on')
expect(params).toEqual(['published-on'])
}
})

test('rows without the field get a defined sort position', () => {
const { sql } = cellOrderSql({ field: 'f', dialect: 'sqlite', column: 'c', paramIndex: 1 })
expect(sql).toContain('coalesce')
})

test('placeholder style follows the dialect', () => {
expect(cellOrderSql({ field: 'f', dialect: 'postgres', column: 'c', paramIndex: 3 }).sql).toContain('$3')
expect(cellOrderSql({ field: 'f', dialect: 'sqlite', column: 'c', paramIndex: 3 }).sql).toContain('?')
})
})

describe('isCellComparableField', () => {
test('scalar fields can be filtered and sorted', () => {
for (const type of ['text', 'longText', 'number', 'boolean', 'date', 'select', 'url', 'email'] as const) {
expect(isCellComparableField({ type, id: 'f', label: 'F' } as never)).toBe(true)
}
})

test('collection-valued fields are excluded', () => {
// Their cell reads back as JSON array text, which no single picked value
// can equal — the control would look live and return nothing.
for (const type of ['multiSelect', 'media', 'repeater', 'pageTree', 'fieldSchema'] as const) {
expect(isCellComparableField({ type, id: 'f', label: 'F' } as never)).toBe(false)
}
})

test('a relation splits on cardinality, not on type', () => {
const single = { type: 'relation', id: 'cat', label: 'Category', targetTableId: 'cats' }
expect(isCellComparableField(single as never)).toBe(true)
expect(isCellComparableField({ ...single, allowMultiple: true } as never)).toBe(false)
expect(isCellComparableField({ ...single, allowMultiple: false } as never)).toBe(true)
})
})

describe('withoutCellFilter', () => {
test('drops every cell key and keeps the rest', () => {
expect(withoutCellFilter({
tableId: 'logos',
cellField: 'featured',
cellOperator: 'isTrue',
cellValue: 'x',
})).toEqual({ tableId: 'logos' })
})

test('does not mutate the bag it was given', () => {
const filters = { tableId: 'team', cellField: 'featured' }
withoutCellFilter(filters)
expect(filters.cellField).toBe('featured')
})
})
Loading
Loading