Skip to content

Commit f00bd2e

Browse files
committed
fix(analytics)!: declare the sqlDialect accept set and diagnose an out-of-contract answer (#16206)
Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
1 parent 228a292 commit f00bd2e

4 files changed

Lines changed: 140 additions & 11 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
fix(analytics)!: `AnalyticsServiceConfig.sqlDialect` declares its three-name accept set, and a host that answers outside it is told once (#16206)
6+
7+
<!-- adr-0087: not-required (runtime-interface-only packages/services/service-analytics/src/analytics-service.ts#AnalyticsServiceConfig) The narrowed member is one hook on a service CONSTRUCTOR CONFIG — a published runtime TypeScript interface with no metadata surface. It has no Zod schema, no `packages/spec` declaration and no stored representation, so `objectstack migrate meta`, `spec-changes.json` and the generated upgrade guide have nothing to rewrite; the affected party is a TypeScript host and the channel that reaches every one of them is the compiler at their own composition site. No metadata key is added, removed, renamed or re-shaped, and `packages/spec` is untouched by this diff. -->
8+
9+
**BREAKING** for a TypeScript host that declares its `sqlDialect` hook as returning
10+
`string`: the hook's declared return is now the three canonical dialect names or
11+
`undefined`, so such a composition stops compiling until the host's own annotation
12+
says which names it can answer. Shipped as `minor` under the repo's launch-window
13+
convention, in which breaking-ness is carried by this banner and the disposition
14+
above rather than by the bump level. Runtime behaviour for every host is unchanged:
15+
the same three names were the only ones that ever did anything.
16+
17+
## What was wrong
18+
19+
`AnalyticsServiceConfig.sqlDialect` — the hook a host answers to say which SQL
20+
dialect backs an object — was typed as free `string`, while `normalizeSqlDialect`
21+
has only ever recognised `sqlite`, `postgres` and `mysql`. Nothing said so, and
22+
nothing told a host that answered otherwise.
23+
24+
So a host that owns a SQLite datasource and answers the spelling its own stack uses
25+
— knex's canonical `sqlite3`, or `better-sqlite3`, both of which `driver-sql` itself
26+
lists in `SQLITE_EMIT_CLIENTS` — was read as `unknown`. And because `sqlDialectFor`
27+
is tiered "cannot answer, do not block", **a wrong answer and no answer were the
28+
same answer**: the host that tried hardest to help got the residue arm, silently.
29+
30+
## What it does now
31+
32+
- **The vocabulary is declared**, on the type and in the docblock, as
33+
`AcceptedSqlDialect` — `sqlite` | `postgres` | `mysql` — so a host reading the
34+
config learns the accept set without running anything. The type and the runtime
35+
membership set are generated from one `const` tuple, so a future widening cannot
36+
land in one and miss the other.
37+
- **A non-empty answer outside the set is diagnosed**: one `warn` naming the object,
38+
the answer and the accepted set. It is emitted **once per distinct unrecognised
39+
spelling** — the failure's identity — so the line count is bounded by the host's
40+
own hook and never grows with query volume.
41+
- **`undefined` stays silent and legal.** The hook is optional and "cannot answer,
42+
do not block" is a supported composition, not a misconfiguration. A pin holds both
43+
halves, because a diagnostic that also shouted at hosts who wired nothing would be
44+
a worse defect than the one being fixed.
45+
- **The accept set is NOT widened.** Teaching this package `driver-sql`'s knex
46+
aliases would be a second copy of that driver's table, and an unrecognised
47+
spelling is sometimes deliberate (`mariadb`, #11756). The answer is still read as
48+
`unknown`; only the silence changed.
49+
- **The plugin bridge translates the driver's own residue.** `SqlDriver.dialectName`
50+
carries a fourth name, `unknown`, meaning "I cannot say"; handed on verbatim it
51+
would have presented a correctly-behaving driver as a host answering out of
52+
contract. It now arrives as `undefined`, this hook's own spelling for the same
53+
thing. The dialect the compilers end up with is unchanged either way.
54+
55+
## Measured, and worth reading before relying on the residue arm
56+
57+
Driven on sql.js through a host answering `sqlite3`, against the shared
58+
`FILTER_TEXT_CASES` fixture, with a host answering `sqlite` as the control: **five of
59+
the six case-EXACT cases come back with the wrong rows** — every case that
60+
discriminates on ASCII case. `{ name: { $contains: 'acme' } }` answers `['1','2']`
61+
where the table says `['2']`, and the negated form DROPS a row that belongs in the
62+
result. That is #15684's fold, live on the arm this population lands on, and it is
63+
reported rather than fixed here: closing it is that card's business, not this one's.

‎packages/services/service-analytics/src/__tests__/sql-dialect-vocabulary.test.ts‎

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,16 @@ const CUBE: Cube = {
9898
public: false,
9999
} as unknown as Cube;
100100

101-
/** A second cube over the SAME table, so "one answer, many objects" is drivable. */
101+
/**
102+
* A second cube over a DIFFERENT object, so "one answer, many objects" is
103+
* drivable. ⚠️ The hook is asked about the OBJECT the cube reads (`sql`), not
104+
* about the cube — which is why the line below names `rows`, and why this
105+
* twin has to point somewhere else to be a second object at all.
106+
*/
102107
const OTHER_CUBE: Cube = {
103108
...(CUBE as unknown as Record<string, unknown>),
104109
name: 'other_texts',
110+
sql: 'other_rows',
105111
} as unknown as Cube;
106112

107113
const query = (where: unknown, cube = 'texts'): AnalyticsQuery =>
@@ -132,6 +138,10 @@ const serviceAnswering = (
132138
logger: logger as unknown as AnalyticsServiceConfig['logger'],
133139
cubes: [CUBE, OTHER_CUBE],
134140
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
141+
// `NativeSQLStrategy.canHandle` requires a raw-SQL door to exist. Nothing
142+
// below EXECUTES through it — the SQL is minted by `generateSql` and run on
143+
// sql.js directly — so it only has to be present and typed.
144+
executeRawSql: async () => [] as Record<string, unknown>[],
135145
sqlDialect: hook as unknown as AnalyticsServiceConfig['sqlDialect'],
136146
} satisfies AnalyticsServiceConfig;
137147
return { service: new AnalyticsService(config), logger };
@@ -145,6 +155,7 @@ const serviceAnsweringNothing = (): { service: AnalyticsService; logger: TestLog
145155
logger: logger as unknown as AnalyticsServiceConfig['logger'],
146156
cubes: [CUBE, OTHER_CUBE],
147157
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
158+
executeRawSql: async () => [] as Record<string, unknown>[],
148159
}),
149160
logger,
150161
};
@@ -205,7 +216,8 @@ describe('[#16206] the ruling\'s named pin — both halves', () => {
205216
const warnings = dialectWarnings(logger);
206217
expect(warnings).toHaveLength(1);
207218
expect(warnings[0]).toContain('"sqlite3"');
208-
expect(warnings[0]).toContain('"texts"');
219+
// The OBJECT the hook was asked about — `texts` reads the object `rows`.
220+
expect(warnings[0]).toContain('"rows"');
209221
for (const accepted of ACCEPTED_SQL_DIALECTS) expect(warnings[0], accepted).toContain(accepted);
210222
});
211223

@@ -246,8 +258,10 @@ describe('[#16206] "once" is keyed on the failure\'s identity, and is bounded',
246258
const warnings = dialectWarnings(logger);
247259
expect(warnings).toHaveLength(1);
248260
// The FIRST object to elicit it is the one named — a concrete place to look,
249-
// not a count that grows with the object registry.
250-
expect(warnings[0]).toContain('"texts"');
261+
// not a count that grows with the object registry. Two DIFFERENT objects
262+
// were asked about (`rows` and `other_rows`); one line came out.
263+
expect(warnings[0]).toContain('"rows"');
264+
expect(warnings[0]).not.toContain('"other_rows"');
251265
});
252266

253267
it('a SECOND, DIFFERENT wrong answer is a second failure and gets its own line', async () => {
@@ -369,20 +383,40 @@ describe('[#16206] the `unknown` arm\'s ROWS for a `sqlite3`-answering host, on
369383
wrong.push({ case: c.name, expected: [...c.expected], measured });
370384
}
371385
}
372-
// At least the two case-folding rows of the shared table come back wrong.
373-
expect(wrong.length).toBeGreaterThan(0);
374-
375-
// Named, so the report reads the rows rather than a count.
386+
// FIVE of the shared table's SIX case-exact cases come back wrong — every
387+
// one that discriminates on ASCII case. The sixth (`$contains 'a_b'`) is
388+
// the LIKE-metacharacter row, which carries no cased letter to fold, and is
389+
// the reason this is a count and not "all of them".
390+
expect(wrong.map((w) => w.case)).toEqual([
391+
'$contains is case-SENSITIVE — a lower-case comparand misses the upper-case row',
392+
'$contains is case-SENSITIVE — an upper-case comparand misses the lower-case row',
393+
'$startsWith is case-SENSITIVE',
394+
'$endsWith is case-SENSITIVE',
395+
'$notContains is case-SENSITIVE, and negation does not widen it',
396+
]);
397+
398+
// Named, so the record reads the rows rather than a count.
376399
expect(await executedIds({ name: { $contains: 'acme' } }, sqlite3Host)).toEqual(['1', '2']);
377400
expect(await executedIds({ name: { $contains: 'acme' } }, sqliteHost)).toEqual(['2']);
401+
expect(await executedIds({ name: { $contains: 'ACME' } }, sqlite3Host)).toEqual(['1', '2']);
402+
expect(await executedIds({ name: { $contains: 'ACME' } }, sqliteHost)).toEqual(['1']);
378403
expect(await executedIds({ name: { $startsWith: 'ACME' } }, sqlite3Host)).toEqual(['1', '2']);
379404
expect(await executedIds({ name: { $startsWith: 'ACME' } }, sqliteHost)).toEqual(['1']);
380405
expect(await executedIds({ name: { $endsWith: 'corp' } }, sqlite3Host)).toEqual(['1', '2']);
381406
expect(await executedIds({ name: { $endsWith: 'corp' } }, sqliteHost)).toEqual(['2']);
382-
// Negation does not widen it back: the over-match becomes an under-match.
407+
// ⭐ Negation turns the over-match into an UNDER-match: row 1 is DROPPED
408+
// from a result set that should contain it. On a read scope that direction
409+
// hides rows; on the query's own `where` it is a wrong chart.
383410
expect(await executedIds({ name: { $notContains: 'acme' } }, sqlite3Host))
384411
.toEqual(['3', '4', '5', '6', '7', '8', '9']);
385412
expect(await executedIds({ name: { $notContains: 'acme' } }, sqliteHost))
386413
.toEqual(['1', '3', '4', '5', '6', '7', '8', '9']);
414+
415+
// The construct that causes it, so the finding names a mechanism: the
416+
// residue arm's plain `LIKE`, which SQLite folds ASCII case on.
417+
const viaSqlite3 = await sqlite3Host.generateSql(query({ name: { $contains: 'acme' } }));
418+
const viaSqlite = await sqliteHost.generateSql(query({ name: { $contains: 'acme' } }));
419+
expect(viaSqlite3.sql).toContain('LIKE');
420+
expect(viaSqlite.sql).toContain('GLOB');
387421
});
388422
});

‎packages/services/service-analytics/src/plugin.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import type { AnalyticsDriverCapabilities } from './strategies/types.js';
1212
import { pickDisplayField, type DimensionLabelDeps } from './dimension-labels.js';
1313
import { assertReadScopeCannotVacate } from './read-scope-sql.js';
1414
import { readScopeUnresolvedError } from './read-scope-refusal.js';
15+
// [#16206] The narrowing from a driver's FOUR-name `dialectName` to the THREE
16+
// this package's config hook declares — see the bridge below.
17+
import { asAcceptedSqlDialect, type AcceptedSqlDialect } from './text-match-sql.js';
1518

1619
/**
1720
* The slice of the DECLARED engine contracts this plugin's auto-bridges
@@ -998,13 +1001,24 @@ export class AnalyticsServicePlugin implements Plugin {
9981001
* `undefined` on every tier that cannot answer — no data engine, a driver
9991002
* that names no dialect (memory, mongo), a throw — and `undefined` keeps
10001003
* the plain `LIKE`, which is exactly the pre-#15684 behaviour.
1004+
*
1005+
* [#16206] ⭐ A FIFTH tier that cannot answer, and the reason this is not a
1006+
* verbatim pass-through: `SqlDriver.dialectName` is a FOUR-name vocabulary
1007+
* whose fourth name is `'unknown'` — that driver's own "I cannot say", which
1008+
* is what it returns for a client it does not model (`'mariadb'`, left
1009+
* unrecognised on purpose by #11756). The config hook's accept set is the
1010+
* other THREE, so handing `'unknown'` on verbatim would present a driver
1011+
* behaving correctly as a host answering out of contract, and every such
1012+
* deployment would carry a warning about itself. ⇒ The residue is
1013+
* translated to this hook's own spelling for the same thing, `undefined`.
1014+
* The dialect the compilers end up with is unchanged either way.
10011015
*/
1002-
const sqlDialect = (objectName: string): string | undefined => {
1016+
const sqlDialect = (objectName: string): AcceptedSqlDialect | undefined => {
10031017
try {
10041018
const svc = ctx.getService<DataEngineLike>('data');
10051019
const driver = svc?.getDriverForObject?.(objectName) as DialectNamingDriver | undefined;
10061020
const named = driver?.dialectName;
1007-
return typeof named === 'string' ? named : undefined;
1021+
return asAcceptedSqlDialect(typeof named === 'string' ? named : undefined);
10081022
} catch {
10091023
// Same tiering as the temporal hooks: an unresolvable driver keeps the
10101024
// dialect-blind construct, which is today's behaviour.

‎packages/services/service-analytics/src/text-match-sql.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,24 @@ export function isUnrecognisedSqlDialectAnswer(name: string | undefined | null):
256256
return typeof name === 'string' && name.length > 0 && !KNOWN_DIALECTS.has(name);
257257
}
258258

259+
/**
260+
* [#16206] `name` if it is one of {@link ACCEPTED_SQL_DIALECTS}, else
261+
* `undefined` — the narrowing a caller needs when it must hand an answer on to
262+
* something that declares the accept set.
263+
*
264+
* ⚠️ It exists for the bridge in `plugin.ts`, which answers from a `SqlDriver`'s
265+
* `dialectName` — a FOUR-name vocabulary whose fourth name is `'unknown'`, that
266+
* driver's own "I cannot say". Passed through verbatim, that residue would
267+
* arrive at the config hook looking like a considered answer outside the accept
268+
* set, and the host would be warned about a driver doing exactly the right
269+
* thing (and about `driver-sql`'s deliberately unrecognised spellings, #11756).
270+
* ⇒ Translate the residue to the hook's own spelling for "cannot answer" —
271+
* `undefined` — rather than teaching the diagnostic a list of exceptions.
272+
*/
273+
export function asAcceptedSqlDialect(name: string | undefined | null): AcceptedSqlDialect | undefined {
274+
return typeof name === 'string' && KNOWN_DIALECTS.has(name) ? (name as AcceptedSqlDialect) : undefined;
275+
}
276+
259277
/**
260278
* The dialect of the datasource backing `objectName`, read off the context's
261279
* `sqlDialect` hook — `'unknown'` when the host wired none.

0 commit comments

Comments
 (0)