Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/mongodb-contains-case-sensitive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
"@objectstack/driver-mongodb": patch
---

fix(driver-mongodb): the `$contains` family is case-SENSITIVE — the hardcoded `$options: 'i'` is gone (#6682)

**Row-set change for anyone filtering text on MongoDB.** `$contains`,
`$notContains`, `$startsWith` and `$endsWith` translated to a `$regex` with a
hardcoded `$options: 'i'` beside it, which is MongoDB's full-Unicode case fold.
That flag is off all four arms. `{ name: { $contains: 'acme' } }` no longer
returns `ACME Corp`.

This is #4706 **Q2 = A** — the family is case-sensitive on every backend —
arriving at the last driver that was on the wrong side of it. #6518 flipped the
SQL family (`GLOB` on the SQLite dialects, `LIKE` over a binary cast on MySQL,
`LIKE` unchanged on Postgres); `formula`, ObjectQL's `having` and
service-analytics' compilers were already case-exact.

**Both directions of the defect mattered.** The fold OVER-matched — it returned
rows the filter excludes, which on an RLS read scope is over-reach rather than a
loose filter (#3948) — and it folded the whole Unicode range, overshooting the
ASCII-only boundary Q1 = A holds `$icontains` to.

**If you were relying on the fold, write `$icontains`.** It is the deliberate
case-insensitive spelling, implemented on this driver since #6520, and it folds
ASCII only (`café` does not match `CAFÉ`) — the one domain every backend can
deliver.

Unchanged: `escapeRegex`, so the comparand is still matched LITERALLY (`a.b`
matches `a.b`, not `axb`), and `$icontains`, whose fold has always lived in the
pattern rather than in `$options`. Every face of this driver —
`find`/`count`/`update`/`delete` and the aggregation `$match` — routes through
the one `translateFilter`, so there is no second answer to align.

The driver's `FILTER_TEXT_CASES` conformance cell is now CLEARED: the new
server-free suite `mongodb-filter-text-conformance.test.ts` imports the shared
case-set and drives all seventeen cases, rejection rows included, and the DEBT
row for this cell is deleted from `scripts/check-driver-conformance.mjs`.
`driver-memory`'s half of #6682 stays open under the #5499 freeze, so that card
remains open.
29 changes: 16 additions & 13 deletions content/docs/protocol/objectql/query-syntax.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,12 +302,13 @@ metacharacters — `{ name: { $icontains: 'a.b' } }` matches `a.b` and not `axb`

One half of the case rules above is still landing: `$contains` /
`$startsWith` / `$endsWith` / `$notContains` are case-**sensitive** by ruling and
are so on the SQL family, but the in-memory driver's query path and MongoDB still
fold them over the whole Unicode range —
[#6682](https://github.com/objectstack-ai/objectstack/issues/6682). Until that
lands, prefer `$icontains` when you *want* a fold rather than relying on
`$contains` being loose on those two backends. The shared standard both halves
are measured against is `FILTER_TEXT_CASES` (`@objectstack/spec/data`).
are so on the SQL family and on MongoDB —
[#6682](https://github.com/objectstack-ai/objectstack/issues/6682) removed the
hardcoded `$options: 'i'` that had folded them there. The in-memory driver's
query and analytics faces still fold over the whole Unicode range. Until that
half lands, prefer `$icontains` when you *want* a fold rather than relying on
`$contains` being loose on that backend. The shared standard both halves are
measured against is `FILTER_TEXT_CASES` (`@objectstack/spec/data`).
</Callout>

### `$regex` — removed
Expand DownExpand Up@@ -920,13 +921,15 @@ a user typing `acme` does not find `ACME Corp`. Only `select` / `status` option
*labels* are matched case-insensitively by the expansion itself.

<Callout type="warn">
**Measured today, and it does not match that rule yet.** The `$contains` alignment
is [#5702](https://github.com/objectstack-ai/objectstack/issues/5702), so until it
lands the answer is still the driver's: `SqlDriver` compiles a parameterised
`LIKE '%…%'` and the dialect decides (SQLite folds ASCII, Postgres does not),
`driver-mongodb` folds the full Unicode range through a hardcoded `$options: 'i'`,
and `driver-memory`'s query path matches with a case-insensitive regex. Which
driver you run therefore still changes which rows a search returns. Whether the
**Measured today: one driver still does not match that rule.** The `$contains`
alignment landed in two steps —
[#6518](https://github.com/objectstack-ai/objectstack/issues/6518) made `SqlDriver`
case-exact per dialect (`GLOB` on the SQLite dialects, `LIKE` unchanged on
Postgres, `LIKE` over a binary cast on MySQL), and
[#6682](https://github.com/objectstack-ai/objectstack/issues/6682) removed
`driver-mongodb`'s hardcoded `$options: 'i'`. `driver-memory`'s query path still
matches with a case-insensitive regex, so running your tests on the in-memory
double can still return rows a SQL or MongoDB deployment would not. Whether the
expansion should emit `$icontains` instead of `$contains` — i.e. whether search is
case-insensitive by definition — is a separate question that rides with that issue,
because it can only be answered once both operators mean one thing everywhere.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#6682] `driver-mongodb` held to `FILTER_TEXT_CASES` — the text-operator
* standard, answered without a server.
*
* ## Why this file only exists now
*
* The cell this file clears carried a measured DEBT row in
* `scripts/check-driver-conformance.mjs` for as long as either half of the
* #4706 ruling was open here, because that gate judges coverage by IMPORT: a
* driver that imports the marker claims the WHOLE case-set, so a face answering
* one requirement and not the other must not import it. The two halves closed
* in different PRs:
*
* | requirement | where it landed |
* |:--|:--|
* | 1 — `$icontains`, ASCII-only (#4706 Q1 = A) | #6520, as a sanctioned one-off inside the #5499 freeze |
* | 2 — the `$contains` family is case-SENSITIVE (#4706 Q2 = A) | #6682, this change |
* | 3 — `$regex` / `$options` REFUSED, naming `$icontains` | #5702 |
*
* `mongodb-icontains.test.ts` drove `FILTER_TEXT_ROWS` and spelled its own
* `$icontains` cases rather than naming the marker, precisely so requirement 1
* would not flip this cell to covered while requirement 2 was open. With
* requirement 2 answered, the marker comes in here and the ledger row goes in
* the same PR — deleting it without this file fails CONSUMED, keeping it
* alongside this file fails RECONCILED.
*
* ## Why the assertions run in-process rather than against mongod
*
* This package's real-mongod suites are OPT-IN
* (`OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`, #5517 — the binary is a ~123 MB
* download that is not reachable on a restricted network), so a suite that
* needed a server would not run in CI, and a standard that does not run is the
* shape #4363 wrote the gate over. `translateFilter` is a pure function and the
* whole of this driver's answer to these cases: the case sensitivity of a
* `$regex` predicate is decided entirely by whether `$options: 'i'` is set
* beside it. So the emitted documents are EVALUATED here, by
* {@link selectIds}, the same judgement `mongodb-filter-logic-translation.test.ts`
* makes for the logic case-set and `mongodb-aggregation-translation.test.ts`
* for the aggregation one. The real-mongod half stays absent and is recorded as
* such on #6682 rather than implied here.
*
* @see FILTER_TEXT_CASES — the standard
* @see https://github.com/objectstack-ai/objectstack/issues/4706 (the ruling)
* @see https://github.com/objectstack-ai/objectstack/issues/6682 (requirement 2 on this driver)
* @see https://github.com/objectstack-ai/objectstack/issues/6518 (the same flip on the SQL family)
*/

import { describe, it, expect } from 'vitest';
import {
FILTER_TEXT_CASES,
FILTER_TEXT_ROWS,
type FilterTextCase,
type FilterTextRejectionCase,
type FilterTextRow,
} from '@objectstack/spec/data';
import { translateFilter } from './mongodb-filter.js';

// ── A deliberately strict reader of the emitted document ────────────────────
//
// Same discipline as `mongodb-filter-logic-translation.test.ts`'s `matchDoc`:
// every shape it does not model is a thrown error, never a silently-true
// predicate. A stand-in more permissive than the real engine turns this suite
// into a green light for broken code. Its own discrimination is proved at the
// bottom of this file — a folding document must FAIL the case-sensitive rows.

/** Thrown for any shape this matcher does not model — never swallowed. */
class UnsupportedShape extends Error {}

/**
* `$options` is modelled rather than ignored, and that is the whole point: the
* defect #6682 fixed was a hardcoded `$options: 'i'`, so a matcher that dropped
* the flag would have reported the fixed answer from the broken translator.
*/
function matchRegexOps(value: unknown, ops: Record<string, unknown>): boolean {
const source = ops.$regex;
if (typeof source !== 'string') throw new UnsupportedShape('$regex without a string pattern');
const options = ops.$options;
if (options !== undefined && typeof options !== 'string') {
throw new UnsupportedShape('$options without a string');
}
for (const key of Object.keys(ops)) {
if (key !== '$regex' && key !== '$options') {
throw new UnsupportedShape(`unsupported key beside $regex: '${key}'`);
}
}
if (typeof value !== 'string') return false;
return new RegExp(source, options ?? '').test(value);
}

/** Operators applied to one field's value. */
function matchOps(value: unknown, ops: Record<string, unknown>): boolean {
const keys = Object.keys(ops);
if (keys.includes('$regex') || keys.includes('$options')) return matchRegexOps(value, ops);
for (const [op, arg] of Object.entries(ops)) {
switch (op) {
case '$eq':
if (value !== arg) return false;
break;
case '$ne':
if (value === arg) return false;
break;
case '$not': {
// The per-field negation — the only `$not` MongoDB accepts, and the
// shape `$notContains` leaves here as.
if (!arg || typeof arg !== 'object' || Array.isArray(arg)) {
throw new UnsupportedShape('per-field $not takes an operator document');
}
if (matchOps(value, arg as Record<string, unknown>)) return false;
break;
}
default:
throw new UnsupportedShape(`unsupported field operator '${op}'`);
}
}
return true;
}

/** Evaluate an emitted MongoDB query document against one fixture row. */
function matchDoc(row: FilterTextRow, doc: Record<string, unknown>): boolean {
for (const [key, value] of Object.entries(doc)) {
if (key.startsWith('$')) throw new UnsupportedShape(`unsupported document operator '${key}'`);
const field = (row as unknown as Record<string, unknown>)[key];
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
if (!matchOps(field, value as Record<string, unknown>)) return false;
} else if (field !== value) {
return false;
}
}
return true;
}

/** Ids the emitted document selects from the shared fixture, ascending. */
function selectIds(doc: Record<string, unknown>): string[] {
return FILTER_TEXT_ROWS.filter((row) => matchDoc(row, doc))
.map((row) => row.id)
.sort((a, b) => a.localeCompare(b));
}

function isRejection(c: FilterTextCase): c is FilterTextRejectionCase {
return c.expectRejection === true;
}

const ROWS_CASES = FILTER_TEXT_CASES.filter(
(c): c is Exclude<FilterTextCase, FilterTextRejectionCase> => !isRejection(c),
);
const REJECTION_CASES = FILTER_TEXT_CASES.filter(isRejection);

// ── The shared standard: the evaluated rows ─────────────────────────────────

describe('[#6682] translateFilter answers FILTER_TEXT_CASES — the evaluated rows', () => {
for (const testCase of ROWS_CASES) {
it(testCase.name, () => {
const doc = translateFilter(testCase.filter) as Record<string, unknown>;
expect(selectIds(doc), `${testCase.note ?? ''}\nemitted: ${JSON.stringify(doc)}`).toEqual([
...testCase.expected,
]);
});
}
});

// ── The shared standard: the refusals ───────────────────────────────────────

describe('[#6682] translateFilter answers FILTER_TEXT_CASES — every rejection row', () => {
for (const testCase of REJECTION_CASES) {
it(`${testCase.name} — refused in the ADR-0112 envelope`, () => {
let err: (Error & { code?: string; status?: number }) | undefined;
try {
translateFilter(testCase.filter);
} catch (e) {
err = e as Error & { code?: string; status?: number };
}
// Not `expected: []`. `FilterTextRejectionCase` is a separate discriminant
// because "translated to a predicate that matched nothing" and "refused to
// run" must be told apart — and `code`/`status` rather than a bare
// `toThrow()` because this driver is the reason that bar exists: its
// `default:` arm threw a bare `new Error` with no envelope until #5702.
expect(err, testCase.note ?? 'expected a refusal').toBeInstanceOf(Error);
expect(err!.code).toBe(testCase.code);
expect(err!.status).toBe(400);
for (const mention of testCase.mustMention) expect(err!.message).toContain(mention);
});
}

it('drives every rejection row the table declares', () => {
// A guard on the filter above: if a rejection row joins the table, this
// list moves and the enrolment is re-read rather than silently skipping it.
expect(REJECTION_CASES.map((c) => c.name)).toEqual([
'$regex is REFUSED, and the refusal names $icontains',
'$regex with $options is REFUSED as one mistake, not two',
'a dangling $options with no $regex is REFUSED',
'an empty $icontains comparand is REFUSED',
'a non-string $icontains comparand is REFUSED',
]);
});
});

// ── The matcher is not the thing being tested, so it gets tested ────────────

describe('[#6682] the in-process matcher discriminates', () => {
it('a folding document FAILS the case-sensitive rows — the #6682 defect itself', () => {
// This is the exact document `translateFilter` emitted before this change.
// If the sweep above can pass with this, it is proving nothing.
expect(selectIds({ name: { $regex: 'acme', $options: 'i' } })).toEqual(['1', '2']);
expect(selectIds({ name: { $regex: 'acme' } })).toEqual(['2']);
});

it('models the per-field $not rather than treating it as true', () => {
expect(selectIds({ name: { $not: { $regex: 'acme' } } })).toEqual([
'1', '3', '4', '5', '6', '7', '8', '9',
]);
});

it('refuses any operator it does not model, rather than answering true', () => {
expect(() => selectIds({ name: { $unmodelled: 1 } })).toThrow(/unsupported field operator/);
expect(() => selectIds({ $where: 'true' })).toThrow(/unsupported document operator/);
});
});
53 changes: 43 additions & 10 deletions packages/drivers/driver-mongodb/src/mongodb-filter.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,30 +85,61 @@ describe('MongoDB Filter Translator', () => {
});
});

/**
* [#6682] Every pin in here used to carry `$options: 'i'` beside its pattern.
* That flag was MongoDB's full-Unicode fold, and #4706 Q2 = A rules this
* family case-SENSITIVE on every backend — so the assertions are not
* "`$options` deleted", they are the ROW SET the flag was widening: the pins
* below assert the emitted pattern, and `mongodb-filter-text-conformance.
* test.ts` asserts which of `FILTER_TEXT_ROWS` it then selects (`$contains:
* 'acme'` no longer returns `ACME Corp`).
*
* `escapeRegex` is untouched and still pinned here: the comparand is LITERAL
* whichever way case is answered, and conflating the two is how a "case fix"
* turns `a.b` back into a metacharacter.
*/
describe('string operators', () => {
it('translates $contains to $regex', () => {
it('translates $contains to a case-SENSITIVE $regex — no $options (#6682)', () => {
const result = translateFilter({ name: { $contains: 'test' } });
expect(result).toEqual({ name: { $regex: 'test', $options: 'i' } });
expect(result).toEqual({ name: { $regex: 'test' } });
});

it('translates $startsWith to ^prefix regex', () => {
it('translates $startsWith to a case-SENSITIVE ^prefix regex (#6682)', () => {
const result = translateFilter({ name: { $startsWith: 'Pre' } });
expect(result).toEqual({ name: { $regex: '^Pre', $options: 'i' } });
expect(result).toEqual({ name: { $regex: '^Pre' } });
});

it('translates $endsWith to suffix$ regex', () => {
it('translates $endsWith to a case-SENSITIVE suffix$ regex (#6682)', () => {
const result = translateFilter({ email: { $endsWith: '.com' } });
expect(result).toEqual({ email: { $regex: '\\.com$', $options: 'i' } });
expect(result).toEqual({ email: { $regex: '\\.com$' } });
});

it('escapes special regex characters', () => {
const result = translateFilter({ name: { $contains: 'a.b+c' } });
expect(result).toEqual({ name: { $regex: 'a\\.b\\+c', $options: 'i' } });
expect(result).toEqual({ name: { $regex: 'a\\.b\\+c' } });
});

it('translates $notContains', () => {
it('translates $notContains — the negation is case-SENSITIVE too (#6682)', () => {
const result = translateFilter({ name: { $notContains: 'spam' } });
expect(result).toEqual({ name: { $not: { $regex: 'spam', $options: 'i' } } });
expect(result).toEqual({ name: { $not: { $regex: 'spam' } } });
});

/**
* The flag is gone from the whole family, asserted as an absence rather
* than arm by arm: `$options` is a RETIRED operator (#5702), so a future
* arm reintroducing it would be emitting a spelling this same driver
* refuses on input.
*/
it('emits no $options anywhere in the family (#6682)', () => {
for (const filter of [
{ name: { $contains: 'acme' } },
{ name: { $startsWith: 'acme' } },
{ name: { $endsWith: 'acme' } },
{ name: { $notContains: 'acme' } },
{ name: { $icontains: 'acme' } },
] as const) {
expect(JSON.stringify(translateFilter(filter))).not.toContain('$options');
}
});
});

Expand DownExpand Up@@ -198,7 +229,9 @@ describe('MongoDB Filter Translator', () => {
['<', ['age', '<', 65], { age: { $lt: 65 } }],
['<=', ['score', '<=', 100], { score: { $lte: 100 } }],
['in', ['status', 'in', ['active', 'pending']], { status: { $in: ['active', 'pending'] } }],
['contains', ['name', 'contains', 'test'], { name: { $regex: 'test', $options: 'i' } }],
// [#6682] Case-SENSITIVE, like the object spelling it lowers to — the
// authoring sugar must not be a second answer to #4706 Q2.
['contains', ['name', 'contains', 'test'], { name: { $regex: 'test' } }],
['implicit AND list', [['name', '=', 'Alice'], ['age', '>', 18]],
{ $and: [{ name: 'Alice' }, { age: { $gt: 18 } }] }],
// PREFIX is the declared spelling of a logical join. The infix form this
Expand Down
Loading
Loading