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
24 changes: 24 additions & 0 deletions .changeset/26601-dotted-add-days-offset-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/spec': minor
---

fix(spec)!: the filter save and validate doors refuse an `addDays` offset whose `{ $field }` is a dotted path, in the words SQL push-down already refuses it with on every read

An `addDays` offset reads its days from a column of the same record: `{ $field: 'due_on', addDays: { $field: 'grace_days' } }`, or `{ $now: true, addDays: { $field: 'review_sla_days' } }`. A dotted offset such as `addDays: { $field: 'contract_type.review_sla_days' }` passed `FieldReferenceSchema`, `NowAnchorSchema`, `FilterConditionSchema`, the dataset `filter` and measure `filter`, and `os validate`, and then failed every SQL read with `INVALID_FILTER` / 400. The one executor that walks the path, the memory evaluator, reads a lookup's id there on a stored row and counts the offset as zero days. Now every authoring door refuses it, naming the path: `the addDays offset "contract_type.review_sla_days" is a dotted path, and SQL push-down compiles same-table column references only (no relation traversal, no alias-qualified columns).`

This retires the earlier reading that the dotted offset validates and is answered in memory (the #14104 ruling-record correction 5550346132, and the spec pin that accepted `addDays: { $field: 'duty.grace_days' }`). Ruling A on #25808 bounds the offset to a same-record column.

Clause-②: no (narrowing) — the save and validate doors refuse a dotted `addDays` `$field` that every SQL read already refused. Nothing is widened.

Metadata-protocol: narrowed (a filter, a dataset filter or a dataset measure filter may no longer carry an `addDays` offset whose `$field` is a dotted path)

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed. The dotted offset never compiled on SQL push-down and, on a stored row, read as zero days in memory, so there is no mechanical rewrite that keeps what the author meant: the related value has to be copied onto a numeric column of the row, which is an application change the author makes. -->

**BREAKING** accept-set narrowing on the `addDays` offset (`@objectstack/spec`).

- **Before.** `FieldReferenceSchema`, `NowAnchorSchema`, the ordering slots of `FieldOperatorsSchema`, `FilterConditionSchema` (data filters, dataset `filter`, dataset measure `filter`) and `os validate` accepted `addDays: { $field: 'a.b' }`. SQL reads answered `INVALID_FILTER` / 400; a dataset measure filter answered it with the reason withheld.
- **Now.** Each of those doors refuses it. `parseFilterAST` and the engine's filter doors (`where`, `aggregations[i].filter`, `having`) refuse it with `INVALID_FILTER` / 400 and the path named, so a dataset measure filter that never met the save door now gets the reason too.
- **Unchanged.** A same-record offset column, a literal offset, and the reference's own `$field` (a dotted base reference keeps its asymmetry: resolved in memory, refused by SQL push-down).
- **Who sees it.** An author who read the offset through a relation. Every SQL deployment already refused that filter on read.

**The remedy.** Copy the related value onto a numeric column of the record (on create and whenever the relation changes) and name that column: `addDays: { $field: 'review_sla_days' }`.
19 changes: 12 additions & 7 deletions content/docs/protocol/objectql/query-syntax.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -509,9 +509,11 @@ On SQL push-down the offset compiles only between two temporal columns of the sa
class (`date` with `date`, `datetime` with `datetime`) against a numeric offset column,
on every dialect the `$field` compiler covers (SQLite, PostgreSQL, MySQL); the
memory evaluator matches, and a fractional offset *value* is truncated toward zero on
both. The same-table rule applies to the offset column too: `addDays: { $field:
'duty.grace_days' }` (a relation path) is resolved by the memory evaluator and refused
by SQL push-down with `INVALID_FILTER`, exactly as a dotted `$field` is.
both. The same-table rule applies to the offset column too, and the offset is held to it
at save time: `addDays: { $field: 'duty.grace_days' }` (a relation path) is refused with
`INVALID_FILTER` by `os validate`, by the filter and dataset save doors and by every read,
in the SQL push-down's own words. Only the referenced `$field` itself keeps the older
asymmetry — resolved by the memory evaluator, refused by SQL push-down.

**A dataset's `include` does not widen this.** A dataset compiles a `LEFT JOIN` for every
relation it `include`s (ADR-0071, up to three hops), and on the native-SQL path those joins
Expand All @@ -521,7 +523,8 @@ declines it so that the driver, which alone holds the declared field set, the co
and the tenant column the cross-field rules check, compiles it (#7598) — and the driver
compiles single-table statements, with no join in scope. So
`completed_on <= due_on + duty.grace_days` is refused with `INVALID_FILTER` even when `duty`
is in the dataset's `include`; measured and pinned in
is in the dataset's `include` — at save time now, and by the driver for a dataset that
never met the save door; measured and pinned in
`packages/services/service-analytics/src/__tests__/include-relation-cross-field-boundary.test.ts`
(#15103). The spelling that answers on both paths is the same-table one — every column the
reference names, offset included, is a column of the object the filter runs over.
Expand Down Expand Up @@ -583,9 +586,11 @@ strategy declines a query carrying an anchor so that the driver compiles it, as

**The SLA held on a related record.** The shape that compiles is the same-table one: the SLA
(or its negative) propagated onto the row, or the dataset's base object carrying the column.
A dotted offset (`addDays: { $field: 'queue.sla_days' }`) validates, is resolved by the
memory evaluator, and is refused by SQL push-down with `INVALID_FILTER`, exactly as a dotted
`$field` is — a dataset's `include` does not widen it (see above).
A dotted offset (`addDays: { $field: 'queue.sla_days' }`) is refused with `INVALID_FILTER`
when the filter, dataset or stack is saved or validated, in the SQL push-down's words — a
dataset's `include` does not widen it (see above). The memory evaluator would walk the path,
but on a stored row the relation holds the related record's id, so the offset would read as
zero days: copy the related value onto a numeric column of the row instead.

### Date, Datetime, and Time Filters

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/filter.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ A `{ $field }` reference to the numeric column holding the day offset

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **$field** | `string` | ✅ | Numeric column whose value is the number of days to add |
| **$field** | `string` | ✅ | Numeric column of the same record whose value is the number of days to add (no dotted path) |


---
Expand Down Expand Up @@ -389,7 +389,7 @@ A `{ $field }` reference to the numeric column holding the day offset

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **$field** | `string` | ✅ | Numeric column whose value is the number of days to add |
| **$field** | `string` | ✅ | Numeric column of the same record whose value is the number of days to add (no dotted path) |


---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,15 +339,20 @@ describe('[#14104] the dataset, valued end to end on the engine path', () => {
expect(Number(byTitle.get('f')?.late ?? 0)).toBe(0);
});

it('the dotted offset spelling of the ruling validates, routes, and is refused by SQL push-down by name', async () => {
// Validates at the schema door (the memory evaluator walks the path).
it('[#26601] the dotted offset spelling of the ruling is refused at the schema door, and past it by SQL push-down', async () => {
// Refused at the schema door: the offset column is a column of the same
// record (ruling A on #25808), and the memory evaluator, which walks the
// path, reads a lookup's id there on a stored row — zero days, silently.
const dotted = { completed_on: { $lte: { $field: 'due_on', addDays: { $field: 'duty.grace_days' } } } };
expect(DatasetSchema.safeParse({
const saved = DatasetSchema.safeParse({
...TASK_HEALTH,
measures: [{ name: 'done_on_time', aggregate: 'count', filter: dotted }],
}).success).toBe(true);
// …and on a SQL deployment it is a loud INVALID_FILTER, never a wrong count:
// the 2026-08-06 same-table ruling on #5222, inherited by the offset.
});
expect(saved.success).toBe(false);
expect(saved.error?.issues[0]?.message).toContain('"duty.grace_days" is a dotted path');
// …and a dataset that never met that door is a loud INVALID_FILTER on a SQL
// deployment, never a wrong count: the 2026-08-06 same-table ruling on
// #5222, inherited by the offset.
let err: WireBearingError | null = null;
try {
await service.queryDataset(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,16 @@ const RELATION_BARE: FilterCondition = {
completed_on: { $lte: { $field: 'duty.grace_days' } },
};

/** The dataset: `duty` IS in `include`, and a dimension already reads through it. */
const DATASET = DatasetSchema.parse({
/**
* The dataset: `duty` IS in `include`, and a dimension already reads through it.
*
* [#26601] The offset arm is a DOTTED `addDays` offset, which the dataset write
* door now refuses (ruling A on #25808 bounds the offset to a same-record
* column). So the dataset is parsed with the same-table filter in that slot
* and the dotted one put back PAST the save door — the read-time half of the
* boundary is still this file's to pin, for a row that never met that door.
*/
const PARSED = DatasetSchema.parse({
name: 'task_health_by_duty',
label: 'Task health by duty',
object: TASK,
Expand All @@ -119,10 +127,14 @@ const DATASET = DatasetSchema.parse({
measures: [
{ name: 'total', aggregate: 'count' },
{ name: 'done_on_time', aggregate: 'count', filter: SAME_TABLE_ON_TIME },
{ name: 'done_on_time_by_duty', aggregate: 'count', filter: RELATION_OFFSET_ON_TIME },
{ name: 'done_on_time_by_duty', aggregate: 'count', filter: SAME_TABLE_ON_TIME },
{ name: 'done_by_duty_bare', aggregate: 'count', filter: RELATION_BARE },
],
});
const DATASET = {
...PARSED,
measures: PARSED.measures.map((m) => (m.name === 'done_on_time_by_duty' ? { ...m, filter: RELATION_OFFSET_ON_TIME } : m)),
} as typeof PARSED;

interface WireBearingError extends Error {
code?: string;
Expand Down Expand Up @@ -212,6 +224,17 @@ describe('[#15103] a cross-field comparand on an `include`d relation — the bou
});
});

describe('[#26601] the save door refuses the dotted OFFSET; the dotted bare reference still validates', () => {
it('DatasetSchema refuses the offset arm at its slot, in the SQL read\'s words, and keeps the bare arm', () => {
const offset = DatasetSchema.safeParse({ ...PARSED, measures: [{ name: 'by_duty', aggregate: 'count', filter: RELATION_OFFSET_ON_TIME }] });
expect(offset.success).toBe(false);
expect(offset.error?.issues[0]?.path).toEqual(['measures', 0, 'filter', 'completed_on', '$lte']);
expect(offset.error?.issues[0]?.message).toContain('"duty.grace_days" is a dotted path');
expect(offset.error?.issues[0]?.message).toContain('same-table column references only');
expect(DatasetSchema.safeParse({ ...PARSED, measures: [{ name: 'by_duty', aggregate: 'count', filter: RELATION_BARE }] }).success).toBe(true);
});
});

describe('the `$field` COMPARAND never reaches that chain — refused even with `duty` in `include`', () => {
for (const [arm, measure, ref] of [
['offset', 'done_on_time_by_duty', 'duty.grace_days'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -332,12 +332,14 @@ describe('[#25808] the dataset, valued end to end on the engine path', () => {
expect(Number(byTitle.get('e')?.past_grace ?? 0)).toBe(0);
});

it('the dotted offset spelling validates at the schema door and is refused by SQL push-down by name', async () => {
it('[#26601] the dotted offset spelling is refused at the schema door, and past it by the read, by name', async () => {
const dotted = { completed_on: { $lte: { $now: true, addDays: { $field: 'duty.sla_days' } } } };
expect(DatasetSchema.safeParse({
const saved = DatasetSchema.safeParse({
...TASK_HEALTH,
measures: [{ name: 'within_grace', aggregate: 'count', filter: dotted }],
}).success).toBe(true);
});
expect(saved.success).toBe(false);
expect(saved.error?.issues[0]?.message).toContain('"duty.sla_days" is a dotted path');
const err = await (async () => {
try {
await service.queryDataset(
Expand Down
92 changes: 92 additions & 0 deletions packages/spec/src/data/filter-comparand-type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,3 +350,95 @@ describe('[#26413] the now-anchor is closed at the door, as NowAnchorSchema decl
}
});
});

describe('[#26413 review] the subDays clause answers subDays alone, so a one-key refusal stays inside the bound', () => {
const NOT_APPLIED_TAIL = 'an unapplied filter would have returned the UNFILTERED result set.';

it('names the subDays rule when subDays is among the keys, and only then', () => {
const subDays = refusalOf(() => normalizeFilterComparandTypes({ opened_at: { $lt: { $now: true, addDays: -30, subDays: 7 } } }));
expect(subDays?.message).toContain('there is no subDays: a negative addDays subtracts');
const note = refusalOf(() => normalizeFilterComparandTypes({ opened_at: { $lt: { $now: true, addDays: -30, note: 'x' } } }));
expect(note?.code).toBe('INVALID_FILTER');
expect(note?.message).toContain('undeclared key "note" — the anchor takes $now and addDays only.');
expect(note?.message).not.toContain('subDays');
});

it('a one-key refusal under the engine prefix, under $and, keeps its last sentence under 500 characters', () => {
// The contract review's measured case was 496 characters with the clause
// printed for `note`; a longer field and a path under `$and` pushed it over.
const err = refusalOf(() => normalizeFilterComparandTypes(
{ $and: [{ status: 'open' }, { review_opened_at: { $lte: { $now: true, addDays: -30, note: 'x' } } }] },
"find('support_ticket')",
));
expect(err?.code).toBe('INVALID_FILTER');
expect(err!.message.length).toBeLessThanOrEqual(500);
expect(err!.message.endsWith(NOT_APPLIED_TAIL)).toBe(true);
});
});

describe('[#26601] a dotted addDays offset is refused at the door, on a reference and on an anchor', () => {
const REF = 'contract_type.review_sla_days';
/** `driver-sql`'s reason for the same refusal on every SQL read, verbatim. */
const SQL_REASON = `the addDays offset "${REF}" is a dotted path, and SQL push-down compiles same-table `
+ 'column references only (no relation traversal, no alias-qualified columns).';
const COMPARANDS: Array<[string, Record<string, unknown>]> = [
['the { $field, addDays } pair', { $field: 'review_due_at', addDays: { $field: REF } }],
['the now-anchor', { $now: true, addDays: { $field: REF } }],
['the now-anchor in its resolved form', { $now: '2026-03-03T12:00:00.000Z', addDays: { $field: REF } }],
];

it('refuses it at every scalar comparison, in the SQL read\'s own words, with the envelope', () => {
for (const [label, comparand] of COMPARANDS) {
for (const op of ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte']) {
const err = refusalOf(() => normalizeFilterComparandTypes({ review_started_at: { [op]: comparand } }));
expect(err?.code, `${label} ${op}`).toBe(StandardErrorCode.enum.INVALID_FILTER);
expect(err?.status, `${label} ${op}`).toBe(400);
expect(err?.message, `${label} ${op}`).toContain(`at where.review_started_at.${op} is refused: ${SQL_REASON}`);
}
}
});

it('refuses it under $and / $or / $not and through parseFilterAST\'s two forms', () => {
for (const [label, comparand] of COMPARANDS) {
for (const filter of [
{ $and: [{ status: 'open' }, { review_started_at: { $lt: comparand } }] },
{ $or: [{ review_started_at: { $gte: comparand } }] },
{ $not: { review_started_at: { $lt: comparand } } },
]) {
expect(refusalOf(() => normalizeFilterComparandTypes(filter))?.code, `${label} ${JSON.stringify(filter)}`).toBe('INVALID_FILTER');
}
expect(refusalOf(() => parseFilterAST({ review_started_at: { $lt: comparand } }))?.code, label).toBe('INVALID_FILTER');
expect(refusalOf(() => parseFilterAST(['review_started_at', '<', comparand]))?.code, label).toBe('INVALID_FILTER');
}
});

it('the refusal at the measure-filter position keeps its last sentence under 500 characters', () => {
const err = refusalOf(() => normalizeFilterComparandTypes(
{ $and: [{ status: 'open' }, { contract_review_started_at: { $lte: { $now: true, addDays: { $field: 'contract_type_of_the_agreement.review_sla_days_offset' } } } }] },
"aggregate('clm_contract_review')",
'aggregations[0].filter',
));
expect(err?.code).toBe('INVALID_FILTER');
expect(err!.message.length).toBeLessThanOrEqual(500);
expect(err!.message.endsWith('an unapplied filter would have returned the UNFILTERED result set.')).toBe(true);
});

it('CONTROL: a same-record offset column, a literal offset and a dotted BASE reference are kept by reference', () => {
for (const comparand of [
{ $field: 'review_due_at', addDays: { $field: 'review_sla_days' } },
{ $now: true, addDays: { $field: 'review_sla_days' } },
{ $now: true, addDays: -3 },
// The reference's own `$field` is not this rule's: memory walks it, SQL refuses it.
{ $field: 'contract_type.review_due_at' },
{ $field: 'contract_type.review_due_at', addDays: 2 },
]) {
const filter = { review_started_at: { $lt: comparand } };
expect(normalizeFilterComparandTypes(filter), JSON.stringify(comparand)).toBe(filter);
}
});

it('CONTROL: the bare implicit form keeps its own fate — an operator spec named $field, not judged here', () => {
const filter = { review_started_at: { $field: 'review_due_at', addDays: { $field: REF } } };
expect(normalizeFilterComparandTypes(filter)).toBe(filter);
});
});
Loading
Loading