diff --git a/.changeset/analytics-text-family-case-exact-per-dialect.md b/.changeset/analytics-text-family-case-exact-per-dialect.md new file mode 100644 index 0000000000..54df4f9997 --- /dev/null +++ b/.changeset/analytics-text-family-case-exact-per-dialect.md @@ -0,0 +1,18 @@ +--- +"@objectstack/service-analytics": minor +"@objectstack/driver-sql": minor +--- + +The analytics SQL compilers compile the case-sensitive text family per dialect, so a `$contains` policy on SQLite stops admitting rows it excludes (#15684) + +`$contains` / `$notContains` / `$startsWith` / `$endsWith` are case-SENSITIVE on every backend (#4706 Q2 = A). All three of `service-analytics`' SQL compilers emitted `col LIKE ? ESCAPE ?` on every dialect, and SQLite's `LIKE` folds ASCII case unconditionally — the fold cannot be turned off per statement, because `PRAGMA case_sensitive_like` is a connection-global switch. Measured on sql.js over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` answered `['1','2']` — `ACME Corp` **and** `acme corp` — where `FILTER_TEXT_CASES` says `['2']`. + +On two of the three compilers that is a wrong chart. The third is `read-scope-sql.ts`, the ADR-0021 D-C read scope: a scope that **admits** rows the policy's case-sensitive predicate excludes is over-reach, not a loose filter — the same reading that file already applied to its own `LIKE` escaping. The `/analytics/sql` echo was wrong in a third way: it printed `LIKE` while the statement it claims to reproduce ran through a driver that has emitted `GLOB` on the SQLite dialects since #6518. + +What changed: + +- **The construct is chosen per dialect** (`text-match-sql.ts`), arm for arm with `driver-sql`'s own table: `GLOB` on SQLite (case-exact by definition, with its own `*` / `?` / `[` escaped class and no `ESCAPE` clause), `LIKE` over `CAST(… AS BINARY)` on MySQL, and `LIKE` **unchanged** on Postgres, where it is already exactly the ruled semantics. There is no single construct that is case-exact and parses on all three, so the dialect had to become an input rather than a guess. +- **The dialect arrives from the driver that will execute the statement.** New optional `AnalyticsServiceConfig.sqlDialect`, wired by `AnalyticsServicePlugin` from `IDataEngine.getDriverForObject`. `SqlDriver.dialectName` is now public so that answer can be read without a second dialect-resolution table drifting behind the driver's own knex spellings; it is derived and read-only. +- **A host that answers no dialect keeps the `LIKE` it always got** — "cannot answer, do not block". Postgres deployments see byte-identical SQL. + +`$icontains` is untouched: it keeps its own ASCII-only fold on both sides, and collapsing the two families onto one path would hand the case-exact family back the fold the ruling took away from it. `LIKE` escaping is unchanged wherever a `LIKE` is still emitted. diff --git a/packages/drivers/driver-sql/src/sql-driver-12732-varchar-emitter-parity-wiring.test.ts b/packages/drivers/driver-sql/src/sql-driver-12732-varchar-emitter-parity-wiring.test.ts index 4c5125f86e..2bae5bf817 100644 --- a/packages/drivers/driver-sql/src/sql-driver-12732-varchar-emitter-parity-wiring.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-12732-varchar-emitter-parity-wiring.test.ts @@ -23,7 +23,10 @@ import { describe, it, expect, vi } from 'vitest'; import { SqlDriver, type SqlDialectName } from './index.js'; class FakePostgresDriver extends SqlDriver { - protected get dialectName(): SqlDialectName { + // [#15684] `public`, tracking the base: TypeScript refuses an override that + // NARROWS visibility, so a `protected` one here would be a compile error the + // moment the base getter became readable from outside the driver. + public override get dialectName(): SqlDialectName { return 'postgres'; } } diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 2f175564e5..ce45e8ded1 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -10616,8 +10616,27 @@ export class SqlDriver implements IDataDriver { // ── Managed-schema drift & reconcile (#2186) ─────────────────────────────── - /** Canonical dialect name for the drift differ. */ - protected get dialectName(): SqlDialectName { + /** + * Canonical dialect name for the drift differ — and, since #15684, the one + * answer to "which SQL does this driver speak" that anything outside the + * driver may read. + * + * PUBLIC for exactly one consumer: `service-analytics` compiles its own + * statements (an analytics `where`, an ADR-0021 D-C read scope, the + * `/analytics/sql` echo) and executes them through this driver, so it needs + * the same per-dialect construct choices {@link textMatchPredicate} makes — + * a plain `LIKE` folds ASCII case on SQLite, which made a case-SENSITIVE + * `$contains` admit rows the predicate excludes, over-reach (#3948) on the + * read scope. That package depends on no driver and reads this structurally + * through `IDataEngine.getDriverForObject`; exposing the getter is what + * keeps the answer THIS driver's rather than a second dialect-resolution + * table drifting one knex spelling behind {@link SQLITE_EMIT_CLIENTS} and + * friends. + * + * Read-only and derived: there is nothing to set, and `'unknown'` is a real + * answer (a client neither emission set names), not a missing one. + */ + public get dialectName(): SqlDialectName { if (this.isSqlite) return 'sqlite'; if (this.isPostgres) return 'postgres'; if (this.isMysql) return 'mysql'; diff --git a/packages/services/service-analytics/src/__tests__/text-operator-case-exactness.test.ts b/packages/services/service-analytics/src/__tests__/text-operator-case-exactness.test.ts new file mode 100644 index 0000000000..472530ee5b --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/text-operator-case-exactness.test.ts @@ -0,0 +1,421 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15684] The case-EXACT text family on this package's three SQL compilers — + * `NativeSQLStrategy.buildFilterClause` (the query's own `where`), + * `compileScopedFilterToSql` (`read-scope-sql.ts`, the ADR-0021 D-C read scope) + * and the `ObjectQLStrategy` echo of that statement. + * + * ## The contract, and the defect + * + * `FILTER_TEXT_CASES` (#4706 Q2 = A): `$contains` / `$notContains` / + * `$startsWith` / `$endsWith` are case-SENSITIVE on every backend. All three + * compilers emitted `col LIKE ? ESCAPE ?` on every dialect, and SQLite's `LIKE` + * folds ASCII case unconditionally. Measured on sql.js over the shared + * `FILTER_TEXT_ROWS` fixture before the fix, and asserted here as the control + * that keeps this suite honest (`the defect, still reachable…` below): + * `{ name: { $contains: 'acme' } }` answered `['1','2']` — `ACME Corp` AND + * `acme corp` — where the table says `['2']`. + * + * Two of the three make that a wrong chart. The third is a READ SCOPE, where a + * predicate that ADMITS rows the policy excludes is over-reach (#3948), not a + * loose filter — the reading `read-scope-sql.ts` already applies to its own + * LIKE escaping (#5567), applied to its keyword. + * + * ## Why the pins are per DIALECT + * + * No construct is case-exact AND parseable on all three dialects + * (`text-match-sql.ts`'s header carries the four measured dead ends), so the + * dialect is an input — `DatasetScopedStrategyContext.sqlDialect`, answered by + * the driver that will execute the statement. Three cells are therefore pinned + * differently, and the difference is stated rather than blurred: + * + * - **sqlite → EXECUTED.** Every assertion below that names row ids ran on + * sql.js, the same engine `driver-sqlite-wasm` uses. + * - **postgres / no hook → byte-identical text.** `LIKE` is already + * case-exact on Postgres, so the pin is that this PR changed nothing there: + * the emitted SQL and its params are asserted verbatim against the + * pre-#15684 shape. + * - **mysql → NOT MEASURED.** The `CAST(… AS BINARY)` arm is asserted as + * TEXT only. No MySQL server is provisionable in this container — the same + * declared skip `driver-sql`'s own #6518 suite records, not a claimed pass. + * + * ## What holds this package's construct table to `driver-sql`'s + * + * `service-analytics` depends on no driver, so `textMatchPredicate` cannot be + * imported (and is module-private, returning knex bindings, besides). The + * anti-drift mechanism is therefore the one `like-pattern.ts` already uses for + * the escaping half: the last describe below runs the SAME `FILTER_TEXT_CASES` + * rows through a real `SqliteWasmDriver` — a devDependency, never a runtime one + * — on the same engine, and requires the same row sets from both. A third + * hand-copy of the table anywhere is the thing to refuse. + * + * ## What is deliberately NOT changed + * + * `$icontains` (#6520) keeps its own construct on every dialect: it folds BOTH + * sides through `asciiLowerSqlExpr`, and the assertions below pin that the + * emitted text is untouched by the dialect. That fold is `translate()`, which + * SQLite does not have (measured: `no such function: translate` on sql.js + * 1.14.1) — so those statements are pinned as TEXT and are NOT executed here. + * That is a separate defect, filed as #15780, and this suite's `$icontains` + * assertions are the control that must stay unchanged while it is open. + * Escaping (#5567) is likewise unchanged for every `LIKE` arm; the GLOB arm + * brings its OWN escaped character class (`*`, `?`, `[`), which is why the + * second fixture below exists. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { Cube, DriverOptions, FilterCondition } from '@objectstack/spec/data'; +import { FILTER_TEXT_CASES, FILTER_TEXT_ROWS } from '@objectstack/spec/data'; +import type { AnalyticsQuery } from '@objectstack/spec/contracts'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; + +import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js'; +import { ObjectQLStrategy } from '../strategies/objectql-strategy.js'; +import { compileScopedFilterToSql } from '../read-scope-sql.js'; +import type { DatasetScopedStrategyContext } from '../strategies/types.js'; +import { escapeGlobPattern, globPattern, normalizeSqlDialect } from '../text-match-sql.js'; + +/** The four operators #4706 Q2 = A rules case-SENSITIVE. */ +const CASE_EXACT_OPS = new Set(['$contains', '$notContains', '$startsWith', '$endsWith']); + +/** + * The shared table's rows that aim a case-EXACT operator at the TEXT column. + * + * Taken from `FILTER_TEXT_CASES` rather than restated, so this suite answers the + * same standard the five drivers answer. The count is asserted below: a row + * added to the family upstream must reach this face too, not silently widen the + * filter here. + */ +const NAME_CASE_EXACT = FILTER_TEXT_CASES.filter( + (c): c is Extract => { + if (c.expectRejection === true) return false; + const entries = Object.entries(c.filter as Record); + if (entries.length !== 1) return false; + const [field, predicate] = entries[0]; + if (field !== 'name' || typeof predicate !== 'object' || predicate === null) return false; + const op = Object.keys(predicate as Record)[0]; + return CASE_EXACT_OPS.has(op); + }, +); + +const CUBE: Cube = { + name: 'texts', + title: 'Texts', + sql: 'rows', + measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + dimensions: { + id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, + name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, + }, + public: false, +} as unknown as Cube; + +/** A second cube over the GLOB-metacharacter fixture — see `GLOB_ROWS`. */ +const GLOB_CUBE: Cube = { + ...(CUBE as unknown as Record), + name: 'globs', + sql: 'globrows', +} as unknown as Cube; + +/** + * The GLOB metacharacters, each paired with a decoy that differs from it by + * exactly that character's wildcard reading — the same discipline + * `FILTER_TEXT_ROWS` applies to `%` and `_`. + * + * `FILTER_TEXT_ROWS` cannot carry these: `*`, `?` and `[` are ordinary + * characters to `LIKE`, so they are meaningless on four of the five drivers + * that answer that table. They become live the moment a compiler emits `GLOB`, + * and an unescaped `*` is the same filter bypass an unescaped `%` is under LIKE + * (#5567) — on a read scope, over-reach. + */ +const GLOB_ROWS = [ + { id: 'g1', name: 'a*b' }, + { id: 'g2', name: 'a?b' }, + { id: 'g3', name: 'a[b' }, + { id: 'g4', name: 'axb' }, + { id: 'g5', name: 'aXb' }, + { id: 'g6', name: 'ab' }, +]; + +const query = (where: unknown, cube = 'texts'): AnalyticsQuery => + ({ cube, measures: ['total'], dimensions: ['id'], timezone: 'UTC', where }) as AnalyticsQuery; + +/** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */ +async function locateWasm(): Promise<((file: string) => string) | undefined> { + try { + const { createRequire } = await import('node:module'); + const require = createRequire(import.meta.url); + const pkgJsonPath = require.resolve('sql.js/package.json'); + const { dirname, join } = await import('node:path'); + return (file: string) => join(dirname(pkgJsonPath), 'dist', file); + } catch { + return undefined; + } +} + +describe('[#15684] the per-dialect construct table', () => { + it('normalises exactly the three dialects it has arms for; everything else is `unknown`', () => { + for (const d of ['sqlite', 'postgres', 'mysql']) expect(normalizeSqlDialect(d), d).toBe(d); + // "Cannot answer, do not block": an unmodelled name must not pick an arm. + for (const d of ['mssql', 'oracle', 'unknown', 'SQLite', '']) { + expect(normalizeSqlDialect(d), d).toBe('unknown'); + } + expect(normalizeSqlDialect(undefined)).toBe('unknown'); + expect(normalizeSqlDialect(null)).toBe('unknown'); + }); + + it('escapes the GLOB metacharacters, and ONLY those — the LIKE class is a different one', () => { + // Character for character `driver-sql`'s `escapeGlobComparand`. `]` gets no + // escape on purpose: every `[` becomes a class that closes itself. + expect(escapeGlobPattern('a*b')).toBe('a[*]b'); + expect(escapeGlobPattern('a?b')).toBe('a[?]b'); + expect(escapeGlobPattern('a[b')).toBe('a[[]b'); + expect(escapeGlobPattern('a]b')).toBe('a]b'); + // `%`, `_` and `\` are ORDINARY to GLOB — escaping them here would search + // for a backslash that is not in the data. + expect(escapeGlobPattern('100%')).toBe('100%'); + expect(escapeGlobPattern('a_b')).toBe('a_b'); + expect(escapeGlobPattern('a\\b')).toBe('a\\b'); + expect(globPattern('contains', 'a*b')).toBe('*a[*]b*'); + expect(globPattern('starts', 'a*b')).toBe('a[*]b*'); + expect(globPattern('ends', 'a*b')).toBe('*a[*]b'); + }); +}); + +describe('[#15684] the compiled TEXT, per dialect', () => { + const ctxFor = (dialect?: string): DatasetScopedStrategyContext => + ({ + getCube: (name: string) => (name === 'texts' ? CUBE : undefined), + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }), + ...(dialect ? { sqlDialect: () => dialect } : {}), + }) as DatasetScopedStrategyContext; + + const nativeSql = async (where: unknown, dialect?: string) => + new NativeSQLStrategy().generateSql(query(where), ctxFor(dialect)); + + it('postgres, and a host that wired NO hook, keep the pre-#15684 bytes exactly', async () => { + // The non-regression half. `LIKE` is already case-exact on Postgres, so the + // correct diff there is no diff at all — asserted verbatim, not by shape. + for (const dialect of [undefined, 'postgres'] as const) { + const out = await nativeSql({ name: { $contains: 'acme' } }, dialect); + expect(out.sql, String(dialect)).toContain('WHERE name LIKE $1 ESCAPE $2'); + expect(out.params, String(dialect)).toEqual(['%acme%', '\\']); + } + const scope = compileScopedFilterToSql({ name: { $contains: 'acme' } } as FilterCondition, 't'); + expect(scope).toEqual({ sql: '"t"."name" LIKE ? ESCAPE ?', params: ['%acme%', '\\'] }); + expect( + compileScopedFilterToSql({ name: { $contains: 'acme' } } as FilterCondition, 't', { dialect: 'postgres' }), + ).toEqual({ sql: '"t"."name" LIKE ? ESCAPE ?', params: ['%acme%', '\\'] }); + }); + + it('sqlite compiles GLOB — one bound value, no ESCAPE clause', async () => { + const out = await nativeSql({ name: { $contains: 'acme' } }, 'sqlite'); + expect(out.sql).toContain('WHERE name GLOB $1'); + expect(out.sql).not.toMatch(/ESCAPE/); + expect(out.params).toEqual(['*acme*']); + expect(compileScopedFilterToSql({ name: { $contains: 'acme' } } as FilterCondition, 't', { dialect: 'sqlite' })) + .toEqual({ sql: '"t"."name" GLOB ?', params: ['*acme*'] }); + // `$notContains` keeps the read scope's NULL-safe wrapper around the + // negated construct — the polarity moved, the #5298 rule did not. + expect(compileScopedFilterToSql({ name: { $notContains: 'acme' } } as FilterCondition, 't', { dialect: 'sqlite' })) + .toEqual({ sql: '("t"."name" IS NULL OR "t"."name" NOT GLOB ?)', params: ['*acme*'] }); + const starts = await nativeSql({ name: { $startsWith: 'ACME' } }, 'sqlite'); + expect(starts.params).toEqual(['ACME*']); + const ends = await nativeSql({ name: { $endsWith: 'corp' } }, 'sqlite'); + expect(ends.params).toEqual(['*corp']); + }); + + it('mysql compiles LIKE over CAST(… AS BINARY) — TEXT ONLY, NOT MEASURED on a server', async () => { + const out = await nativeSql({ name: { $contains: 'acme' } }, 'mysql'); + expect(out.sql).toContain('WHERE CAST(name AS BINARY) LIKE CAST($1 AS BINARY) ESCAPE $2'); + expect(out.params).toEqual(['%acme%', '\\']); + expect(compileScopedFilterToSql({ name: { $contains: 'acme' } } as FilterCondition, 't', { dialect: 'mysql' })) + .toEqual({ sql: 'CAST("t"."name" AS BINARY) LIKE CAST(? AS BINARY) ESCAPE ?', params: ['%acme%', '\\'] }); + }); + + it('$icontains is untouched by the dialect — the fold arm still emits translate() on both sides', async () => { + // The control that must stay green. #6520's construct is case-INSENSITIVE + // by ruling, so it never wants the case-exact table; if a future edit routes + // it through `text-match-sql.ts`, the `$contains` family gets back the fold + // #4706 Q2 = A took away from it and this line reds first. + for (const dialect of [undefined, 'sqlite', 'postgres', 'mysql'] as const) { + const out = await nativeSql({ name: { $icontains: 'acme' } }, dialect); + expect(out.sql, String(dialect)).toContain( + "WHERE translate(name, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') LIKE translate($1,", + ); + expect(out.sql, String(dialect)).toContain('ESCAPE $2'); + expect(out.params, String(dialect)).toEqual(['%acme%', '\\']); + } + expect( + compileScopedFilterToSql({ name: { $icontains: 'acme' } } as FilterCondition, 't', { dialect: 'sqlite' }).params, + ).toEqual(['%acme%', '\\']); + }); +}); + +describe('[#15684] the three compilers, EXECUTED on a real SQLite engine', () => { + let db: any; + /** The host that answers the dialect — what the plugin wires from the driver. */ + let sqliteCtx: DatasetScopedStrategyContext; + /** The same host with no dialect hook: the pre-#15684 compiler, still reachable. */ + let unawareCtx: DatasetScopedStrategyContext; + + const run = (sql: string, params: unknown[]): string[] => { + const stmt = db.prepare(sql.replace(/\$\d+/g, '?')); + stmt.bind(params as any[]); + const rows: Record[] = []; + while (stmt.step()) rows.push(stmt.getAsObject()); + stmt.free(); + return rows.map((r) => String(r.id)).sort((a, b) => a.localeCompare(b)); + }; + + beforeAll(async () => { + const mod: any = await import('sql.js'); + const initSqlJs = mod.default ?? mod; + const locateFile = await locateWasm(); + const SQL = await initSqlJs(locateFile ? { locateFile } : undefined); + db = new SQL.Database(); + db.run(`CREATE TABLE "rows" ("id" TEXT PRIMARY KEY, "name" TEXT);`); + const insert = db.prepare(`INSERT INTO "rows" ("id","name") VALUES (?,?)`); + for (const r of FILTER_TEXT_ROWS) insert.run([r.id, r.name]); + insert.free(); + db.run(`CREATE TABLE "globrows" ("id" TEXT PRIMARY KEY, "name" TEXT);`); + const gi = db.prepare(`INSERT INTO "globrows" ("id","name") VALUES (?,?)`); + for (const r of GLOB_ROWS) gi.run([r.id, r.name]); + gi.free(); + + const base = { + getCube: (name: string) => (name === 'texts' ? CUBE : name === 'globs' ? GLOB_CUBE : undefined), + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }), + }; + unawareCtx = { ...base } as DatasetScopedStrategyContext; + sqliteCtx = { ...base, sqlDialect: () => 'sqlite' } as DatasetScopedStrategyContext; + }); + + afterAll(() => { + db?.close(); + }); + + const executedIds = async (where: unknown, ctx: DatasetScopedStrategyContext, cube = 'texts'): Promise => { + const { sql, params } = await new NativeSQLStrategy().generateSql(query(where, cube), ctx); + return run(sql, params); + }; + + it('the fixture is the shared nine rows, and the case-exact family is six of the table\'s cases', () => { + expect(run('SELECT "id" FROM "rows"', [])).toEqual(['1', '2', '3', '4', '5', '6', '7', '8', '9']); + expect(NAME_CASE_EXACT.map((c) => c.name)).toEqual([ + '$contains treats _ as a literal character too', + '$contains is case-SENSITIVE — a lower-case comparand misses the upper-case row', + '$contains is case-SENSITIVE — an upper-case comparand misses the lower-case row', + '$startsWith is case-SENSITIVE', + '$endsWith is case-SENSITIVE', + '$notContains is case-SENSITIVE, and negation does not widen it', + ]); + }); + + it('NativeSQLStrategy answers the shared table\'s case-exact rows', async () => { + for (const c of NAME_CASE_EXACT) { + expect(await executedIds(c.filter, sqliteCtx), c.name).toEqual([...c.expected]); + } + }); + + it('the READ SCOPE answers them too — the compiler where a wrong row set is over-reach', async () => { + for (const c of NAME_CASE_EXACT) { + const scoped = { + ...sqliteCtx, + getReadScope: (object: string) => (object === 'rows' ? (c.filter as FilterCondition) : null), + } as DatasetScopedStrategyContext; + const { sql, params } = await new NativeSQLStrategy().generateSql(query(undefined), scoped); + expect(sql, c.name).toMatch(/GLOB/); + expect(run(sql, params), c.name).toEqual([...c.expected]); + } + }); + + it('the ObjectQL echo prints the statement the native compiler runs', async () => { + for (const c of NAME_CASE_EXACT) { + const echoCtx = { + getCube: sqliteCtx.getCube, + queryCapabilities: sqliteCtx.queryCapabilities, + sqlDialect: () => 'sqlite', + } as DatasetScopedStrategyContext; + const echo = await new ObjectQLStrategy().generateSql(query(c.filter), echoCtx); + const native = await new NativeSQLStrategy().generateSql(query(c.filter), sqliteCtx); + expect(echo.sql, c.name).toMatch(/GLOB/); + expect(echo.sql, c.name).not.toMatch(/ LIKE /); + // Same predicate, same bound pattern: an echo that prints LIKE while the + // engine runs GLOB is the #5333 failure this render block exists to stop. + expect(echo.params, c.name).toEqual(native.params); + expect(run(echo.sql, echo.params), `echo of ${c.name}`).toEqual([...c.expected]); + } + }); + + it('the defect, still reachable through a host that answers no dialect — so these pins discriminate', async () => { + // Before-red, stated as the wrong answer rather than argued: the same query, + // the same engine, the pre-#15684 compiler. + expect(await executedIds({ name: { $contains: 'acme' } }, unawareCtx)).toEqual(['1', '2']); + expect(await executedIds({ name: { $startsWith: 'ACME' } }, unawareCtx)).toEqual(['1', '2']); + expect(await executedIds({ name: { $endsWith: 'corp' } }, unawareCtx)).toEqual(['1', '2']); + expect(await executedIds({ name: { $notContains: 'acme' } }, unawareCtx)).toEqual(['3', '4', '5', '6', '7', '8', '9']); + // …and the same four, case-exact, once the dialect is answered. + expect(await executedIds({ name: { $contains: 'acme' } }, sqliteCtx)).toEqual(['2']); + expect(await executedIds({ name: { $startsWith: 'ACME' } }, sqliteCtx)).toEqual(['1']); + expect(await executedIds({ name: { $endsWith: 'corp' } }, sqliteCtx)).toEqual(['2']); + }); + + it('the LIKE metacharacters stay LITERAL under GLOB — where they are ordinary characters', async () => { + // #5567's rows, re-run on the new construct. `%` and `_` are not GLOB + // wildcards, so the escaping that matters here is the absence of the LIKE + // one: escaping them would search for a backslash that is not in the data. + expect(await executedIds({ name: { $contains: '100%' } }, sqliteCtx)).toEqual(['5']); + expect(await executedIds({ name: { $contains: 'a_b' } }, sqliteCtx)).toEqual(['7']); + expect(await executedIds({ name: { $contains: 'a.b' } }, sqliteCtx)).toEqual(['9']); + expect(await executedIds({ name: { $endsWith: '% match' } }, sqliteCtx)).toEqual(['5']); + }); + + it('the GLOB metacharacters are escaped — an unescaped `*` is the same bypass an unescaped `%` is', async () => { + expect(await executedIds({}, sqliteCtx, 'globs')).toEqual(['g1', 'g2', 'g3', 'g4', 'g5', 'g6']); + // Unescaped, `*a*b*` matches all six; `*a?b*` matches five; `*a[b*` is an + // unclosed class. Each exact set is therefore a real discrimination. + expect(await executedIds({ name: { $contains: 'a*b' } }, sqliteCtx, 'globs')).toEqual(['g1']); + expect(await executedIds({ name: { $contains: 'a?b' } }, sqliteCtx, 'globs')).toEqual(['g2']); + expect(await executedIds({ name: { $contains: 'a[b' } }, sqliteCtx, 'globs')).toEqual(['g3']); + expect(await executedIds({ name: { $startsWith: 'a*' } }, sqliteCtx, 'globs')).toEqual(['g1']); + expect(await executedIds({ name: { $endsWith: '*b' } }, sqliteCtx, 'globs')).toEqual(['g1']); + // …and case exactness holds on this fixture too. + expect(await executedIds({ name: { $contains: 'axb' } }, sqliteCtx, 'globs')).toEqual(['g4']); + expect(await executedIds({ name: { $notContains: 'axb' } }, sqliteCtx, 'globs')).toEqual(['g1', 'g2', 'g3', 'g5', 'g6']); + }); +}); + +/** + * The anti-drift pin: this package's construct table against the DRIVER's, run + * rather than compared. + * + * `driver-sqlite-wasm` inherits `driver-sql`'s compiler, so its answers are + * `textMatchPredicate`'s answers on the engine this container can run. Both + * faces are handed the same table and must return the same rows — which is what + * makes "the same construct table, re-emitted through this package's + * placeholder plumbing" a checkable claim rather than a comment. + */ +describe('[#15684] this package and driver-sql answer the shared table alike on SQLite', () => { + let driver: SqliteWasmDriver; + const BYPASS: DriverOptions = { bypassTenantAudit: true }; + + beforeAll(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.initObjects([{ name: 'txt', fields: { name: { type: 'string' }, score: { type: 'number' } } }]); + for (const row of FILTER_TEXT_ROWS) await driver.create('txt', { ...row }, BYPASS); + }); + + afterAll(async () => { + await driver.disconnect(); + }); + + it('every case-exact row: same ids from the driver and from this package\'s compilers', async () => { + for (const c of NAME_CASE_EXACT) { + const rows = await driver.find('txt', { where: c.filter as FilterCondition }, BYPASS); + const driverIds = rows.map((r) => String(r.id)).sort((a, b) => a.localeCompare(b)); + expect(driverIds, `driver: ${c.name}`).toEqual([...c.expected]); + } + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/text-operator-non-text-column.test.ts b/packages/services/service-analytics/src/__tests__/text-operator-non-text-column.test.ts index 4cf595795b..289202ab46 100644 --- a/packages/services/service-analytics/src/__tests__/text-operator-non-text-column.test.ts +++ b/packages/services/service-analytics/src/__tests__/text-operator-non-text-column.test.ts @@ -31,6 +31,18 @@ * A host that wires no `declaredFieldType` gets the `LIKE` it always got — * "cannot answer, do not block" — and a comparand the contract refuses is * refused BEFORE the constant is considered, on every compiler. + * + * ## [#15684] The control that had to avoid the case axis, restored + * + * One assertion below (`$not over the constant composes`) once compared + * against `a.b` rather than `acme`, with a comment saying why: the compiler + * emitted a plain `LIKE`, SQLite folds ASCII case, and a case-bearing + * comparand therefore answered rows 1 AND 2 whether the disjunction worked or + * not. #15684 made the case-EXACT family compile per dialect, so this suite + * now says which dialect its sql.js engine is and the control is back on the + * case axis, answering row 2 alone. `unawareCtx` deliberately keeps the + * dialect-blind configuration: the coercion rows this file exists for were + * measured through it. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -187,7 +199,7 @@ describe('[#14079] the two strategies, on a real SQLite engine over a REAL colum let db: any; /** `ctx` for the raw-SQL strategy, with the declared-type hook a host wires. */ let nativeCtx: DatasetScopedStrategyContext; - /** The same ctx without the hook — the "cannot answer" host. */ + /** The same ctx with NEITHER hook — the "cannot answer" host. */ let unawareCtx: DatasetScopedStrategyContext; let echoCtx: DatasetScopedStrategyContext; @@ -217,8 +229,14 @@ describe('[#14079] the two strategies, on a real SQLite engine over a REAL colum executeRawSql: async (_object: string, sql: string, params: unknown[]) => run(sql, params).rows, }; unawareCtx = { ...base } as DatasetScopedStrategyContext; - nativeCtx = { ...base, declaredFieldType } as DatasetScopedStrategyContext; - echoCtx = { getCube: base.getCube, queryCapabilities: base.queryCapabilities, declaredFieldType } as DatasetScopedStrategyContext; + // [#15684] The engine under this suite IS SQLite, so the host says so — + // the same answer `plugin.ts` reads off the driver that owns the object. + // Without it the case-EXACT family compiles a plain `LIKE`, which folds + // ASCII case here; `unawareCtx` above keeps that configuration on purpose, + // because the coercion this file pins was measured through it. + const sqlDialect = () => 'sqlite'; + nativeCtx = { ...base, declaredFieldType, sqlDialect } as DatasetScopedStrategyContext; + echoCtx = { getCube: base.getCube, queryCapabilities: base.queryCapabilities, declaredFieldType, sqlDialect } as DatasetScopedStrategyContext; }); afterAll(() => { @@ -269,20 +287,25 @@ describe('[#14079] the two strategies, on a real SQLite engine over a REAL colum expect(echo.sql, `echo of ${JSON.stringify(where)}`).not.toMatch(/LIKE/); expect(echo.params, `echo of ${JSON.stringify(where)}`).toEqual([]); } - // The text column beside it still compiles its LIKE on both. + // The text column beside it still compiles a real predicate on both — and + // since #15684 that predicate is the dialect's case-EXACT construct, `GLOB` + // on the SQLite engine this suite runs, with the escaped pattern its own. const native = await new NativeSQLStrategy().generateSql(query({ name: { $contains: 'acme' } }), nativeCtx); - expect(native.sql).toMatch(/LIKE/); - expect(native.params).toEqual(['%acme%', '\\']); + expect(native.sql).toMatch(/GLOB/); + expect(native.params).toEqual(['*acme*']); }); it('$not over the constant composes: every row for !contains, no row for !notContains', async () => { expect(await executedIds({ $not: { score: { $contains: '5' } } }, nativeCtx)).toEqual(ALL_IDS); expect(await executedIds({ $not: { score: { $notContains: '5' } } }, nativeCtx)).toEqual([]); // The constant is one disjunct among ordinary ones: the text column's own - // LIKE still decides the rest. (`a.b`, not `acme`: this compiler emits a - // plain `LIKE`, which folds ASCII case on SQLite — a known property of - // this face, not this card's subject — so the control must not turn on case.) - expect(await executedIds({ $or: [{ score: { $contains: '5' } }, { name: { $contains: 'a.b' } }] }, nativeCtx)).toEqual(['9']); + // predicate still decides the rest. [#15684] The comparand is back on the + // CASE axis — this control had to be steered off it (`a.b`) while the + // compiler emitted a plain `LIKE` that folds ASCII case on SQLite, so + // `acme` answered rows 1 AND 2 and could not distinguish a working + // disjunction from a folding one. It answers row 2 alone now, which is + // both the #4706 Q2 = A contract and a stronger control. + expect(await executedIds({ $or: [{ score: { $contains: '5' } }, { name: { $contains: 'acme' } }] }, nativeCtx)).toEqual(['2']); }); it('the read scope takes the same rule through the same hook', async () => { diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 43b6533193..008dbddee2 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -581,6 +581,24 @@ export interface AnalyticsServiceConfig { * - Date bucketing: a date vs datetime dimension drills by the right bound. */ sourceFieldMeta?: (object: string, field: string) => { type?: string; defaultCurrency?: string; max?: number } | undefined; + /** + * [#15684] The SQL dialect of the datasource backing `object` — `'sqlite'`, + * `'postgres'`, `'mysql'`, or `undefined` when the host cannot answer. + * + * The three SQL compilers need it for ONE thing: the case-EXACT text family + * (`$contains` / `$notContains` / `$startsWith` / `$endsWith`, #4706 Q2 = A) + * has no construct that is case-exact and parses on every dialect. A plain + * `LIKE` is case-exact on Postgres alone — SQLite folds ASCII case + * unconditionally, so the query's own `where` AND the RLS read scope + * admitted rows the predicate excludes there (over-reach, #3948). The + * per-dialect table lives in `text-match-sql.ts`. + * + * Answered by the plugin from the driver that will EXECUTE the statement, so + * the driver stays the single source of truth for its own dialect. A host + * that wires nothing keeps the `LIKE` the compilers always emitted — + * "cannot answer, do not block". + */ + sqlDialect?: (object: string) => string | undefined; /** Pre-defined datasets to compile + register at construction (ADR-0021). */ datasets?: Dataset[]; /** @@ -773,6 +791,10 @@ export class AnalyticsService implements IAnalyticsService { // at compile time. A host that wired no hook answers `undefined`, and // the compilers keep the behaviour they had. declaredFieldType: (object: string, field: string) => config.sourceFieldMeta?.(object, field)?.type, + // [#15684] The dialect that will run the compiled statement, so the + // case-EXACT text family picks a construct that IS case-exact there. + // Same tiering as the hook above: `undefined` keeps today's `LIKE`. + sqlDialect: (object: string) => config.sqlDialect?.(object), }; // Build strategy chain (built-in + custom, sorted by priority) diff --git a/packages/services/service-analytics/src/like-pattern.ts b/packages/services/service-analytics/src/like-pattern.ts index 1f49953851..a18d8ecef7 100644 --- a/packages/services/service-analytics/src/like-pattern.ts +++ b/packages/services/service-analytics/src/like-pattern.ts @@ -80,41 +80,38 @@ * character. A third hand-copy of this logic anywhere is the thing to refuse — * import from here, or add a consumer to that test. * - * ## [#6518] Case sensitivity: why this file still emits a plain `LIKE` - * - * #4706 Q2 = A rules the `$contains` family case-SENSITIVE on every backend, and - * #6518 moved the driver family onto that answer — `SqlDriver`'s - * `textMatchPredicate` now picks the construct per DIALECT, because `LIKE` folds - * ASCII on SQLite and follows the collation on MySQL. The obvious question is - * why the compilers here did not move with it, and the answer is measured - * rather than assumed: - * - * 1. **These compilers emit Postgres-shaped SQL, and on Postgres `LIKE` is - * already exactly the ruled semantics.** Both consumers number their - * placeholders `$1`, `$2`, … (`native-sql-strategy.ts`'s `buildFilterClause` - * and `objectql-strategy.ts`'s filter render), and `applyReadScope` / - * `generateSql` rewrite this file's `?` into `$N` on the way out; - * identifiers are `"double quoted"`. Measured on a live PostgreSQL 16 - * against the shared nine-row fixture: `LIKE '%acme%'` answers row 2 alone - * and `LIKE '%ACME%'` answers row 1 alone — case-exact, which is the - * contract. So there is no divergence to close HERE, and changing the - * construct would create one. - * 2. **The RLS fork the issue warned about does not open.** #6518's concern - * was that a driver-only fix would compile one permission rule into two row - * sets. It does not, because the two paths meet only on Postgres — where - * `textMatchPredicate`'s postgres arm is also a plain `LIKE`, unchanged. - * - * What that reasoning DEPENDS on is the dialect, so it is the thing to re-open - * rather than the code: **if these compilers ever emit for SQLite or MySQL, this - * file is wrong and `$contains` silently over-matches there** — on - * `read-scope-sql.ts`'s output that is ADR-0021 read-scope over-reach, not a - * loose filter (#3948). Two things would have to arrive together: a dialect - * input reaching these three compilers, and the per-dialect construct table - * `textMatchPredicate` already carries. Neither exists today and neither is - * invented here on speculation. `__tests__/like-metacharacter-escape.test.ts` - * pins both halves of the claim — that the emitted statement is - * Postgres-shaped, and that the family is compiled case-EXACT — so this - * paragraph goes red rather than merely stale. + * ## [#6518 / #15684] Case sensitivity: this file builds the PATTERN, not the keyword + * + * #4706 Q2 = A rules the `$contains` family case-SENSITIVE on every backend, + * and #6518 moved the driver family onto that answer — `SqlDriver`'s + * `textMatchPredicate` picks the construct per DIALECT, because `LIKE` folds + * ASCII on SQLite and follows the collation on MySQL. + * + * This file did not move with it, and the argument for staying was written + * here: that these compilers emit Postgres-shaped SQL, so `LIKE` was already + * exactly the ruled semantics and changing the construct would create a + * divergence rather than close one. **That argument was wrong, and #15684 + * measured how.** The placeholders are Postgres-shaped; the STATEMENT is not + * addressed to Postgres. `plugin.ts`'s raw-SQL auto-bridge rewrites `$N` into + * `?` and hands the statement to whichever driver owns the object — so on a + * SQLite datasource these compilers' output runs on SQLite. Measured on sql.js + * over the shared `FILTER_TEXT_ROWS` fixture: `{name: {$contains: 'acme'}}` + * answered `['1','2']` — `ACME Corp` AND `acme corp` — where + * `FILTER_TEXT_CASES` says `['2']`. On `read-scope-sql.ts`'s output that is + * ADR-0021 read-scope over-reach, not a loose filter (#3948). + * + * The two things that had to arrive together arrived together: a dialect input + * reaching these three compilers (`DatasetScopedStrategyContext.sqlDialect`, + * answered by the driver that will execute the statement) and the per-dialect + * construct table (`text-match-sql.ts`, #6518's arm for arm). So the division + * of labour here is now explicit: + * + * - {@link likePattern} / {@link escapeLikePattern} build the LIKE PATTERN, + * and are used by the `LIKE` arms — Postgres, MySQL, and the `unknown` + * residue — plus `$icontains` on every dialect. + * - Which KEYWORD those patterns hang off, and whether a GLOB pattern with a + * different escaped character class is built instead, is + * `text-match-sql.ts`'s answer. ⛔ Do not re-derive it here. * * `$icontains` IS implemented here since #6520, and it is a separate construct * rather than a flag on the family above: it folds ASCII case on BOTH sides via @@ -204,15 +201,22 @@ const ASCII_LOWER_LETTERS = 'abcdefghijklmnopqrstuvwxyz'; * * ## The dialect this assumes, stated so it can go red rather than stale * - * Postgres, like everything else these three compilers emit — the claim this - * file's header already makes and `__tests__/like-metacharacter-escape.test.ts` - * pins. `translate()` is Postgres/Oracle; SQLite has no such function. So the - * warning in the header applies to this helper WORD FOR WORD: if these compilers - * ever emit for SQLite or MySQL, this expression does not merely over-match, it - * fails to parse. The remedy is the per-dialect construct table `driver-sql`'s - * `textMatchPredicate` already carries — nested `REPLACE` for the dialects - * without `translate` — not a quiet fallback to `LOWER()`, which would silently - * restore the Unicode fold this function exists to avoid. + * Postgres. `translate()` is Postgres/Oracle; SQLite has no such function — and + * [#15684] MEASURED that these compilers' statements do reach SQLite, so the + * warning this paragraph used to write in the conditional is now a live defect, + * filed as #15780: on sql.js 1.14.1, `SELECT translate('ABC','ABC','abc')` + * answers `no such function: translate`, so an `$icontains` in an analytics + * `where` or in an RLS read scope over a SQLite datasource does not merely + * over-match — it fails to parse. + * + * ⛔ Do NOT close that by falling back to `LOWER()`, which would silently + * restore the Unicode fold this function exists to avoid. The remedy is one + * more arm on `text-match-sql.ts`'s per-dialect table, whose shapes + * `driver-sql`'s `textMatchPredicate` already carries: `lower(col) GLOB + * lower(?)` on SQLite (measured ASCII-only there — `lower('CAFÉ')` is + * `cafÉ`), nested `REPLACE` over `CAST(… AS BINARY)` on MySQL. #15684 + * deliberately did not build it: its scope was the case-EXACT four, and its + * suite pins THIS expression as the control that must stay unchanged. * * The caller must apply it to BOTH sides of the comparison. Folding only the * comparand compares a folded needle against a raw column and matches just the diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 60da818b87..8074e13470 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -67,6 +67,23 @@ type TemporalDriverSurface = Pick< 'temporalFilterValue' | 'temporalFilterColumnSql' >; +/** + * [#15684] The slice of a SQL driver that NAMES ITS OWN DIALECT — `'sqlite'` / + * `'postgres'` / `'mysql'` / `'unknown'`, `SqlDriver.dialectName`. + * + * Read STRUCTURALLY rather than through `IDataDriver`, unlike + * {@link TemporalDriverSurface} above, and the difference is deliberate: a + * dialect is a property of the SQL driver FAMILY (`SqlDriver` and the three + * drivers that extend it), not of every driver — the memory and mongo drivers + * have no dialect to name, and a contract member they can only answer + * `undefined` to declares a capability the platform does not have. The runtime + * `typeof` guard below is what makes the read safe either way, and the value + * is passed through verbatim: `text-match-sql.ts` normalises an unrecognised + * name to `'unknown'`, which compiles the `LIKE` these compilers always + * emitted. + */ +type DialectNamingDriver = { readonly dialectName?: unknown }; + /** * Re-parse a bridge-supplied aggregation `method` as the engine contract's * `AggregationFunction` before it is forwarded as `function`, refusing @@ -664,6 +681,32 @@ export class AnalyticsServicePlugin implements Plugin { return columnSql; }; + /** + * [#15684] The dialect that will EXECUTE what the three SQL compilers emit. + * + * Asked of the DRIVER that owns the object, through the same + * `getDriverForObject` seam the two temporal hooks above use, because the + * driver is the single source of truth for its own dialect — recomputing + * it here from a datasource config would be a second implementation + * drifting one knex spelling behind the driver's own emission sets. + * + * `undefined` on every tier that cannot answer — no data engine, a driver + * that names no dialect (memory, mongo), a throw — and `undefined` keeps + * the plain `LIKE`, which is exactly the pre-#15684 behaviour. + */ + const sqlDialect = (objectName: string): string | undefined => { + try { + const svc = ctx.getService('data'); + const driver = svc?.getDriverForObject?.(objectName) as DialectNamingDriver | undefined; + const named = driver?.dialectName; + return typeof named === 'string' ? named : undefined; + } catch { + // Same tiering as the temporal hooks: an unresolvable driver keeps the + // dialect-blind construct, which is today's behaviour. + return undefined; + } + }; + const config: AnalyticsServiceConfig = { cubes: this.options.cubes, logger: ctx.logger, @@ -707,6 +750,8 @@ export class AnalyticsServicePlugin implements Plugin { // prevent: it drifts by one step, silently, and the drift only surfaces as // an error message pointing at the wrong database. getObjectDatasource: (objectName: string) => dataEngine()?.resolveEffectiveDatasource?.(objectName), + // [#15684] The executing driver's own dialect — see `sqlDialect` above. + sqlDialect, // ADR-0062 D6 — a federated object carries an `external` block (ADR-0015). // Reported so NativeSQLStrategy declines it (its hand-compiled FROM would // hit the wrong physical table) and the driver-correct ObjectQL path runs. diff --git a/packages/services/service-analytics/src/read-scope-sql.ts b/packages/services/service-analytics/src/read-scope-sql.ts index 8a21323402..94f6bd3084 100644 --- a/packages/services/service-analytics/src/read-scope-sql.ts +++ b/packages/services/service-analytics/src/read-scope-sql.ts @@ -2,7 +2,8 @@ import type { FilterCondition } from '@objectstack/spec/data'; import type { RegisteredErrorCode } from '@objectstack/spec/api'; -import { likePattern, LIKE_ESCAPE_CHAR, asciiLowerSqlExpr } from './like-pattern.js'; +import { likePattern, LIKE_ESCAPE_CHAR, asciiLowerSqlExpr, type LikeShape } from './like-pattern.js'; +import { textMatchPredicateSql, normalizeSqlDialect } from './text-match-sql.js'; import { textOperatorPolarity } from './non-text-column.js'; import { CROSS_FIELD_COMPARISON_OPERATORS, @@ -96,6 +97,23 @@ import { * the file was fail-closed everywhere else — the LIKE family was the one place an * author's literal was silently reinterpreted rather than refused. * + * ## The case-EXACT family is compiled per DIALECT (#15684) + * + * The escaping above closed one half of "the comparand means what the author + * wrote"; the KEYWORD was the other half and kept the same over-reach for two + * more years. `$contains` / `$notContains` / `$startsWith` / `$endsWith` are + * case-SENSITIVE by ruling (#4706 Q2 = A), and a plain `LIKE` is that only on + * Postgres: SQLite folds ASCII case unconditionally and MySQL follows the + * column's collation. Measured on sql.js over the shared `FILTER_TEXT_ROWS` + * fixture, `{name: {$contains: 'acme'}}` compiled here ADMITTED `ACME Corp` — + * a row the policy excludes, which is the #3948 reading this file already + * applies to its escaping, applied to its keyword. + * + * `text-match-sql.ts` carries the per-dialect construct table (#6518's, arm for + * arm), and {@link ReadScopeCompileOptions.dialect} is how the executing + * engine's name reaches it. A caller that does not name a dialect keeps the + * plain `LIKE` — "cannot answer, do not block" — so this is purely additive. + * * ## Every refusal here is a SERVER fault, and says so (#5367, maintainer ruling 2026-08-06) * * The ten fail-closed refusals below were bare `throw new Error(…)`, and @@ -455,6 +473,19 @@ export interface ReadScopeCompileOptions { * cannot classify one (`non-text-column.ts` carries the argument). */ nonTextColumn?: (field: string) => boolean; + /** + * [#15684] The SQL dialect that will EXECUTE this scope — `'sqlite'` / + * `'postgres'` / `'mysql'`. Anything else, including absent, is `'unknown'` + * and keeps the plain `LIKE` this compiler always emitted. + * + * The case-EXACT text family (#4706 Q2 = A) needs it: SQLite's `LIKE` folds + * ASCII case unconditionally, so a policy written `{ name: { $contains: + * 'acme' } }` ADMITTED `ACME Corp` there — rows the predicate excludes, + * which on a read scope is over-reach (#3948), not a loose filter. The + * per-dialect construct table lives in `text-match-sql.ts`; both of this + * compiler's consumers fill this in from the driver that owns the object. + */ + dialect?: string; } /** A node the compiler can walk: a plain object, not `null` and not an array. */ @@ -746,24 +777,38 @@ function bind(params: unknown[], v: unknown): string { } /** - * [#5567] Bind a LIKE pattern together with its escape character: `? ESCAPE ?`. - * - * Both are ordinary bound values, so this whole concern stays inside the - * predicate: `applyReadScope` (`native-sql-strategy.ts`) and `generateSql` - * (`objectql-strategy.ts`) rewrite `?` → `$N` while pushing the matching value - * from `params`, and they carry the escape character for free — neither consumer - * needed a change. A SQL literal `ESCAPE '\'` would have pushed the problem up a - * layer AND been unportable: MySQL strips one backslash inside a string literal, - * so the literal spelling differs per dialect while a bound value does not. - * - * The clause is not optional decoration. SQLite honours no default escape - * character, so the escaped pattern alone would search for a literal backslash - * there and match nothing — the two halves are one fix (see `like-pattern.ts`). + * [#15684] Compile one case-EXACT text predicate for the dialect that will run + * this scope — `text-match-sql.ts` picks the construct, this wrapper supplies + * the placeholder plumbing. + * + * `bind` pushes left to right, which is the order the `?` appear, and both + * consumers of this compiler (`NativeSQLStrategy.applyReadScope`, + * `ObjectQLStrategy.generateSql`) renumber `?` into `$N` while pushing the + * matching value — so a dialect arm that binds ONE value (SQLite's `GLOB`, + * which has no `ESCAPE` clause) is carried by that rewrite with no change at + * the upper layer, exactly as the two-value `LIKE ? ESCAPE ?` shape was. + * + * [#5567] The escaping travels with the construct and is NOT optional: an + * escaped LIKE pattern with no escape character in force is a search for a + * literal backslash on SQLite (no default one there), and the GLOB arm's + * escaped character class is a DIFFERENT one — see `text-match-sql.ts`. */ -function bindLike(params: unknown[], pattern: string): string { - // Left-to-right evaluation of the template puts the pattern in `params` before - // the escape character, which is the order the `?` appear. - return `${bind(params, pattern)} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`; +function textMatch( + col: string, + shape: LikeShape, + val: unknown, + negate: boolean, + params: unknown[], + opts: ReadScopeCompileOptions, +): string { + return textMatchPredicateSql({ + dialect: normalizeSqlDialect(opts.dialect), + column: col, + shape, + value: val, + negate, + bind: (v) => bind(params, v), + }); } /** @@ -1258,16 +1303,19 @@ function compileOperator( return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`; } // [#5567] The comparand is a LITERAL, so it is escaped and the escape - // character is bound with it. See {@link bindLike}. + // character is bound with it. See {@link textMatch}. // [#5234] …and it must be a value `String()` can render, which is asserted - // BEFORE `likePattern` sees it — see {@link assertRenderableText}. - // [#14079] Every LIKE arm asks {@link textOverNonTextColumn} AFTER its + // BEFORE a pattern is built from it — see {@link assertRenderableText}. + // [#14079] Every text arm asks {@link textOverNonTextColumn} AFTER its // comparand gate and BEFORE it binds: a comparand the contract refuses is // still refused, and a column whose stored value is never text gets the - // contract's constant instead of a `LIKE` over a number. + // contract's constant instead of a match over a number. + // [#15684] …and the four case-EXACT arms take their construct from the + // DIALECT ({@link textMatch}): a plain `LIKE` folds ASCII case on SQLite, + // so this scope ADMITTED rows the policy excludes — over-reach (#3948). case '$contains': assertRenderableText(op, field, val); - return textOverNonTextColumn(op, field, opts) ?? `${col} LIKE ${bindLike(params, likePattern('contains', val))}`; + return textOverNonTextColumn(op, field, opts) ?? textMatch(col, 'contains', val, false, params, opts); /** * [#6520] `$icontains` on the READ-SCOPE lowering — the one compiler in this * package where a wrong answer is an ADR-0021 scope over-reach rather than a @@ -1288,10 +1336,11 @@ function compileOperator( assertRenderableText(op, field, val); const gated = textOverNonTextColumn(op, field, opts); if (gated) return gated; - // The two binds are spelled out rather than taken from `bindLike`, because - // only the PATTERN placeholder is folded and the `ESCAPE` one must not be. - // Left-to-right, so the values land in `params` in placeholder order — - // the ordering invariant `bindLike`'s own comment states. + // The two binds are spelled out rather than taken from {@link textMatch}, + // because only the PATTERN placeholder is folded, the `ESCAPE` one must + // not be, and this operator is case-INSENSITIVE by ruling so it never + // wants the per-dialect case-exact construct. Left-to-right, so the + // values land in `params` in placeholder order. const patternRef = asciiLowerSqlExpr(bind(params, likePattern('contains', val))); return `${asciiLowerSqlExpr(col)} LIKE ${patternRef} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`; } @@ -1300,13 +1349,13 @@ function compileOperator( case '$notContains': assertRenderableText(op, field, val); return textOverNonTextColumn(op, field, opts) - ?? nullSafeNegative(col, `${col} NOT LIKE ${bindLike(params, likePattern('contains', val))}`); + ?? nullSafeNegative(col, textMatch(col, 'contains', val, true, params, opts)); case '$startsWith': assertRenderableText(op, field, val); - return textOverNonTextColumn(op, field, opts) ?? `${col} LIKE ${bindLike(params, likePattern('starts', val))}`; + return textOverNonTextColumn(op, field, opts) ?? textMatch(col, 'starts', val, false, params, opts); case '$endsWith': assertRenderableText(op, field, val); - return textOverNonTextColumn(op, field, opts) ?? `${col} LIKE ${bindLike(params, likePattern('ends', val))}`; + return textOverNonTextColumn(op, field, opts) ?? textMatch(col, 'ends', val, false, params, opts); // [#6387] `val` is a boolean here — {@link assertBooleanFlagComparands} // refused anything else at {@link compileField}, before this emitter runs. // So `=== true` is an exhaustive TWO-WAY choice over the declared domain, diff --git a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts index c10d079573..ba8b6a7e07 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -16,6 +16,7 @@ import { assertReadScopeCannotVacate, compileScopedFilterToSql } from '../read-s import { nonTextColumnResolver, textOperatorPolarity } from '../non-text-column.js'; import { datasetInvalidError, invalidMemberError } from '../dataset-refusal.js'; import { likePattern, LIKE_ESCAPE_CHAR, asciiLowerSqlExpr, type LikeShape } from '../like-pattern.js'; +import { textMatchPredicateSql, sqlDialectFor } from '../text-match-sql.js'; import { nextUtcCalendarDay } from '@objectstack/core'; /** @@ -621,8 +622,14 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // over a numeric or boolean column compiles to the contract's constant // rather than a `LIKE` Postgres refuses at query time (a 500 on a scope the // platform accepted). `undefined` when the host wired no field metadata. + // [#15684] …and so does the dialect, so a policy's case-SENSITIVE + // `$contains` compiles to a construct that IS case-exact on the engine that + // will run it. A read scope admitting rows the predicate excludes is + // over-reach (#3948), not a loose filter — and this is the merge site where + // that scope becomes an executed statement. const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias, { nonTextColumn: nonTextColumnResolver(ctx, objectName), + dialect: sqlDialectFor(ctx, objectName), }); // [#13926] The #13640 door guard, at THIS strategy's merge site. This is // not an echo: `execute()` runs this method's output through @@ -1071,6 +1078,9 @@ export class NativeSQLStrategy implements AnalyticsStrategy { ): string | null { const opMap: Record = { equals: '=', notEquals: '!=', gt: '>', gte: '>=', lt: '<', lte: '<=', + // [#15684] For the case-EXACT four these entries are the OPERATOR GATE, + // not the emitted keyword: `text-match-sql.ts` picks `LIKE` or `GLOB` + // per dialect below. `$icontains` still emits the `LIKE` written here. contains: 'LIKE', notContains: 'NOT LIKE', startsWith: 'LIKE', endsWith: 'LIKE', // [#6520] `$icontains` — `LIKE` like its neighbours; what separates it is @@ -1127,20 +1137,36 @@ export class NativeSQLStrategy implements AnalyticsStrategy { if (polarity && nonTextColumnResolver(ctx, target.object)?.(target.field)) { return polarity === 'negative' ? SQL_CONST_TRUE : SQL_CONST_FALSE; } - // [#5567] Escaped pattern AND an explicit `ESCAPE`, bound together: the - // escaping alone would search for a literal backslash on SQLite (no - // default escape character there), the clause alone would change nothing. - params.push(likePattern(shape, values[0])); - const patternRef = `$${params.length}`; - params.push(LIKE_ESCAPE_CHAR); // [#6520] `$icontains` folds ASCII case on BOTH sides. Only this operator // folds: the rest of the family is case-EXACT by ruling (#4706 Q2 = A), // and `objectql-strategy.ts`'s echo of this statement carries the same // `fold` flag on the same single row so the two keep describing one query. + // + // [#5567] Escaped pattern AND an explicit `ESCAPE`, bound together: the + // escaping alone would search for a literal backslash on SQLite (no + // default escape character there), the clause alone would change nothing. if (operator === 'icontains') { + params.push(likePattern(shape, values[0])); + const patternRef = `$${params.length}`; + params.push(LIKE_ESCAPE_CHAR); return `${asciiLowerSqlExpr(rawCol)} ${sqlOp} ${asciiLowerSqlExpr(patternRef)} ESCAPE $${params.length}`; } - return `${rawCol} ${sqlOp} ${patternRef} ESCAPE $${params.length}`; + // [#15684] …and the case-EXACT family picks its construct per DIALECT, + // because a plain `LIKE` folds ASCII case on SQLite and follows the + // collation on MySQL — admitting rows #4706 Q2 = A excludes. `sqlOp` + // above still gates the operator into this branch; which KEYWORD it + // becomes is `text-match-sql.ts`'s answer, not this table's, and the + // escaping travels with it (the GLOB arm escapes a different character + // class and binds no `ESCAPE`). A host that wired no dialect hook + // answers `'unknown'` and keeps the `LIKE` this line always emitted. + return textMatchPredicateSql({ + dialect: sqlDialectFor(ctx, target.object), + column: rawCol, + shape, + value: values[0], + negate: operator === 'notContains', + bind: (v) => { params.push(v); return `$${params.length}`; }, + }); } // A bare-day `lte` bound means "through that whole day" (#3777): compile diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index aa06843b15..f30793f418 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -20,6 +20,7 @@ import { assertReadScopeCannotVacate, compileScopedFilterToSql } from '../read-s import { nonTextColumnResolver, textOperatorPolarity } from '../non-text-column.js'; import { invalidMemberError } from '../dataset-refusal.js'; import { likePattern, LIKE_ESCAPE_CHAR, asciiLowerSqlExpr, type LikeShape } from '../like-pattern.js'; +import { textMatchPredicateSql, sqlDialectFor } from '../text-match-sql.js'; import { nextUtcCalendarDay } from '@objectstack/core'; import { rebucketCrossObject, @@ -81,9 +82,15 @@ const SCALAR_SQL_OPS: Record = { * than the query it claims to reproduce whenever the comparand carried a `_` or * `%` — the #3601 / #3602 / #3650 failure this render block exists to prevent. */ -const LIKE_SQL_OPS: Record = { +const LIKE_SQL_OPS: Record = { + // [#15684] `sql` is the keyword for the FOLDING row only. The four + // case-EXACT rows take their construct from the DIALECT — `text-match-sql.ts` + // emits `GLOB` on SQLite and `LIKE` over `CAST(… AS BINARY)` on MySQL, since + // a plain `LIKE` is case-exact on Postgres alone. `negate` is what survives + // that move: which POLARITY the row is, spelled once, so the construct table + // and not this one decides how the negation is written. contains: { sql: 'LIKE', shape: 'contains' }, - notContains: { sql: 'NOT LIKE', shape: 'contains' }, + notContains: { sql: 'NOT LIKE', shape: 'contains', negate: true }, startsWith: { sql: 'LIKE', shape: 'starts' }, endsWith: { sql: 'LIKE', shape: 'ends' }, // [#6520] `$icontains`: the same escaped pattern and bound `ESCAPE` as its @@ -541,8 +548,12 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // [#14079] The same declared-type rule `NativeSQLStrategy.applyReadScope` // hands the compiler, so the echo prints the constant the executed // statement runs for a text operator over a numeric or boolean column. + // [#15684] …and the same dialect, so the echoed scope prints the + // construct the executed one runs — `GLOB` on SQLite, where a plain + // `LIKE` folds ASCII case and admits rows the policy excludes. const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName, { nonTextColumn: nonTextColumnResolver(ctx, tableName), + dialect: sqlDialectFor(ctx, tableName), }); // [#13926] The same door guard `execute()` trusts (`withReadScope`, // #13640), at the ECHO's own merge — so one read scope gets ONE verdict @@ -1231,16 +1242,30 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // [#5567] Escaped pattern + an explicit `ESCAPE`, matching what // `driver-sql`'s `applyLike` binds for the same operator — so an author // who copies this statement out runs the predicate that ran. - params.push(likePattern(like.shape, values[0])); - const patternRef = `$${params.length}`; - params.push(LIKE_ESCAPE_CHAR); // [#6520] The fold, when the operator carries one, wraps BOTH sides: // folding only the comparand compares a folded needle against a raw column // and returns just the rows that were already lower-case — a wrong row set // that looks like a working predicate. - const lhs = like.fold ? asciiLowerSqlExpr(col) : col; - const rhs = like.fold ? asciiLowerSqlExpr(patternRef) : patternRef; - return `${lhs} ${like.sql} ${rhs} ESCAPE $${params.length}`; + if (like.fold) { + params.push(likePattern(like.shape, values[0])); + const patternRef = `$${params.length}`; + params.push(LIKE_ESCAPE_CHAR); + return `${asciiLowerSqlExpr(col)} ${like.sql} ${asciiLowerSqlExpr(patternRef)} ESCAPE $${params.length}`; + } + // [#15684] The case-EXACT four print what the DIALECT will run. Asked of + // the same hook `NativeSQLStrategy` asks, on the same target, so the echo + // and the executed statement stay one description — the whole reason this + // render block exists (#5333). A caller that handed no target and no + // context cannot be asked (the four-argument shape the operator-coverage + // suite drives), and keeps the `LIKE` it always got. + return textMatchPredicateSql({ + dialect: target && ctx ? sqlDialectFor(ctx, target.object) : 'unknown', + column: col, + shape: like.shape, + value: values[0], + negate: like.negate === true, + bind: (v) => { params.push(v); return `$${params.length}`; }, + }); } const op = SCALAR_SQL_OPS[operator]; diff --git a/packages/services/service-analytics/src/strategies/types.ts b/packages/services/service-analytics/src/strategies/types.ts index 8ff916c3f9..58f1d18d91 100644 --- a/packages/services/service-analytics/src/strategies/types.ts +++ b/packages/services/service-analytics/src/strategies/types.ts @@ -70,4 +70,29 @@ export interface DatasetScopedStrategyContext extends StrategyContext { * hook keeps the behaviour it had — "cannot answer, do not block". */ declaredFieldType?(objectName: string, field: string): string | undefined; + /** + * [#15684] The SQL dialect of the datasource backing `objectName` — + * `'sqlite'` / `'postgres'` / `'mysql'`, or `undefined` when the host cannot + * answer (no data engine wired, a non-SQL driver, a client neither side + * models). + * + * The second question this package's three SQL compilers cannot answer from + * the filter alone. The case-EXACT text family (`$contains` / + * `$notContains` / `$startsWith` / `$endsWith`, #4706 Q2 = A) has no single + * construct that is case-exact AND parses on every dialect: SQLite's `LIKE` + * folds ASCII unconditionally and needs `GLOB`, MySQL follows the column's + * collation and needs `CAST(… AS BINARY)`, and both of those are errors on + * Postgres — where `LIKE` is already exactly the ruled semantics. So the + * construct is chosen per dialect (`text-match-sql.ts`), and the dialect is + * an input rather than a guess. + * + * The service answers it from `AnalyticsServiceConfig.sqlDialect`, which the + * plugin fills from the driver that will EXECUTE the statement — the driver + * stays the single source of truth for its own dialect, the same posture + * `coerceTemporalFilterColumn` takes. Declared HERE rather than on the + * spec's {@link StrategyContext} for the reason `declaredFieldType` is: + * nothing about it is an authorable surface, and a strategy that does not + * know the hook keeps the behaviour it had — "cannot answer, do not block". + */ + sqlDialect?(objectName: string): string | undefined; } diff --git a/packages/services/service-analytics/src/text-match-sql.ts b/packages/services/service-analytics/src/text-match-sql.ts new file mode 100644 index 0000000000..de55a654a6 --- /dev/null +++ b/packages/services/service-analytics/src/text-match-sql.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15684] The case-EXACT text family — `$contains` / `$notContains` / + * `$startsWith` / `$endsWith` — compiled per DIALECT, for this package's three + * SQL compilers (`read-scope-sql.ts`, `NativeSQLStrategy.buildFilterClause`, + * the `ObjectQLStrategy` echo of that statement). + * + * ## The defect this closes + * + * Every one of the three emitted `col LIKE ? ESCAPE ?` on every dialect, and + * SQLite's `LIKE` folds ASCII case unconditionally — the fold cannot be turned + * off per statement (`PRAGMA case_sensitive_like` is a CONNECTION-global + * switch). Measured on sql.js over the shared `FILTER_TEXT_ROWS` fixture: + * `{ name: { $contains: 'acme' } }` answered `['1','2']` — `ACME Corp` AND + * `acme corp` — where `FILTER_TEXT_CASES` (#4706 Q2 = A) says `['2']`. + * + * Two of the three compilers make that a correctness bug in a chart. The third + * is `read-scope-sql.ts`, the ADR-0021 D-C read scope: a scope that ADMITS rows + * the policy's case-sensitive predicate excludes is over-reach (#3948), not a + * loose filter — the reading that file already writes down for its own LIKE + * escaping (#5567) and the reason #6518 moved the DRIVER family off `LIKE`. + * + * ## Why the construct has to be chosen per dialect, and cannot be chosen here + * + * There is no single construct that is case-exact on all three dialects and + * parses on all three, so a dialect-blind fix does not exist: + * + * - `GLOB` is case-exact by definition but is SQLite-only — a syntax error on + * Postgres and MySQL. + * - `CAST(… AS BINARY)` is byte-wise on MySQL, is not a type on Postgres, and + * takes NUMERIC affinity on SQLite (it would compare a number). + * - `CAST(col AS BLOB) LIKE ?` was measured on the driver side to return + * NOTHING at all — SQLite's LIKE is false for a BLOB operand. + * - The portable primitives that ARE case-sensitive everywhere (`replace()`) + * express "occurs somewhere" but not "occurs at the start / at the end" + * without character-length arithmetic that is spelled differently on every + * dialect (`LENGTH` is bytes on MySQL, characters elsewhere; `right()` does + * not exist on SQLite; a negative `substr` start is not portable to + * Postgres). A prefix/suffix arm built on them would be a fourth spelling + * of the family with no way to hold it to the other three. + * + * So the dialect is an INPUT, exactly as `like-pattern.ts`'s header said the + * remedy would have to be, and it arrives the way every other thing these + * compilers cannot see from the filter alone arrives: an optional hook on the + * context, tiered "cannot answer, do not block" ({@link sqlDialectFor}). + * + * ## Why this is a second implementation of `driver-sql`'s `textMatchPredicate` + * + * The same reason `escapeLikePattern` is a second implementation of the same + * driver's `applyLike` escaping, stated in `like-pattern.ts`: `service-analytics` + * depends on NO driver — importing one would invert the dependency (a service + * reaching down into a driver), and `textMatchPredicate` is a module-private + * function that returns knex bindings (`??` the identifier, `?` the value) + * while these three compilers each carry their own placeholder scheme (`$N` + * here, `?` in the read scope). There is nothing importable even if the + * dependency existed. + * + * What is shared is therefore the CONSTRUCT TABLE, not the code: arm for arm, + * escaped character class for escaped character class, this is #6518's table + * re-emitted through a caller-supplied {@link TextMatchBind}. What keeps the two + * from drifting is not this comment — it is + * `__tests__/text-operator-case-exactness.test.ts`, which runs the shared + * `FILTER_TEXT_CASES` through BOTH this package's compilers and a real + * `SqliteWasmDriver` (a devDependency, never a runtime one) on the same engine + * and requires the same row sets. A third hand-copy of this table anywhere is + * the thing to refuse — import from here, or add a consumer to that test. + * + * ## The arms, and why each cell + * + * Character for character #6518's, whose header carries the measurements: + * + * - **`sqlite` → `GLOB`.** Case-exact by definition, and it carries its OWN + * escape mechanism — a single-character class — because SQLite's grammar + * has no `ESCAPE` clause for `GLOB`. So this arm binds ONE value where the + * others bind two, which is precisely the kind of divergence a second + * emitter drops on the floor; every arm below therefore goes through one + * {@link TextMatchBind} and one shared shape wrapper. + * - **`postgres` → `LIKE`, unchanged.** `LIKE` is already case-exact there, + * so the bytes this package emitted before #15684 are the bytes it emits + * now — a Postgres deployment sees no change at all. + * - **`mysql` → `LIKE` over `CAST(… AS BINARY)`**, byte-wise and therefore + * case-exact whatever the column's collation says. NOT MEASURED here: no + * MySQL server is provisionable in this container, exactly as on the driver + * side, so this cell is a declared skip rather than a claimed pass. + * - **`unknown` → `LIKE`.** The dialect nothing answered for (no hook wired, + * a driver that does not name its dialect, a client neither side models — + * mssql, oracle) keeps the shape it has always had. It is not an + * endorsement: it is the only answer that still RUNS, and it is the residue + * this file's own suite names. + * + * ## What is NOT here + * + * `$icontains` (#6520) keeps its own construct in `like-pattern.ts` and is + * untouched by this file: it folds BOTH sides through `asciiLowerSqlExpr`, and + * collapsing the two families onto one path would hand the case-EXACT family + * the fold #4706 Q2 = A took away from it. Escaping (#5567) is likewise + * unchanged — {@link likePattern} still builds every LIKE-arm pattern, and the + * GLOB arm's own escaped class is a DIFFERENT one, not a shared regex. + */ + +import { likePattern, LIKE_ESCAPE_CHAR, type LikeShape } from './like-pattern.js'; +import type { StrategyContext } from '@objectstack/spec/contracts'; +import type { DatasetScopedStrategyContext } from './strategies/types.js'; + +/** + * The dialects this package's compilers distinguish — deliberately the same + * four names `driver-sql`'s `SqlDialectName` carries, including `'unknown'`, + * so a driver's own answer can be handed straight through with no second + * mapping table to drift. + */ +export type AnalyticsSqlDialect = 'sqlite' | 'postgres' | 'mysql' | 'unknown'; + +/** Every dialect this file has an arm for; anything else is `'unknown'`. */ +const KNOWN_DIALECTS = new Set(['sqlite', 'postgres', 'mysql']); + +/** + * Read a host's / driver's dialect answer as one of {@link AnalyticsSqlDialect}. + * + * Anything unrecognised — including `undefined` from a host that wired no hook + * — is `'unknown'`, which compiles the pre-#15684 `LIKE`. "Cannot answer, do + * not block": a name this file does not model must not silently pick an arm. + */ +export function normalizeSqlDialect(name: string | undefined | null): AnalyticsSqlDialect { + return typeof name === 'string' && KNOWN_DIALECTS.has(name) ? (name as AnalyticsSqlDialect) : 'unknown'; +} + +/** + * The dialect of the datasource backing `objectName`, read off the context's + * `sqlDialect` hook — `'unknown'` when the host wired none. + * + * The same tiering, and the same shape, as `nonTextColumnResolver` (#14079): + * the compilers cannot see this from the filter, the host can answer it from + * the driver that will execute the statement, and a host that cannot answer + * keeps the behaviour it had. + */ +export function sqlDialectFor(ctx: StrategyContext, objectName: string): AnalyticsSqlDialect { + const hook = (ctx as DatasetScopedStrategyContext).sqlDialect; + if (typeof hook !== 'function') return 'unknown'; + return normalizeSqlDialect(hook.call(ctx, objectName)); +} + +/** + * [#6518] Escape the GLOB metacharacters (`*`, `?`, `[`) so a comparand matches + * literally, using GLOB's ONLY escape mechanism: a single-character class. + * + * Character for character `driver-sql`'s `escapeGlobComparand`. `]` needs no + * escape and deliberately gets none — every `[` this function sees becomes a + * class that closes itself, so no unclosed class survives for a later `]` to + * terminate. `%` and `_` are ORDINARY characters to GLOB and are left alone: + * this is NOT the LIKE escaped class, and writing the two as one shared regex + * is the mistake to refuse. + */ +export function escapeGlobPattern(value: unknown): string { + return String(value).replace(/[*?[]/g, '[$&]'); +} + +/** Wrap an already-escaped comparand in the wildcards `shape` calls for. */ +function wrapShape(escaped: string, shape: LikeShape, wildcard: string): string { + if (shape === 'starts') return `${escaped}${wildcard}`; + if (shape === 'ends') return `${wildcard}${escaped}`; + return `${wildcard}${escaped}${wildcard}`; +} + +/** + * Build the GLOB pattern for one comparand: escaped, then wrapped in `*`. + * + * The GLOB twin of {@link likePattern}, and separate from it because the + * escaped character class differs — see {@link escapeGlobPattern}. + */ +export function globPattern(shape: LikeShape, value: unknown): string { + return wrapShape(escapeGlobPattern(value), shape, '*'); +} + +/** + * Push a value onto the caller's parameter list and return the placeholder text + * that references it. + * + * Each of the three compilers has its own scheme — `$N` for + * `NativeSQLStrategy` and the echo, `?` for the read scope, which its two + * consumers renumber on the way out — so the numbering stays with the compiler + * and only the CONSTRUCT lives here. Calls happen left to right in the emitted + * SQL, which is the order the placeholders appear. + */ +export type TextMatchBind = (value: unknown) => string; + +/** One case-EXACT text predicate, ready to splice into a WHERE clause. */ +export interface TextMatchRequest { + /** The dialect that will execute the statement. */ + dialect: AnalyticsSqlDialect; + /** The already-quoted column expression the predicate reads. */ + column: string; + /** Where the wildcard sits: `contains` / `starts` / `ends`. */ + shape: LikeShape; + /** The author's comparand, at its own type ({@link likePattern} renders it). */ + value: unknown; + /** `$notContains` — the negated keyword, on whichever construct the arm picks. */ + negate?: boolean; + /** The caller's placeholder plumbing. */ + bind: TextMatchBind; +} + +/** + * The one place a case-EXACT text predicate becomes SQL in this package. + * + * `$icontains` does NOT come through here — see this file's header. + */ +export function textMatchPredicateSql(req: TextMatchRequest): string { + const { dialect, column, shape, value, bind } = req; + const negate = req.negate === true; + + if (dialect === 'sqlite') { + // GLOB takes no ESCAPE clause, so this arm binds ONE value, not two. + return `${column} ${negate ? 'NOT GLOB' : 'GLOB'} ${bind(globPattern(shape, value))}`; + } + + const keyword = negate ? 'NOT LIKE' : 'LIKE'; + // [#5567] The escape character is BOUND, never written as a literal: MySQL + // applies C escape syntax inside string literals, so the literal spelling + // differs per dialect while a bound value has one spelling everywhere. + if (dialect === 'mysql') { + const binary = (expr: string) => `CAST(${expr} AS BINARY)`; + return `${binary(column)} ${keyword} ${binary(bind(likePattern(shape, value)))} ESCAPE ${bind(LIKE_ESCAPE_CHAR)}`; + } + + // `postgres` — where LIKE is already case-exact — and `unknown`, the residue. + return `${column} ${keyword} ${bind(likePattern(shape, value))} ESCAPE ${bind(LIKE_ESCAPE_CHAR)}`; +}