@ultimat3/db 24.0.0 → 25.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sql-scan.ts CHANGED
@@ -3,10 +3,27 @@
3
3
  // disagreed with a guard about where a literal ends is a `;` sent as data or a `delete` read as
4
4
  // prose, and the two answers must be the same answer.
5
5
 
6
- const IDENTIFIER_START = /[A-Za-z_]/;
7
- export const IDENTIFIER_PART = /[A-Za-z0-9_]/;
8
- /** `$` is legal in an identifier after the first character — `a$b` is one name, not three. */
9
- const IDENTIFIER_TAIL = /[A-Za-z0-9_$]/;
6
+ /**
7
+ * Postgres's `ident_start`/`dolq_start`: any non-ASCII character too (`\200-\377` per UTF-8 byte),
8
+ * so `$é$` opens a body and `é$x$` is one name — read against a server, `sql-scan.test.ts`.
9
+ */
10
+ const IDENTIFIER_START = /[A-Za-z_\u0080-\uffff]/;
11
+ /** A tag's `dolq_cont`: an identifier character without the `$`, non-ASCII included. */
12
+ const TAG_PART = /[A-Za-z0-9_\u0080-\uffff]/;
13
+ /**
14
+ * `ident_cont`: the one class every word scan in this package reads a name with. `$` is legal
15
+ * after the first character (`a$b` is one name, not three) and so is any non-ASCII one — an
16
+ * ASCII-only scan found `col` inside `écol` and never found `prénom` at all.
17
+ */
18
+ export const IDENTIFIER_CHAR = /[A-Za-z0-9_$\u0080-\uffff]/;
19
+
20
+ /**
21
+ * An unquoted name as a UTF8 server stores it: ASCII folded to lower case and nothing else —
22
+ * `Évent` stays `Évent` (read against a server, `dependent-view.test.ts`). `toLowerCase()` folded
23
+ * the `É` too and named a column the catalog does not hold.
24
+ */
25
+ export const foldIdentifier = (name: string): string =>
26
+ name.replace(/[A-Z]+/g, (run) => run.toLowerCase());
10
27
 
11
28
  export type NoiseKind = 'line-comment' | 'block-comment' | 'string' | 'identifier' | 'dollar-body';
12
29
 
@@ -23,11 +40,13 @@ function skipLineComment(script: string, index: number): number {
23
40
  }
24
41
 
25
42
  /**
26
- * Past a block comment. Postgres **nests** them, so the depth is counted rather than matched to
27
- * the first terminator — a commented-out block that itself contains a comment closes once, and
28
- * every `;` after that point would otherwise be read as data.
43
+ * Past a block comment opening at `index`, or `null` when it never closes. Postgres **nests**
44
+ * them, so the depth is counted rather than matched to the first terminator — a commented-out
45
+ * block that itself contains a comment closes once, and every `;` after that point would otherwise
46
+ * be read as data. Exported for `@ultimat3/mcp`'s read-only check, which REFUSES an unclosed
47
+ * comment where this lexer runs it to the end: one rule for where a comment ends, two readers.
29
48
  */
30
- function skipBlockComment(script: string, index: number): number {
49
+ export function endOfBlockComment(script: string, index: number): number | null {
31
50
  let depth = 0;
32
51
  let at = index;
33
52
  while (at < script.length) {
@@ -46,9 +65,13 @@ function skipBlockComment(script: string, index: number): number {
46
65
  }
47
66
  at += 1;
48
67
  }
49
- return script.length;
68
+ return null;
50
69
  }
51
70
 
71
+ /** An unclosed comment runs to the end: Postgres reports that syntax error, not this lexer. */
72
+ const skipBlockComment = (script: string, index: number): number =>
73
+ endOfBlockComment(script, index) ?? script.length;
74
+
52
75
  /**
53
76
  * Past a run closing on `quote`, where a doubled quote is an escaped one — `'it''s'` and
54
77
  * `"a""b"` are each one token. `escapes` is the `E''` dialect, the only one where a backslash
@@ -84,7 +107,7 @@ function skipQuoted(script: string, index: number, quote: string, escapes: boole
84
107
  */
85
108
  function insideIdentifier(script: string, index: number): boolean {
86
109
  let at = index - 1;
87
- while (at >= 0 && IDENTIFIER_TAIL.test(script[at] ?? '')) at -= 1;
110
+ while (at >= 0 && IDENTIFIER_CHAR.test(script[at] ?? '')) at -= 1;
88
111
  const first = script[at + 1];
89
112
  return at + 1 < index && first !== undefined && IDENTIFIER_START.test(first);
90
113
  }
@@ -104,7 +127,7 @@ export function dollarTagAt(script: string, index: number): string | null {
104
127
  let at = index + 1;
105
128
  while (at < script.length) {
106
129
  const char = script[at] ?? '';
107
- const valid = at === index + 1 ? IDENTIFIER_START.test(char) : IDENTIFIER_PART.test(char);
130
+ const valid = at === index + 1 ? IDENTIFIER_START.test(char) : TAG_PART.test(char);
108
131
  if (!valid) break;
109
132
  at += 1;
110
133
  }
@@ -125,7 +148,9 @@ function escapesAt(script: string, index: number): boolean {
125
148
  const prefix = script[index - 1];
126
149
  if (prefix !== 'E' && prefix !== 'e') return false;
127
150
  const before = script[index - 2];
128
- return before === undefined || !IDENTIFIER_PART.test(before);
151
+ // `IDENTIFIER_CHAR`, never an ASCII-only class: `éE` and `a$E` are names to Postgres,
152
+ // and reading their `E` as a prefix let a `\'` swallow the `;` before a `drop table`.
153
+ return before === undefined || !IDENTIFIER_CHAR.test(before);
129
154
  }
130
155
 
131
156
  /**
@@ -1,23 +0,0 @@
1
- // TEST-ONLY. The two builders every drift suite compares with — a `TableDescription` of text
2
- // columns and the `SchemaDescription` around it. One copy, because three suites arguing about
3
- // schemas built differently would each be judging a different fixture. Never exported from
4
- // `index.ts`.
5
-
6
- import type { SchemaDescription, TableDescription } from './introspect';
7
-
8
- export const table = (name: string, columns: readonly string[]): TableDescription => ({
9
- schema: 'public',
10
- name,
11
- columns: columns.map((column, index) => ({
12
- name: column,
13
- dataType: 'text',
14
- nullable: true,
15
- default: null,
16
- position: index + 1,
17
- })),
18
- primaryKey: ['id'],
19
- indexes: [],
20
- foreignKeys: [],
21
- });
22
-
23
- export const schema = (...tables: readonly TableDescription[]): SchemaDescription => ({ tables });
@@ -1,32 +0,0 @@
1
- // Single responsibility: the PGlite driver fake the adapter tests record statements against.
2
- // Shared rather than copied for the same reason `fake-reservable.ts` is: the assertion in every
3
- // one of these tests is the recorded ORDER, and two copies of the recorder drift into two orders.
4
-
5
- import type { PgliteDriver, PgliteResult } from './pglite';
6
-
7
- /** One statement as the driver received it — the text after binding, and the bound values. */
8
- export interface Recorded {
9
- readonly text: string;
10
- readonly values: readonly unknown[];
11
- }
12
-
13
- export type RecordingPgliteDriver = PgliteDriver & {
14
- readonly calls: Recorded[];
15
- closed: number;
16
- };
17
-
18
- /** A driver that answers every statement with `result` and remembers the order it saw them in. */
19
- export function fakeDriver(result: PgliteResult): RecordingPgliteDriver {
20
- const calls: Recorded[] = [];
21
- return {
22
- calls,
23
- closed: 0,
24
- async query(text, values) {
25
- calls.push({ text, values: values ?? [] });
26
- return result;
27
- },
28
- async close() {
29
- this.closed += 1;
30
- },
31
- };
32
- }
@@ -1,50 +0,0 @@
1
- // Single responsibility: a `ReservableClient` whose pin is countable, wrapped over any `DbClient`.
2
- // The leak it exists to catch is invisible to the recording client — the statements are identical
3
- // whether or not the reservation ever came back, and only the counter says which happened. Shared
4
- // rather than copied: a second copy of a fixture asserts less than the first, silently.
5
-
6
- import type { DbClient, ReservableClient } from './client';
7
-
8
- /** Pins taken and pins given back. A leak is `reserves > releases`; the fix makes them equal. */
9
- export interface PinCounts {
10
- reserves: number;
11
- releases: number;
12
- }
13
-
14
- export interface ReservableFake {
15
- readonly client: ReservableClient;
16
- readonly pins: PinCounts;
17
- }
18
-
19
- /**
20
- * Wrap `inner` in a pool that counts its reservations. `release()` is idempotent and
21
- * `[Symbol.dispose]` is the same call, exactly as both real drivers are — so a double release
22
- * counts once and only a genuine leak leaves the counters uneven.
23
- */
24
- export function reservableOver(inner: DbClient): ReservableFake {
25
- const pins: PinCounts = { reserves: 0, releases: 0 };
26
- return {
27
- pins,
28
- client: {
29
- query: (fragment) => inner.query(fragment),
30
- one: (fragment) => inner.one(fragment),
31
- execute: (fragment) => inner.execute(fragment),
32
- reserve: async () => {
33
- pins.reserves += 1;
34
- let held = true;
35
- const release = (): void => {
36
- if (!held) return;
37
- held = false;
38
- pins.releases += 1;
39
- };
40
- return {
41
- query: (fragment) => inner.query(fragment),
42
- one: (fragment) => inner.one(fragment),
43
- execute: (fragment) => inner.execute(fragment),
44
- release,
45
- [Symbol.dispose]: release,
46
- };
47
- },
48
- },
49
- };
50
- }