import type { SqlCatalog, SqlColumn, SqlTable } from './catalog' import { findColumn, findEnum, findSchema, findTable } from './catalog' import type { DialectSpec } from './dialect' import type { StatementScope } from './scope' import { statementScope, TABLE_INTRODUCERS } from './scope' import type { Statement } from './statements' import { separatorLineEnd, statementsFromTokens } from './statements' import type { Token } from './tokenizer' import { identifierName, isKeyword, isPunctuation, tokenize } from './tokenizer' interface Diagnostic { start: number end: number message: string severity: 'error' | 'warning' } const STATEMENT_STARTERS = new Set([ '(', 'ALTER', 'ANALYZE', 'ATTACH', 'BEGIN', 'CALL', 'CHECK', 'CLUSTER', 'COMMENT', 'COMMIT', 'COPY', 'CREATE', 'DEALLOCATE', 'DECLARE', 'DELETE', 'DESC', 'DESCRIBE', 'DETACH', 'DISCARD', 'DO', 'DROP', 'END', 'EXEC', 'EXECUTE', 'EXPLAIN', 'GRANT', 'IF', 'INSERT', 'KILL', 'LISTEN', 'LOAD', 'LOCK', 'MERGE', 'NOTIFY', 'OPTIMIZE', 'PREPARE', 'PRINT', 'RAISERROR', 'REFRESH', 'REINDEX', 'RELEASE', 'RENAME', 'REPLACE', 'RESET', 'REVOKE', 'ROLLBACK', 'SAVEPOINT', 'SELECT', 'SET', 'SHOW', 'START', 'SYSTEM', 'TABLE', 'THROW', 'TRUNCATE', 'UNLOCK', 'UPDATE', 'USE', 'VACUUM', 'VALUES', 'WAITFOR', 'WHILE', 'WITH', ]) const CLOSE_TO_OPEN: Record = { ')': '(', ']': '[', '}': '{' } const getSpan = ({ start, end }: Token) => ({ end, start }) const UNCLOSED_MESSAGES: Partial> = { comment: 'Unterminated block comment', identifier: 'Unterminated quoted identifier', string: 'Unterminated string', } const unclosedDiagnostics = (tokens: Token[]): Diagnostic[] => tokens .filter((token) => token.unclosed) .map((token) => ({ ...getSpan(token), message: UNCLOSED_MESSAGES[token.kind] ?? 'Unterminated token', severity: 'error', })) const repeatCountDiagnostics = ( text: string, tokens: Token[], dialect: DialectSpec ) => { const significant = tokens.filter((token) => token.kind !== 'comment') return significant.flatMap((_, index): Diagnostic[] => { const lineEnd = separatorLineEnd(text, significant, index, dialect) const count = significant[index + 1] return lineEnd && count && count.start < lineEnd ? [ { ...getSpan(count), message: 'The repeat count is ignored — the batch runs once', severity: 'warning', }, ] : [] }) } const bracketDiagnostics = (statement: Statement): Diagnostic[] => { const diagnostics: Diagnostic[] = [] const open: Token[] = [] for (const token of statement.tokens) { if (token.kind !== 'punctuation') { continue } if (Object.values(CLOSE_TO_OPEN).includes(token.text)) { open.push(token) } else if (token.text in CLOSE_TO_OPEN) { const last = open.at(-1) if (last && last.text === CLOSE_TO_OPEN[token.text]) { open.pop() } else { diagnostics.push({ ...getSpan(token), message: `Unexpected \`${token.text}\``, severity: 'error', }) } } } for (const token of open) { diagnostics.push({ ...getSpan(token), message: `Unclosed \`${token.text}\``, severity: 'error', }) } return diagnostics } const unknownStatementDiagnostics = (statement: Statement): Diagnostic[] => { const [first] = statement.tokens if ( !first || first.kind !== 'identifier' || first.quoted || STATEMENT_STARTERS.has(first.text.toUpperCase()) ) { return [] } return [ { ...getSpan(first), message: `Unknown statement \`${first.text}\``, severity: 'error', }, ] } const PSEUDO_TABLES = new Set(['excluded', 'inserted', 'deleted', 'new', 'old']) // Only these read qualifiers against a FROM list; DDL qualifies by schema. const QUERY_VERBS = ['SELECT', 'WITH', 'UPDATE', 'DELETE', 'INSERT'] const isKnownQualifier = ( scope: StatementScope, catalog: SqlCatalog, qualifier: string ) => PSEUDO_TABLES.has(qualifier.toLowerCase()) || findSchema(catalog, qualifier) !== undefined || scope.tables.some( (table) => table.alias?.toLowerCase() === qualifier.toLowerCase() || table.name.toLowerCase() === qualifier.toLowerCase() ) || [...scope.ctes, ...scope.derived].some( (name) => name.toLowerCase() === qualifier.toLowerCase() ) const unknownQualifier = ( qualifier: Token, scope: StatementScope ): Diagnostic => { const available = [ ...scope.tables.map((table) => table.alias ?? table.name), ...scope.ctes, ...scope.derived, ].map((name) => `\`${name}\``) return { ...getSpan(qualifier), message: `\`${identifierName(qualifier)}\` is not a table or alias in this statement${ available.length > 0 ? ` — available: ${available.join(', ')}` : '' }`, severity: 'warning', } } const resolveQualifier = ( scope: StatementScope, catalog: SqlCatalog, qualifier: string ) => { const ref = scope.tables.find( (table) => table.alias?.toLowerCase() === qualifier.toLowerCase() || (!table.alias && table.name.toLowerCase() === qualifier.toLowerCase()) ) return ref ? findTable(catalog, ref.name, ref.schema) : undefined } const qualifiedColumnDiagnostics = ( tokens: Token[], scope: StatementScope, catalog: SqlCatalog ): Diagnostic[] => { const diagnostics: Diagnostic[] = [] const refTokens = new Set(scope.tables.map((table) => table.token)) const checksQualifiers = isKeyword(tokens[0], ...QUERY_VERBS) for (const [index, token] of tokens.entries()) { const qualifier = tokens[index - 2] if ( token.kind !== 'identifier' || !isPunctuation(tokens[index - 1], '.') || qualifier?.kind !== 'identifier' || isPunctuation(tokens[index - 3], '.') || isPunctuation(tokens[index + 1], '(') || refTokens.has(token) ) { continue } const qualifierName = identifierName(qualifier) if ( checksQualifiers && tokens[index - 3]?.text !== '::' && !isKnownQualifier(scope, catalog, qualifierName) ) { diagnostics.push(unknownQualifier(qualifier, scope)) continue } const table = resolveQualifier(scope, catalog, qualifierName) if (!table?.columns || findColumn(catalog, table, identifierName(token))) { continue } diagnostics.push({ ...getSpan(token), message: `Unknown column \`${identifierName(token)}\` on \`${table.name}\``, severity: 'warning', }) } return diagnostics } const FUNCTION_ARGUMENT_WORDS = new Set([ 'at', 'both', 'day', 'dow', 'doy', 'epoch', 'for', 'hour', 'leading', 'minute', 'month', 'quarter', 'second', 'trailing', 'week', 'year', 'zone', ]) const unqualifiedColumnDiagnostics = ( tokens: Token[], scope: StatementScope, catalog: SqlCatalog ): Diagnostic[] => { const tables = scope.tables.map((ref) => findTable(catalog, ref.name, ref.schema) ) if ( !isKeyword(tokens[0], ...QUERY_VERBS) || scope.hasUnknownColumns || scope.ctes.length > 0 || tables.length === 0 || tables.some((table) => !table?.columns) ) { return [] } const key = (name: string) => (catalog.nameCase ? name : name.toLowerCase()) const refTokens = new Set(scope.tables.map((ref) => ref.token)) const known = new Set( [ ...scope.tables.flatMap((ref) => (ref.alias ? [ref.alias] : [])), ...scope.derived, ...tokens.flatMap((token, index) => isKeyword(tokens[index - 1], 'AS') && token.kind === 'identifier' ? [identifierName(token)] : [] ), ...tables.flatMap((table) => (table?.columns ?? []).map((column) => column.name) ), ].map(key) ) const names = tables.flatMap((table) => (table ? [`\`${table.name}\``] : [])) return tokens.flatMap((token, index): Diagnostic[] => { const name = key(identifierName(token)) const previous = tokens[index - 1] const next = tokens[index + 1] if ( token.kind !== 'identifier' || refTokens.has(token) || known.has(name) || FUNCTION_ARGUMENT_WORDS.has(name) || isPunctuation(previous, '.') || isPunctuation(next, '.') || isPunctuation(next, '(') || previous?.text === '::' || isKeyword(previous, 'AS', ...TABLE_INTRODUCERS) ) { return [] } return [ { ...getSpan(token), message: `Unknown column \`${identifierName(token)}\` — not in ${names.join(', ')}`, severity: 'warning', }, ] }) } const UUID_TYPE = /^(?:uuid|uniqueidentifier)$/iu // Keep as lenient as Postgres (braces, no hyphens, any version nibble) — stricter warns on values that run. const UUID_VALUE = /^\{?[\da-f]{8}(?:-?[\da-f]{4}){3}-?[\da-f]{12}\}?$/iu const INTEGER_TYPE = /^(?:u?int\d*|integer|bigint|smallint|tinyint|mediumint)$/iu // Postgres 16 reads underscores and 0x/0o/0b prefixes in integer input. const INTEGER_VALUE = /^\s*[+-]?(?:\d[\d_]*|0x[\da-f_]+|0o[0-7_]+|0b[01_]+)\s*$/iu const EQUALITY = new Set(['=', '<>', '!=']) const columnBefore = ( tokens: Token[], index: number, scope: StatementScope, catalog: SqlCatalog ) => { const token = tokens[index] const qualifier = tokens[index - 2] if (token?.kind !== 'identifier') { return } const tables = isPunctuation(tokens[index - 1], '.') ? [qualifier && resolveQualifier(scope, catalog, identifierName(qualifier))] : scope.tables.map((ref) => findTable(catalog, ref.name, ref.schema)) for (const table of tables) { const column = table && findColumn(catalog, table, identifierName(token)) if (table && column) { return { column, table } } } } const literalMismatch = ( catalog: SqlCatalog, table: SqlTable, column: SqlColumn, value: string ) => { const values = findEnum(catalog, table.name, column)?.values if (values) { return values.some((item) => item.toLowerCase() === value.toLowerCase()) ? undefined : `one of ${values.map((item) => `'${item}'`).join(', ')}` } if (UUID_TYPE.test(column.type) && !UUID_VALUE.test(value)) { return 'a valid UUID' } if (INTEGER_TYPE.test(column.type) && !INTEGER_VALUE.test(value)) { return 'an integer' } } const literalDiagnostics = ( tokens: Token[], scope: StatementScope, catalog: SqlCatalog ): Diagnostic[] => tokens.flatMap((token, index): Diagnostic[] => { const literal = tokens[index + 1] if ( !EQUALITY.has(token.text) || literal?.kind !== 'string' || !literal.text.startsWith("'") || literal.unclosed ) { return [] } const found = columnBefore(tokens, index - 1, scope, catalog) if (!found) { return [] } const { column, table } = found const expected = literalMismatch( catalog, table, column, literal.text.slice(1, -1).replaceAll("''", "'") ) return expected ? [ { ...getSpan(literal), message: `${literal.text} is not ${expected} for \`${column.name}\` (${column.type})`, severity: 'warning', }, ] : [] }) const CONDITION_CLAUSES = ['WHERE', 'ON', 'HAVING', 'WHEN'] const CLAUSES = [ ...CONDITION_CLAUSES, 'SET', 'SELECT', 'FROM', 'VALUES', 'THEN', 'ELSE', 'DECLARE', 'BY', 'RETURNING', ] const nullComparisonDiagnostics = ({ tokens }: Statement): Diagnostic[] => tokens.flatMap((token, index): Diagnostic[] => { const next = tokens[index + 1] if ( !next || !EQUALITY.has(token.text) || !isKeyword(next, 'NULL') || !isKeyword( tokens.slice(0, index).findLast((item) => isKeyword(item, ...CLAUSES)), ...CONDITION_CLAUSES ) ) { return [] } return [ { end: next.end, message: `\`${token.text} NULL\` is never true — use \`IS ${token.text === '=' ? '' : 'NOT '}NULL\``, severity: 'warning', start: token.start, }, ] }) const LOCK_STRENGTHS = new Set(['KEY', 'NO', 'SHARE', 'UPDATE']) const withoutLockingClauses = (tokens: Token[]) => { let locking = false return tokens.filter((token, index) => { if (isPunctuation(token, ')') || isPunctuation(token, ';')) { locking = false } else if ( token.text.toUpperCase() === 'FOR' && LOCK_STRENGTHS.has(tokens[index + 1]?.text.toUpperCase() ?? '') ) { locking = true } return !locking }) } const catalogDiagnostics = ( statement: Statement, catalog: SqlCatalog ): Diagnostic[] => { const tokens = withoutLockingClauses(statement.tokens) const scope = statementScope(tokens) const ctes = new Set(scope.ctes.map((name) => name.toLowerCase())) return [ ...scope.tables .filter( (ref) => !(ctes.has(ref.name.toLowerCase()) && !ref.schema) && !findTable(catalog, ref.name, ref.schema) ) .map((ref): Diagnostic => ({ ...getSpan(ref.token), message: `Unknown table \`${ref.schema ? `${ref.schema}.` : ''}${ref.name}\``, severity: 'warning', })), ...qualifiedColumnDiagnostics(tokens, scope, catalog), ...unqualifiedColumnDiagnostics(tokens, scope, catalog), ...literalDiagnostics(tokens, scope, catalog), ] } export const diagnose = ( text: string, dialect: DialectSpec, catalog: SqlCatalog | null ): Diagnostic[] => { const { tokens } = tokenize(text, dialect) const statements = statementsFromTokens(text, tokens, dialect) return [ ...unclosedDiagnostics(tokens), ...(dialect.goBatches ? repeatCountDiagnostics(text, tokens, dialect) : []), ...statements.flatMap((statement) => [ ...bracketDiagnostics(statement), ...unknownStatementDiagnostics(statement), ...nullComparisonDiagnostics(statement), ...(catalog && catalog.schemas.length > 0 ? catalogDiagnostics(statement, catalog) : []), ]), ].toSorted((a, b) => a.start - b.start) }