Skip to content

Commit ed44512

Browse files
os-muskclaude
andauthored
fix(driver-turso): escape the groupBy alias on the remote transport instead of gating it (#14235) (#14480)
* fix(driver-turso): escape the groupBy alias on the remote transport instead of gating it (#14235) The second output-name position of `RemoteTransport.aggregate`. #14113 moved the aggregation alias to `aliasIdentifierSql` and left this one gated because it carried a landed #6401 pin asserting the refusal; #13714 had already routed both of driver-sql's output-name positions through escaping. An output-column key is a NAME — quoted and escaped; a column reference is grammar — validated. So `field` keeps `assertSafeIdentifier` and `outKey` no longer has it. The #6401 pin is rewritten in place on the same input to assert what the transport now emits, plus dotted/spaced round-trip cases and two executing cases against the SQLite-backed libsql stub. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * test(driver-turso): pin the emitted statement for the escaped groupBy alias (#14235) The input the rewritten #6401 pin carries has no aggregation `field`, so the emission is `count(*)`, not `count("stage")`; and the one-statement assertion now counts SELECTs rather than probing for a quote that the escape never produces. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * docs(driver-turso): record the #14235 ablation in the suite header (#14235) 4 failed / 14 passed of 18, case for case as predicted; every failure throws inside the call rather than failing a comparison, and the `field`-position control stays green. Notes why no `dist/` leg applies and how the mutation and the restore were each proved on disk. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * test(driver-turso): close the two groupBy-alias scope lines #14113 and #14287 left (#14235) Both controls were hand-offs addressed to this card in their own comments — #14113's "this control records the state it was left in", #14287's "the day that card lands, this is the test that has to be rewritten deliberately rather than found red by surprise". PM ruling on the stop condition (comment 5506404777) amended this card's file surface to include them. Each file has exactly one case rewritten, on the same input, to assert the quoted emission instead of the refusal; the envelope file also records in its `## The accept set is UNTOUCHED` paragraph that the groupBy OUT KEY position moved. Every other refusal position — object, field, groupBy FIELD, aggregation alias, DDL — still asserts INVALID_REQUEST / 400, the accept-set describe that drives the groupBy FIELD is byte-identical, and `unsafeIdentifierError` / `UNSAFE_IDENTIFIER_CODE` are untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c7d9d4 commit ed44512

5 files changed

Lines changed: 326 additions & 74 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/driver-turso": patch
3+
---
4+
5+
fix(driver-turso): escape the groupBy alias on the remote transport instead of gating it (#14235)
6+
7+
`RemoteTransport.aggregate` emits a caller-supplied output NAME in exactly two
8+
positions. #14113 moved the aggregation alias to escaping and deliberately left
9+
the groupBy alias (`GroupByNodeSchema.alias`, reaching the driver as
10+
`g.alias ?? g.field`) on `assertSafeIdentifier`, because that position carried a
11+
landed pin asserting the refusal. So a groupBy alias that was not a bare
12+
`[A-Za-z_][A-Za-z0-9_]*``'Region Name'`, `'deal.stage_bucket'` — was refused
13+
on this face while the in-memory, MongoDB and (post-#13714) SQL faces all
14+
project it verbatim: one query, two answers, decided by a connection string.
15+
16+
The groupBy select site now emits `"<field>" AS <aliasIdentifierSql(outKey)>`,
17+
the same quote-doubling escape the aggregation alias beside it already uses, so
18+
both output-name positions of the method agree with `driver-sql`. The `field`
19+
position keeps `assertSafeIdentifier` — a column REFERENCE is grammar and a
20+
qualified one is legitimate, so it must be validated; an output NAME is one
21+
name by definition and is quoted and escaped. `outKey === field` still emits the
22+
alias-less `"<field>"`, byte-identical to before.
23+
24+
The #6401 pin that asserted the refusal is rewritten in place, on the same
25+
input, to assert what the transport now emits — the recorded, non-silent
26+
reversal the card asked for rather than a rider on someone else's change. The
27+
escaped alias is pinned against a real SQLite-backed libsql stub as well as on
28+
the captured statement, because only executing it tells "escaped" apart from
29+
"broke out".
30+
31+
No accept set moves at the contract: `GroupByNodeSchema.alias` already declares
32+
this key and the spec already admits these names. What moves is this driver's
33+
accept set, toward the contract the other three faces already implement —
34+
declared = enforced, restored. The refusal envelope for the positions that stay
35+
gated (#14287, `INVALID_REQUEST` / 400) is untouched.

packages/drivers/driver-turso/src/remote-transport-aggregation-alias-quoting.test.ts

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -269,37 +269,44 @@ describe('[#14113] RemoteTransport — the aggregation alias is escaped, not gat
269269
expect(seen).toEqual([]);
270270
});
271271

272-
it('the groupBy alias position is UNCHANGED — still refused, and that is a separate card', async () => {
273-
// ⚠️ Deliberate scope line, pinned so it cannot drift silently. The
274-
// groupBy `alias` is the same class of thing (an output NAME) and
275-
// `driver-sql` escapes it post-#13714 (`aliasIdentifierSql` at its
276-
// groupBy select site), so this face diverges there too — but that
277-
// position carries a LANDED pin (#6401, `remote-transport-groupby-node`)
278-
// asserting the refusal, so reversing it is a judgement this card was not
279-
// dispatched to make. Filed separately rather than patched inline; this
280-
// control records the state it was left in.
272+
it('[#14235] the groupBy alias position is now ESCAPED TOO — the scope line this card left is closed', async () => {
273+
// ⚠️ This case REPLACES the deliberate scope line #14113 left here, which
274+
// asserted this same input is REFUSED and said of itself: "Filed
275+
// separately rather than patched inline; this control records the state
276+
// it was left in." #14235 is the card it was waiting for. It reached the
277+
// same reference-versus-name line by the same reasoning — an output NAME
278+
// is quoted and escaped, a column REFERENCE is validated — so BOTH
279+
// output-name positions of `aggregate` now route through
280+
// `aliasIdentifierSql`, and this face agrees with `driver-sql` (which has
281+
// routed both since #13714) on the whole method rather than one loop.
281282
//
282-
// It is also NOT on the reproducing path: `ObjectQLStrategy` resolves a
283-
// dimension to a bare column name, so no analytics query sends a dotted
284-
// groupBy alias.
283+
// What is pinned here is what the two positions do TOGETHER in one
284+
// statement: a dotted dimension alias beside a dotted measure alias, both
285+
// quoted, neither refused. That is the assertion #14113's control could
286+
// not make while it was holding the scope line.
285287
const { t, seen } = await capturing();
286-
const err = await t
287-
.aggregate(DELIVERY_OBJECT.name, {
288-
object: DELIVERY_OBJECT.name,
289-
groupBy: [{ field: 'region', alias: 'showcase_delivery.region' }],
290-
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
291-
} as never)
292-
.then(
293-
() => { throw new Error('expected the groupBy alias to still be refused') },
294-
(e) => e as Error,
295-
);
296-
expect(err.message).toContain('unsafe identifier rejected');
297-
expect(err.message).toContain('showcase_delivery.region');
298-
// [#14287] The GATING is what this control holds; the ENVELOPE is what
299-
// that card added to it. Both are pinned, so the open gating card cannot
300-
// be mistaken for having landed.
301-
expect(envelopeOf(err)).toEqual(ENVELOPE);
302-
expect(seen).toEqual([]);
288+
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
289+
object: DELIVERY_OBJECT.name,
290+
groupBy: [{ field: 'region', alias: 'showcase_delivery.region' }],
291+
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
292+
} as never);
293+
expect(seen).toEqual([
294+
'SELECT "region" AS "showcase_delivery.region", count(*) AS "showcase_delivery.count" ' +
295+
'FROM "showcase_delivery" GROUP BY "region"',
296+
]);
297+
// ⛔ Neither dot may become a qualified reference: both stay INSIDE their
298+
// own quotes.
299+
expect(seen[0]).not.toContain('"showcase_delivery"."region"');
300+
expect(seen[0]).not.toContain('"showcase_delivery"."count"');
301+
// Executed, not merely emitted — the values come back under the caller's
302+
// own keys, and GROUP BY still keys on the FIELD.
303+
const byRegion = Object.fromEntries(
304+
(rows as Array<Record<string, unknown>>).map((r) => [
305+
r['showcase_delivery.region'],
306+
r['showcase_delivery.count'],
307+
]),
308+
);
309+
expect(byRegion).toEqual({ west: 2, east: 1 });
303310
});
304311

305312
it('the default alias is byte-identical to what it was before', async () => {

packages/drivers/driver-turso/src/remote-transport-groupby-node.test.ts

Lines changed: 193 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,79 @@
6363
* reversed — the condition it named ("only one face would read it") is what
6464
* stopped being true.
6565
*
66-
* # Reverse verification — direction predicted BEFORE it was run, per case
66+
* # `alias` is ESCAPED, not gated — as of #14235
67+
*
68+
* `RemoteTransport.aggregate` emits a caller-supplied output NAME in exactly
69+
* two positions. #13714 routed BOTH of `driver-sql`'s through
70+
* `SqlDriver.aliasIdentifierSql`; #14113 moved this transport's AGGREGATION
71+
* alias to the same escaping and deliberately left the groupBy alias alone,
72+
* because the groupBy position carried the landed #6401 pin below asserting the
73+
* refusal. #14235 is the card that reverses that pin on the record: an
74+
* output-column key is a NAME — quoted and escaped — and a column REFERENCE is
75+
* grammar, so `field` keeps `assertSafeIdentifier` and `outKey` no longer has
76+
* it. `GroupByNodeSchema.alias` is the same class of key as
77+
* `AggregationNodeSchema.alias`, and the in-memory face projects
78+
* `g.alias ?? g.field` verbatim, so this face was the last one refusing names
79+
* the contract permits — with a bare `Error` before #14287, an opaque 500 after
80+
* `mapDataError`, and a 400 `INVALID_REQUEST` since.
81+
*
82+
* ⚠️ The `[#6401] refuses an unsafe identifier in \`alias\`` case named in the
83+
* #6212 record below **no longer exists** — it is replaced in place by
84+
* `[#14235] ESCAPES an alias that would close the quoting`, on the same input.
85+
* The record is left as it was measured rather than rewritten to match today's
86+
* cases: it is the #6212 ablation, not this one.
87+
*
88+
* ## Reverse verification (#14235) — direction predicted BEFORE it was run
89+
*
90+
* Restore the two pre-#14235 lines at the groupBy select site —
91+
* `this.assertSafeIdentifier(outKey)` above the push, and
92+
* `` `"${field}" AS "${outKey}"` `` as the aliased emission:
93+
*
94+
* - the two capture cases (`ESCAPES an alias that would close the quoting`,
95+
* `a dotted or spaced alias round-trips`) go RED by THROWING inside the call
96+
* — `unsafe identifier rejected: "bucket"; DROP TABLE deal; --"` /
97+
* `"Region Name"` — not on a comparison.
98+
* - both executing cases go RED the same way, inside `driver.aggregate`.
99+
* - the `field`-position control (`still refuses an unsafe identifier inside a
100+
* structured entry`) stays GREEN — untouched by this change, and that is
101+
* exactly what it is here to hold.
102+
* - `projects \`alias\` as the column name` and `an alias equal to the field
103+
* name emits no self-rename` stay GREEN: `bucket` and `stage` pass
104+
* `SAFE_IDENTIFIER` either way, so they pin byte-identical emission across
105+
* the change.
106+
* - every date-bucket, parity and string-form case stays GREEN.
107+
*
108+
* MEASURED, case for case as predicted — **4 failed / 14 passed of 18**:
109+
*
110+
* ```
111+
* ESCAPES an alias that would close Error: RemoteTransport: unsafe identifier
112+
* the quoting rejected: "bucket"; DROP TABLE deal; --"
113+
* a dotted or spaced alias …rejected: "Region Name", then
114+
* round-trips …rejected: "deal.stage_bucket"
115+
* a dotted alias comes back under …rejected: "deal.stage_bucket"
116+
* the result key (executing)
117+
* an alias that tries to close the …rejected: "bucket"; DROP TABLE deal; --"
118+
* quoting (executing)
119+
* ── green ──
120+
* still refuses an unsafe identifier inside a structured entry (the CONTROL)
121+
* projects `alias` as the column name · no self-rename for alias === field
122+
* the string-form control · all five date-bucket refusals · both parity cases
123+
* ```
124+
*
125+
* Not one failure came through a comparison: the restored gate throws before
126+
* any SQL is built, which is why the guard could not simply be deleted from the
127+
* `field` position and why the control above is the case that holds it.
128+
*
129+
* ⚠️ No `dist/` leg applies to this ablation. The suite imports the mutated
130+
* unit as `./remote-transport.js` — a RELATIVE, in-package specifier that
131+
* vitest resolves to `src/remote-transport.ts`, not through the package's
132+
* `exports` — and `vitest.config.ts` declares no alias (it sets
133+
* `disableConsoleIntercept` and nothing else). The mutation was proved on disk
134+
* by grep counts on both the injected and the removed text and by
135+
* `git hash-object` against the HEAD blob, and the restore by the same hash
136+
* matching again plus an empty `git diff HEAD`.
137+
*
138+
* # Reverse verification (#6212) — direction predicted BEFORE it was run, per case
67139
*
68140
* Restore `const groupBy: string[] = Array.isArray(query?.groupBy) ? … : []`
69141
* (with a cast, since the narrowed signature no longer permits it):
@@ -115,9 +187,11 @@
115187
* rather than merely that something was thrown.
116188
*/
117189

118-
import { describe, it, expect, vi } from 'vitest';
190+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
119191
import { SqlDriver } from '@objectstack/driver-sql';
120192
import { RemoteTransport } from './remote-transport.js';
193+
import { TursoDriver } from './turso-driver.js';
194+
import { makeLibsqlSqliteStub, asLibsqlClient, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';
121195

122196
interface WireBearingError extends Error {
123197
code?: string;
@@ -218,25 +292,59 @@ describe('[#6212] RemoteTransport compiles the GroupByNode union', () => {
218292
expect(calls[0].sql).toBe('SELECT "stage", count("stage") AS "n" FROM "deal" GROUP BY "stage"');
219293
});
220294

221-
it('[#6401] refuses an unsafe identifier in `alias`, not only in `field`', async () => {
222-
// The alias is caller-supplied text that now reaches the statement as a
223-
// quoted identifier, so it needs the gate `field` already has. The
224-
// assertion names the OFFENDING TEXT, not just the sentence (#6144): a
225-
// `field` that is itself safe is what makes this case reach the alias
226-
// check at all.
295+
it('[#14235] ESCAPES an alias that would close the quoting — one inert name, one statement', async () => {
296+
// ⚠️ This case REPLACES the #6401 pin that asserted the same input is
297+
// REFUSED (`unsafe identifier rejected`, naming the offending text). That
298+
// pin was the deliberate call when the alias was newly read here, and
299+
// reversing it is a recorded, non-silent reversal rather than a rider:
300+
// #13714 routed BOTH of driver-sql's output-name positions through
301+
// `aliasIdentifierSql`, #14113 moved this transport's aggregation alias
302+
// to escaping, and #14235 brings the second output-name position of the
303+
// same method to the same line. An output-column key is a NAME: it is
304+
// quoted and escaped, never gated.
305+
//
306+
// The old assertion is REPLACED, not dropped — the exact input it named
307+
// is the input here, and what is pinned now is the statement it produces.
227308
const { t, calls } = transportWithCapturingClient();
228-
const err = await t
229-
.aggregate('deal', {
230-
groupBy: [{ field: 'stage', alias: 'bucket"; DROP TABLE deal; --' }],
231-
aggregations: [{ function: 'count', alias: 'n' }],
232-
})
233-
.then(
234-
() => { throw new Error('expected the transport to refuse an unsafe alias'); },
235-
(e) => e as Error,
309+
const rows = await t.aggregate('deal', {
310+
groupBy: [{ field: 'stage', alias: 'bucket"; DROP TABLE deal; --' }],
311+
aggregations: [{ function: 'count', alias: 'n' }],
312+
});
313+
expect(rows).toEqual([]);
314+
// The whole payload is ONE column name, the quote doubled — the standard
315+
// escape inside a quoted SQL identifier. It is data, never grammar.
316+
expect(calls).toHaveLength(1);
317+
expect(calls[0].sql).toBe(
318+
'SELECT "stage" AS "bucket""; DROP TABLE deal; --", count(*) AS "n" FROM "deal" GROUP BY "stage"',
319+
);
320+
// ⛔ ONE statement, not two: a payload that had broken out of its quoting
321+
// would appear as a second one here. The executing block at the foot of
322+
// this file proves the same thing against a real database, which is the
323+
// only instrument that tells "escaped" apart from "broke out".
324+
expect(calls[0].sql.match(/SELECT/g)).toHaveLength(1);
325+
// And the grouping key is still the FIELD, exactly as for a bare alias.
326+
expect(calls[0].sql.endsWith('GROUP BY "stage"')).toBe(true);
327+
});
328+
329+
it('[#14235] a dotted or spaced alias round-trips as one quoted output column', async () => {
330+
// The reachable population the card measured: a caller writing
331+
// `groupBy: [{ field, alias }]` through the Query Protocol directly.
332+
// Both spellings work on the in-memory, MongoDB and SQL faces and were an
333+
// opaque 500 on this one — no `code`, no `status`, out of `mapDataError`.
334+
for (const alias of ['Region Name', 'deal.stage_bucket']) {
335+
const { t, calls } = transportWithCapturingClient();
336+
await t.aggregate('deal', {
337+
groupBy: [{ field: 'stage', alias }],
338+
aggregations: [{ function: 'count', field: 'stage', alias: 'n' }],
339+
});
340+
expect(calls).toHaveLength(1);
341+
expect(calls[0].sql).toBe(
342+
`SELECT "stage" AS "${alias}", count("stage") AS "n" FROM "deal" GROUP BY "stage"`,
236343
);
237-
expect(err.message).toContain('unsafe identifier rejected');
238-
expect(err.message).toContain('bucket"; DROP TABLE deal; --');
239-
expect(calls).toEqual([]);
344+
// ⛔ The dot stays INSIDE the quotes. The failure this rules out is a
345+
// face that reads an output NAME as a qualified REFERENCE.
346+
expect(calls[0].sql).not.toContain('"deal"."stage_bucket"');
347+
}
240348
});
241349

242350
it('still refuses an unsafe identifier inside a structured entry', async () => {
@@ -394,4 +502,70 @@ describe('[#6212] RemoteTransport compiles the GroupByNode union', () => {
394502
expect(err.message).toContain("dialect 'better-sqlite3'");
395503
});
396504
});
505+
506+
// ── [#14235] Executed, not merely emitted ─────────────────────────────────
507+
508+
/**
509+
* ⚠️ Only EXECUTING the statement tells "escaped" apart from "broke out" —
510+
* #14113's reasoning one position over, and the reason its pin is backed by a
511+
* real database rather than a captured string. A capture assertion alone
512+
* passes on an alias that terminates the quoting, because the text still
513+
* *looks* like a select list. libsql IS SQLite, so the stub runs exactly what
514+
* this transport emits: an alias that escaped its quoting is a syntax error
515+
* (or a second statement better-sqlite3 refuses to prepare), and reading the
516+
* value back under the literal alias is the proof that it did not.
517+
*/
518+
describe('[#14235] the escaped groupBy alias is inert against a real database', () => {
519+
const DEAL = {
520+
name: 'deal',
521+
fields: { id: { type: 'string' }, stage: { type: 'string' }, amount: { type: 'number' } },
522+
};
523+
let driver: TursoDriver;
524+
let stub: LibsqlSqliteStub;
525+
526+
beforeAll(async () => {
527+
stub = makeLibsqlSqliteStub();
528+
driver = new TursoDriver({ url: 'libsql://groupby-alias.turso.io', client: asLibsqlClient(stub) });
529+
await driver.connect();
530+
// The mode this block is about — the one with its own hand-written SQL.
531+
expect(driver.transportMode).toBe('remote');
532+
await driver.syncSchema(DEAL.name, DEAL);
533+
for (const row of [
534+
{ id: '1', stage: 'won', amount: 10 },
535+
{ id: '2', stage: 'won', amount: 20 },
536+
{ id: '3', stage: 'lost', amount: 30 },
537+
]) {
538+
await driver.create(DEAL.name, { ...row });
539+
}
540+
});
541+
542+
afterAll(async () => {
543+
await driver.disconnect();
544+
stub.close();
545+
});
546+
547+
it('a dotted alias comes back under the result key the caller asked for, on rows', async () => {
548+
const rows = (await driver.aggregate(DEAL.name, {
549+
object: DEAL.name,
550+
groupBy: [{ field: 'stage', alias: 'deal.stage_bucket' }],
551+
aggregations: [{ function: 'sum', field: 'amount', alias: 'deal.total' }],
552+
} as never)) as Array<Record<string, unknown>>;
553+
const byBucket = Object.fromEntries(rows.map((r) => [r['deal.stage_bucket'], r['deal.total']]));
554+
// A dot is inert inside a quoted identifier — the whole claim of the card.
555+
expect(byBucket).toEqual({ won: 30, lost: 30 });
556+
});
557+
558+
it('an alias that tries to close the quoting and append a statement leaves the table standing', async () => {
559+
const alias = 'bucket"; DROP TABLE deal; --';
560+
const rows = (await driver.aggregate(DEAL.name, {
561+
object: DEAL.name,
562+
groupBy: [{ field: 'stage', alias }],
563+
aggregations: [{ function: 'count', alias: 'n' }],
564+
} as never)) as Array<Record<string, unknown>>;
565+
// The payload came back as a COLUMN NAME — it was data, never grammar.
566+
expect(rows.map((r) => r[alias]).sort()).toEqual(['lost', 'won']);
567+
// And the table it named is still there, with every row.
568+
expect(stub.raw.prepare('select count(*) as c from deal').all()).toEqual([{ c: 3 }]);
569+
});
570+
});
397571
});

0 commit comments

Comments
 (0)