@suss/sql 0.33.1 → 0.34.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/README.md CHANGED
@@ -6,7 +6,7 @@ Reads what a SQL statement touches: which tables, which fields, and what it pick
6
6
 
7
7
  ## What this package is
8
8
 
9
- A reader, not a pack. A pattern pack that meets a query written as SQL rather than through an ORM hands the text here and gets back the same shape its own recognizer would have produced.
9
+ This package is a reader and does not register as a pack. When a pattern pack finds a query written as SQL instead of through an ORM, it passes the text here and gets back the same shape its own recognizer would have produced.
10
10
 
11
11
  ```ts
12
12
  import { readSqlAccess } from "@suss/sql";
@@ -15,44 +15,44 @@ readSqlAccess("SELECT id, email FROM users WHERE tenant_id = $1");
15
15
  // [{ table: "users", qualifier: [], kind: "read", fields: ["id", "email"], selector: ["tenant_id"] }]
16
16
  ```
17
17
 
18
- `qualifier` is the namespaces written in front of the table, outermost first. Postgres and MySQL hand the schema back separately and it never reaches the table, so those are always empty. BigQuery addresses a table as `` `project.dataset.table` `` and the parser hands back the whole quoted string, so this splits it: the table is the one a provider declares, and the dataset is the namespace to record the access under.
18
+ `qualifier` lists the namespaces written in front of the table, outermost first. For Postgres and MySQL the parser returns the schema separately and it never reaches the table, so `qualifier` is always empty there. BigQuery addresses a table as `` `project.dataset.table` ``, and the parser returns the whole quoted string, so this package splits it. The table is the one a provider declares, and the dataset is the namespace the access is recorded under.
19
19
 
20
20
  ```ts
21
21
  readSqlAccess("SELECT id FROM `analytics.core.dim_account`", { dialect: "bigquery" });
22
22
  // [{ table: "dim_account", qualifier: ["analytics", "core"], ... }]
23
23
  ```
24
24
 
25
- A pack that reads a table off a call's argument rather than out of a statement, the way `client.get_table("analytics.core.dim_account")` states one, splits it through `splitQualifiedTable` so both spellings land on the same table and the same namespace. It gives back nothing for a table that came through as a parameter.
25
+ Some packs read a table off a call's argument, as in `client.get_table("analytics.core.dim_account")`, and not out of a statement. They split it through `splitQualifiedTable`, so both spellings land on the same table and the same namespace. It returns nothing for a table that came through as a parameter.
26
26
 
27
27
  ```ts
28
28
  splitQualifiedTable("analytics.core.dim_account");
29
29
  // { table: "dim_account", qualifier: ["analytics", "core"] }
30
30
  ```
31
31
 
32
- The statement is parsed rather than pattern-matched, so a join contributes every table it reads:
32
+ The statement is parsed with a grammar, so a join contributes every table it reads:
33
33
 
34
34
  ```ts
35
35
  readSqlAccess("SELECT u.email, o.total FROM users u JOIN orders o ON o.user_id = u.id");
36
36
  // [{ table: "users", fields: ["email"], ... }, { table: "orders", fields: ["total"], ... }]
37
37
  ```
38
38
 
39
- A query written in place of a table is read for its own tables. The alias belongs to that query's columns rather than to a base table, so a column read through the alias is dropped rather than attributed to one:
39
+ A query written in place of a table is read for its own tables. The alias refers to that query's columns and not to a base table, so a column read through the alias is dropped instead of being attributed to a table:
40
40
 
41
41
  ```ts
42
42
  readSqlAccess("SELECT dc.account_id FROM (SELECT account_id FROM dim_contact) dc JOIN dim_account a ON a.id = dc.account_id");
43
43
  // [{ table: "dim_account", fields: [], ... }, { table: "dim_contact", fields: ["account_id"], ... }]
44
44
  ```
45
45
 
46
- A write reads too. An insert can take its rows from a query, and an update or a delete can reach another table to pick the rows it changes:
46
+ A write can read too. An insert can take its rows from a query, and an update or a delete can reach another table to pick the rows it changes:
47
47
 
48
48
  ```ts
49
49
  readSqlAccess("INSERT INTO runs (id) SELECT id FROM jobs");
50
50
  // [{ table: "runs", kind: "write", fields: ["id"], ... }, { table: "jobs", kind: "read", fields: ["id"], ... }]
51
51
  ```
52
52
 
53
- Every branch of a set operation is read, whether the statement writes it on its own, inside a `WITH` clause, or in place of a table. `SELECT id FROM orders UNION SELECT id FROM refunds` gives back a read on `orders` and one on `refunds`, each with the field `id`.
53
+ Every branch of a set operation is read, whether the statement writes it on its own, inside a `WITH` clause, or in place of a table. `SELECT id FROM orders UNION SELECT id FROM refunds` returns a read on `orders` and one on `refunds`, each with the field `id`.
54
54
 
55
- Wherever a field turns up, it goes on the table the statement qualified it to, and a `WHERE` puts it in the selector rather than the fields. So the table an update reads from lists its join key the way a table a select joins to would:
55
+ Wherever a field appears, it goes on the table the statement qualified it with. A field in a `WHERE` goes in the selector and not in the fields. So the table an update reads from lists its join key, the same way a table joined in a select would:
56
56
 
57
57
  ```ts
58
58
  readSqlAccess("UPDATE runs SET state = $1 FROM jobs WHERE runs.job_id = jobs.id");
@@ -60,24 +60,24 @@ readSqlAccess("UPDATE runs SET state = $1 FROM jobs WHERE runs.job_id = jobs.id"
60
60
  // { table: "jobs", kind: "read", fields: [], selector: ["id"] }]
61
61
  ```
62
62
 
63
- What a `RETURNING` hands back, and what an `ON CONFLICT` matches on and then sets, are all columns of the one table the statement writes, so they go on that write rather than on a second access: a consumer pairs one access per table against the schema, and a field the statement both writes and hands back is listed once. `INSERT INTO users (email) VALUES ($1) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name RETURNING id` writes `users` with the fields `email`, `id` and `name`.
63
+ The columns a `RETURNING` returns, and the columns an `ON CONFLICT` matches on and then sets, all belong to the one table the statement writes. So they go on that write, and no second access is created. A consumer pairs one access per table against the schema, and a field the statement both writes and returns is listed once. `INSERT INTO users (email) VALUES ($1) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name RETURNING id` writes `users` with the fields `email`, `id` and `name`.
64
64
 
65
- Not every grammar takes every spelling of these. `UPDATE ... FROM` is Postgres alone, and BigQuery parses the statement but drops the clause, so there it gives back the write by itself. No grammar here takes `DELETE ... USING`. `INTERSECT` and `EXCEPT` are Postgres and MySQL, `RETURNING` is Postgres and SQLite, and `ON CONFLICT` is Postgres, where the other three grammars take `ON DUPLICATE KEY UPDATE` instead.
65
+ Not every grammar accepts every spelling of these. Only Postgres accepts `UPDATE ... FROM`. BigQuery parses the statement but drops the clause, so there it returns the write by itself. No grammar here accepts `DELETE ... USING`. `INTERSECT` and `EXCEPT` work in Postgres and MySQL, and `RETURNING` in Postgres and SQLite. `ON CONFLICT` is Postgres only, and the other three grammars accept `ON DUPLICATE KEY UPDATE` instead.
66
66
 
67
- The grammar behind this turns down two spellings that say nothing about which table is touched, so a statement it refuses is tried again without them: a parameter written with the type it is read as, such as `$1::text` or `$1::int[]`, and a select that locks the rows it picks, such as `FOR UPDATE SKIP LOCKED`. Neither is rewritten inside a literal, a quoted name or a comment.
67
+ The grammar rejects two spellings that have nothing to do with which table is touched, so a statement it rejects is tried again with them removed. One is a parameter written with the type it is read as, such as `$1::text` or `$1::int[]`. The other is a select that locks the rows it picks, such as `FOR UPDATE SKIP LOCKED`. Neither is rewritten inside a literal, a quoted name or a comment.
68
68
 
69
- The grammar also takes a `WITH` clause only in front of a select. In front of an insert, an update or a delete the clause is split off and each query read on its own, which is the most that can be read of a statement the grammar will not take whole:
69
+ The grammar also accepts a `WITH` clause only in front of a select. In front of an insert, an update or a delete, the clause is split off and each query is read on its own. That is as much as can be read of a statement the grammar will not accept whole:
70
70
 
71
71
  ```ts
72
72
  readSqlAccess("WITH due AS (SELECT id FROM jobs FOR UPDATE SKIP LOCKED) INSERT INTO job_runs (job_id) SELECT id FROM due");
73
73
  // [{ table: "job_runs", kind: "write", fields: ["job_id"], ... }, { table: "jobs", kind: "read", fields: ["id"], ... }]
74
74
  ```
75
75
 
76
- A query written as a tagged template becomes readable through `sqlFromParts`, which writes each interpolation as a parameter. What a query interpolates is a value nearly every time, and a parameter is how the statement would supply one anyway.
76
+ `sqlFromParts` makes a query written as a tagged template readable, by writing each interpolation as a parameter. A query interpolates a value nearly every time, and a parameter is how the statement would supply a value anyway.
77
77
 
78
- Two things override that. A caller who knows a hole is a table passes it in `substitutions`, which is how a Drizzle query that interpolates a schema object reaches the statement as a table name. And a caller who ran the source's own evaluator over each hole passes the results in `settled`, which are used where the statement writes a name.
78
+ Two options override that. A caller who knows a hole is a table passes it in `substitutions`. That is how a Drizzle query that interpolates a schema object reaches the statement as a table name. A caller who ran the source's own evaluator over each hole passes the results in `settled`, and they are used where the statement writes a name.
79
79
 
80
- There are two of those places. One is inside a quoted name, which is how BigQuery addresses a table:
80
+ The statement writes a name in two places. One is inside a quoted name, which is how BigQuery addresses a table:
81
81
 
82
82
  ```ts
83
83
  // `SELECT id FROM \`${TABLE}\`` with TABLE = "analytics.core.dim_account"
@@ -85,7 +85,7 @@ sqlFromParts(["SELECT id FROM `", "`"], [], ["analytics.core.dim_account"]);
85
85
  // SELECT id FROM `analytics.core.dim_account`
86
86
  ```
87
87
 
88
- The other is straight after `FROM`, `JOIN`, `INTO`, `UPDATE` or `TABLE`, in any case. Postgres code leaves the table unquoted nearly every time, so the quote alone would miss the commonest way a project interpolates one:
88
+ The other is directly after `FROM`, `JOIN`, `INTO`, `UPDATE` or `TABLE`, in any case. Postgres code nearly always leaves the table unquoted, so checking for the quote alone would miss the most common way a project interpolates a table:
89
89
 
90
90
  ```ts
91
91
  // `SELECT id FROM ${TABLE} WHERE id = $1` with TABLE = "users"
@@ -93,9 +93,9 @@ sqlFromParts(["SELECT id FROM ", " WHERE id = $1"], [], ["users"]);
93
93
  // SELECT id FROM users WHERE id = $1
94
94
  ```
95
95
 
96
- A hole anywhere else is left alone even when the evaluator settled it. `WHERE tier = ${TIER}` with `TIER = "gold"` would parse as a column called `gold` and put it in the selector, which is worse than the parameter the reader would otherwise see. Where the statement writes a name there is no such ambiguity: whatever the hole came to is part of the name.
96
+ A hole anywhere else is left alone, even when the evaluator settled it. `WHERE tier = ${TIER}` with `TIER = "gold"` would parse as a column called `gold` and put it in the selector, which is worse than the parameter the reader would otherwise see. Where the statement writes a name, there is no such ambiguity, because whatever the hole came to is part of the name.
97
97
 
98
- A part of a name nothing settled stays a parameter, and the qualifier is then read from the table outward and stops there. The part beside the table is the one a scope comes from, so a project read as though it were a dataset would place the access somewhere it never went:
98
+ A part of a name that nothing settled stays a parameter. The qualifier is then read from the table outward, and stops at that parameter. The part next to the table is the one a scope comes from, so reading a project as if it were a dataset would place the access somewhere it never went:
99
99
 
100
100
  ```
101
101
  analytics.core.dim_account → dim_account, ["analytics", "core"]
@@ -104,9 +104,9 @@ analytics.$1.dim_account → dim_account, []
104
104
  $1 → nothing
105
105
  ```
106
106
 
107
- A whole table nothing settled produces nothing at all.
107
+ A whole table that nothing settled produces nothing at all.
108
108
 
109
- It reads Postgres, MySQL, SQLite, and BigQuery. Pass the dialect the way a pack states its store:
109
+ It reads Postgres, MySQL, SQLite, and BigQuery. Pass the dialect the same way a pack declares its store:
110
110
 
111
111
  ```ts
112
112
  readSqlAccess("SELECT `id` FROM `users`", { dialect: "mysql" });
@@ -114,12 +114,12 @@ readSqlAccess("SELECT `id` FROM `users`", { dialect: "mysql" });
114
114
 
115
115
  ## What it will not tell you
116
116
 
117
- - **A dialect it does not read gives back nothing.** So does a statement it cannot parse. Neither produces a guess built out of whatever the text happens to spell.
118
- - **An unqualified field in a join is left out.** `SELECT id FROM users u JOIN orders o ON ...` says nothing about which table `id` is on, so it is dropped rather than attributed to both. A query written in place of a table counts as one of the sides, since it could have supplied the field too.
117
+ - **A dialect it does not read returns nothing.** So does a statement it cannot parse. In both cases it does not guess from whatever words the text happens to contain.
118
+ - **An unqualified field in a join is left out.** `SELECT id FROM users u JOIN orders o ON ...` does not show which table `id` is on, so the field is dropped and not attributed to both. A query written in place of a table counts as one of the sides, since it could have supplied the field too.
119
119
  - **An interpolated table or clause makes a statement unreadable.** ``sql`SELECT * FROM ${table}` `` cannot be settled without running it, so it produces nothing.
120
120
 
121
121
  ## Where it fits in suss
122
122
 
123
123
  `@suss/framework-drizzle` uses it for ``db.execute(sql`...`)``. Nothing else depends on it, and it depends on nothing inside suss, so it stays a plain function over a string.
124
124
 
125
- The grammars come from `node-sql-parser` and are bundled into this package's build rather than installed, which is why it has no runtime dependencies. See THIRD-PARTY-NOTICES.md.
125
+ The grammars come from `node-sql-parser`. They are bundled into this package's build and not installed, which is why the package has no runtime dependencies. See THIRD-PARTY-NOTICES.md.
package/dist/index.cjs CHANGED
@@ -22008,8 +22008,16 @@ var import_postgresql = __toESM(require_postgresql(), 1);
22008
22008
  var import_sqlite = __toESM(require_sqlite(), 1);
22009
22009
  var DIALECTS = {
22010
22010
  postgresql: { module: import_postgresql.default, database: "postgresql" },
22011
- mysql: { module: import_mysql.default, database: "mysql" },
22012
- sqlite: { module: import_sqlite.default, database: "sqlite" },
22011
+ mysql: {
22012
+ module: import_mysql.default,
22013
+ database: "mysql",
22014
+ bindsByPosition: true
22015
+ },
22016
+ sqlite: {
22017
+ module: import_sqlite.default,
22018
+ database: "sqlite",
22019
+ bindsByPosition: true
22020
+ },
22013
22021
  bigquery: { module: import_bigquery.default, database: "bigquery" }
22014
22022
  };
22015
22023
  function readSqlAccess(sql, options = {}) {
@@ -22017,7 +22025,27 @@ function readSqlAccess(sql, options = {}) {
22017
22025
  if (grammar === void 0) {
22018
22026
  return [];
22019
22027
  }
22020
- return accessesInSql(sql, grammar).map(qualified).filter((access) => access !== null);
22028
+ const numbered = grammar.bindsByPosition === true ? numberedBinds(sql) : sql;
22029
+ return accessesInSql(numbered, grammar).map(qualified).filter((access) => access !== null).map(
22030
+ (access) => options.placeholders === true ? access : withoutPlaceholders(access)
22031
+ );
22032
+ }
22033
+ function numberedBinds(sql) {
22034
+ let count = 0;
22035
+ return sql.replace(
22036
+ /'(?:[^']|'')*'|"(?:[^"]|"")*"|`[^`]*`|-{2}[^\n]*|\/\*[\s\S]*?\*\/|\?/g,
22037
+ (token) => {
22038
+ if (token !== "?") {
22039
+ return token;
22040
+ }
22041
+ count += 1;
22042
+ return `$${count}`;
22043
+ }
22044
+ );
22045
+ }
22046
+ function withoutPlaceholders(access) {
22047
+ const { placeholders: _left, ...rest } = access;
22048
+ return rest;
22021
22049
  }
22022
22050
  function accessesInSql(sql, grammar) {
22023
22051
  const written = parsedAccesses(sql, grammar);
@@ -22317,7 +22345,8 @@ function insertAccesses(node, defined) {
22317
22345
  ...refsIn(node.returning),
22318
22346
  ...conflict.fields
22319
22347
  ],
22320
- selector: conflict.selector
22348
+ selector: conflict.selector,
22349
+ bound: boundInRows(namesOf(node.columns), node.values)
22321
22350
  };
22322
22351
  const sources = sourcesIn(node.table, defined);
22323
22352
  return [
@@ -22329,7 +22358,8 @@ function updateAccesses(node, defined) {
22329
22358
  const sources = sourcesAcross([node.table, node.from], defined);
22330
22359
  const stated = {
22331
22360
  fields: [...setTargets(node.set), ...refsIn(node.returning)],
22332
- selector: refsIn(node.where)
22361
+ selector: refsIn(node.where),
22362
+ bound: [...boundInSet(node.set), ...boundInWhere(node.where)]
22333
22363
  };
22334
22364
  return accessesAcross(sources, firstTable(node.table), stated, defined);
22335
22365
  }
@@ -22337,7 +22367,8 @@ function deleteAccesses(node, defined) {
22337
22367
  const written = deletedTable(node);
22338
22368
  const stated = {
22339
22369
  fields: refsIn(node.returning),
22340
- selector: refsIn(node.where)
22370
+ selector: refsIn(node.where),
22371
+ bound: boundInWhere(node.where)
22341
22372
  };
22342
22373
  const sources = withTable(sourcesIn(node.from, defined), written);
22343
22374
  return accessesAcross(sources, written, stated, defined);
@@ -22382,7 +22413,8 @@ function deletedTable(node) {
22382
22413
  function selectAccesses(statement, names) {
22383
22414
  const stated = {
22384
22415
  fields: refsIn(statement.columns),
22385
- selector: refsIn(statement.where)
22416
+ selector: refsIn(statement.where),
22417
+ bound: boundInWhere(statement.where)
22386
22418
  };
22387
22419
  return accessesAcross(sourcesIn(statement.from, names), null, stated, names);
22388
22420
  }
@@ -22391,15 +22423,40 @@ function accessesAcross(sources, written, stated, defined) {
22391
22423
  const only = named.length === 1 && !sources.derived ? named[0] ?? null : null;
22392
22424
  const fields = byTable(stated.fields, sources.tables, written ?? only);
22393
22425
  const selector = byTable(stated.selector, sources.tables, only);
22394
- const own = named.filter((table) => !defined.has(table)).map((table) => ({
22395
- table,
22396
- qualifier: [],
22397
- kind: table === written ? "write" : "read",
22398
- fields: fields.get(table) ?? [],
22399
- selector: selector.get(table) ?? []
22400
- }));
22426
+ const bound = boundByTable(stated.bound, sources.tables, {
22427
+ fields: written ?? only,
22428
+ selector: only
22429
+ });
22430
+ const own = named.filter((table) => !defined.has(table)).map((table) => {
22431
+ const placeholders = bound.get(table) ?? [];
22432
+ return {
22433
+ table,
22434
+ qualifier: [],
22435
+ kind: table === written ? "write" : "read",
22436
+ fields: fields.get(table) ?? [],
22437
+ selector: selector.get(table) ?? [],
22438
+ ...placeholders.length > 0 ? { placeholders } : {}
22439
+ };
22440
+ });
22401
22441
  return [...own, ...sources.inside];
22402
22442
  }
22443
+ function boundByTable(refs, tables, only) {
22444
+ const found = /* @__PURE__ */ new Map();
22445
+ for (const ref of refs) {
22446
+ const table = ref.table === void 0 ? only[ref.clause] : tables.get(ref.table) ?? null;
22447
+ if (table === null) {
22448
+ continue;
22449
+ }
22450
+ const already = found.get(table) ?? [];
22451
+ already.push({
22452
+ field: ref.field,
22453
+ clause: ref.clause,
22454
+ placeholder: ref.placeholder
22455
+ });
22456
+ found.set(table, already);
22457
+ }
22458
+ return found;
22459
+ }
22403
22460
  function byTable(refs, tables, only) {
22404
22461
  const found = /* @__PURE__ */ new Map();
22405
22462
  for (const ref of refs) {
@@ -22496,6 +22553,79 @@ function refsIn(value) {
22496
22553
  walk(value);
22497
22554
  return found;
22498
22555
  }
22556
+ function placeholderOf(value) {
22557
+ const node = asNode(value);
22558
+ if (node?.type === "var" && node.prefix === "$") {
22559
+ return typeof node.name === "number" ? node.name : null;
22560
+ }
22561
+ return node?.type === "param" ? stringOf(node.value) : null;
22562
+ }
22563
+ function boundInWhere(value) {
22564
+ const found = [];
22565
+ const walk = (node) => {
22566
+ if (Array.isArray(node)) {
22567
+ node.forEach(walk);
22568
+ return;
22569
+ }
22570
+ const record = asNode(node);
22571
+ if (record === null) {
22572
+ return;
22573
+ }
22574
+ if (record.type === "binary_expr" && record.operator === "=") {
22575
+ const pair = columnAndPlaceholder(record.left, record.right) ?? columnAndPlaceholder(record.right, record.left);
22576
+ if (pair !== null) {
22577
+ found.push({ ...pair, clause: "selector" });
22578
+ return;
22579
+ }
22580
+ }
22581
+ for (const entry of Object.values(record)) {
22582
+ walk(entry);
22583
+ }
22584
+ };
22585
+ walk(value);
22586
+ return found;
22587
+ }
22588
+ function columnAndPlaceholder(column, placeholder) {
22589
+ const ref = asNode(column);
22590
+ const field = ref?.type === "column_ref" ? columnName(ref.column) : null;
22591
+ const given = placeholderOf(placeholder);
22592
+ if (ref === null || field === null || given === null) {
22593
+ return null;
22594
+ }
22595
+ return { table: stringOf(ref.table) ?? void 0, field, placeholder: given };
22596
+ }
22597
+ function boundInSet(value) {
22598
+ const found = [];
22599
+ for (const entry of Array.isArray(value) ? value : []) {
22600
+ const node = asNode(entry);
22601
+ const field = node === null ? null : columnName(node.column);
22602
+ const given = placeholderOf(node?.value);
22603
+ if (node !== null && field !== null && given !== null) {
22604
+ const table = stringOf(node.table) ?? void 0;
22605
+ found.push({ table, field, clause: "fields", placeholder: given });
22606
+ }
22607
+ }
22608
+ return found;
22609
+ }
22610
+ function boundInRows(columns, values) {
22611
+ const rows = asNode(values)?.values;
22612
+ const found = [];
22613
+ for (const row of Array.isArray(rows) ? rows : []) {
22614
+ const items = asNode(row)?.value;
22615
+ columns.forEach((field, at) => {
22616
+ const given = placeholderOf(Array.isArray(items) ? items[at] : null);
22617
+ if (given !== null) {
22618
+ found.push({
22619
+ table: void 0,
22620
+ field,
22621
+ clause: "fields",
22622
+ placeholder: given
22623
+ });
22624
+ }
22625
+ });
22626
+ }
22627
+ return found;
22628
+ }
22499
22629
  function unqualified(names) {
22500
22630
  return names.map((field) => ({ table: void 0, field }));
22501
22631
  }
package/dist/index.d.cts CHANGED
@@ -1,40 +1,56 @@
1
1
  /**
2
- * What a SQL statement touches: which tables, which fields, and what it
3
- * picks rows by.
2
+ * Reads what a SQL statement touches: which tables, which fields, and
3
+ * what it picks rows by.
4
4
  *
5
- * A pack that meets a query written as SQL rather than through an ORM
6
- * hands the text here and gets the same shape its own recognizer would
7
- * have produced. The statement is parsed rather than pattern-matched,
8
- * so a join contributes every table it reads and a `WHERE` contributes
9
- * a selector.
5
+ * A pack that finds a query written as SQL instead of through an ORM
6
+ * passes the text here and gets back the same `SqlAccess` records its
7
+ * own recognizer would have produced. The statement is parsed with a
8
+ * grammar, so a join contributes every table it reads and a `WHERE`
9
+ * contributes a selector.
10
10
  *
11
- * A statement this cannot parse gives back nothing, which is what a
12
- * reader that cannot settle something says everywhere else. The README
13
- * says which dialects it reads and what it leaves out. A spelling the
14
- * grammar turns down is rewritten and tried again rather than given up.
11
+ * A statement the grammar rejects is rewritten and tried again. If it
12
+ * still fails the result is empty, and nothing is guessed from the
13
+ * words in the text. The README lists the dialects and what is left out.
15
14
  */
16
15
  /** One table a statement touches, and what it does to it. */
17
16
  interface SqlAccess {
18
17
  table: string;
19
18
  /**
20
- * The namespaces the statement put in front of the table, outermost
21
- * first. BigQuery writes `project.dataset.table`, so a reader that
22
- * kept the whole string would record a container no provider spells.
23
- * A part the caller could not settle is left out rather than carried
24
- * through as a parameter.
19
+ * The namespaces written in front of the table, outermost first.
20
+ * BigQuery writes `project.dataset.table`, and keeping the whole
21
+ * string as the table would record a name no provider declares. A
22
+ * part the caller could not resolve, and every part outside it, is
23
+ * left out.
25
24
  */
26
25
  qualifier: string[];
27
26
  kind: "read" | "write";
28
- /** The fields the statement states, or `["*"]` for a whole row. */
27
+ /** The fields the statement lists, or `["*"]` for a whole row. */
29
28
  fields: string[];
30
29
  /** The fields it picks rows by. */
31
30
  selector: string[];
31
+ /**
32
+ * The placeholder that gives a column its value, where the statement
33
+ * writes one: `tenant_id = $1` in a `WHERE`, a column of an `INSERT`
34
+ * and its place under `VALUES`, an assignment in a `SET`. Present only
35
+ * when the caller asked for it and a column is given one.
36
+ */
37
+ placeholders?: SqlPlaceholder[];
38
+ }
39
+ /** One column the statement gives a placeholder's value. */
40
+ interface SqlPlaceholder {
41
+ field: string;
42
+ /** Which of the access's lists the column is in. */
43
+ clause: "fields" | "selector";
44
+ /** `$1` is 1, counted the way the driver counts; `:tenant` is `tenant`. */
45
+ placeholder: number | string;
32
46
  }
33
47
  interface SqlReadOptions {
34
48
  /** Which dialect the statement is written in. */
35
49
  dialect?: string;
50
+ /** Whether to say which placeholder gives each column its value. */
51
+ placeholders?: boolean;
36
52
  }
37
- /** Every table a statement touches, or nothing when it cannot be read. */
53
+ /** Every table a statement touches. Empty when the dialect is unknown or the statement cannot be parsed. */
38
54
  declare function readSqlAccess(sql: string, options?: SqlReadOptions): SqlAccess[];
39
55
  /** A table name, split from the namespaces written in front of it. */
40
56
  interface QualifiedTable {
@@ -43,24 +59,24 @@ interface QualifiedTable {
43
59
  qualifier: string[];
44
60
  }
45
61
  /**
46
- * A table split from the namespaces written in front of it, or nothing
47
- * when the table itself came through as a parameter. A pack that reads
48
- * a table off an argument splits it through here too, so it agrees with
49
- * a table read out of a statement. The README says more.
62
+ * Splits a table name from the namespaces written in front of it.
63
+ * Returns null when the table itself came through as a parameter. A
64
+ * pack that reads a table off a call's argument splits it here too, so
65
+ * the result matches the same table read out of a statement.
50
66
  */
51
67
  declare function splitQualifiedTable(name: string): QualifiedTable | null;
52
68
  /**
53
- * The SQL a tagged template states, with each interpolation written as
54
- * a parameter. What a query interpolates is a value nearly every time,
55
- * and a parameter is how the statement would carry one anyway, so the
56
- * text parses as what it means.
69
+ * Joins a tagged template's parts into SQL, writing each interpolation
70
+ * as a parameter (`$1`, `$2`). A query interpolates a value nearly every
71
+ * time, and a parameter is how the statement would pass a value anyway,
72
+ * so the text parses with the meaning it has at run time.
57
73
  *
58
- * A caller that knows what an interpolation is passes it in
59
- * `substitutions`, which is how a table interpolated as an object
60
- * reaches the statement as its own name. An interpolation nobody can
61
- * settle stays a parameter, and a statement that needed one somewhere a
62
- * parameter cannot go reads as nothing.
74
+ * `substitutions` replaces a hole wherever it is, for a caller that
75
+ * knows the hole is a table, such as an interpolated schema object.
76
+ * `settled` replaces a hole only where the statement writes a name.
77
+ * Any other hole stays a parameter, and a statement with a parameter
78
+ * where the grammar does not allow one parses to no accesses.
63
79
  */
64
80
  declare function sqlFromParts(parts: readonly string[], substitutions?: ReadonlyArray<string | null>, settled?: ReadonlyArray<string | null>): string;
65
81
 
66
- export { type QualifiedTable, type SqlAccess, type SqlReadOptions, readSqlAccess, splitQualifiedTable, sqlFromParts };
82
+ export { type QualifiedTable, type SqlAccess, type SqlPlaceholder, type SqlReadOptions, readSqlAccess, splitQualifiedTable, sqlFromParts };
package/dist/index.d.ts CHANGED
@@ -1,40 +1,56 @@
1
1
  /**
2
- * What a SQL statement touches: which tables, which fields, and what it
3
- * picks rows by.
2
+ * Reads what a SQL statement touches: which tables, which fields, and
3
+ * what it picks rows by.
4
4
  *
5
- * A pack that meets a query written as SQL rather than through an ORM
6
- * hands the text here and gets the same shape its own recognizer would
7
- * have produced. The statement is parsed rather than pattern-matched,
8
- * so a join contributes every table it reads and a `WHERE` contributes
9
- * a selector.
5
+ * A pack that finds a query written as SQL instead of through an ORM
6
+ * passes the text here and gets back the same `SqlAccess` records its
7
+ * own recognizer would have produced. The statement is parsed with a
8
+ * grammar, so a join contributes every table it reads and a `WHERE`
9
+ * contributes a selector.
10
10
  *
11
- * A statement this cannot parse gives back nothing, which is what a
12
- * reader that cannot settle something says everywhere else. The README
13
- * says which dialects it reads and what it leaves out. A spelling the
14
- * grammar turns down is rewritten and tried again rather than given up.
11
+ * A statement the grammar rejects is rewritten and tried again. If it
12
+ * still fails the result is empty, and nothing is guessed from the
13
+ * words in the text. The README lists the dialects and what is left out.
15
14
  */
16
15
  /** One table a statement touches, and what it does to it. */
17
16
  interface SqlAccess {
18
17
  table: string;
19
18
  /**
20
- * The namespaces the statement put in front of the table, outermost
21
- * first. BigQuery writes `project.dataset.table`, so a reader that
22
- * kept the whole string would record a container no provider spells.
23
- * A part the caller could not settle is left out rather than carried
24
- * through as a parameter.
19
+ * The namespaces written in front of the table, outermost first.
20
+ * BigQuery writes `project.dataset.table`, and keeping the whole
21
+ * string as the table would record a name no provider declares. A
22
+ * part the caller could not resolve, and every part outside it, is
23
+ * left out.
25
24
  */
26
25
  qualifier: string[];
27
26
  kind: "read" | "write";
28
- /** The fields the statement states, or `["*"]` for a whole row. */
27
+ /** The fields the statement lists, or `["*"]` for a whole row. */
29
28
  fields: string[];
30
29
  /** The fields it picks rows by. */
31
30
  selector: string[];
31
+ /**
32
+ * The placeholder that gives a column its value, where the statement
33
+ * writes one: `tenant_id = $1` in a `WHERE`, a column of an `INSERT`
34
+ * and its place under `VALUES`, an assignment in a `SET`. Present only
35
+ * when the caller asked for it and a column is given one.
36
+ */
37
+ placeholders?: SqlPlaceholder[];
38
+ }
39
+ /** One column the statement gives a placeholder's value. */
40
+ interface SqlPlaceholder {
41
+ field: string;
42
+ /** Which of the access's lists the column is in. */
43
+ clause: "fields" | "selector";
44
+ /** `$1` is 1, counted the way the driver counts; `:tenant` is `tenant`. */
45
+ placeholder: number | string;
32
46
  }
33
47
  interface SqlReadOptions {
34
48
  /** Which dialect the statement is written in. */
35
49
  dialect?: string;
50
+ /** Whether to say which placeholder gives each column its value. */
51
+ placeholders?: boolean;
36
52
  }
37
- /** Every table a statement touches, or nothing when it cannot be read. */
53
+ /** Every table a statement touches. Empty when the dialect is unknown or the statement cannot be parsed. */
38
54
  declare function readSqlAccess(sql: string, options?: SqlReadOptions): SqlAccess[];
39
55
  /** A table name, split from the namespaces written in front of it. */
40
56
  interface QualifiedTable {
@@ -43,24 +59,24 @@ interface QualifiedTable {
43
59
  qualifier: string[];
44
60
  }
45
61
  /**
46
- * A table split from the namespaces written in front of it, or nothing
47
- * when the table itself came through as a parameter. A pack that reads
48
- * a table off an argument splits it through here too, so it agrees with
49
- * a table read out of a statement. The README says more.
62
+ * Splits a table name from the namespaces written in front of it.
63
+ * Returns null when the table itself came through as a parameter. A
64
+ * pack that reads a table off a call's argument splits it here too, so
65
+ * the result matches the same table read out of a statement.
50
66
  */
51
67
  declare function splitQualifiedTable(name: string): QualifiedTable | null;
52
68
  /**
53
- * The SQL a tagged template states, with each interpolation written as
54
- * a parameter. What a query interpolates is a value nearly every time,
55
- * and a parameter is how the statement would carry one anyway, so the
56
- * text parses as what it means.
69
+ * Joins a tagged template's parts into SQL, writing each interpolation
70
+ * as a parameter (`$1`, `$2`). A query interpolates a value nearly every
71
+ * time, and a parameter is how the statement would pass a value anyway,
72
+ * so the text parses with the meaning it has at run time.
57
73
  *
58
- * A caller that knows what an interpolation is passes it in
59
- * `substitutions`, which is how a table interpolated as an object
60
- * reaches the statement as its own name. An interpolation nobody can
61
- * settle stays a parameter, and a statement that needed one somewhere a
62
- * parameter cannot go reads as nothing.
74
+ * `substitutions` replaces a hole wherever it is, for a caller that
75
+ * knows the hole is a table, such as an interpolated schema object.
76
+ * `settled` replaces a hole only where the statement writes a name.
77
+ * Any other hole stays a parameter, and a statement with a parameter
78
+ * where the grammar does not allow one parses to no accesses.
63
79
  */
64
80
  declare function sqlFromParts(parts: readonly string[], substitutions?: ReadonlyArray<string | null>, settled?: ReadonlyArray<string | null>): string;
65
81
 
66
- export { type QualifiedTable, type SqlAccess, type SqlReadOptions, readSqlAccess, splitQualifiedTable, sqlFromParts };
82
+ export { type QualifiedTable, type SqlAccess, type SqlPlaceholder, type SqlReadOptions, readSqlAccess, splitQualifiedTable, sqlFromParts };
package/dist/index.js CHANGED
@@ -21995,8 +21995,16 @@ var import_postgresql = __toESM(require_postgresql(), 1);
21995
21995
  var import_sqlite = __toESM(require_sqlite(), 1);
21996
21996
  var DIALECTS = {
21997
21997
  postgresql: { module: import_postgresql.default, database: "postgresql" },
21998
- mysql: { module: import_mysql.default, database: "mysql" },
21999
- sqlite: { module: import_sqlite.default, database: "sqlite" },
21998
+ mysql: {
21999
+ module: import_mysql.default,
22000
+ database: "mysql",
22001
+ bindsByPosition: true
22002
+ },
22003
+ sqlite: {
22004
+ module: import_sqlite.default,
22005
+ database: "sqlite",
22006
+ bindsByPosition: true
22007
+ },
22000
22008
  bigquery: { module: import_bigquery.default, database: "bigquery" }
22001
22009
  };
22002
22010
  function readSqlAccess(sql, options = {}) {
@@ -22004,7 +22012,27 @@ function readSqlAccess(sql, options = {}) {
22004
22012
  if (grammar === void 0) {
22005
22013
  return [];
22006
22014
  }
22007
- return accessesInSql(sql, grammar).map(qualified).filter((access) => access !== null);
22015
+ const numbered = grammar.bindsByPosition === true ? numberedBinds(sql) : sql;
22016
+ return accessesInSql(numbered, grammar).map(qualified).filter((access) => access !== null).map(
22017
+ (access) => options.placeholders === true ? access : withoutPlaceholders(access)
22018
+ );
22019
+ }
22020
+ function numberedBinds(sql) {
22021
+ let count = 0;
22022
+ return sql.replace(
22023
+ /'(?:[^']|'')*'|"(?:[^"]|"")*"|`[^`]*`|-{2}[^\n]*|\/\*[\s\S]*?\*\/|\?/g,
22024
+ (token) => {
22025
+ if (token !== "?") {
22026
+ return token;
22027
+ }
22028
+ count += 1;
22029
+ return `$${count}`;
22030
+ }
22031
+ );
22032
+ }
22033
+ function withoutPlaceholders(access) {
22034
+ const { placeholders: _left, ...rest } = access;
22035
+ return rest;
22008
22036
  }
22009
22037
  function accessesInSql(sql, grammar) {
22010
22038
  const written = parsedAccesses(sql, grammar);
@@ -22304,7 +22332,8 @@ function insertAccesses(node, defined) {
22304
22332
  ...refsIn(node.returning),
22305
22333
  ...conflict.fields
22306
22334
  ],
22307
- selector: conflict.selector
22335
+ selector: conflict.selector,
22336
+ bound: boundInRows(namesOf(node.columns), node.values)
22308
22337
  };
22309
22338
  const sources = sourcesIn(node.table, defined);
22310
22339
  return [
@@ -22316,7 +22345,8 @@ function updateAccesses(node, defined) {
22316
22345
  const sources = sourcesAcross([node.table, node.from], defined);
22317
22346
  const stated = {
22318
22347
  fields: [...setTargets(node.set), ...refsIn(node.returning)],
22319
- selector: refsIn(node.where)
22348
+ selector: refsIn(node.where),
22349
+ bound: [...boundInSet(node.set), ...boundInWhere(node.where)]
22320
22350
  };
22321
22351
  return accessesAcross(sources, firstTable(node.table), stated, defined);
22322
22352
  }
@@ -22324,7 +22354,8 @@ function deleteAccesses(node, defined) {
22324
22354
  const written = deletedTable(node);
22325
22355
  const stated = {
22326
22356
  fields: refsIn(node.returning),
22327
- selector: refsIn(node.where)
22357
+ selector: refsIn(node.where),
22358
+ bound: boundInWhere(node.where)
22328
22359
  };
22329
22360
  const sources = withTable(sourcesIn(node.from, defined), written);
22330
22361
  return accessesAcross(sources, written, stated, defined);
@@ -22369,7 +22400,8 @@ function deletedTable(node) {
22369
22400
  function selectAccesses(statement, names) {
22370
22401
  const stated = {
22371
22402
  fields: refsIn(statement.columns),
22372
- selector: refsIn(statement.where)
22403
+ selector: refsIn(statement.where),
22404
+ bound: boundInWhere(statement.where)
22373
22405
  };
22374
22406
  return accessesAcross(sourcesIn(statement.from, names), null, stated, names);
22375
22407
  }
@@ -22378,15 +22410,40 @@ function accessesAcross(sources, written, stated, defined) {
22378
22410
  const only = named.length === 1 && !sources.derived ? named[0] ?? null : null;
22379
22411
  const fields = byTable(stated.fields, sources.tables, written ?? only);
22380
22412
  const selector = byTable(stated.selector, sources.tables, only);
22381
- const own = named.filter((table) => !defined.has(table)).map((table) => ({
22382
- table,
22383
- qualifier: [],
22384
- kind: table === written ? "write" : "read",
22385
- fields: fields.get(table) ?? [],
22386
- selector: selector.get(table) ?? []
22387
- }));
22413
+ const bound = boundByTable(stated.bound, sources.tables, {
22414
+ fields: written ?? only,
22415
+ selector: only
22416
+ });
22417
+ const own = named.filter((table) => !defined.has(table)).map((table) => {
22418
+ const placeholders = bound.get(table) ?? [];
22419
+ return {
22420
+ table,
22421
+ qualifier: [],
22422
+ kind: table === written ? "write" : "read",
22423
+ fields: fields.get(table) ?? [],
22424
+ selector: selector.get(table) ?? [],
22425
+ ...placeholders.length > 0 ? { placeholders } : {}
22426
+ };
22427
+ });
22388
22428
  return [...own, ...sources.inside];
22389
22429
  }
22430
+ function boundByTable(refs, tables, only) {
22431
+ const found = /* @__PURE__ */ new Map();
22432
+ for (const ref of refs) {
22433
+ const table = ref.table === void 0 ? only[ref.clause] : tables.get(ref.table) ?? null;
22434
+ if (table === null) {
22435
+ continue;
22436
+ }
22437
+ const already = found.get(table) ?? [];
22438
+ already.push({
22439
+ field: ref.field,
22440
+ clause: ref.clause,
22441
+ placeholder: ref.placeholder
22442
+ });
22443
+ found.set(table, already);
22444
+ }
22445
+ return found;
22446
+ }
22390
22447
  function byTable(refs, tables, only) {
22391
22448
  const found = /* @__PURE__ */ new Map();
22392
22449
  for (const ref of refs) {
@@ -22483,6 +22540,79 @@ function refsIn(value) {
22483
22540
  walk(value);
22484
22541
  return found;
22485
22542
  }
22543
+ function placeholderOf(value) {
22544
+ const node = asNode(value);
22545
+ if (node?.type === "var" && node.prefix === "$") {
22546
+ return typeof node.name === "number" ? node.name : null;
22547
+ }
22548
+ return node?.type === "param" ? stringOf(node.value) : null;
22549
+ }
22550
+ function boundInWhere(value) {
22551
+ const found = [];
22552
+ const walk = (node) => {
22553
+ if (Array.isArray(node)) {
22554
+ node.forEach(walk);
22555
+ return;
22556
+ }
22557
+ const record = asNode(node);
22558
+ if (record === null) {
22559
+ return;
22560
+ }
22561
+ if (record.type === "binary_expr" && record.operator === "=") {
22562
+ const pair = columnAndPlaceholder(record.left, record.right) ?? columnAndPlaceholder(record.right, record.left);
22563
+ if (pair !== null) {
22564
+ found.push({ ...pair, clause: "selector" });
22565
+ return;
22566
+ }
22567
+ }
22568
+ for (const entry of Object.values(record)) {
22569
+ walk(entry);
22570
+ }
22571
+ };
22572
+ walk(value);
22573
+ return found;
22574
+ }
22575
+ function columnAndPlaceholder(column, placeholder) {
22576
+ const ref = asNode(column);
22577
+ const field = ref?.type === "column_ref" ? columnName(ref.column) : null;
22578
+ const given = placeholderOf(placeholder);
22579
+ if (ref === null || field === null || given === null) {
22580
+ return null;
22581
+ }
22582
+ return { table: stringOf(ref.table) ?? void 0, field, placeholder: given };
22583
+ }
22584
+ function boundInSet(value) {
22585
+ const found = [];
22586
+ for (const entry of Array.isArray(value) ? value : []) {
22587
+ const node = asNode(entry);
22588
+ const field = node === null ? null : columnName(node.column);
22589
+ const given = placeholderOf(node?.value);
22590
+ if (node !== null && field !== null && given !== null) {
22591
+ const table = stringOf(node.table) ?? void 0;
22592
+ found.push({ table, field, clause: "fields", placeholder: given });
22593
+ }
22594
+ }
22595
+ return found;
22596
+ }
22597
+ function boundInRows(columns, values) {
22598
+ const rows = asNode(values)?.values;
22599
+ const found = [];
22600
+ for (const row of Array.isArray(rows) ? rows : []) {
22601
+ const items = asNode(row)?.value;
22602
+ columns.forEach((field, at) => {
22603
+ const given = placeholderOf(Array.isArray(items) ? items[at] : null);
22604
+ if (given !== null) {
22605
+ found.push({
22606
+ table: void 0,
22607
+ field,
22608
+ clause: "fields",
22609
+ placeholder: given
22610
+ });
22611
+ }
22612
+ });
22613
+ }
22614
+ return found;
22615
+ }
22486
22616
  function unqualified(names) {
22487
22617
  return names.map((field) => ({ table: void 0, field }));
22488
22618
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suss/sql",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
4
4
  "type": "module",
5
5
  "description": "Read which tables and fields a SQL statement touches, so suss can check the query against the schema it runs on.",
6
6
  "keywords": [
@@ -13,7 +13,7 @@
13
13
  ],
14
14
  "license": "Apache-2.0",
15
15
  "author": "Nimbus Cloud AI LLC",
16
- "homepage": "https://github.com/nimbuscloud-ai/suss/tree/main/packages/sql#readme",
16
+ "homepage": "https://suss.sh",
17
17
  "repository": {
18
18
  "type": "git",
19
19
  "url": "git+https://github.com/nimbuscloud-ai/suss.git",