diff --git a/.changeset/objectql-test-doubles-conjoin-or.md b/.changeset/objectql-test-doubles-conjoin-or.md new file mode 100644 index 0000000000..2d17fe5810 --- /dev/null +++ b/.changeset/objectql-test-doubles-conjoin-or.md @@ -0,0 +1,58 @@ +--- +"@objectstack/objectql": patch +--- + +test(objectql): six in-memory driver doubles conjoin `$or`/`$and` with their sibling filters instead of short-circuiting (part of #7620) + +Six test files in `packages/objectql/src` build an in-memory driver whose `WHERE` +matcher **returned early** on `$and`/`$or`, discarding every sibling equality key +in the same object: + +```ts +if (Array.isArray(where.$and)) return where.$and.every((w) => matchesWhere(row, w)); +if (Array.isArray(where.$or)) return where.$or.some((w) => matchesWhere(row, w)); +for (const [k, v] of Object.entries(where)) { /* siblings, never reached */ } +``` + +A real driver ANDs them. So a query shaped like `SysMetadataRepository.listDrafts`'s — +`{ state:'draft', package_id:'app.x', $or:[{organization_id:ORG},{organization_id:null}] }` +— would have been answered on the `$or` alone, handing back rows matching neither +`state` nor `package_id`. That is not a stricter or looser edge case; it is a +different query, and the suite stays **green** while testing it. + +The corrected form is the one `protocol-revert-org-scope.test.ts` already carries +from #7619: fold `$and`/`$or` into the entries loop so they compose with their +siblings rather than replacing them. + +Files corrected: `protocol-recorded-by-null.test.ts`, +`save-meta-response-conformance.test.ts`, `plugin.authoring-channel.test.ts`, +`publish-meta-response-conformance.test.ts`, +`protocol-save-meta-repo-path-real-engine.test.ts`, +`protocol-registry-shadow.test.ts`. + +**All six are dormant today — measured, not assumed.** A probe installed in each +matcher, logging every `where` it was handed across the six suites, recorded +**132 matcher calls and not one `$or` or `$and`** (44 / 60 / 21 / 7 plain-equality +calls in four of the files; the matchers in `save-meta-response-conformance` and +`plugin.authoring-channel` were never invoked at all — those suites drive writes, +not reads). The control that makes that silence evidence rather than a dead probe +is the 132 plain calls it did record through the same instrumentation. So no +existing test outcome changes, and none should: `packages/objectql` is +**185 files / 3274 tests, all passing**, before and after. + +Dormant is not harmless, which is the point of closing it: nothing distinguished +"this double is faithful here" from "this double quietly changed the fixture", +and the next test to add an `$or` would have inherited a matcher that lies. + +No product code changed, and no test assertion changed. Each matcher keeps +exactly the operator surface it already had — `$eq` unwrapping, the +`undefined`→`null` comparison normalisation, and the skip for any other +`$`-prefixed key — and a non-array `$and`/`$or` still falls through to that skip, +as before. **Deliberately not extracted into a shared helper**: the six are +identical, but `publish-meta-response-conformance.test.ts` carries the repo's own +rationale for keeping these harnesses self-contained ("a gate that imports its own +substrate from another gate's file couples two tripwires that must be able to fail +independently"), #7619's reference correction is inline for the same reason, and an +objectql-local helper could not serve the ten remaining files in `plugin-sharing`, +`plugin-security` and `runtime` anyway — it would add a second convention rather +than consolidate to one. diff --git a/packages/objectql/src/plugin.authoring-channel.test.ts b/packages/objectql/src/plugin.authoring-channel.test.ts index da2dbbd461..5761689356 100644 --- a/packages/objectql/src/plugin.authoring-channel.test.ts +++ b/packages/objectql/src/plugin.authoring-channel.test.ts @@ -69,11 +69,24 @@ function makeMemoryDriver() { return s; }; let nextId = 0; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. The short-circuiting shape this stub used to carry + // (`if ($or) return $or.some(...)`) discarded every sibling equality key in + // the same object, so a query like + // `{ state:'draft', package_id, $or:[{organization_id:ORG},{organization_id:null}] }` + // was silently answered on the `$or` alone — a different query than the one + // written, with the suite still green. See #7620. const matchesWhere = (row: Record, where: any): boolean => { if (!where || typeof where !== 'object') return true; - if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w)); - if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w)); for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } if (k.startsWith('$')) continue; const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; const a = row[k] === undefined ? null : row[k]; diff --git a/packages/objectql/src/protocol-recorded-by-null.test.ts b/packages/objectql/src/protocol-recorded-by-null.test.ts index 382e8b79c9..2d6804e031 100644 --- a/packages/objectql/src/protocol-recorded-by-null.test.ts +++ b/packages/objectql/src/protocol-recorded-by-null.test.ts @@ -86,11 +86,24 @@ function makeStubDriver() { }; let nextId = 0; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. The short-circuiting shape this stub used to carry + // (`if ($or) return $or.some(...)`) discarded every sibling equality key in + // the same object, so a query like + // `{ state:'draft', package_id, $or:[{organization_id:ORG},{organization_id:null}] }` + // was silently answered on the `$or` alone — a different query than the one + // written, with the suite still green. See #7620. const matchesWhere = (row: Record, where: any): boolean => { if (!where || typeof where !== 'object') return true; - if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w)); - if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w)); for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } if (k.startsWith('$')) continue; const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; const a = row[k] === undefined ? null : row[k]; diff --git a/packages/objectql/src/protocol-registry-shadow.test.ts b/packages/objectql/src/protocol-registry-shadow.test.ts index d869c78661..688190a7fe 100644 --- a/packages/objectql/src/protocol-registry-shadow.test.ts +++ b/packages/objectql/src/protocol-registry-shadow.test.ts @@ -78,11 +78,24 @@ function makeStubDriver() { }; let nextId = 0; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. The short-circuiting shape this stub used to carry + // (`if ($or) return $or.some(...)`) discarded every sibling equality key in + // the same object, so a query like + // `{ state:'draft', package_id, $or:[{organization_id:ORG},{organization_id:null}] }` + // was silently answered on the `$or` alone — a different query than the one + // written, with the suite still green. See #7620. const matchesWhere = (row: Record, where: any): boolean => { if (!where || typeof where !== 'object') return true; - if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w)); - if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w)); for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } if (k.startsWith('$')) continue; const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; const a = row[k] === undefined ? null : row[k]; diff --git a/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts b/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts index 827bea7e85..e4775b257e 100644 --- a/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts +++ b/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts @@ -44,11 +44,24 @@ function makeStubDriver() { }; let nextId = 0; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. The short-circuiting shape this stub used to carry + // (`if ($or) return $or.some(...)`) discarded every sibling equality key in + // the same object, so a query like + // `{ state:'draft', package_id, $or:[{organization_id:ORG},{organization_id:null}] }` + // was silently answered on the `$or` alone — a different query than the one + // written, with the suite still green. See #7620. const matchesWhere = (row: Record, where: any): boolean => { if (!where || typeof where !== 'object') return true; - if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w)); - if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w)); for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } if (k.startsWith('$')) continue; const rowVal = row[k]; const expected = (v && typeof v === 'object' && '$eq' in (v as any)) diff --git a/packages/objectql/src/publish-meta-response-conformance.test.ts b/packages/objectql/src/publish-meta-response-conformance.test.ts index c110df236b..72f38bb022 100644 --- a/packages/objectql/src/publish-meta-response-conformance.test.ts +++ b/packages/objectql/src/publish-meta-response-conformance.test.ts @@ -68,11 +68,24 @@ function makeMemoryDriver() { return s; }; let nextId = 0; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. The short-circuiting shape this stub used to carry + // (`if ($or) return $or.some(...)`) discarded every sibling equality key in + // the same object, so a query like + // `{ state:'draft', package_id, $or:[{organization_id:ORG},{organization_id:null}] }` + // was silently answered on the `$or` alone — a different query than the one + // written, with the suite still green. See #7620. const matchesWhere = (row: Record, where: any): boolean => { if (!where || typeof where !== 'object') return true; - if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w)); - if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w)); for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } if (k.startsWith('$')) continue; const rowVal = row[k]; const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; diff --git a/packages/objectql/src/save-meta-response-conformance.test.ts b/packages/objectql/src/save-meta-response-conformance.test.ts index 66ce32dd3d..59f69402c0 100644 --- a/packages/objectql/src/save-meta-response-conformance.test.ts +++ b/packages/objectql/src/save-meta-response-conformance.test.ts @@ -73,11 +73,24 @@ function makeMemoryDriver() { return s; }; let nextId = 0; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. The short-circuiting shape this stub used to carry + // (`if ($or) return $or.some(...)`) discarded every sibling equality key in + // the same object, so a query like + // `{ state:'draft', package_id, $or:[{organization_id:ORG},{organization_id:null}] }` + // was silently answered on the `$or` alone — a different query than the one + // written, with the suite still green. See #7620. const matchesWhere = (row: Record, where: any): boolean => { if (!where || typeof where !== 'object') return true; - if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w)); - if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w)); for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } if (k.startsWith('$')) continue; const rowVal = row[k]; const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;