File size: 6,228 Bytes
aa89cb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/** Render provider placeholders as SQL literals so the statement can be copied into a SQL client. */
export type SQLDatabaseKind = 'postgresql' | 'mysql' | 'sqlite';
export type SQLParameterLogPolicy = 'plain' | 'masked' | 'credential' | 'unknown';

export function debugSQL(
  parameterizedSQL: string,
  parameters: readonly unknown[],
  databaseKind: SQLDatabaseKind = 'sqlite',
): string {
  return renderSQL(parameterizedSQL, parameters, databaseKind);
}

/** Shared scanner for diagnostics. A callback enables strict bind accounting. */
export function renderSQL(
  parameterizedSQL: string,
  parameters: readonly unknown[],
  databaseKind: SQLDatabaseKind,
  parameterLiteral?: (index: number) => string,
): string {
  if (parameterLiteral && !parameterizedSQL.trim()) throw new Error('Missing SQL template');
  let positionalIndex = 0;
  const used = new Set<number>();
  const literal = (index: number): string => {
    if (index < 0 || index >= parameters.length) throw new Error('SQL bind count mismatch');
    used.add(index);
    return parameterLiteral ? parameterLiteral(index) : sqlLiteral(parameters[index], databaseKind);
  };
  let result = '';
  let state: 'sql' | 'single' | 'double' | 'backtick' | 'line-comment' | 'block-comment' = 'sql';
  for (let index = 0; index < parameterizedSQL.length; index++) {
    const char = parameterizedSQL[index];
    const next = parameterizedSQL[index + 1] ?? '';
    if (state === 'sql' && char === "'") {
      result += char;
      state = 'single';
      continue;
    }
    if (state === 'sql' && char === '"') { result += char; state = 'double'; continue; }
    if (state === 'sql' && char === '`') { result += char; state = 'backtick'; continue; }
    if (state === 'sql' && char === '-' && next === '-') {
      result += '--'; index++; state = 'line-comment'; continue;
    }
    if (state === 'sql' && char === '/' && next === '*') {
      result += '/*'; index++; state = 'block-comment'; continue;
    }
    if (state === 'single') {
      result += char;
      if (char === "'" && next === "'") result += parameterizedSQL[++index];
      else if (char === "'") state = 'sql';
      continue;
    }
    if (state === 'double') {
      result += char;
      if (char === '"' && next === '"') result += parameterizedSQL[++index];
      else if (char === '"') state = 'sql';
      continue;
    }
    if (state === 'backtick') {
      result += char;
      if (char === '`' && next === '`') result += parameterizedSQL[++index];
      else if (char === '`') state = 'sql';
      continue;
    }
    if (state === 'line-comment') {
      result += char;
      if (char === '\r' || char === '\n') state = 'sql';
      continue;
    }
    if (state === 'block-comment') {
      result += char;
      if (char === '*' && next === '/') { result += '/'; index++; state = 'sql'; }
      continue;
    }
    if (char === '?') {
      if (parameterLiteral || positionalIndex < parameters.length) result += literal(positionalIndex++);
      else result += char;
      continue;
    }
    if (char === '$' && /[0-9]/.test(parameterizedSQL[index + 1] ?? '')) {
      let end = index + 1;
      while (/[0-9]/.test(parameterizedSQL[end] ?? '')) end++;
      const parameterIndex = Number(parameterizedSQL.slice(index + 1, end)) - 1;
      result += parameterLiteral || (parameterIndex >= 0 && parameterIndex < parameters.length)
        ? literal(parameterIndex) : parameterizedSQL.slice(index, end);
      index = end - 1;
      continue;
    }
    if (parameterizedSQL.slice(index).match(/^@p[0-9]+/i)) {
      const placeholder = parameterizedSQL.slice(index).match(/^@p([0-9]+)/i)!;
      const parameterIndex = Number(placeholder[1]) - 1;
      result += parameterLiteral || (parameterIndex >= 0 && parameterIndex < parameters.length)
        ? literal(parameterIndex) : placeholder[0];
      index += placeholder[0].length - 1;
      continue;
    }
    result += char;
  }
  if (parameterLiteral && (used.size !== parameters.length || (state !== 'sql' && state !== 'line-comment'))) {
    throw new Error('Incomplete SQL diagnostic rendering');
  }
  return result;
}

export function sqlLiteral(value: unknown, databaseKind: SQLDatabaseKind): string {
  if (value && typeof value === 'object' && 'type' in value) {
    const typed = value as { type: string; value?: unknown };
    if (typed.type === 'Null' || typed.type === 'TypedNull') return 'NULL';
    if (typed.type === 'Date') {
      const date = typed.value instanceof Date
        ? typed.value.toISOString().slice(0, 10) : String(typed.value);
      if (databaseKind === 'postgresql') return `DATE ${quoteSQLString(date)}`;
      if (databaseKind === 'mysql') return `CAST(${quoteSQLString(date)} AS DATE)`;
      return quoteSQLString(date);
    }
    if (typed.type === 'Timestamp') {
      if (databaseKind === 'sqlite') return String(typed.value);
      const iso = new Date(Number(typed.value)).toISOString();
      if (databaseKind === 'postgresql') return `TIMESTAMPTZ ${quoteSQLString(iso)}`;
      return `CAST(${quoteSQLString(iso.slice(0, 23).replace('T', ' '))} AS DATETIME(3))`;
    }
    if (typed.type === 'Bool') return typed.value ? 'TRUE' : 'FALSE';
    if (['I64', 'U64', 'F64', 'Decimal'].includes(typed.type)) return String(typed.value);
    if (typed.type === 'Text') return quoteSQLString(String(typed.value));
  }
  if (value === null || value === undefined) return 'NULL';
  if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE';
  if (typeof value === 'number' || typeof value === 'bigint') return String(value);
  if (value instanceof Date) {
    if (databaseKind === 'sqlite') return String(value.getTime());
    if (databaseKind === 'postgresql') return `TIMESTAMPTZ ${quoteSQLString(value.toISOString())}`;
    return `CAST(${quoteSQLString(value.toISOString().slice(0, 23).replace('T', ' '))} AS DATETIME(3))`;
  }
  if (value instanceof Uint8Array) {
    return `X'${Array.from(value, byte => byte.toString(16).padStart(2, '0')).join('')}'`;
  }
  if (typeof value === 'object') return quoteSQLString(JSON.stringify(value));
  return quoteSQLString(String(value));
}

function quoteSQLString(value: string): string {
  return `'${value.replace(/'/g, "''")}'`;
}