fix(objectql): gate having's $icontains comparand shape (#7158) - #7236
Conversation
An empty comparand matched EVERY aggregated row — a predicate that constrains nothing widens the result set rather than narrowing it (#3948) — and a non-string comparand was answered "no rows" for a shape `StringOperatorSchema` does not permit. Both are now refused with INVALID_FILTER / 400 in the ADR-0112 envelope, the same gate `driver-memory` and `driver-sql`'s `icontainsComparandError` make. The two FILTER_TEXT_CASES rejection rows #7047 could not enrol are enrolled: all five rejection rows are driven now, and the pins that measured the exclusion are kept in their flipped direction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016R9de1FqP7NvwKvqXi92Gh
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #7158
havingwas the sixth JS evaluation face of one filter vocabulary and the only one with no comparand-shape gate. It now refuses an$icontainscomparand that is not a non-empty string, with the same gatedriver-memory'sicontainsComparandError(filter-refusal.ts:670) anddriver-sql's word-for-word twin make: both rejections in one constructor,INVALID_FILTER/ 400, ADR-0112 envelope.Premise re-verified on
origin/main@1bd6525(post-#7161), by executionThe card's three measured expressions, re-run before the first edit — all three reproduce exactly as filed:
The empty-comparand row is the sharp one: every string contains the empty substring, so the predicate constrained nothing and the author got the UNFILTERED aggregate back with no error — a filter that widens rather than narrows (#3948). The non-string row is the quieter half:
42was answered "no rows" for a shapeStringOperatorSchemadoes not permit.The change
packages/objectql/src/having-filter.tsicontainsComparandError(field, value, path)— the twin's constructor, message verbatim after the position clause. The position is spelled from thehavingroot (having.$and[0].name.$icontains) where the driver faces spell it fromwhere; the clause is the difference, and a caller reading a 400 needs to know which of the two they mis-wrote. It calls theinvalidFilterErrorhelper fix(objectql): puthaving's operator refusals inside the ADR-0112 envelope (#7047) #7161 established for this face, so the twoobjectqlrefusal sites still answer one mistake with one envelope (the finding:rest-server.ts里三个相邻/metahandler 的错误信封是三种不同形状,其中两种不符合 ADR-0112 #7035 defect class).checkCondition, deliberately. A comparand's shape is a property of the FILTER, not of the row being judged; below the exit,{ missing_column: { $icontains: '' } }would be refused only for rows that carry the column and answeredfalsefor the rest — one filter, two verdicts, decided by the data.matchesHavinggained an optional thirdpathargument (default'having') so the refusal can name the position; every existing two-argument call is unaffected.$icontainsarm'stypeof target !== 'string'limb is hoisted, not relaxed. It was this face's entire opinion about a bad comparand and its opinion wasreturn false; with the gate above it, it was dead code that read as a check. The guard on the COLUMN VALUE stays — a value that is not text cannot contain a substring, which is a judgement about the row, not about the filter.packages/objectql/src/having-filter-text-conformance.test.ts— the two rows #7047 could not enrol are enrolled.UNENROLLED_REJECTION_CASESand thenamesRetiredOperatorpartition are gone because the partition existed to name an exclusion and there is none;REJECTION_CASESis now every rejection row the table declares, and the guard test lists all five names. The pins that measured the exclusion are kept, flipped rather than deleted: the same four expressions, now asserted to refuse.Both directions, as counts
FILTER_TEXT_CASESevaluation rows driven, greenFILTER_TEXT_CASESrejection rows enrolledThe accepted surface does not shrink: all 13 evaluation rows pass with the same expected id lists, and
having-icontains.test.ts's 11 accept cases are untouched (its one non-string case is a non-string COLUMN VALUE with a valid comparand — a different judgement, deliberately left).Rejection-case assertions
code+status+mustMention, nevertoThrow()alone — a throw-only assertion carries one bit where the defect has two, and is exactly what stayed green through #7047's missing envelope. One sharedexpectRefusalhelper drives all five rows, so the retired-operator rows and the comparand rows are held to the identical envelope.Reverse verification
Predicted direction: red, and red only on the two comparand rows. Took the gate out with
git checkout origin/main -- packages/objectql/src/having-filter.tsand re-ran the conformance file:The 13 evaluation rows, the 3 retired-operator rows and the table guard all stayed green — the gate is load-bearing for exactly the two rows it claims, and for nothing else.
Consumer sweep
applyHaving/matchesHavinghave one non-test caller in the repo,packages/objectql/src/engine.ts(lines 8283 and 8292, the aggregate path); nothing outsidepackages/objectqlimports either. Grepped the repo for tests pinning the tolerated behaviour in any spelling — literal, escaped and cross-line: the only ones werehaving-filter-text-conformance.test.ts's two pins, and they are flipped rather than deleted per the card.Tests
2908 vs 2906 on the pre-change tree, i.e. exactly the +2 the conformance file gains; no test was lost in the restructure.
Changeset:
.changeset/silly-jars-shake.md, patch on@objectstack/objectql, stating the client-visible effect plainly — twohavingfilters that used to return rows now return a 400.Generated by Claude Code