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: 17 additions & 7 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6603,10 +6603,16 @@ export class ObjectStackProtocolImplementation implements
+ 'returns no records where the same test against a stored boolean returns every '
+ 'record.'
// Deliberately the same remedy, in the same words, as the SORT
// axis' formula refusal (#6994) and #6673's SEARCH-axis
// correction, with only the verb changed to name this axis. One
// vocabulary across the doors: an author refused on two axes must
// not be sent two different ways.
// axis' formula refusal (#6994), with only the verb changed to
// name this axis. One vocabulary across the doors: an author
// refused on two axes must not be sent two different ways.
// [#8648] The SEARCH axis (#6673) agrees in SUBSTANCE and words it
// its own way — "Mirror the computed value onto a stored text
// field on '<object>' and search that instead." Same prescription,
// narrowed to the column type that axis can scan; claiming
// word-identity with it was false. `query-expression-conformance
// .test.ts` pins both halves — each door's exact sentence, and the
// substance all three axes share.
+ ` Denormalise the value onto '${object}' (a stored field, written when the source`
+ ' changes) and filter that.',
);
Expand DownExpand Up@@ -6815,9 +6821,13 @@ export class ObjectStackProtocolImplementation implements
+ (unmaterialized.length > 1 ? ` (also: ${unmaterialized.slice(1).join(', ')})` : ''),
{
// Deliberately the same remedy, in the same words, as the
// dotted refusal above and as #6673's SEARCH-axis correction:
// one vocabulary across the doors, so an author refused twice
// is not sent two different ways.
// dotted refusal above: one vocabulary across the doors, so an
// author refused twice is not sent two different ways.
// [#8648] #6673's SEARCH-axis correction agrees in SUBSTANCE,
// in its own words ("Mirror the computed value onto a stored
// text field on '<object>' and search that instead") — the
// same prescription with a TEXT target, not the same sentence.
// Pinned as such in `query-expression-conformance.test.ts`.
hint: ` Denormalise the value onto '${object}' (a stored field, written when the`
+ ' source changes) and sort by that. A formula field is virtual: with no'
+ ' column behind it the ORDER BY reaches the driver, finds nothing, and is'
Expand Down
26 changes: 20 additions & 6 deletions packages/objectql/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -853,12 +853,26 @@ function assertOrderByIsMaterializable(
+ '. It was not applied, and an unapplied sort returns the rows in an arbitrary order — '
+ "which 'limit'/'offset' then slices into an arbitrary page."
// Deliberately the SAME remedy, in the same words, as the ingress door's
// formula and dotted refusals (#6994, #6924) and #6673's SEARCH-axis
// correction. One vocabulary across the doors: a caller refused at the REST
// boundary and a caller refused here must not be sent two different ways.
// `query-expression-conformance.test.ts` pins the three wordings as EQUAL
// rather than each separately, because separate wordings is exactly how
// #4256 and #6673 drifted apart in the first place.
// formula and dotted refusals (#6994, #6924). One vocabulary across the
// doors: a caller refused at the REST boundary and a caller refused here
// must not be sent two different ways.
//
// [#8648] With the SEARCH axis the agreement is in SUBSTANCE, not in
// words, and saying otherwise here was simply false — measured from the
// running doors, #6673's correction emits "Mirror the computed value onto
// a stored text field on '<object>' and search that instead." All three
// axes prescribe the same thing (put the value on a stored column of the
// queried object and query THAT instead); SEARCH narrows the target to a
// TEXT column, which is right for an axis that scans text-like columns and
// is a shipped, user-visible message on an axis that already landed.
//
// `query-expression-conformance.test.ts` pins that agreement as ONE thing
// rather than each wording separately, because separate wordings is
// exactly how #4256 and #6673 drifted apart in the first place. It holds
// three layers: the four SORT/FILTER doors' emitted remedies compared
// against EACH OTHER, every axis' exact sentence (SEARCH included — the
// door this claim used to skip), and the substance none of them may drop.
// So a rewording of any axis goes red.
+ ` Denormalise the value onto '${object}' (a stored field, written when the`
+ ' source changes) and sort by that. A formula field is virtual: with no'
+ ' column behind it the ORDER BY reaches the driver, finds nothing, and is'
Expand Down
171 changes: 149 additions & 22 deletions packages/objectql/src/query-expression-conformance.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1008,32 +1008,159 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin
expect(err.fields).toEqual(['is_open']);
});

it('the FILTER refusals agree word-for-word with the SORT refusals on the remedy', async () => {
it('the FILTER, SORT and SEARCH refusals agree in SUBSTANCE on the remedy — every door pinned to its emitted wording', async () => {
// Pins the AGREEMENT itself rather than each wording separately — the
// one-vocabulary-across-doors discipline. Four doors share one
// sentence, differing only in the verb that names the axis: an author
// one-vocabulary-across-doors discipline, and the invariant three
// source comments assert as a fact (`engine.ts`
// `assertOrderByIsMaterializable`, and `protocol.ts`
// `assertFilterFieldsExist` / `assertSortFieldsExist`). An author
// refused on two axes must not be sent two different ways, which is
// exactly how #4256 and #6673 drifted apart in the first place.
const stem = /Denormalise the value onto 'showcase_task' \(a stored field, written when the source changes\) and /;
const filterIngress: any = await protocol
.findData({ object: 'showcase_task', query: { where: { is_open: true } } })
.then(() => null, (e: unknown) => e);
const filterEngine: any = await engine
.find('showcase_task', { where: { is_open: true } })
.then(() => null, (e: unknown) => e);
const sortIngress: any = await protocol
.findData({ object: 'showcase_task', query: { sort: 'sort_key' } })
.then(() => null, (e: unknown) => e);
const sortEngine: any = await engine
.find('showcase_task', { orderBy: [{ field: 'sort_key', order: 'asc' }] })
.then(() => null, (e: unknown) => e);
for (const err of [filterIngress, filterEngine, sortIngress, sortEngine]) {
expect(err.message).toMatch(stem);
//
// [#8648] It used to assert only that the four SORT/FILTER doors share
// one stem — which left the claim unpinned in the one place where
// reading it as word-identity was FALSE. The SEARCH axis cannot match
// that stem and never did; measured from the running doors on this
// fixture (a literal in the tree can be dead or shadowed, so the pin
// triggers each refusal and reads what a caller would actually see):
//
// FILTER Denormalise the value onto 'showcase_task' (a stored
// field, written when the source changes) and filter that.
// SORT …the same sentence, closing "and sort by that."
// SEARCH Mirror the computed value onto a stored text field on
// 'showcase_task' and search that instead.
//
// The divergence is DELIBERATE and stays: SEARCH's message is shipped
// and user-visible on an axis that already landed, and narrowing the
// target to a TEXT column is correct for an axis that scans text-like
// columns. So the enforceable invariant is SUBSTANCE — put the value
// on a stored column of the queried object and query THAT instead —
// and it is pinned in three layers, each catching what the others
// cannot:
//
// 1. AGREEMENT, emitted-vs-emitted. The four SORT/FILTER doors are
// compared against EACH OTHER, not against a literal: their
// longest common prefix must be exactly the shared stem. Reword
// one door and the prefix shrinks; reword all four together and
// it stops equalling the stem. No single-door drift survives it.
// 2. WORDING, per axis, including SEARCH — the door this pin used to
// skip. Each axis' remedy is pinned to the sentence it emits
// today, so rewording ANY axis goes red rather than only the two
// that happen to share a stem.
// 3. SUBSTANCE, as a ratchet on layer 2's constants. When layer 2
// goes red the next author updates those strings; these three
// assertions are what that rewrite cannot quietly walk away from
// — a STORED target, named ON the queried object, with THIS axis
// redirected onto it. Whatever the words, the remedy stays
// actionable.
//
// ⛔ Unifying the SEARCH wording onto the shared stem is route 3 of
// #8648 and was NOT taken: it changes a shipped error message and
// needs somewhere for the `text` narrowing to live. Layer 2 is what
// makes that a decision someone takes on purpose instead of a silent
// edit — if you are here because it went red, that is the pin working.
const stem = "Denormalise the value onto 'showcase_task' "
+ '(a stored field, written when the source changes) and ';

/**
* The remedy SPAN of an emitted refusal — the imperative sentence that
* tells the author what to do instead, sliced off the real message
* rather than assumed. A door that stops emitting one yields '' and
* fails loudly, instead of silently comparing two empty strings.
*/
const remedyOf = (message: string): string => {
const m = /(?:Denormalise|Mirror) [\s\S]*$/.exec(message);
return m ? m[0] : '';
};
/** Longest common prefix — how layer 1 compares doors to each other. */
const sharedPrefix = (parts: readonly string[]): string => parts.reduce((a, b) => {
let i = 0;
while (i < a.length && i < b.length && a[i] === b[i]) i++;
return a.slice(0, i);
});

const doors: ReadonlyArray<{
door: string;
axis: 'filter' | 'sort' | 'search';
remedy: string;
emit: () => Promise<unknown>;
}> = [
{
door: 'filter-ingress', axis: 'filter', remedy: `${stem}filter that.`,
emit: () => protocol.findData({ object: 'showcase_task', query: { where: { is_open: true } } }),
},
{
door: 'filter-engine', axis: 'filter', remedy: `${stem}filter that.`,
emit: () => engine.find('showcase_task', { where: { is_open: true } }),
},
{
door: 'sort-ingress', axis: 'sort', remedy: `${stem}sort by that.`,
emit: () => protocol.findData({ object: 'showcase_task', query: { sort: 'sort_key' } }),
},
{
door: 'sort-engine', axis: 'sort', remedy: `${stem}sort by that.`,
emit: () => engine.find('showcase_task', { orderBy: [{ field: 'sort_key', order: 'asc' }] }),
},
{
// [#8648] The door the claim was missing. SEARCH has no engine
// twin to pair with: `search` is expanded at ingress into the
// `$or` of `$icontains` the engine receives (ADR-0061), so
// this axis has exactly one door — which is why "the three
// wordings" was never a count of doors, and why its absence
// from a four-door pin was so easy to miss.
door: 'search-ingress', axis: 'search',
remedy: "Mirror the computed value onto a stored text field on 'showcase_task' and search that instead.",
emit: () => protocol.findData({ object: 'showcase_task', query: { search: 'x', searchFields: 'sort_key' } }),
},
];

const emitted = new Map<string, string>();
for (const { door, axis, remedy, emit } of doors) {
const err: any = await emit().then(() => null, (e: unknown) => e);
// Non-vacuity: a door that stopped refusing would otherwise make
// every assertion below unreachable and this pin silently green.
expect(err, `${door} must refuse`).toBeTruthy();
expect(err.status, `${door} status`).toBe(400);

// Layer 2 — this axis' exact wording, as emitted today. Asserted
// FIRST because it is the assertion that DIAGNOSES a rewording: it
// prints the sentence this pin expects beside the one the door now
// emits, which is what the author needs to see. (Measured while
// building this pin: with the extraction below running first, a
// reworded SEARCH failed as "emits no remedy" — true of the
// extractor, misleading about the door, and the wrong sentence to
// hand someone whose next move is to update this pin.)
expect(err.message, `${door} remedy wording`).toContain(remedy);

// Layer 3 — the substance that must survive any rewording of it.
expect(remedy, `${door} prescribes a STORED column`).toMatch(/\bstored\b/);
expect(remedy, `${door} names the queried object`).toContain("'showcase_task'");
expect(remedy, `${door} redirects its own axis onto it`)
.toMatch(new RegExp(`\\band ${axis}(?: by)? that\\b`));

// The emitted span, collected for layer 1's cross-door comparison
// below. Its own guard is about THIS pin's extractor, not about
// the door — hence the message: an author who deliberately
// reworded the opening imperative updates the verb list above.
const span = remedyOf(String(err.message));
expect(span, `${door} remedy opens with an imperative remedyOf() knows`).not.toBe('');
emitted.set(door, span);
}
expect(filterIngress.message).toMatch(new RegExp(stem.source + 'filter that\\.'));
expect(filterEngine.message).toMatch(new RegExp(stem.source + 'filter that\\.'));
expect(sortIngress.message).toMatch(new RegExp(stem.source + 'sort by that\\.'));
expect(sortEngine.message).toMatch(new RegExp(stem.source + 'sort by that\\.'));

// Layer 1 — the four stem-sharing doors, compared to each other.
const stemDoors = ['filter-ingress', 'filter-engine', 'sort-ingress', 'sort-engine']
.map((d) => emitted.get(d) as string);
expect(sharedPrefix(stemDoors)).toBe(stem);
// …and per axis the agreement is total, not merely a shared prefix.
expect(emitted.get('filter-engine')).toBe(emitted.get('filter-ingress'));
expect(emitted.get('sort-engine')).toBe(emitted.get('sort-ingress'));

// SEARCH's divergence, pinned as deliberate. Asserting it cannot match
// the stem is what stops a future author "repairing" the difference —
// i.e. taking route 3 — while believing they are aligning wording that
// was always meant to be identical.
expect(emitted.get('search-ingress')?.startsWith(stem)).toBe(false);
expect(emitted.get('search-ingress')).toContain('a stored text field');
});

it('BLAST RADIUS — a formula field is still readable, projectable and computed', async () => {
Expand Down
Loading