@askdb/core 1.0.0-beta.50 → 1.0.0-beta.52

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.
Files changed (57) hide show
  1. package/README.md +6 -6
  2. package/dist/errors.d.ts +13 -8
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/errors.js.map +1 -1
  5. package/dist/index.d.ts +9 -9
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +8 -9
  8. package/dist/index.js.map +1 -1
  9. package/dist/logging/pino-supported-levels.d.ts +1 -1
  10. package/dist/logging/pino-supported-levels.js +1 -1
  11. package/dist/schema/v2/loader.d.ts.map +1 -1
  12. package/dist/schema/v2/loader.js +8 -5
  13. package/dist/schema/v2/loader.js.map +1 -1
  14. package/dist/schema/v2/tenant-policy-loader.d.ts +6 -1
  15. package/dist/schema/v2/tenant-policy-loader.d.ts.map +1 -1
  16. package/dist/schema/v2/tenant-policy-loader.js +28 -41
  17. package/dist/schema/v2/tenant-policy-loader.js.map +1 -1
  18. package/dist/schema/v2/tenant-policy.d.ts +12 -9
  19. package/dist/schema/v2/tenant-policy.d.ts.map +1 -1
  20. package/dist/schema/v2/tenant-policy.js +2 -11
  21. package/dist/schema/v2/tenant-policy.js.map +1 -1
  22. package/dist/sql/bind.d.ts.map +1 -1
  23. package/dist/sql/bind.js +3 -2
  24. package/dist/sql/bind.js.map +1 -1
  25. package/dist/sql/guardrails.d.ts.map +1 -1
  26. package/dist/sql/guardrails.js +2 -2
  27. package/dist/sql/guardrails.js.map +1 -1
  28. package/dist/sql/lexer.d.ts +12 -2
  29. package/dist/sql/lexer.d.ts.map +1 -1
  30. package/dist/sql/lexer.js +17 -4
  31. package/dist/sql/lexer.js.map +1 -1
  32. package/dist/sql/prompt.js +1 -1
  33. package/dist/sql/prompt.js.map +1 -1
  34. package/dist/sql/readings.d.ts +109 -0
  35. package/dist/sql/readings.d.ts.map +1 -0
  36. package/dist/sql/readings.js +287 -0
  37. package/dist/sql/readings.js.map +1 -0
  38. package/dist/sql/schema-question-precheck.d.ts.map +1 -1
  39. package/dist/sql/schema-question-precheck.js +2 -2
  40. package/dist/sql/schema-question-precheck.js.map +1 -1
  41. package/dist/sql/sensitive-guardrail.d.ts +5 -0
  42. package/dist/sql/sensitive-guardrail.d.ts.map +1 -1
  43. package/dist/sql/sensitive-guardrail.js +49 -24
  44. package/dist/sql/sensitive-guardrail.js.map +1 -1
  45. package/dist/sql/tenant-guardrail.d.ts +3 -1
  46. package/dist/sql/tenant-guardrail.d.ts.map +1 -1
  47. package/dist/sql/tenant-guardrail.js +337 -238
  48. package/dist/sql/tenant-guardrail.js.map +1 -1
  49. package/dist/sql/tenant-placeholders.d.ts +2 -2
  50. package/dist/sql/tenant-placeholders.d.ts.map +1 -1
  51. package/dist/sql/tenant-placeholders.js +2 -2
  52. package/dist/sql/tenant-placeholders.js.map +1 -1
  53. package/dist/sql/validate.d.ts +8 -5
  54. package/dist/sql/validate.d.ts.map +1 -1
  55. package/dist/sql/validate.js +33 -21
  56. package/dist/sql/validate.js.map +1 -1
  57. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
1
  import { placeholderForTenantRoot, } from "../schema/v2/tenant-policy.js";
2
- import { isBuiltInDialectId, SQLSERVER_DIALECT } from "./dialect-spec.js";
2
+ import { SQLSERVER_DIALECT } from "./dialect-spec.js";
3
3
  import { decide, tenantFindings, throwIfDenied } from "./guardrail-decide.js";
4
- import { ENGINE_LEXER_PROFILES, findPostgresOnlyForms, lexerProfileFor, lexSql, startsDashComment } from "./lexer.js";
4
+ import { findPostgresOnlyForms } from "./lexer.js";
5
+ import { mayBeSqlite, readSql, stringsSqliteReadsAsNames, tenantCodeReadings, tenantStatementReadings, unmatchedNonAsciiName, } from "./readings.js";
5
6
  import { EXPRESSION_KEYWORDS, EXPRESSION_PUNCTUATION } from "./tsql-batch.js";
6
7
  import { denylistFor } from "./validate.js";
7
8
  /**
@@ -58,17 +59,20 @@ export function validateTenantGuardrails(sql, policy, scope, options) {
58
59
  /**
59
60
  * The tenant rule alone: what {@link validateTenantGuardrails} finds in `sql`, with no
60
61
  * mode. Internal to `@askdb/core`; the guardrail checks and the validator share it.
62
+ * `schemaNames` are the schema's table, namespace and column names, when the caller has the
63
+ * schema: a name outside printable ASCII counts when it is one of them or a name in the policy.
61
64
  */
62
- export function tenantRuleWarnings(sql, policy, scope, dialect) {
65
+ export function tenantRuleWarnings(sql, policy, scope, dialect, schemaNames) {
63
66
  // Global scope bypasses tenant guardrails
64
67
  if (scope.access.kind === "global")
65
68
  return [];
66
69
  const warnings = [];
70
+ const read = readSql(sql);
67
71
  // The per-table checks below read queries. A statement that isn't one (a `DO` block, `CALL`,
68
72
  // `EXPLAIN`, `SHOW`) can touch tables they never see, a `DO` body as a dollar-quoted string,
69
73
  // so it is refused here, under every dialect: read-only validation may be a custom
70
74
  // AskDialect's job (ADR 0010), tenant enforcement isn't (#698).
71
- const keyword = nonQueryKeyword(sql, dialect);
75
+ const keyword = nonQueryKeyword(read, dialect);
72
76
  if (keyword !== undefined) {
73
77
  warnings.push(warn("NOT_SELECT_OR_WITH", "", `A statement starts with ${keyword}, not SELECT or WITH. The tenant check can only read a query's ` +
74
78
  "tenant predicates, so under a tenant policy it refuses any other statement."));
@@ -78,7 +82,7 @@ export function tenantRuleWarnings(sql, policy, scope, dialect) {
78
82
  // reads. The read-only check allows some of these as reads, and a custom AskDialect skips
79
83
  // it, so the tenant check refuses them under every dialect (#702). A listed name counts in any
80
84
  // code position, since an engine can reach a function without `(` right after its name.
81
- const name = uncheckableName(sql, dialect);
85
+ const name = uncheckableName(read, dialect);
82
86
  if (name !== undefined) {
83
87
  warnings.push(warn("UNCHECKABLE_FUNCTION", "", `The SQL names ${name}, a function that reads or scopes rows the tenant check can't see. Under a ` +
84
88
  "tenant policy, query the tables directly (pivot with CASE or FILTER, not crosstab), and don't " +
@@ -94,6 +98,15 @@ export function tenantRuleWarnings(sql, policy, scope, dialect) {
94
98
  warnings.push(warn("UNICODE_ESCAPED_IDENTIFIER", "", `The SQL uses the Unicode-escaped name ${escaped}, which the tenant check can't read. ` +
95
99
  'Write the name in plain double quotes ("orders") or unquoted.'));
96
100
  }
101
+ // The checks below match names by spelling. SQL Server reads a name through the database
102
+ // collation, which ignores some characters and reads others as ASCII letters, so a name outside
103
+ // printable ASCII may reach a table they never see: unless the schema spells it exactly, refuse it.
104
+ const folded = unmatchedNonAsciiName(read, dialect, knownNames(policy, schemaNames));
105
+ if (folded !== undefined) {
106
+ warnings.push(warn("UNICODE_ESCAPED_IDENTIFIER", "", `The SQL uses the name ${folded}, which has a character outside printable ASCII and isn't spelled that way in the schema. ` +
107
+ "SQL Server reads names through its collation, which ignores some characters and reads others as ASCII letters, so the " +
108
+ "tenant check can't tell which table or column it names. Spell the name exactly as the schema does."));
109
+ }
97
110
  // Past an E'…' string continued on a later line, Postgres and the checks below read different
98
111
  // strings, so a table or call could sit in what they take for a string: refuse it (#703).
99
112
  const continued = postgresOnly.escapeStringContinuation;
@@ -108,7 +121,15 @@ export function tenantRuleWarnings(sql, policy, scope, dialect) {
108
121
  warnings.push(warn("ESCAPED_BYTE_STRING", "", `The SQL has a b'…' byte string at offset ${bytes.start} with a backslash or continued on a later line, which ` +
109
122
  "CockroachDB reads with backslash escapes and the tenant check doesn't. Write the bytes as a hex string cast to bytea ('\\x00'::bytea)."));
110
123
  }
111
- const views = codeViews(sql, dialect);
124
+ // On a Postgres reading the lexer reads `$1OR` as one parameter, while PostgreSQL 14 and earlier
125
+ // and CockroachDB end the parameter at its last digit and read the word as a keyword the checks
126
+ // below would miss. Refuse it rather than guess how the server splits it (GHSA-rhwf-7742-8hwj).
127
+ const glued = parameterAgainstWord(read, dialect);
128
+ if (glued !== undefined) {
129
+ warnings.push(warn("NOT_SELECT_OR_WITH", "", `The SQL writes a word right after a parameter ('${glued}'), which PostgreSQL 14 and CockroachDB read as a separate ` +
130
+ "keyword, so the tenant check can't tell which words are keywords. Put a space after the parameter."));
131
+ }
132
+ const views = codeViews(read, dialect);
112
133
  // Check the roots this scope covers: a query on the root table itself is scoped by its tenant ID column.
113
134
  for (const rootId of scopeRootIds(scope)) {
114
135
  const root = policy.roots.find((r) => r.id === rootId);
@@ -255,6 +276,57 @@ function idCarriers(policy, rootId) {
255
276
  }
256
277
  return carriers;
257
278
  }
279
+ /**
280
+ * The names a statement may spell outside printable ASCII: every table the policy covers and its
281
+ * namespace, every column the policy names, and `schemaNames` when the caller has the schema.
282
+ */
283
+ function knownNames(policy, schemaNames) {
284
+ const names = new Set(schemaNames);
285
+ const table = (id) => {
286
+ const path = id.replace(/^table:/, "");
287
+ const dot = path.lastIndexOf(".");
288
+ names.add(path.slice(dot + 1));
289
+ if (dot !== -1)
290
+ names.add(path.slice(0, dot));
291
+ };
292
+ const column = (id) => {
293
+ const hash = id.lastIndexOf("#");
294
+ if (hash !== -1)
295
+ table(id.slice(0, hash));
296
+ names.add(extractColumnName(id));
297
+ };
298
+ for (const entry of policy.coverage)
299
+ table(entry.tableId);
300
+ for (const id of policy.globalTables)
301
+ table(id);
302
+ for (const root of policy.roots) {
303
+ table(root.id);
304
+ column(root.tenantIdColumn);
305
+ if (root.parent)
306
+ column(root.parent.foreignKey);
307
+ }
308
+ for (const edge of policy.hierarchy)
309
+ column(edge.foreignKey);
310
+ for (const st of policy.scopedTables) {
311
+ table(st.id);
312
+ for (const path of st.scopeThrough) {
313
+ if ("column" in path)
314
+ column(path.column);
315
+ else {
316
+ for (const step of path.join) {
317
+ column(step.from);
318
+ column(step.to);
319
+ }
320
+ }
321
+ }
322
+ }
323
+ for (const pt of policy.polymorphicTables) {
324
+ table(pt.id);
325
+ column(pt.typeColumn);
326
+ column(pt.idColumn);
327
+ }
328
+ return names;
329
+ }
258
330
  /** `rootId` and every root above it in the policy's hierarchy (`hierarchy[]`, `roots[].parent`). */
259
331
  function selfAndAncestors(policy, rootId) {
260
332
  const chain = [rootId];
@@ -303,21 +375,6 @@ function continuesExpression(prev) {
303
375
  return prev.text !== ")" && EXPRESSION_PUNCTUATION.has(prev.text);
304
376
  return prev.kind === "word" && (EXPRESSION_KEYWORDS.has(prev.lower) || prev.lower === "as" || prev.lower === "for");
305
377
  }
306
- /**
307
- * Every way a server may lex `sql` into statements: the dialect's profile when it names a
308
- * built-in engine, otherwise every engine's, and for Postgres and MySQL both string settings
309
- * (a backslash ends a literal under one and escapes the quote under the other). A MySQL
310
- * profile is read with its `/*!NNNNN … *\/` bodies as code and as comments, since a server
311
- * older than NNNNN skips them.
312
- */
313
- function statementReadings(dialect) {
314
- const known = dialect?.id !== undefined ? lexerProfileFor(dialect) : undefined;
315
- // A `postgres` dialect may point at CockroachDB, where only `\n` ends a line comment: read both ways.
316
- const profiles = known
317
- ? known.id === "postgres" ? [known, { ...known, lineCommentEndsAtLfOnly: true }] : [known]
318
- : ENGINE_LEXER_PROFILES.flatMap((p) => p.id === "postgres" || p.id === "mysql" ? [p, { ...p, backslashEscapes: !p.backslashEscapes }] : [p]);
319
- return profiles.flatMap((p) => (p.executableComments ? [p, { ...p, executableComments: false }] : [p]));
320
- }
321
378
  /**
322
379
  * The leading token of the first statement in `sql` that isn't a query, upper-cased (a
323
380
  * keyword, or the token's own text when it isn't a word), or undefined when every statement
@@ -325,15 +382,15 @@ function statementReadings(dialect) {
325
382
  * reading also at a top-level word from {@link TSQL_STATEMENT_WORDS} that doesn't
326
383
  * {@link continuesExpression continue an expression}. Its first token is read
327
384
  * past comments and `(` with the shared lexer, under every reading from
328
- * {@link statementReadings}, so a statement any of them reads as a non-query counts.
385
+ * `tenantStatementReadings`, so a statement any of them reads as a non-query counts.
329
386
  */
330
- function nonQueryKeyword(sql, dialect) {
331
- for (const profile of statementReadings(dialect)) {
387
+ function nonQueryKeyword(read, dialect) {
388
+ for (const profile of tenantStatementReadings(dialect)) {
332
389
  const tsql = profile.id === "sqlserver";
333
390
  let statementStart = true;
334
391
  let depth = 0;
335
392
  let prev;
336
- for (const token of lexSql(sql, profile)) {
393
+ for (const token of read.tokens(profile)) {
337
394
  if (token.kind === "comment")
338
395
  continue;
339
396
  const before = prev;
@@ -362,6 +419,20 @@ function nonQueryKeyword(sql, dialect) {
362
419
  }
363
420
  return undefined;
364
421
  }
422
+ /**
423
+ * The first `$N` parameter with a word written right after its digits (`$1OR`) under a reading
424
+ * from `tenantStatementReadings` on the Postgres lexer, where the lexer reads both as one token.
425
+ */
426
+ function parameterAgainstWord(read, dialect) {
427
+ for (const profile of tenantStatementReadings(dialect)) {
428
+ if (profile.id !== "postgres")
429
+ continue;
430
+ const token = read.tokens(profile).find((t) => t.kind === "parameter" && /^\$\d+\D/.test(t.text));
431
+ if (token)
432
+ return token.text.slice(0, 40);
433
+ }
434
+ return undefined;
435
+ }
365
436
  /**
366
437
  * Functions whose reads the tenant check can't see, lowercase (#702). A function belongs here when
367
438
  * it is built into Postgres or SQL Server, or comes from an extension that ships with Postgres
@@ -392,11 +463,11 @@ const UNCHECKABLE_FUNCTIONS = new Set([
392
463
  * in any code position, in any case, bare, quoted or qualified, not only right before `(`: an
393
464
  * engine can reach a function in forms with no `(` after its name, so a column, an alias or a
394
465
  * placeholder named like a listed function counts too. Text in comments and single-quoted strings
395
- * doesn't. Read under every reading from {@link statementReadings}, so a name any of them sees counts.
466
+ * doesn't. Read under every reading from `tenantStatementReadings`, so a name any of them sees counts.
396
467
  */
397
- function uncheckableName(sql, dialect) {
398
- for (const profile of statementReadings(dialect)) {
399
- for (const token of lexSql(sql, profile)) {
468
+ function uncheckableName(read, dialect) {
469
+ for (const profile of tenantStatementReadings(dialect)) {
470
+ for (const token of read.tokens(profile)) {
400
471
  const name = nameOf(token);
401
472
  if (name !== undefined && UNCHECKABLE_FUNCTIONS.has(name))
402
473
  return name;
@@ -436,208 +507,94 @@ function extractColumnName(columnId) {
436
507
  const hash = columnId.lastIndexOf("#");
437
508
  return hash !== -1 ? columnId.slice(hash + 1) : columnId;
438
509
  }
439
- const STANDARD_READING = {
440
- doubleQuoted: "identifier",
441
- backslashEscapes: false,
442
- hashComments: false,
443
- eStrings: false,
444
- dollarQuotes: false,
445
- pipesAreOr: false,
446
- dashCommentNeedsSpace: false,
447
- lineCommentEndsAtCr: false,
448
- executableComments: false,
449
- };
450
- const POSTGRES_READING = { ...STANDARD_READING, eStrings: true, dollarQuotes: true };
451
- const MYSQL_READING = {
452
- doubleQuoted: "string",
453
- backslashEscapes: true,
454
- hashComments: true,
455
- eStrings: false,
456
- dollarQuotes: false,
457
- pipesAreOr: true,
458
- dashCommentNeedsSpace: true,
459
- lineCommentEndsAtCr: false,
460
- executableComments: true,
461
- };
462
- /** MySQL with its executable comments read as comments: a server too old for their version skips them. */
463
- const MYSQL_SKIPPING_EXECUTABLE = { ...MYSQL_READING, executableComments: false };
464
510
  /**
465
- * A known dialect gets its own single reading. An unknown one gets every reading
466
- * (standard SQL, Postgres with `E'…'` escape strings, MySQL): the statement is
467
- * ambiguous exactly where they differ, and the matchers below resolve that
468
- * ambiguity toward a warning (tables: any view; predicates: every view). A MySQL
469
- * server running with `ANSI_QUOTES` reads `"…"` as an identifier; the MySQL
470
- * reading still treats it as a string, so a predicate written only as
471
- * `"agency_id"` is flagged there, never passed. A MySQL reading comes twice, with the
472
- * bodies of `/*! … *\/` executable comments read as code and as comments, since whether
473
- * the server runs one depends on its version.
511
+ * The statement's code views. A built-in engine gets its family's reading; any other id, or none,
512
+ * gets every reading (standard SQL, Postgres with `E'…'` escape strings, MySQL): the statement is
513
+ * ambiguous exactly where they differ, and the matchers below resolve that ambiguity toward a
514
+ * warning (tables: any view; predicates: every view). A MySQL server running with `ANSI_QUOTES`
515
+ * reads `"…"` as an identifier; the MySQL reading still treats it as a string, so a predicate
516
+ * written only as `"agency_id"` is flagged there, never passed. A MySQL reading comes twice, with
517
+ * the bodies of `/*! … *\/` executable comments read as code and as comments, since whether the
518
+ * server runs one depends on its version.
474
519
  */
475
- function codeViews(sql, dialect) {
476
- // A CR ends a line comment on Postgres and SQL Server, not on MySQL, MariaDB, SQLite or CockroachDB.
477
- const crEnds = (r) => ({ ...r, lineCommentEndsAtCr: true });
478
- if (dialect === undefined || !isBuiltInDialectId(dialect.id)) {
479
- return [
480
- STANDARD_READING, crEnds(STANDARD_READING), POSTGRES_READING, crEnds(POSTGRES_READING), MYSQL_READING, MYSQL_SKIPPING_EXECUTABLE,
481
- ].map((r) => view(sql, r));
482
- }
483
- // As the shared lexer reads it (`lexerProfileFor`): an unset value on a MySQL-family dialect is the
484
- // server default, escapes on; elsewhere escapes are on only when the dialect says so.
485
- const mysqlFamily = dialect.id === "mysql" || dialect.id === "mariadb";
486
- const backslashEscapes = mysqlFamily ? dialect.backslashEscapes !== false : dialect.backslashEscapes === true;
487
- // A `postgres` dialect may point at CockroachDB, which is wire-compatible: read both ways.
488
- const bases = dialect.id === "mysql" || dialect.id === "mariadb"
489
- ? [MYSQL_READING, MYSQL_SKIPPING_EXECUTABLE]
490
- : dialect.id === "postgres" || dialect.id === "cockroachdb"
491
- ? [POSTGRES_READING, crEnds(POSTGRES_READING)]
492
- : dialect.id === "sqlserver"
493
- ? [crEnds(STANDARD_READING)]
494
- : [STANDARD_READING];
495
- return bases.map((base) => view(sql, { ...base, backslashEscapes }));
496
- }
497
- function view(sql, reading) {
498
- return { text: codeView(sql, reading), source: sql, reading };
520
+ function codeViews(read, dialect) {
521
+ return tenantCodeReadings(dialect).map((reading) => {
522
+ const tokens = read.tokens(reading.profile);
523
+ // A reading that may be SQLite's reads a '…' in a table's place as the table's name, and one right
524
+ // after a table or CTE reference as its alias.
525
+ const names = mayBeSqlite(reading.profile) ? stringsSqliteReadsAsNames(tokens, true) : new Set();
526
+ const quoted = new Uint8Array(read.sql.length);
527
+ tokens.forEach((token, i) => {
528
+ if (token.kind === "quoted_identifier" || names.has(i))
529
+ quoted.fill(1, token.start, token.end);
530
+ });
531
+ return { text: codeView(read.sql, tokens, names), source: read.sql, reading, tokens, quoted };
532
+ });
499
533
  }
500
534
  /**
501
535
  * What a quoted identifier's delimiters become in a code view: not a word character, so
502
- * identifier matching still sees `"agency_id"` as `agency_id`, but the tokenizer can tell
503
- * a quoted `"where"` (an identifier) from the keyword `WHERE`.
536
+ * identifier matching still sees `"agency_id"` as `agency_id`. The tokenizer skips it, and
537
+ * tells a quoted `"where"` (an identifier) from the keyword `WHERE` by the lexer's spans.
504
538
  */
505
539
  const QUOTE_MARK = "\u0001";
506
- /** A character that continues an identifier, so an `E` before it is not a string prefix. */
507
- const IDENTIFIER_CONTINUE = /[A-Za-z0-9_$\u0080-\uffff]/;
508
540
  /**
509
- * Blank out everything that is not SQL code, so identifier checks only see code
510
- * regions. String literals (`'…'`, and `$tag$…$tag$` bodies and `"…"` when the
511
- * reading says so) and comments become spaces. A comment starts where the reading
512
- * says (`--` only before whitespace on MySQL, see `startsDashComment` in the shared
513
- * lexer), and an executable comment's body stays code when the reading says so. The output has the same length
514
- * and keeps the original case (placeholders are case-sensitive), so word
541
+ * `sql` with everything that is not code blanked, from the shared lexer's `tokens`, so identifier
542
+ * checks only see code. Strings and comments become spaces (a newline stays); the body of an
543
+ * executable comment the reading runs is code, since the lexer reads it as tokens. The output has
544
+ * the same length and keeps the original case (placeholders are case-sensitive), so word
515
545
  * boundaries at the seams are unchanged.
516
546
  *
517
- * Quoted identifiers keep their contents; their delimiters become {@link QUOTE_MARK}:
518
- * `"agency_id"` *is* the identifier `agency_id` on Postgres, and blanking it
519
- * would hide `FROM "orders"` from the table check and skip that table.
547
+ * Quoted identifiers keep their contents, and each of their delimiters becomes
548
+ * {@link QUOTE_MARK}, a doubled one inside them too: `"agency_id"` *is* the identifier
549
+ * `agency_id` on Postgres, and blanking it would hide `FROM "orders"` from the table check. The
550
+ * strings at the indices in `names` are read the same way, as SQLite reads them (`FROM 'orders'`).
520
551
  */
521
- function codeView(sql, reading) {
552
+ function codeView(sql, tokens, names) {
522
553
  const out = sql.split("");
523
- const blank = (from, to) => {
524
- for (let k = from; k < to; k++)
525
- if (out[k] !== "\n")
526
- out[k] = " ";
527
- };
528
- /**
529
- * End (exclusive) of the string literal opened by `quote` at `open`. `escapes`
530
- * says whether backslash escapes the next character inside it.
531
- */
532
- const stringEnd = (open, quote, escapes) => {
533
- let j = open + 1;
534
- while (j < sql.length) {
535
- const c = sql[j];
536
- if (c === "\\" && escapes) {
537
- j += 2;
538
- continue;
539
- }
540
- if (c === quote) {
541
- if (sql[j + 1] === quote) {
542
- j += 2;
543
- continue;
544
- }
545
- return j + 1;
546
- }
547
- j++;
548
- }
549
- return sql.length;
550
- };
551
- const dollarTag = /\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/y;
552
- const executableOpener = /\/\*M?!\d*/y;
553
- const lineEnd = reading.lineCommentEndsAtCr ? /[\n\r]/g : /\n/g;
554
- let inExecutable = false;
555
- let i = 0;
556
- while (i < sql.length) {
557
- const ch = sql[i];
558
- const next = sql[i + 1];
559
- if (startsDashComment(sql, i, reading.dashCommentNeedsSpace) || (ch === "#" && reading.hashComments)) {
560
- lineEnd.lastIndex = i;
561
- const end = lineEnd.exec(sql)?.index ?? sql.length;
562
- blank(i, end);
563
- i = end;
564
- continue;
565
- }
566
- if (ch === "*" && next === "/" && inExecutable) {
567
- blank(i, i + 2);
568
- inExecutable = false;
569
- i += 2;
570
- continue;
571
- }
572
- if (ch === "/" && next === "*" && reading.executableComments && !inExecutable) {
573
- executableOpener.lastIndex = i;
574
- const opener = executableOpener.exec(sql);
575
- if (opener) {
576
- blank(i, i + opener[0].length);
577
- inExecutable = true;
578
- i += opener[0].length;
579
- continue;
580
- }
554
+ for (const [index, token] of tokens.entries()) {
555
+ if (names.has(index)) {
556
+ for (let k = token.start; k < token.end; k++)
557
+ if (sql[k] === "'")
558
+ out[k] = QUOTE_MARK;
581
559
  }
582
- if (ch === "/" && next === "*") {
583
- const close = sql.indexOf("*/", i + 2);
584
- const end = close === -1 ? sql.length : close + 2;
585
- blank(i, end);
586
- i = end;
587
- continue;
588
- }
589
- if (ch === "'" || (ch === '"' && reading.doubleQuoted === "string")) {
590
- const escapes = reading.backslashEscapes || (ch === "'" && isEStringPrefix(out, i, reading));
591
- const end = stringEnd(i, ch, escapes);
592
- blank(i, end);
593
- i = end;
594
- continue;
560
+ else if (token.kind === "string" || token.kind === "comment") {
561
+ // A string's prefix letter (`E'…'`, `N'…'`) stays code, as the check has always read it.
562
+ const from = token.kind === "string" && /^[A-Za-z]/.test(token.text) ? token.start + 1 : token.start;
563
+ for (let k = from; k < token.end; k++)
564
+ if (out[k] !== "\n")
565
+ out[k] = " ";
595
566
  }
596
- // A `$` right after a name character continues the name (`x$$y`), as every engine reads it.
597
- if (ch === "$" && reading.dollarQuotes && !IDENTIFIER_CONTINUE.test(out[i - 1] ?? "")) {
598
- dollarTag.lastIndex = i;
599
- const tag = dollarTag.exec(sql);
600
- const close = tag ? sql.indexOf(tag[0], i + tag[0].length) : -1;
601
- if (tag && close !== -1) {
602
- const end = close + tag[0].length;
603
- blank(i, end);
604
- i = end;
605
- continue;
606
- }
607
- i++;
608
- continue;
609
- }
610
- if (ch === '"' || ch === "`" || ch === "[") {
611
- const close = sql.indexOf(ch === "[" ? "]" : ch, i + 1);
612
- out[i] = QUOTE_MARK;
613
- if (close === -1)
614
- break;
615
- out[close] = QUOTE_MARK;
616
- i = close + 1;
617
- continue;
567
+ else if (token.kind === "quoted_identifier") {
568
+ const close = token.quote === "[" ? "]" : token.quote;
569
+ out[token.start] = QUOTE_MARK;
570
+ for (let k = token.start + 1; k < token.end; k++)
571
+ if (sql[k] === close)
572
+ out[k] = QUOTE_MARK;
618
573
  }
619
- i++;
620
574
  }
621
575
  return out.join("");
622
576
  }
623
577
  /**
624
- * Whether the `'` at `quote` opens a Postgres `E'…'` escape string: the character
625
- * before it is `E`/`e` and that `E` starts a new token (it is not the tail of an
626
- * identifier such as `date'…'`). `out` is the view built so far, where blanked
627
- * strings, comments, and quoted-identifier delimiters are spaces.
578
+ * A table counts as referenced when any reading sees its name in code, except as a word of
579
+ * `ORDER BY` or `GROUP BY` (#828).
628
580
  */
629
- function isEStringPrefix(out, quote, reading) {
630
- if (!reading.eStrings || quote === 0)
631
- return false;
632
- const prefix = out[quote - 1];
633
- if (prefix !== "E" && prefix !== "e")
634
- return false;
635
- return quote < 2 || !IDENTIFIER_CONTINUE.test(out[quote - 2]);
636
- }
637
- /** A table counts as referenced when any reading sees it. */
638
581
  function mentionsTable(views, tableName) {
639
- const pattern = new RegExp(`\\b${escapeRegex(tableName)}\\b`, "i");
640
- return views.some((view) => pattern.test(view.text));
582
+ const pattern = new RegExp(`\\b${escapeRegex(tableName)}\\b`, "gi");
583
+ return views.some((view) => [...view.text.matchAll(pattern)].some((m) => !inOrderOrGroupBy(view.text, m.index, m.index + m[0].length)));
584
+ }
585
+ /**
586
+ * Whether `text[start, end)` in a code view is a word of `ORDER BY` or `GROUP BY`. `ORDER` and
587
+ * `GROUP` are reserved on every engine, so a table named after one is written quoted (`"order"`,
588
+ * whose closing quote mark breaks the phrase) or qualified (`billing.order`, which Postgres takes
589
+ * unquoted), and the `BY` after them is the keyword.
590
+ */
591
+ function inOrderOrGroupBy(text, start, end) {
592
+ if (text[start - 1] === ".")
593
+ return false;
594
+ const word = text.slice(start, end).toLowerCase();
595
+ if (word === "order" || word === "group")
596
+ return /^\s+by(?![\w$\u0080-\uffff])/i.test(text.slice(end));
597
+ return word === "by" && /(?:^|[^\w$\u0080-\uffff.\u0001])(?:order|group)\s+$/i.test(text.slice(0, start));
641
598
  }
642
599
  /** A tenant column counts only when every reading sees it in code. */
643
600
  function mentionsIdentifier(views, identifier) {
@@ -645,26 +602,86 @@ function mentionsIdentifier(views, identifier) {
645
602
  return views.every((view) => pattern.test(view.text));
646
603
  }
647
604
  const TOKEN = /:[A-Za-z0-9_]+|::|\|\||<>|!=|<=|>=|[A-Za-z_\u0080-\uffff][\w$\u0080-\uffff]*|\d[\w.]*|\S/g;
648
- /** Split a code view into tokens with their nesting depth. Strings and comments are already blanked. */
649
- function tokenize(text) {
605
+ /** A {@link TOKEN} match that is a word or a number. */
606
+ const WORD_OR_NUMBER = /^[\w\u0080-\uffff]/;
607
+ /**
608
+ * Split a code view into tokens with their nesting depth. Strings and comments are already blanked.
609
+ * A token is quoted when it lies inside a name the view reads as quoted, as the reading's lexer
610
+ * spans it, so a {@link QUOTE_MARK} character written inside a quoted name changes nothing.
611
+ */
612
+ function tokenize(view) {
650
613
  const tokens = [];
614
+ const split = lexerWordEnds(view);
615
+ const quoted = view.quoted;
651
616
  let depth = 0;
652
- let quoted = false;
653
- for (const m of text.matchAll(TOKEN)) {
654
- const t = m[0];
655
- if (t === QUOTE_MARK) {
656
- quoted = !quoted;
617
+ for (const m of view.text.matchAll(TOKEN)) {
618
+ if (m[0] === QUOTE_MARK)
657
619
  continue;
620
+ for (const [t, offset] of WORD_OR_NUMBER.test(m[0]) ? split(m[0], m.index) : [[m[0], m.index]]) {
621
+ if (t === ")")
622
+ depth = Math.max(0, depth - 1);
623
+ const lower = t.toLowerCase();
624
+ tokens.push({ text: t, lower, kw: quoted[offset] === 1 ? "" : lower, depth, offset });
625
+ if (t === "(")
626
+ depth++;
658
627
  }
659
- if (t === ")")
660
- depth = Math.max(0, depth - 1);
661
- const lower = t.toLowerCase();
662
- tokens.push({ text: t, lower, kw: quoted ? "" : lower, depth, offset: m.index });
663
- if (t === "(")
664
- depth++;
665
628
  }
666
629
  return tokens;
667
630
  }
631
+ /**
632
+ * Where a word or number of the view ends under its reading (#623, GHSA-rhwf-7742-8hwj), so a
633
+ * keyword written right after one is a word of its own, as the engine reads it:
634
+ *
635
+ * - SQL Server: wherever the shared lexer ends a token. That is after `0x` or `1E`, after a number
636
+ * before a word, and at whitespace such as U+00A0.
637
+ * - PostgreSQL and CockroachDB: after a number, before a word written right after it (`1OR`,
638
+ * `1.0OR`), as PostgreSQL 14 and earlier read it. A `$N` parameter written against a word
639
+ * (`$1OR`) is refused instead ({@link parameterAgainstWord}).
640
+ * - MySQL and MariaDB: after a number with a decimal point or an exponent (`1.0OR`, `1e1OR`). A
641
+ * name that starts with digits (`3d_models`, `1OR`) is one name there.
642
+ *
643
+ * Any other reading keeps the check's own word ends. Returns a function that cuts a word or number
644
+ * of the view at those ends; under SQL Server it also drops the characters the lexer reads as
645
+ * whitespace. A quoted name is one lexer token, so it is never cut.
646
+ */
647
+ function lexerWordEnds(view) {
648
+ const { profile } = view.reading;
649
+ const owner = new Int32Array(view.text.length).fill(-1);
650
+ if (profile.tsqlLiterals) {
651
+ view.tokens.forEach((token, index) => owner.fill(index, token.start, token.end));
652
+ }
653
+ else {
654
+ // Every character is code to this reading; a new piece starts only at a number's end.
655
+ owner.fill(0);
656
+ let piece = 0;
657
+ view.tokens.forEach((token, index) => {
658
+ const next = view.tokens[index + 1];
659
+ const wordAfter = next !== undefined && next.start === token.end && next.kind === "word";
660
+ const numberEnds = token.kind === "number" &&
661
+ wordAfter &&
662
+ (profile.id === "postgres" || (profile.id === "mysql" && /^(?!0x)[\d.]*[.eE]/i.test(token.text)));
663
+ if (numberEnds)
664
+ owner.fill(++piece, next.start);
665
+ });
666
+ }
667
+ return (text, offset) => {
668
+ const pieces = [];
669
+ let start = -1;
670
+ const cut = (at) => {
671
+ if (start >= 0)
672
+ pieces.push([view.text.slice(start, at), start]);
673
+ start = -1;
674
+ };
675
+ for (let k = offset; k < offset + text.length; k++) {
676
+ if (owner[k] === -1 || (start >= 0 && owner[k] !== owner[start]))
677
+ cut(k);
678
+ if (owner[k] !== -1 && start < 0)
679
+ start = k;
680
+ }
681
+ cut(offset + text.length);
682
+ return pieces;
683
+ };
684
+ }
668
685
  /** Keywords that open a clause whose conjuncts filter rows. */
669
686
  const FILTER_CLAUSE = new Set(["where", "on", "having"]);
670
687
  /** Keywords that end a filter clause at its own nesting level. */
@@ -701,7 +718,12 @@ function isOrderOrGroupBy(tokens, i) {
701
718
  function hasTenantPredicate(views, table, targets) {
702
719
  const name = table.toLowerCase();
703
720
  return views.every((view) => {
704
- const tokens = tokenize(view.text);
721
+ const tokens = tokenize(view);
722
+ // Read the Postgres way, a column list after the table's alias renames its columns, and a quoted
723
+ // name is compared exactly.
724
+ const postgres = view.reading.profile.id === "postgres";
725
+ if (postgres && renamesColumns(tokens, name))
726
+ return false;
705
727
  // Every spelling of the name counts as the table, quoted or not, except the words of `ORDER BY`.
706
728
  const mentions = (from, to) => {
707
729
  for (let i = from; i < to; i++)
@@ -714,9 +736,10 @@ function hasTenantPredicate(views, table, targets) {
714
736
  .every(([from, to]) => {
715
737
  for (let k = from; k < to; k++) {
716
738
  for (const target of targets) {
717
- if (tokens[k].text !== target.placeholder)
739
+ // A quoted name spelled like the placeholder is a name, which nothing binds.
740
+ if (tokens[k].text !== target.placeholder || tokens[k].kw === "")
718
741
  continue;
719
- const span = predicateSpan(tokens, k, target);
742
+ const span = predicateSpan(tokens, k, target, postgres);
720
743
  if (!span || !isFilterConjunct(tokens, span[0], span[1], view.reading))
721
744
  continue;
722
745
  if (!target.discriminator || hasDiscriminator(tokens, from, to, view, target))
@@ -727,6 +750,57 @@ function hasTenantPredicate(views, table, targets) {
727
750
  });
728
751
  });
729
752
  }
753
+ /**
754
+ * Words Postgres never takes as a bare table alias: its reserved keywords, and those it reserves
755
+ * except as a function or type name. So in `orders WHERE (`, `orders ON (` or `orders USING (` the
756
+ * word after the table is no alias, and the `(` no column list.
757
+ */
758
+ const NOT_A_TABLE_ALIAS = new Set([
759
+ "all", "analyse", "analyze", "and", "any", "array", "as", "asc", "asymmetric", "authorization", "binary", "both",
760
+ "case", "cast", "check", "collate", "collation", "column", "concurrently", "constraint", "create", "cross",
761
+ "current_catalog", "current_date", "current_role", "current_schema", "current_time", "current_timestamp",
762
+ "current_user", "default", "deferrable", "desc", "distinct", "do", "else", "end", "except", "false", "fetch", "for",
763
+ "foreign", "freeze", "from", "full", "grant", "group", "having", "ilike", "in", "initially", "inner", "intersect",
764
+ "into", "is", "isnull", "join", "lateral", "leading", "left", "like", "limit", "localtime", "localtimestamp",
765
+ "natural", "not", "notnull", "null", "offset", "on", "only", "or", "order", "outer", "overlaps", "placing",
766
+ "primary", "references", "returning", "right", "select", "session_user", "similar", "some", "symmetric",
767
+ "system_user", "table", "tablesample", "then", "to", "trailing", "true", "union", "unique", "user", "using",
768
+ "variadic", "verbose", "when", "where", "window", "with",
769
+ ]);
770
+ /**
771
+ * Whether a read of the table `name` has its columns renamed by a column list after an alias, at any
772
+ * depth: right after the table (`orders t (a, b)`, `orders AS t (a, b)`, `orders * t (a, b)`), or
773
+ * after a parenthesized `FROM` item the read sits in, a join or a derived table
774
+ * (`(orders o JOIN s ON true) AS j (a, b, agency_id)`). Postgres and CockroachDB read the list as
775
+ * new names for the first columns, so a predicate on `agency_id` filters whatever column the list
776
+ * names that, and the real one is renamed out of the way: the read can't be checked.
777
+ */
778
+ function renamesColumns(tokens, name) {
779
+ for (let i = 0; i < tokens.length; i++) {
780
+ if (tokens[i].lower !== name)
781
+ continue;
782
+ for (let open = enclosing(tokens, i, "("); open >= 0; open = enclosing(tokens, open, "(")) {
783
+ if (subqueryUse(tokens, open) !== "table")
784
+ continue;
785
+ const close = enclosing(tokens, open + 1, ")");
786
+ if (close >= 0 && aliasWithColumnList(tokens, close + 1))
787
+ return true;
788
+ }
789
+ if (tokens[i + 1]?.text === ".")
790
+ continue;
791
+ if (aliasWithColumnList(tokens, tokens[i + 1]?.text === "*" ? i + 2 : i + 1))
792
+ return true;
793
+ }
794
+ return false;
795
+ }
796
+ /** Whether `tokens[j]` starts an alias and a column list: `AS t (` or `t (`, where a bare `t` is no reserved word. */
797
+ function aliasWithColumnList(tokens, j) {
798
+ const as = tokens[j]?.kw === "as";
799
+ const alias = tokens[as ? j + 1 : j];
800
+ if (alias === undefined || !isIdentifier(alias) || tokens[as ? j + 2 : j + 1]?.text !== "(")
801
+ return false;
802
+ return as || alias.kw === "" || !NOT_A_TABLE_ALIAS.has(alias.kw);
803
+ }
730
804
  /**
731
805
  * Whether the block `tokens[from, to)` ANDs in `col = '<value>'` for `target.discriminator`:
732
806
  * its column (optionally qualified with the target's table or alias), `=`, and a plain
@@ -811,15 +885,20 @@ function qualifiedStart(tokens, end) {
811
885
  * The token span `[start, end]` of a tenant predicate around the placeholder at `k`:
812
886
  * `col = P`, `P = col`, `col IN (P)` or `col = ANY (P)`, where `col` (the last part of a
813
887
  * possibly qualified name) is the target's column. A qualifier that names, or is the
814
- * alias of, a table other than the target's makes it no predicate for that table.
888
+ * alias of, a table other than the target's makes it no predicate for that table. With
889
+ * `exactQuoted` (read the Postgres way, where a quoted name is compared exactly), a quoted
890
+ * `col` counts only when spelled as the schema spells the column: `"AGENCY_ID"` is another
891
+ * column there, which another source in the query may hold.
815
892
  */
816
- function predicateSpan(tokens, k, target) {
893
+ function predicateSpan(tokens, k, target, exactQuoted) {
817
894
  const at = (i) => tokens[i];
818
895
  const column = target.column.toLowerCase();
819
896
  const matches = (i) => {
820
897
  const t = at(i);
821
898
  if (t === undefined || !isIdentifier(t) || t.lower !== column)
822
899
  return false;
900
+ if (exactQuoted && t.kw === "" && t.text !== target.column)
901
+ return false;
823
902
  const qualifier = at(i - 1)?.text === "." ? at(i - 2) : undefined;
824
903
  return qualifier === undefined || !namesOtherTable(tokens, qualifier.lower, target.table.toLowerCase());
825
904
  };
@@ -910,23 +989,43 @@ function subqueryUse(tokens, open) {
910
989
  }
911
990
  return "other";
912
991
  }
913
- /** SQL Server join hints, written between the join kind and `JOIN` (`LEFT HASH JOIN`). */
914
- const JOIN_HINTS = new Set(["loop", "hash", "merge", "remote"]);
915
- /** Whether the `ON` at `on` belongs to an outer join, whose `ON` doesn't filter the preserved side. */
992
+ /** Words that make a join outer, wherever they stand in it: `LEFT HASH JOIN`, `LEFT STRAIGHT JOIN`, `FULL OUTER JOIN`. */
993
+ const OUTER_JOIN_WORDS = new Set(["left", "right", "full", "outer"]);
994
+ /** Words a join's own words start after, at its level: the source before it begins or ends at one of these. */
995
+ const BEFORE_A_JOIN = new Set(["from", "join", "on", "using", "apply"]);
996
+ /**
997
+ * Whether the `ON` at `on` belongs to an outer join, whose `ON` doesn't filter the preserved side.
998
+ * The join is outer when `LEFT`, `RIGHT`, `FULL` or `OUTER` stands anywhere among its words, between
999
+ * the source before it and `JOIN`, as a keyword (not `LEFT(…)` or a qualified name), so a hint
1000
+ * between them (SQL Server's `HASH`, CockroachDB's `STRAIGHT`, `LOOKUP` or `INVERTED`) changes nothing.
1001
+ * An `ON` with another `ON` or `USING` between it and the nearest `JOIN` closes an earlier join
1002
+ * (`a LEFT JOIN b JOIN c ON … ON …`), which may be outer, so it counts as outer.
1003
+ */
916
1004
  function isOuterJoinOn(tokens, on) {
917
1005
  const depth = tokens[on].depth;
918
1006
  for (let i = on - 1; i >= 0; i--) {
919
1007
  const t = tokens[i];
920
1008
  if (t.depth < depth)
921
1009
  return false;
922
- if (t.depth > depth || t.kw !== "join")
1010
+ if (t.depth > depth)
1011
+ continue;
1012
+ if (t.kw === "on" || t.kw === "using")
1013
+ return true;
1014
+ if (t.kw !== "join")
923
1015
  continue;
924
- let kind = i - 1;
925
- if (JOIN_HINTS.has(tokens[kind]?.kw ?? ""))
926
- kind--;
927
- if (tokens[kind]?.kw === "outer")
928
- kind--;
929
- return ["left", "right", "full"].includes(tokens[kind]?.kw ?? "");
1016
+ for (let k = i - 1; k >= 0; k--) {
1017
+ const w = tokens[k];
1018
+ if (w.depth < depth)
1019
+ break;
1020
+ if (w.depth > depth)
1021
+ continue;
1022
+ if (BEFORE_A_JOIN.has(w.kw) || w.text === ",")
1023
+ break;
1024
+ const qualified = tokens[k - 1]?.text === "." || tokens[k + 1]?.text === ".";
1025
+ if (OUTER_JOIN_WORDS.has(w.kw) && !isFunctionCall(tokens, k) && !qualified)
1026
+ return true;
1027
+ }
1028
+ return false;
930
1029
  }
931
1030
  return false;
932
1031
  }