Skip to content

objectql having has no $icontains comparand-shape gate — an empty comparand matches EVERY row (2 of 5 FILTER_TEXT_CASES rejection rows unenrollable) #7158

Description

@os-zhuang

Found while closing #7047 (the ADR-0112 envelope on having's refusals), by driving the having face against FILTER_TEXT_CASES for the first time. Out of #7047's fence — that card is about the ENVELOPE on refusals that already happen; this is a refusal that does not happen at all.

Measured (2026-08-10, origin/main @ 3e8e669, by execution)

FILTER_TEXT_CASES declares five expectRejection rows. Driven against matchesHaving / applyHaving over FILTER_TEXT_ROWS:

row having
$regex is REFUSED refused (enveloped by #7047)
$regex + $options refused as one mistake refused (enveloped by #7047)
a dangling $options is REFUSED refused (enveloped by #7047)
an empty $icontains comparand is REFUSED NOT refused — evaluated, matched ALL NINE rows
a non-string $icontains comparand is REFUSED NOT refused — evaluated, matched none
matchesHaving({ name: 'ACME Corp' }, { name: { $icontains: '' } })  // => true
applyHaving(FILTER_TEXT_ROWS, { name: { $icontains: '' } })         // => all 9 rows
matchesHaving({ name: 'ACME Corp' }, { name: { $icontains: 42 } })  // => false

Consequence

The empty-comparand row is the sharp one. Every string contains the empty substring, so the predicate constrains NOTHING — a filter that compiles to nothing does not narrow a query, it widens it (#3948). The author wrote a constraint and got the unfiltered aggregate back, with no error.

The non-string row is the quieter half: 42 is answered false — "no rows" — for a comparand the declared type (StringOperatorSchema: $icontains: z.string()) does not permit, which is the silent-wrong-answer shape #4706 retired $regex over.

What the other faces do

The driver faces get this from a comparand gate having has never had:

  • driver-memoryfilter-refusal.ts icontainsComparandError(), both rejections in one constructor, INVALID_FILTER / 400.
  • driver-sql — the twin icontainsComparandError, word for word.

having is the sixth JS evaluation face of the same vocabulary (#6520 gave it the shared ASCII fold) and the only one with no comparand gate.

Why this is not #7047

Closing this means REFUSING filters this face evaluates today — a behaviour change beyond an envelope card, with its own changeset and its own client-visible effect. #7047 kept the same fence #6993 kept before it.

Current state of the coverage

packages/objectql/src/having-filter-text-conformance.test.ts (added by #7047) enrolls the 13 rows cases and the 3 retired-operator rejection rows, and pins these two exclusions as measurements — asserting that the face does not refuse them, and that the empty comparand matches all nine rows. So the gap is red-on-change rather than a silent hole: adding the gate turns those pins red and forces the two rows into the enrolment.

Update that file's UNENROLLED_REJECTION_CASES block when closing this.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions