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
21 changes: 21 additions & 0 deletions .changeset/analytics-unknown-dialect-icontains-portable-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@objectstack/service-analytics": patch
---

Analytics `$icontains` no longer compiles a `translate()` call on the `unknown` dialect arm, so a datasource whose dialect nothing answered — which includes SQLite — gets a statement its engine can parse. **Graded `patch`:** no exported type, signature or option changes; the package's own contract for the operator (#4706 Q1 = A, an ASCII-only fold on both sides) is unchanged, and this repairs an arm that could not run rather than adding or retiring behaviour. What moves is emitted SQL text on one arm, measured and enumerated below.

`normalizeSqlDialect` maps **everything it cannot name** onto `unknown`: an unset `sqlDialect` hook, `'oracle'`, `'libsql'`, a `SqlDriver` handed a knex Client **class** rather than a spelling. #15780 left that arm folding with `translate()` and recorded it as "never broken", which was true of the dialects the arm was *pictured* as — mssql and oracle, which have `translate()` — and false of the ones actually routed there. Measured on sql.js 1.14.1 (SQLite 3.49.1, the engine `driver-sqlite-wasm` runs), `SELECT translate('ABC','ABC','abc')` answers `no such function: translate`, so on all three of this package's compilers — the query's own `where` (`NativeSQLStrategy.buildFilterClause`), the ADR-0021 D-C read scope (`compileScopedFilterToSql`) and the `ObjectQLStrategy` echo — the statement failed to **parse**. It reached the client as a 500, not an ADR-0112 refusal. One of the four constructions that land there is a directly-constructed public `AnalyticsService` with its **optional** `sqlDialect` omitted: leaving out an optional field turned a documented operator into a 500.

The `unknown` arm now folds with one nested `REPLACE` per ASCII letter — the chain the MySQL arm already used, minus its `CAST(… AS BINARY)`, so there is one builder and the two arms cannot fold different alphabets. `REPLACE` is the one string function every SQL dialect has, and the domain is the same 26-letter constant, so the fold is ASCII-only **by construction**:

- **PostgreSQL / Oracle-like** — same result set as `translate()`. The chain equals the simultaneous `A`-`Z` map because no step can feed a later one: every replacement writes a lower-case letter and every later step matches an upper-case one. Measured on the engine over **every ASCII code point** plus accented, Greek, Cyrillic and dotted-I probes, required equal to the ASCII-only map exactly.
- **SQLite-like** — it runs. Executed over the shared `FILTER_TEXT_CASES` `$icontains` rows through all three compilers on sql.js: the same row sets the `sqlite` arm is required to answer, including the `CAFÉ`/`café` pair that separates an ASCII fold from a Unicode one.
- ⛔ **Not `LOWER()`**, which is what `driver-sql`'s own `unknown` arm folds with. `LOWER()` follows the collation, so adopting it would trade this parse failure for **silently wrong rows** on PostgreSQL — the Unicode fold #4706 Q1 = A rules out. ⚠️ Measuring `LOWER()` in this container proves nothing about that: SQLite's `lower()` is ASCII-only and passes the same fixture, which is exactly the trap of letting a green SQLite reading stand in for a PostgreSQL one. No PostgreSQL server was contacted.

**Which cells moved.** The emitted SQL and bound params of `{NativeSQLStrategy, ObjectQLStrategy echo, compileScopedFilterToSql} × {undefined, 'unknown', 'oracle', 'libsql', 'postgres', 'sqlite', 'mysql'} × 5 text operators × 17 comparands` = **1,785 cells**, generated at this head and again with the emitter reverted to its merge-base blob (both legs hash-verified on disk and rebuilt, the marker's presence and absence checked in `dist/`): **204 moved, 1,581 byte-identical, 0 error cells either side.** Every moved cell is `$icontains` on one of the four dialect inputs that normalize to `unknown` (51 each = 17 comparands × 3 compilers). **0 of the 204 changed their bound params** — only the fold's spelling moved, never the escaping or the `ESCAPE` binding. Nothing moved on `postgres`, `sqlite` or `mysql`, and no case-exact operator moved on any dialect input.

⚠️ **The cost, stated rather than left to be found:** the predicate grows from 168 to 1,014 characters on the read scope (233 → 1,079 on the other two). Both constructs are non-sargable scalar expressions over the column, so the plan class is unchanged — what grows is statement text and per-row work, on the arm where the alternative was a statement that did not run.

⚠️ **The residue that remains**, because this arm is a residue and not a dialect: the fold is exact everywhere, but the comparison is `LIKE`, which on a case- or accent-insensitive collation (MySQL/MariaDB arriving here through the `'mariadb'` spelling #11756 deliberately leaves unrecognised; SQL Server) over-matches beyond ASCII. That is the **same** residue this arm's case-exact neighbour already carries and names — not a new one — and on those engines `translate()` did not run at all, so nothing that answered correctly before stops answering.

`SqliteWasmDriver.dialectName` gains a direct pin. It answers `"sqlite"` only through an `isSqlite` override (the base class string-matches `config.client`, and this transport passes a class), that override had **0 direct test hits**, and it is the sole reason no in-repo SQLite driver reaches the arm above. The new pin includes the control: the base class answers `'unknown'` for that very config.
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#16028] The `isSqlite` override, pinned DIRECTLY — the one thing that makes
* this transport answer `"sqlite"` when something outside the driver asks which
* SQL it speaks.
*
* ## Why this file exists
*
* `SqlDriver.dialectName` is 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 needs the same
* per-dialect construct choices the driver makes. Answering `'sqlite'` is what
* routes `$icontains` onto `lower(col) GLOB lower(?)`; answering `'unknown'`
* routes it onto the residue arm instead.
*
* The base class derives that answer by STRING-MATCHING `config.client` against
* {@link SqlDriver}'s emission sets — and this transport passes a knex Client
* CLASS, which is no string at all. So the correct answer here is produced by
* one three-line override and by nothing else.
*
* ⚠️ #16028 measured that override at **0 direct test hits**: the only cover was
* an indirect row-set pin (#15684), which would keep passing if the override
* moved, because the ROWS come out the same either way — the driver runs its own
* SQL through its own SQLite. What changes silently is the answer handed to a
* package that compiles SQL for a DIFFERENT engine. That is the gap this file
* closes, and it is the reason the control below is not decoration: it shows the
* base class answering `'unknown'` for this exact config, so the pin above is a
* measurement of the override rather than of the class hierarchy.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SqlDriver } from '@objectstack/driver-sql';

import { SqliteWasmDriver } from '../src/index.js';

/** Nothing here connects — but every knex instance built is still torn down. */
const opened: Array<{ disconnect(): Promise<void> }> = [];
const dirs: string[] = [];

afterEach(async () => {
await Promise.all(opened.splice(0).map((d) => d.disconnect().catch(() => {})));
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});

const track = <T extends { disconnect(): Promise<void> }>(d: T): T => {
opened.push(d);
return d;
};

describe('[#16028] SqliteWasmDriver names its dialect', () => {
it('answers "sqlite" — the answer service-analytics compiles against', () => {
// Read WITHOUT connecting, deliberately: `service-analytics` asks this
// while BUILDING a statement, so an answer that needed a live pool would
// arrive after the SQL it decides.
expect(track(new SqliteWasmDriver({ filename: ':memory:' })).dialectName).toBe('sqlite');
});

it('…on a file-backed database too, and with persistence on', () => {
const dir = mkdtempSync(join(tmpdir(), 'wasm-dialect-'));
dirs.push(dir);
const file = join(dir, 'test.db');
expect(track(new SqliteWasmDriver({ filename: file })).dialectName).toBe('sqlite');
expect(track(new SqliteWasmDriver({ filename: file, persist: 'on-write' })).dialectName).toBe('sqlite');
});

it('the client is a CLASS, so no string table could have answered it', () => {
// The override's premise, asserted rather than assumed: if this ever became
// a string knex spelling, the base class would answer on its own and the
// override would be dead code rather than the load-bearing line it is.
const client = (SqliteWasmDriver.toKnexConfig({ filename: ':memory:' }) as { client: unknown }).client;
expect(typeof client).toBe('function');
expect(typeof client).not.toBe('string');
});

it('CONTROL: the base class answers "unknown" for this very config', () => {
// Delete `isSqlite` from the subclass and this is what `service-analytics`
// would be told — #16028's residue arm, which for `$icontains` emitted a
// statement SQLite cannot parse at all until that card. This is what makes
// the pin above a measurement of the OVERRIDE rather than of the hierarchy.
const base = track(new SqlDriver(SqliteWasmDriver.toKnexConfig({ filename: ':memory:', pool: { min: 0, max: 1 } })));
expect(base.dialectName).toBe('unknown');
});
});
Loading
Loading