Skip to content

Commit dfa2502

Browse files
committed
test(objectql): type the #7095 query-options sites instead of erasing them to any
The four call sites the #7095 pins added tripped the #4918 query-options-erasure ratchet (test surface 249 -> 253). Fixed at the call sites, per the rule's own prescription — the ceiling is unchanged and no pin is weakened. Three were ON-contract and are now typed: - the `it.each` sort table is `Array<[string, NonNullable<EngineQueryOptions['orderBy']>]>`, so the three refused sorts are checked as the well-formed `SortNode[]` they are. It is the FIELD they name that the engine refuses, never their shape, and an `as any` there would have erased the one channel that enforces `{ field, order }` on a direct engine call — the `direction`-vs-`order` mistake #4674 is about. - both `expand` sites drop the assertion entirely: `EngineQueryOptions.expand` is `Record<string, QuerySchema>`, so the nested `{ orderBy }` was always assignable and the cast was never buying anything. One is DELIBERATELY off-contract — the negative pin that smuggles an opt-out flag onto the public options bag — and is now `as unknown as EngineQueryOptions` rather than a bare `as any`: it names the contract being bypassed, keeps the rest of the call type-checked, and greps as an intentional act. That is exactly the case #4918 carved the spelling out for, since the assertion's whole subject is that the engine rejects the unknown key. Refs #7095, #4918, #4674, #4721 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJgGahRqaYPRJ2oKmk9Czc
1 parent 1ff0e37 commit dfa2502

1 file changed

Lines changed: 20 additions & 6 deletions

File tree

packages/objectql/src/query-expression-conformance.test.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252

5353
import { describe, it, expect, beforeEach } from 'vitest';
5454
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
55+
import type { EngineQueryOptions } from '@objectstack/spec/data';
5556
import { ObjectQL } from './engine.js';
5657

5758
const projectObject = {
@@ -698,15 +699,23 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin
698699
expect(bySummary.map((r: any) => r.title)).toEqual(['E', 'C', 'D', 'B', 'A']);
699700
});
700701

701-
it.each([
702+
// Typed as the contract rather than asserted through it: these three sorts
703+
// are perfectly well-formed `SortNode[]` — it is the FIELD they name that
704+
// the engine refuses, not their shape. An `as any` here would erase the one
705+
// channel that enforces `{ field, order }` on a direct engine call
706+
// (`query-options/no-any-erasure`, #4674/#4918), and would have hidden the
707+
// very `direction`-vs-`order` mistake that rule exists to catch.
708+
const REFUSED_SORTS: Array<[string, NonNullable<EngineQueryOptions['orderBy']>]> = [
702709
['ascending', [{ field: 'sort_key', order: 'asc' }]],
703710
['descending', [{ field: 'sort_key', order: 'desc' }]],
704711
['second of two', [{ field: 'title', order: 'asc' }, { field: 'sort_key', order: 'asc' }]],
705-
])('`engine.find` REFUSES a formula ORDER BY instead of dropping it — %s', async (_label, orderBy) => {
712+
];
713+
714+
it.each(REFUSED_SORTS)('`engine.find` REFUSES a formula ORDER BY instead of dropping it — %s', async (_label, orderBy) => {
706715
// The public boundary, reached directly — no protocol, no ingress gate.
707716
// This is the exact call that answered 200-in-insertion-order before
708717
// #7095, for both directions, byte-identically.
709-
await expect(engine.find('showcase_task', { orderBy: orderBy as any }))
718+
await expect(engine.find('showcase_task', { orderBy }))
710719
.rejects.toMatchObject({
711720
status: 400,
712721
code: 'INVALID_SORT',
@@ -769,7 +778,7 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin
769778
// deliberately NOT ridden in on this card. Tracked as follow-up.
770779
const rows: any = await engine.find('showcase_task', {
771780
expand: { parent_id: { orderBy: [{ field: 'sort_key', order: 'asc' }] } },
772-
} as any);
781+
});
773782
// The call succeeds and the FK ids are retained UNEXPANDED — that is
774783
// the backstop's contract, and what makes this observable-not-refused.
775784
expect(rows).toHaveLength(5);
@@ -780,7 +789,7 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin
780789
// expand being broken for every sort.
781790
const ok: any = await engine.find('showcase_task', {
782791
expand: { parent_id: { orderBy: [{ field: 'title', order: 'asc' }] } },
783-
} as any);
792+
});
784793
expect(ok.some((r: any) => typeof r.parent_id === 'object' && r.parent_id !== null)).toBe(true);
785794
});
786795

@@ -797,10 +806,15 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin
797806
// the ADR-0112 wire envelope — so this asserts the message, which is
798807
// what a caller reaching for such a flag would actually be told.)
799808
for (const smuggled of ['allowUnmaterializedSort', 'internal', '__internal', 'tolerateDroppedSort']) {
809+
// `as unknown as EngineQueryOptions`, never a bare `as any`: this
810+
// input is DELIBERATELY off-contract — that is the whole subject of
811+
// the assertion — so the cast names the contract being bypassed and
812+
// greps as an intentional act, while the rest of the call stays
813+
// type-checked (#4918's prescription for exactly this case).
800814
await expect(engine.find('showcase_task', {
801815
orderBy: [{ field: 'sort_key', order: 'asc' }],
802816
[smuggled]: true,
803-
} as any)).rejects.toThrow(new RegExp(`does not recognise option.*'${smuggled}'`));
817+
} as unknown as EngineQueryOptions)).rejects.toThrow(new RegExp(`does not recognise option.*'${smuggled}'`));
804818
}
805819
// And the tolerance really is gone rather than merely unreachable: the
806820
// shape the old hole answered 200 for now throws.

0 commit comments

Comments
 (0)