All files / src idents.ts

100% Statements 102/102
100% Branches 10/10
100% Functions 2/2
100% Lines 102/102

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172                                                        1x               1x                         1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x                               1x 238x 6x 6x 238x 39x 39x 39x 39x       238x 86x 86x 238x 93x 93x 14x 14x         1x 14x 14x  
/**
 * Identifier quoting helpers for SQL emitted by pgrls-test.
 *
 * `pg_class.relname`, `pg_policy.polname`, and `pg_namespace.nspname`
 * all return raw, unquoted identifiers. When pgrls-test emits
 * `SET LOCAL ROLE <role>` or `INSERT INTO schema.name (...)`, a name
 * containing special characters or matching a reserved keyword needs
 * Postgres-style double-quoting (`"weird name"`, `"order"`). Plain
 * `snake_case` non-keywords don't.
 *
 * We DO consult Postgres's reserved-keyword list — `RESERVED_KEYWORDS`
 * below — so a role named `"order"` or a table named `"select"` gets
 * quoted automatically. A test that calls
 * `client.asRole('order', {}, async () => …)` shouldn't fail with a
 * confusing parse error from the server.
 *
 * Beyond keywords: identifiers outside `[a-z_][a-z0-9_]*` (mixed
 * case, embedded spaces/dots/punctuation, non-ASCII letters, leading
 * digit) also get quoted. C0 control characters and DEL are rejected
 * outright — Postgres rejects them at CREATE time, but a hand-passed
 * role name or table identifier could carry them; failing fast here
 * beats producing SQL that parses confusingly on the server.
 *
 * Direct port of `src/pgrls/fixers/_idents.py`. The reserved-keyword
 * set, the C0+DEL rejection, the doubled-quote escaping, and the
 * "plain identifier" regex are all byte-for-byte equivalent.
 */
 
const PLAIN_IDENT_RE = /^[a-z_][a-z0-9_]*$/;
 
/**
 * C0 controls (`\x00..\x1f`) and DEL (`\x7f`). Catching the whole
 * range so the defense is uniform — a future reader doesn't see a
 * null-byte-only test and assume tab is fine.
 */
// eslint-disable-next-line no-control-regex
const CONTROL_CHARS_RE = /[\x00-\x1f\x7f]/;
 
/**
 * Postgres 16 fully-reserved keywords (appendix C, "reserved" column).
 * Type-context-only reserved words (e.g. `bigint`, `numeric`) are NOT
 * here — those parse as identifiers in non-type position. Keep
 * alphabetized; new keywords are extremely rare.
 *
 * Mirrors `_RESERVED_KEYWORDS` in `src/pgrls/fixers/_idents.py`. If
 * Postgres adds a fully-reserved keyword in a future major, both
 * lists must update together — the cross-language conformance suite
 * pins identical behaviour.
 */
export const RESERVED_KEYWORDS: ReadonlySet<string> = new Set([
  'all',
  'analyse',
  'analyze',
  'and',
  'any',
  'array',
  'as',
  'asc',
  'asymmetric',
  'both',
  'case',
  'cast',
  'check',
  'collate',
  'column',
  'constraint',
  'create',
  'current_catalog',
  'current_date',
  'current_role',
  'current_time',
  'current_timestamp',
  'current_user',
  'default',
  'deferrable',
  'desc',
  'distinct',
  'do',
  'else',
  'end',
  'except',
  'false',
  'fetch',
  'for',
  'foreign',
  'from',
  'grant',
  'group',
  'having',
  'in',
  'initially',
  'intersect',
  'into',
  'lateral',
  'leading',
  'limit',
  'localtime',
  'localtimestamp',
  'not',
  'null',
  'offset',
  'on',
  'only',
  'or',
  'order',
  'placing',
  'primary',
  'references',
  'returning',
  'select',
  'session_user',
  'some',
  'symmetric',
  'system_user',
  'table',
  'then',
  'to',
  'trailing',
  'true',
  'union',
  'unique',
  'user',
  'using',
  'variadic',
  'when',
  'where',
  'window',
  'with',
]);
 
/**
 * Quote `name` with double quotes if Postgres syntax requires it.
 *
 * Doubled quotes inside the name are escaped (`he"llo` →
 * `"he""llo"`), matching Postgres's standard escaping rule.
 *
 * Rejects null bytes, control characters, and DEL explicitly.
 * Postgres rejects them at CREATE time so they can't reach this
 * helper from real introspection, but a hand-passed identifier
 * could; failing fast here is clearer than emitting `"a\x00b"` and
 * getting a confusing parse error from the server.
 *
 * @throws {Error} If `name` is empty or contains a control character.
 */
export function quoteIdent(name: string): string {
  if (!name) {
    throw new Error('identifier is empty');
  }
  if (CONTROL_CHARS_RE.test(name)) {
    throw new Error(
      `identifier contains a control character; refusing to emit: ${JSON.stringify(name)}`,
    );
  }
  // Reserved-keyword check is case-insensitive: `SELECT`/`Select`/
  // `select` all map to the same token in Postgres's parser, so
  // any case folding into the reserved set must be quoted.
  if (RESERVED_KEYWORDS.has(name.toLowerCase())) {
    return '"' + name.replace(/"/g, '""') + '"';
  }
  if (PLAIN_IDENT_RE.test(name)) {
    return name;
  }
  return '"' + name.replace(/"/g, '""') + '"';
}
 
/**
 * Quote a `schema.name` pair, each component independently.
 */
export function quoteQualified(schema: string, name: string): string {
  return `${quoteIdent(schema)}.${quoteIdent(name)}`;
}