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
18 changes: 18 additions & 0 deletions .changeset/analytics-text-family-case-exact-per-dialect.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
}
Expand Down
23 changes: 21 additions & 2 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading