Skip to content

Commit 255f2d7

Browse files
huangyiireneclaude
andauthored
test(objectql): conjoin $or/$and with sibling filters in six driver doubles (part of #7620) (#7846)
Six in-memory driver doubles in `packages/objectql/src` returned early on `$and`/`$or`, discarding every sibling equality key in the same `where` object. A real driver ANDs them, so a query like { state: 'draft', package_id: 'app.x', $or: [{ organization_id: ORG }, { organization_id: null }] } was answered on the `$or` alone — a different query than the one written, with the suite still green. Fold `$and`/`$or` into the entries loop, matching the corrected form `protocol-revert-org-scope.test.ts` already carries from #7619. Each matcher keeps exactly the operator surface it had: `$eq` unwrapping, the `undefined`->`null` comparison normalisation, and the skip for any other `$`-prefixed key (which a non-array `$and`/`$or` still falls through to). Measured, not assumed: a probe in each matcher logging every `where` it was handed recorded 132 calls across the six suites and not one `$or` or `$and` — all six are dormant. The 132 plain-equality calls are the control proving the probe was live. No existing outcome changes; packages/objectql is 185 files / 3274 tests passing before and after. Deliberately not extracted into a shared helper: the repo's own rationale in `publish-meta-response-conformance.test.ts` keeps these harnesses self-contained so two gates can fail independently, and an objectql-local helper could not serve the ten remaining files in other packages anyway. Part of #7620 — this is the `packages/objectql` lane only; the plugin-sharing, plugin-security and runtime files are routed separately. Claude-Session: https://claude.ai/code/session_01Da8i4RxJBSv73tgr92D9KB Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9bf4dd0 commit 255f2d7

7 files changed

Lines changed: 148 additions & 12 deletions
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
test(objectql): six in-memory driver doubles conjoin `$or`/`$and` with their sibling filters instead of short-circuiting (part of #7620)
6+
7+
Six test files in `packages/objectql/src` build an in-memory driver whose `WHERE`
8+
matcher **returned early** on `$and`/`$or`, discarding every sibling equality key
9+
in the same object:
10+
11+
```ts
12+
if (Array.isArray(where.$and)) return where.$and.every((w) => matchesWhere(row, w));
13+
if (Array.isArray(where.$or)) return where.$or.some((w) => matchesWhere(row, w));
14+
for (const [k, v] of Object.entries(where)) { /* siblings, never reached */ }
15+
```
16+
17+
A real driver ANDs them. So a query shaped like `SysMetadataRepository.listDrafts`'s —
18+
`{ state:'draft', package_id:'app.x', $or:[{organization_id:ORG},{organization_id:null}] }`
19+
— would have been answered on the `$or` alone, handing back rows matching neither
20+
`state` nor `package_id`. That is not a stricter or looser edge case; it is a
21+
different query, and the suite stays **green** while testing it.
22+
23+
The corrected form is the one `protocol-revert-org-scope.test.ts` already carries
24+
from #7619: fold `$and`/`$or` into the entries loop so they compose with their
25+
siblings rather than replacing them.
26+
27+
Files corrected: `protocol-recorded-by-null.test.ts`,
28+
`save-meta-response-conformance.test.ts`, `plugin.authoring-channel.test.ts`,
29+
`publish-meta-response-conformance.test.ts`,
30+
`protocol-save-meta-repo-path-real-engine.test.ts`,
31+
`protocol-registry-shadow.test.ts`.
32+
33+
**All six are dormant today — measured, not assumed.** A probe installed in each
34+
matcher, logging every `where` it was handed across the six suites, recorded
35+
**132 matcher calls and not one `$or` or `$and`** (44 / 60 / 21 / 7 plain-equality
36+
calls in four of the files; the matchers in `save-meta-response-conformance` and
37+
`plugin.authoring-channel` were never invoked at all — those suites drive writes,
38+
not reads). The control that makes that silence evidence rather than a dead probe
39+
is the 132 plain calls it did record through the same instrumentation. So no
40+
existing test outcome changes, and none should: `packages/objectql` is
41+
**185 files / 3274 tests, all passing**, before and after.
42+
43+
Dormant is not harmless, which is the point of closing it: nothing distinguished
44+
"this double is faithful here" from "this double quietly changed the fixture",
45+
and the next test to add an `$or` would have inherited a matcher that lies.
46+
47+
No product code changed, and no test assertion changed. Each matcher keeps
48+
exactly the operator surface it already had — `$eq` unwrapping, the
49+
`undefined``null` comparison normalisation, and the skip for any other
50+
`$`-prefixed key — and a non-array `$and`/`$or` still falls through to that skip,
51+
as before. **Deliberately not extracted into a shared helper**: the six are
52+
identical, but `publish-meta-response-conformance.test.ts` carries the repo's own
53+
rationale for keeping these harnesses self-contained ("a gate that imports its own
54+
substrate from another gate's file couples two tripwires that must be able to fail
55+
independently"), #7619's reference correction is inline for the same reason, and an
56+
objectql-local helper could not serve the ten remaining files in `plugin-sharing`,
57+
`plugin-security` and `runtime` anyway — it would add a second convention rather
58+
than consolidate to one.

packages/objectql/src/plugin.authoring-channel.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,24 @@ function makeMemoryDriver() {
6969
return s;
7070
};
7171
let nextId = 0;
72+
// `$and` / `$or` are conjoined WITH their sibling keys, the way a real
73+
// driver ANDs them. The short-circuiting shape this stub used to carry
74+
// (`if ($or) return $or.some(...)`) discarded every sibling equality key in
75+
// the same object, so a query like
76+
// `{ state:'draft', package_id, $or:[{organization_id:ORG},{organization_id:null}] }`
77+
// was silently answered on the `$or` alone — a different query than the one
78+
// written, with the suite still green. See #7620.
7279
const matchesWhere = (row: Record<string, unknown>, where: any): boolean => {
7380
if (!where || typeof where !== 'object') return true;
74-
if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w));
75-
if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w));
7681
for (const [k, v] of Object.entries(where)) {
82+
if (k === '$and' && Array.isArray(v)) {
83+
if (!v.every((w: any) => matchesWhere(row, w))) return false;
84+
continue;
85+
}
86+
if (k === '$or' && Array.isArray(v)) {
87+
if (!v.some((w: any) => matchesWhere(row, w))) return false;
88+
continue;
89+
}
7790
if (k.startsWith('$')) continue;
7891
const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
7992
const a = row[k] === undefined ? null : row[k];

packages/objectql/src/protocol-recorded-by-null.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,24 @@ function makeStubDriver() {
8686
};
8787
let nextId = 0;
8888

89+
// `$and` / `$or` are conjoined WITH their sibling keys, the way a real
90+
// driver ANDs them. The short-circuiting shape this stub used to carry
91+
// (`if ($or) return $or.some(...)`) discarded every sibling equality key in
92+
// the same object, so a query like
93+
// `{ state:'draft', package_id, $or:[{organization_id:ORG},{organization_id:null}] }`
94+
// was silently answered on the `$or` alone — a different query than the one
95+
// written, with the suite still green. See #7620.
8996
const matchesWhere = (row: Record<string, unknown>, where: any): boolean => {
9097
if (!where || typeof where !== 'object') return true;
91-
if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w));
92-
if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w));
9398
for (const [k, v] of Object.entries(where)) {
99+
if (k === '$and' && Array.isArray(v)) {
100+
if (!v.every((w: any) => matchesWhere(row, w))) return false;
101+
continue;
102+
}
103+
if (k === '$or' && Array.isArray(v)) {
104+
if (!v.some((w: any) => matchesWhere(row, w))) return false;
105+
continue;
106+
}
94107
if (k.startsWith('$')) continue;
95108
const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
96109
const a = row[k] === undefined ? null : row[k];

packages/objectql/src/protocol-registry-shadow.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,24 @@ function makeStubDriver() {
7878
};
7979
let nextId = 0;
8080

81+
// `$and` / `$or` are conjoined WITH their sibling keys, the way a real
82+
// driver ANDs them. The short-circuiting shape this stub used to carry
83+
// (`if ($or) return $or.some(...)`) discarded every sibling equality key in
84+
// the same object, so a query like
85+
// `{ state:'draft', package_id, $or:[{organization_id:ORG},{organization_id:null}] }`
86+
// was silently answered on the `$or` alone — a different query than the one
87+
// written, with the suite still green. See #7620.
8188
const matchesWhere = (row: Record<string, unknown>, where: any): boolean => {
8289
if (!where || typeof where !== 'object') return true;
83-
if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w));
84-
if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w));
8590
for (const [k, v] of Object.entries(where)) {
91+
if (k === '$and' && Array.isArray(v)) {
92+
if (!v.every((w: any) => matchesWhere(row, w))) return false;
93+
continue;
94+
}
95+
if (k === '$or' && Array.isArray(v)) {
96+
if (!v.some((w: any) => matchesWhere(row, w))) return false;
97+
continue;
98+
}
8699
if (k.startsWith('$')) continue;
87100
const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
88101
const a = row[k] === undefined ? null : row[k];

packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,24 @@ function makeStubDriver() {
4444
};
4545
let nextId = 0;
4646

47+
// `$and` / `$or` are conjoined WITH their sibling keys, the way a real
48+
// driver ANDs them. The short-circuiting shape this stub used to carry
49+
// (`if ($or) return $or.some(...)`) discarded every sibling equality key in
50+
// the same object, so a query like
51+
// `{ state:'draft', package_id, $or:[{organization_id:ORG},{organization_id:null}] }`
52+
// was silently answered on the `$or` alone — a different query than the one
53+
// written, with the suite still green. See #7620.
4754
const matchesWhere = (row: Record<string, unknown>, where: any): boolean => {
4855
if (!where || typeof where !== 'object') return true;
49-
if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w));
50-
if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w));
5156
for (const [k, v] of Object.entries(where)) {
57+
if (k === '$and' && Array.isArray(v)) {
58+
if (!v.every((w: any) => matchesWhere(row, w))) return false;
59+
continue;
60+
}
61+
if (k === '$or' && Array.isArray(v)) {
62+
if (!v.some((w: any) => matchesWhere(row, w))) return false;
63+
continue;
64+
}
5265
if (k.startsWith('$')) continue;
5366
const rowVal = row[k];
5467
const expected = (v && typeof v === 'object' && '$eq' in (v as any))

packages/objectql/src/publish-meta-response-conformance.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,24 @@ function makeMemoryDriver() {
6868
return s;
6969
};
7070
let nextId = 0;
71+
// `$and` / `$or` are conjoined WITH their sibling keys, the way a real
72+
// driver ANDs them. The short-circuiting shape this stub used to carry
73+
// (`if ($or) return $or.some(...)`) discarded every sibling equality key in
74+
// the same object, so a query like
75+
// `{ state:'draft', package_id, $or:[{organization_id:ORG},{organization_id:null}] }`
76+
// was silently answered on the `$or` alone — a different query than the one
77+
// written, with the suite still green. See #7620.
7178
const matchesWhere = (row: Record<string, unknown>, where: any): boolean => {
7279
if (!where || typeof where !== 'object') return true;
73-
if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w));
74-
if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w));
7580
for (const [k, v] of Object.entries(where)) {
81+
if (k === '$and' && Array.isArray(v)) {
82+
if (!v.every((w: any) => matchesWhere(row, w))) return false;
83+
continue;
84+
}
85+
if (k === '$or' && Array.isArray(v)) {
86+
if (!v.some((w: any) => matchesWhere(row, w))) return false;
87+
continue;
88+
}
7689
if (k.startsWith('$')) continue;
7790
const rowVal = row[k];
7891
const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;

packages/objectql/src/save-meta-response-conformance.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,24 @@ function makeMemoryDriver() {
7373
return s;
7474
};
7575
let nextId = 0;
76+
// `$and` / `$or` are conjoined WITH their sibling keys, the way a real
77+
// driver ANDs them. The short-circuiting shape this stub used to carry
78+
// (`if ($or) return $or.some(...)`) discarded every sibling equality key in
79+
// the same object, so a query like
80+
// `{ state:'draft', package_id, $or:[{organization_id:ORG},{organization_id:null}] }`
81+
// was silently answered on the `$or` alone — a different query than the one
82+
// written, with the suite still green. See #7620.
7683
const matchesWhere = (row: Record<string, unknown>, where: any): boolean => {
7784
if (!where || typeof where !== 'object') return true;
78-
if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w));
79-
if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w));
8085
for (const [k, v] of Object.entries(where)) {
86+
if (k === '$and' && Array.isArray(v)) {
87+
if (!v.every((w: any) => matchesWhere(row, w))) return false;
88+
continue;
89+
}
90+
if (k === '$or' && Array.isArray(v)) {
91+
if (!v.some((w: any) => matchesWhere(row, w))) return false;
92+
continue;
93+
}
8194
if (k.startsWith('$')) continue;
8295
const rowVal = row[k];
8396
const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;

0 commit comments

Comments
 (0)