Skip to content

Commit 54bb2f1

Browse files
os-warrenclaude
andauthored
fix(service-analytics): compile the case-sensitive text family per SQL dialect, so a $contains read scope stops admitting rows it excludes on SQLite (#15790)
* fix(service-analytics): compile the case-exact text family per SQL dialect Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * test(service-analytics): pin the case-exact family on sql.js; changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * docs(service-analytics): record the measured $icontains translate() gap; keep the fake driver's dialectName override public Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 89758ac commit 54bb2f1

13 files changed

Lines changed: 1010 additions & 102 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
"@objectstack/driver-sql": minor
4+
---
5+
6+
The analytics SQL compilers compile the case-sensitive text family per dialect, so a `$contains` policy on SQLite stops admitting rows it excludes (#15684)
7+
8+
`$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']`.
9+
10+
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.
11+
12+
What changed:
13+
14+
- **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.
15+
- **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.
16+
- **A host that answers no dialect keeps the `LIKE` it always got** — "cannot answer, do not block". Postgres deployments see byte-identical SQL.
17+
18+
`$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.

packages/drivers/driver-sql/src/sql-driver-12732-varchar-emitter-parity-wiring.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ import { describe, it, expect, vi } from 'vitest';
2323
import { SqlDriver, type SqlDialectName } from './index.js';
2424

2525
class FakePostgresDriver extends SqlDriver {
26-
protected get dialectName(): SqlDialectName {
26+
// [#15684] `public`, tracking the base: TypeScript refuses an override that
27+
// NARROWS visibility, so a `protected` one here would be a compile error the
28+
// moment the base getter became readable from outside the driver.
29+
public override get dialectName(): SqlDialectName {
2730
return 'postgres';
2831
}
2932
}

packages/drivers/driver-sql/src/sql-driver.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10616,8 +10616,27 @@ export class SqlDriver implements IDataDriver {
1061610616

1061710617
// ── Managed-schema drift & reconcile (#2186) ───────────────────────────────
1061810618

10619-
/** Canonical dialect name for the drift differ. */
10620-
protected get dialectName(): SqlDialectName {
10619+
/**
10620+
* Canonical dialect name for the drift differ — and, since #15684, the one
10621+
* answer to "which SQL does this driver speak" that anything outside the
10622+
* driver may read.
10623+
*
10624+
* PUBLIC for exactly one consumer: `service-analytics` compiles its own
10625+
* statements (an analytics `where`, an ADR-0021 D-C read scope, the
10626+
* `/analytics/sql` echo) and executes them through this driver, so it needs
10627+
* the same per-dialect construct choices {@link textMatchPredicate} makes —
10628+
* a plain `LIKE` folds ASCII case on SQLite, which made a case-SENSITIVE
10629+
* `$contains` admit rows the predicate excludes, over-reach (#3948) on the
10630+
* read scope. That package depends on no driver and reads this structurally
10631+
* through `IDataEngine.getDriverForObject`; exposing the getter is what
10632+
* keeps the answer THIS driver's rather than a second dialect-resolution
10633+
* table drifting one knex spelling behind {@link SQLITE_EMIT_CLIENTS} and
10634+
* friends.
10635+
*
10636+
* Read-only and derived: there is nothing to set, and `'unknown'` is a real
10637+
* answer (a client neither emission set names), not a missing one.
10638+
*/
10639+
public get dialectName(): SqlDialectName {
1062110640
if (this.isSqlite) return 'sqlite';
1062210641
if (this.isPostgres) return 'postgres';
1062310642
if (this.isMysql) return 'mysql';

0 commit comments

Comments
 (0)