Ctrl K
ComunidadGuías…

packages/sql/diagnostics.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<string, string> = { ')': '(', ']': '[', '}': '{' }

const getSpan = ({ start, end }: Token) => ({ end, start })

const UNCLOSED_MESSAGES: Partial<Record<Token['kind'], string>> = {
  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)
}