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/search-case-insensitive-icontains.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
'@objectstack/objectql': patch
'@objectstack/metadata-protocol': patch
---

fix(search): `$search` compiles to `$icontains`, so textual matching is actually case-insensitive

`$search` was case-SENSITIVE on textual fields, contrary to three places that
all declared the opposite: the `search.cross-field-object-search` checklist item
title, `search-filter.ts`'s own docblock (*"Matching: case-insensitive"*), and
the `search-conformance` ledger row. Searching `Retail` returned "Acme Retail";
searching `retail` returned nothing.

The cause was operator choice, not operator behaviour. `fieldClausesForTerm`
emitted `{field: {$contains: term}}`, and `$contains` is contractually
case-SENSITIVE (#4706 Q2 = A) — `$icontains` is the case-insensitive one.
SQLite's `LIKE` used to fold ASCII incidentally and hid the mismatch; #6518's
`LIKE`→`GLOB` change removed that accident and exposed it.

**Nothing about either operator changed.** `$contains` remains case-sensitive
and `$icontains` remains the ASCII-folding twin; only which one `$search`
compiles to moved. Every filter backend already answers `$icontains` (#6520 /
#6682), so no driver changes were needed.

Fixed in both producers of search clauses:

- `objectql` `search-filter.ts` — per-object `find({ $search })`, for textual
fields and for the select raw-value fallback.
- `metadata-protocol` `searchAll` — the global-search palette behind
`GET /api/v1/search`, which built the same AND-of-OR from `$contains` under a
comment asserting `$contains` was the case-insensitive operator.

Deliberately unchanged: the select label→value path (`optionValuesMatching`
folds in JS and emits an exact-value `$in`), and the `__search` companion
clause, which stays `$contains` because both of its sides are already lowercase.

The three declarations are reconciled with the behaviour, and the dogfood pin
that stayed green through the whole defect — its only case assertion was a
select label, which passes on a case-sensitive build — now carries the
`['name']`-narrowed lowercase-vs-capitalized assertion that catches it.
26 changes: 14 additions & 12 deletions docs/qa/platform-checklist/areas/search.json

Large diffs are not rendered by default.

110 changes: 110 additions & 0 deletions packages/metadata-protocol/src/protocol.search-case-fold.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#7641] `searchAll` — the global-search palette — is the SECOND producer of
// search clauses, and it carried the same declared≠enforced defect as
// objectql's `search-filter.ts`: it built its AND-of-OR from `$contains` under
// a comment asserting that `$contains` was "case-insensitive substring
// matching". It is not — #4706 Q2 = A rules the `$contains` family case-
// SENSITIVE, and `$icontains` is the operator that folds.
//
// The two producers were found and fixed together, but they are NOT one code
// path: `search-filter.ts` serves per-object `find({ $search })` and this one
// serves `GET /api/v1/search`. `search.console-global-search`'s knownGaps had
// already recorded that the palette inherits the gap and that #7641 owns it.
//
// Why this asserts on the FILTER handed to `engine.find` rather than on matched
// rows: the fake below deliberately does not implement filtering, so a row
// assertion here would pass against any filter at all. The operator IS the
// contract this producer is responsible for — every backend that executes it is
// separately conformance-checked against `$icontains` (`FILTER_TEXT_CASES`).

import { describe, it, expect, vi } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';

interface SearchRow {
id: string;
name: string;
updated_at: string;
}

/** The shape `searchAll` builds: `{$or:[…]}`, or `{$and:[{$or:[…]}, …]}`. */
type SearchFilter = Record<string, unknown>;

interface FindOptions {
where?: SearchFilter;
orderBy?: Array<{ field: string; order?: string }>;
limit?: number;
}

const ROWS: SearchRow[] = [
{ id: 'c1', name: 'Acme Retail', updated_at: '2024-01-01T00:00:00.000Z' },
{ id: 'c2', name: 'Northwind', updated_at: '2024-02-01T00:00:00.000Z' },
];

const CONTACT = {
name: 'contact',
fields: { name: { name: 'name', type: 'text', searchable: true } },
};

function makeProtocol(): {
p: ObjectStackProtocolImplementation;
find: ReturnType<typeof vi.fn>;
} {
// No filtering: see the header — what is under test is the filter this
// producer EMITS, so the double must not be able to satisfy an assertion
// by filtering correctly on its own.
const find = vi.fn(async (_object: string, _opts: FindOptions = {}) => ROWS);
const engine = {
registry: {
getObject: (n: string) => (n === 'contact' ? CONTACT : undefined),
getAllObjects: () => [CONTACT],
},
find,
};
return { p: new ObjectStackProtocolImplementation(engine as never), find };
}

/** The filter the protocol handed to `engine.find` on its first call. */
function filterFrom(find: ReturnType<typeof vi.fn>): SearchFilter {
const opts = find.mock.calls[0][1] as FindOptions;
return opts.where as SearchFilter;
}

describe('[#7641] searchAll compiles to the case-folding operator', () => {
it('emits $icontains — never the case-SENSITIVE $contains', async () => {
const { p, find } = makeProtocol();
await p.searchAll({ q: 'retail', perObject: 5 });

expect(filterFrom(find)).toEqual({ $or: [{ name: { $icontains: 'retail' } }] });
// Spelled separately so a regression reads as "went back to the
// case-sensitive operator" rather than as an object-shape diff.
expect(JSON.stringify(filterFrom(find))).not.toContain('$contains');
});

it('picks the operator by field type, not by the term\'s own casing', async () => {
// The defect was invisible whenever the term's casing happened to match
// the stored value. Both spellings must compile the same way — folding
// is the operator's job, not the caller's.
const lower = makeProtocol();
await lower.p.searchAll({ q: 'retail', perObject: 5 });
const upper = makeProtocol();
await upper.p.searchAll({ q: 'Retail', perObject: 5 });

expect(filterFrom(lower.find)).toEqual({ $or: [{ name: { $icontains: 'retail' } }] });
expect(filterFrom(upper.find)).toEqual({ $or: [{ name: { $icontains: 'Retail' } }] });
});

it('folds every term of a multi-term query, which stays AND-of-OR', async () => {
const { p, find } = makeProtocol();
await p.searchAll({ q: 'acme retail', perObject: 5 });

// Term semantics are untouched by #7641 — asserted here so the operator
// change is pinned as operator-only.
expect(filterFrom(find)).toEqual({
$and: [
{ $or: [{ name: { $icontains: 'acme' } }] },
{ $or: [{ name: { $icontains: 'retail' } }] },
],
});
});
});
11 changes: 9 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7408,9 +7408,16 @@ export class ObjectStackProtocolImplementation implements
objectsScanned++;

// Build AND-of-OR filter: every term must hit at least one field.
// ObjectQL exposes case-insensitive substring matching via `$contains`.
// [#7641] Case-insensitive substring matching is `$icontains`, NOT
// `$contains` — the comment this replaced asserted the opposite and
// was the same declared≠enforced defect as `search-filter.ts`'s
// (`$contains` is contractually case-SENSITIVE, #4706 Q2 = A). The
// global-search palette is a SECOND producer of search clauses and
// was wrong the same way; `search.global-search`'s knownGaps already
// recorded it as this issue's to fix. Neither operator's semantics
// changed — only which one the palette compiles to.
const andClauses = terms.map(term => ({
$or: searchableFields.map(f => ({ [f]: { $contains: term } })),
$or: searchableFields.map(f => ({ [f]: { $icontains: term } })),
}));
const where = andClauses.length === 1 ? andClauses[0] : { $and: andClauses };

Expand Down
8 changes: 6 additions & 2 deletions packages/objectql/src/engine-author-state-query.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,8 +71,12 @@ function makeRecordingDriver() {
if (!(v as any).$in.map(String).includes(String(row[k]))) return false;
continue;
}
if (v && typeof v === 'object' && '$contains' in (v as any)) {
const needle = String((v as any).$contains).toLowerCase();
// [#7641] `$icontains` — what `$search` compiles to. This arm folded
// both sides while keyed on `$contains`, so the double already
// answered case-insensitively under the case-SENSITIVE operator's
// name; only the key moved.
if (v && typeof v === 'object' && '$icontains' in (v as any)) {
const needle = String((v as any).$icontains).toLowerCase();
if (!String(row[k] ?? '').toLowerCase().includes(needle)) return false;
continue;
}
Expand Down
20 changes: 15 additions & 5 deletions packages/objectql/src/engine-findone-contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -68,8 +68,14 @@ function makeRecordingDriver() {
if (k === '$and') return (v as any[]).every((w) => matches(row, w));
if (k === '$or') return (v as any[]).some((w) => matches(row, w));
if (k.startsWith('$')) continue;
if (v && typeof v === 'object' && '$contains' in (v as any)) {
const needle = String((v as any).$contains).toLowerCase();
// [#7641] `$icontains` — the operator `$search` compiles to. This
// arm folded BOTH sides while it was still keyed on `$contains`,
// i.e. it implemented `$icontains` semantics under the case-
// SENSITIVE operator's name. That is part of why no unit test on
// this path noticed the compiler was emitting the wrong operator:
// the double answered the way the fixed compiler asks for.
if (v && typeof v === 'object' && '$icontains' in (v as any)) {
const needle = String((v as any).$icontains).toLowerCase();
if (!String(row[k] ?? '').toLowerCase().includes(needle)) return false;
continue;
}
Expand DownExpand Up@@ -146,11 +152,14 @@ describe('findOne executes what it declares and refuses an empty predicate (#441
expect(row?.id).not.toBe(one.id);
});

it('the search term reaches the driver as a $contains predicate — `search` never does', async () => {
it('the search term reaches the driver as an $icontains predicate — `search` never does', async () => {
await engine.findOne('crm_account', { search: 'Two' });
const { ast } = lastRead();
expect(ast.where).toBeTruthy();
expect(JSON.stringify(ast.where)).toContain('$contains');
// [#7641] `$icontains`, not `$contains`: the latter is contractually
// case-SENSITIVE (#4706 Q2 = A), so `$search` compiling to it made
// matching case-sensitive against three declarations saying otherwise.
expect(JSON.stringify(ast.where)).toContain('$icontains');
expect(ast.search).toBeUndefined();
expect(ast.searchFields).toBeUndefined();
});
Expand DownExpand Up@@ -332,7 +341,8 @@ describe('findOne executes what it declares and refuses an empty predicate (#441
call: { search: 'Two' },
expect: ({ ast }) => {
expect(ast.search).toBeUndefined();
expect(JSON.stringify(ast.where)).toContain('$contains');
// [#7641] the case-folding operator, not the case-sensitive one.
expect(JSON.stringify(ast.where)).toContain('$icontains');
},
},
searchFields: {
Expand Down
6 changes: 4 additions & 2 deletions packages/objectql/src/engine.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -246,8 +246,10 @@ describe('ObjectQL Engine', () => {
await engine.find('account', { search: 'retail' });

const ast = (mockDriver.find as any).mock.calls.at(-1)[1];
// [#7641] `$icontains` on the textual field — the case-folding
// operator — while the select label→value path stays an exact `$in`.
expect(ast.where).toEqual({ $or: [
{ name: { $contains: 'retail' } },
{ name: { $icontains: 'retail' } },
{ industry: { $in: ['retail'] } },
] });
expect(ast.search).toBeUndefined();
Expand All@@ -264,7 +266,7 @@ describe('ObjectQL Engine', () => {

const ast = (mockDriver.find as any).mock.calls.at(-1)[1];
expect(ast.where.$and).toContainEqual({ status: 'active' });
expect(ast.where.$and).toContainEqual({ $or: [{ name: { $contains: 'acme' } }] });
expect(ast.where.$and).toContainEqual({ $or: [{ name: { $icontains: 'acme' } }] });
});
});

Expand Down
14 changes: 10 additions & 4 deletions packages/objectql/src/query-expression-conformance.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,8 +125,11 @@ function makeStubDriver() {
if (!v.every((arm) => matchesWhere(row, arm))) return false;
continue;
}
// [#4254] `$or` + `$contains` are the shape the engine expands
// `search` into (an `$or` of case-insensitive `$contains`, ADR-0061).
// [#4254] `$or` + `$icontains` are the shape the engine expands
// `search` into (an `$or` of case-insensitive `$icontains`,
// ADR-0061). [#7641] It read "`$contains`" until the compiler moved
// onto the operator that actually folds — `$contains` is
// contractually case-SENSITIVE (#4706 Q2 = A).
// Without them the driver would MATCH EVERY ROW for any search, and
// the "searchFields really narrows the row set" controls below would
// hold vacuously against a search that never filtered anything.
Expand All@@ -139,9 +142,12 @@ function makeStubDriver() {
if (!(v as any).$in.map(String).includes(String(row[k]))) return false;
continue;
}
if (v && typeof v === 'object' && '$contains' in (v as any)) {
// [#7641] Keyed on `$icontains` since the compiler emits it. The
// body is unchanged — it already folded both sides, which is
// `$icontains`' semantics wearing `$contains`' name.
if (v && typeof v === 'object' && '$icontains' in (v as any)) {
const haystack = String(row[k] ?? '').toLowerCase();
if (!haystack.includes(String((v as any).$contains).toLowerCase())) return false;
if (!haystack.includes(String((v as any).$icontains).toLowerCase())) return false;
continue;
}
const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
Expand Down
9 changes: 7 additions & 2 deletions packages/objectql/src/search-companion.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,9 +142,14 @@ describe('expandSearchToFilter with companion column (query-time, additive)', ()

it('ORs the companion clause for latin terms (lowercased)', () => {
const filter = expandSearchToFilter('ZhangWei', { fields });
// [#7641] The two clauses use DIFFERENT operators, on purpose. The
// companion is a normalized blob — lowercase by construction on the column
// side and lowercased here on the term side — so case-SENSITIVE
// `$contains` over two already-folded values is exact.
expect(filter.$or).toContainEqual({ [SEARCH_COMPANION_FIELD]: { $contains: 'zhangwei' } });
// Source-field clauses are untouched alongside it.
expect(filter.$or).toContainEqual({ name: { $contains: 'ZhangWei' } });
// The SOURCE field compares against raw stored text, so it needs the
// folding operator; it carries the term at the caller's own casing.
expect(filter.$or).toContainEqual({ name: { $icontains: 'ZhangWei' } });
});

it('skips the companion clause for CJK and letterless terms', () => {
Expand Down
Loading
Loading