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
17 changes: 17 additions & 0 deletions .changeset/21505-read-scope-temporal-coercion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/service-analytics": minor
---

fix(service-analytics)!: the analytics read scope and the draft preview compare a temporal comparand in the column's storage form, as the engine does (ADR-0053 D-A1 / D-A2) (#21505)

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) a change of which rows the analytics read scope and the draft preview select for a value comparison on a declared datetime, date or time column, not of anything an author writes: no spec key, spelling or stored shape moves. AnalyticsQuerySchema, CubeSchema, DatasetSchema and every RLS policy parse and save as before, the package index exports the same names, and no stored row is read or rewritten. The two new options members are optional and identity when absent, so every existing caller of compileScopedFilterToSql compiles as before. What moves is the row set such a comparison selects, which now equals the engine's own answer for the same filter, so there is nothing for objectstack migrate meta to rewrite. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a filter comparand's storage form and this diff adds none (not registered / already-registered); and the change is runtime behaviour, not a declaration alone (not runtime-interface-only / type-surface-only). -->

**BREAKING**: this changes the rows two analytics faces select for a value comparison on a declared temporal column, in both directions, onto the rows `engine.find` selects for the same filter: on some filters fewer rows than before, on others more. The faces are the row-level read scope compiled into the native statement, and the draft preview (`queryDataset` with `previewDrafts`). It ships as `minor` under the launch-window convention for answer changes. No export is removed, no accepted input is refused and no error code changes.

**The read scope.** `compileScopedFilterToSql` takes two new optional members in its options, `coerceTemporalFilterValue(field, value)` and `coerceTemporalFilterColumn(field, columnSql)`. Together they are the driver's `temporalFilterValue` / `temporalFilterColumnSql` pair, bound to the object the scope reads. After the shared lowering, every value comparison binds its comparand through the first and reads its column through the second: equality, `$ne`, the four orderings, `$in`, `$nin` and `$between`. Null tests, `$empty` and the text operators read the column as stored. An absent member is identity: the comparand and the column stay as written, which is what a host that passes neither got before. `NativeSQLStrategy` (the read scope merged into the native statement) and the `ObjectQLStrategy` echo (`/analytics/sql`) pass the context's pair, which `AnalyticsServicePlugin` wires to the driver. Before, the comparand was bound as written and the database read it by its own rules, on SQLite and on PostgreSQL whatever the server's time zone.

**The draft preview.** It has no driver, so each value comparison on a column the host declares `datetime`, `date` or `time` now puts both sides in the storage form `@objectstack/core`'s `temporalStorageForm` gives: the comparand, and the drafted row's value, as `driver-memory` reads them. Before, it compared the two spellings as text. A column the host names no type for is compared as written, as before.

A `date` column answered the engine's rows on both faces before and still does when both sides are spelled as days. No `@objectstack/spec` contract changes and no dependency edge is added. A host that calls `compileScopedFilterToSql` directly gets the coercion by passing the pair from its driver.
Original file line number Diff line number Diff line change
Expand Up @@ -409,16 +409,22 @@ for (const cell of DB_CELLS) {
});
}

// F9 binds a comparand as written (no storage-form coercion), so its
// datetime cells are pinned on SQLite, whose stored form is ISO text.
// PostgreSQL reads a bare-day text bound in the session's zone; that is
// the read scope's temporal-coercion question, not its lowering.
it.skipIf(cell.id !== 'sqlite')('the read scope (F9), typed by its declared value shape, answers the same rows', async () => {
// [#21505] F9 binds each comparand through the driver's coercion pair
// (ADR-0053 D-A1 / D-A2), as both of its consumers wire it, so its
// datetime cells hold on PostgreSQL too, where a bare-day text bound
// would otherwise be read in the session's zone.
it('the read scope (F9), typed by its declared value shape, answers the same rows', async () => {
const declaredValueShape = (field: string) => (DECLARED[field] ? { type: DECLARED[field], multiple: false } : undefined);
for (const [label, query, expected] of CELLS) {
if (!query.where) continue;
const { sql, params } = compileScopedFilterToSql(query.where as FilterCondition, OBJECT, { declaredValueShape, dialect: 'sqlite' });
const rows = await (engine as any).execute(`select "id" from "${OBJECT}" where ${sql}`, { args: params, object: OBJECT });
const { sql, params } = compileScopedFilterToSql(query.where as FilterCondition, OBJECT, {
declaredValueShape,
dialect: cell.id === 'pg' ? 'postgres' : 'sqlite',
coerceTemporalFilterValue: (field, value) => driver.temporalFilterValue(OBJECT, field, value),
coerceTemporalFilterColumn: (field, columnSql) => driver.temporalFilterColumnSql(OBJECT, field, columnSql),
});
const res = await (engine as any).execute(`select "id" from "${OBJECT}" where ${sql}`, { args: params, object: OBJECT });
const rows = Array.isArray(res) ? res : (res as { rows: Array<Record<string, unknown>> }).rows;
expect(ids(rows as Array<Record<string, unknown>>), label).toBe(expected);
}
});
Expand Down
Loading
Loading