Skip to content

driver-sql: a multiple: true BOOLEAN column loses its $contains membership filter — the #14079 type-gate compiles it to 1 = 0, where the same shape on a multiple: true number keeps its GLOB #17343

Description

@os-sam

Filed unassigned by the #15683 dev while landing the temporal half of the declared-type gate. Recording only — no severity asserted, routing is triage's. Dedup: one targeted MCP search_issues (2026-09-10) returned no open card on this cell; a control query in the same session hit its target, so the empty result is a reading.

Measured

Compile-shape probe against SqlDriver at origin/main + the #15683 branch (the behaviour below is on main and is NOT introduced by that branch — its temporal limb carries the carve-out this one lacks). Object registered through registerExternalObject, dialect better-sqlite3, filter { FIELD: { $contains: 'true' } }:

declared field compiled WHERE verdict
{ type: 'boolean', multiple: true } select * from `probe_tbl` where 1 = 0 ⚠️ the membership filter is dead
{ type: 'number', multiple: true } select * from `probe_tbl` where `nums` GLOB '*true*' correct
{ type: 'tags' } select * from `probe_tbl` where `tags_` GLOB '*true*' correct

Why the boolean row is a defect

A multiple: true field is stored as a JSON TEXT array (SqlDriver.isJsonField = JSON_COLUMN_TYPES.has(type) || !!field.multiple). On such a column $contains is not a substring test at all — it is the MEMBERSHIP spelling, and it is the ONE operator #7398 left working there after refusing the equality family. sql-driver-json-column-operator-refusal.test.ts pins it in prose as well: "$contains unchanged … It is the only working membership spelling and downstream code depends on it".

Compiling it to 1 = 0 turns a working membership filter into "matches nothing" — the fail-CLOSED direction that suite's own table calls out.

Where it comes from

SqlDriver.isNonTextColumn reads numericFields and booleanFields. The two registries are filled asymmetrically, in both initObjects and registerExternalObject:

if (type === 'boolean' || type === 'toggle') {
  booleanCols.push(name);                                  // no `multiple` condition
}
if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) {
  numericCols.push(name);                                  // carries the carve-out
}

booleanFields exists for READ COERCION (stored 1/0 back to a JS boolean), a question that legitimately applies to a multi-valued column too, so narrowing the registry is probably the wrong repair — three other seams read it. The #15683 branch spells the equivalent condition at the predicate instead, for the temporal limb only:

(this.temporalFieldKind(table, localField) !== null && !this.isJsonColumn(table, localField))

The same !this.isJsonColumn(table, localField) on the boolean limb is the candidate shape. It was deliberately NOT taken on the #15683 branch: that branch implements the temporal ruling, and the boolean limb is #14079's cell, so widening it there would have been a second ruling riding on the first.

Repro

class P extends SqlDriver {
  compileWhere(where: FilterCondition): string {
    const b = this.getKnex()('probe_tbl');
    this.applyFilters(b, where);
    return b.toString();
  }
}
const d = new P({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
d.registerExternalObject({ name: 'probe_tbl', fields: { flags: { type: 'boolean', multiple: true } } });
d.compileWhere({ flags: { $contains: 'true' } });   // -> `where 1 = 0`

toggle is in the same registry arm and is expected to answer identically; not separately probed.

Related

#14079 (the non-string-column row and the gate this sits in) · #15683 (the temporal half, where the carve-out was first spelled) · #7398 (the JSON-column refusal family and the $contains membership spelling it preserves)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions