Ctrl K
ComunidadGuías…

packages/sql/destructive.ts

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

Archivo original ↗Preguntar a KiBot
import type { DialectSpec } from './dialect'
import type { Statement } from './statements'
import { splitStatements, statementsFromTokens } from './statements'
import { identifierName, isKeyword, isPunctuation, tokenize } from './tokenizer'

const RUNNING_EXPLAIN_OPTIONS = new Set(['ANALYSE', 'ANALYZE'])

// Never a read-only admission: SQL Server runs `EXPLAIN 1 DELETE FROM t` as a procedure call, then the DELETE.
const isPlanOnlyExplain = ({ tokens }: Statement) =>
  isKeyword(tokens[0], 'EXPLAIN') &&
  !tokens.some((token) =>
    RUNNING_EXPLAIN_OPTIONS.has(identifierName(token).toUpperCase())
  )

const showsDefinition = (words: string[], index: number) =>
  index === 1 && words[0] === 'SHOW' && words[1] === 'CREATE'

const REFERENTIAL_ACTIONS = new Set(['CASCADE', 'NO', 'RESTRICT', 'SET'])

const TRIGGER_TIMINGS = new Set(['AFTER', 'BEFORE', 'OF'])

const TRIGGER_EVENTS = new Set(['DELETE', 'INSERT', 'TRUNCATE', 'UPDATE'])

const namesTriggerEvent = (words: string[], index: number) => {
  let before = index - 1
  while (
    words[before] === 'OR' &&
    TRIGGER_EVENTS.has(words[before - 1] ?? '')
  ) {
    before -= 2
  }
  return TRIGGER_TIMINGS.has(words[before] ?? '')
}

const namesWithoutRunning = (
  words: string[],
  index: number,
  dialect: DialectSpec
) =>
  words[index - 1] === 'FOR' ||
  namesTriggerEvent(words, index) ||
  (words[index - 1] === 'ON' &&
    // SQL Server runs `SET NOCOUNT ON DELETE FROM t` as a setting and a DELETE.
    (!dialect.separatorFreeStatements ||
      REFERENTIAL_ACTIONS.has(words[index + 1] ?? ''))) ||
  (words[index - 3] === 'FOR' && words[index - 2] === 'NO') ||
  showsDefinition(words, index)

const DESTRUCTIVE = new Set([
  'ALTER',
  'DELETE',
  'DROP',
  'MERGE',
  'RENAME',
  'REPLACE',
  'TRUNCATE',
  'UPDATE',
])

const DYNAMIC_SQL_COMMANDS = new Set(['CALL', 'DO', 'EXEC', 'EXECUTE'])

const DDL_KEYWORDS = new Set(['ALTER', 'CREATE', 'DROP', 'RENAME', 'TRUNCATE'])

const DATA_WRITE_KEYWORDS = new Set([
  'COPY',
  'DELETE',
  'INSERT',
  'MERGE',
  'REPLACE',
  'TRUNCATE',
  'UPDATE',
])

const keywordsRun = (
  keywords: Set<string>,
  text: string,
  dialect: DialectSpec
) =>
  splitStatements(text, dialect).flatMap((statement) => {
    const words = statement.tokens.map((token) => token.text.toUpperCase())
    if (
      isPlanOnlyExplain(statement) ||
      ['GRANT', 'REVOKE'].includes(words[0] ?? '')
    ) {
      return []
    }
    return words.filter(
      (word, index) =>
        (index === 0 && DYNAMIC_SQL_COMMANDS.has(word)) ||
        (keywords.has(word) &&
          // A first word counts whatever it tokenized as: MySQL's `REPLACE INTO` and `MERGE` are not keywords in every dialect's list.
          (statement.tokens[index]?.kind === 'keyword' || index === 0) &&
          !namesWithoutRunning(words, index, dialect))
    )
  })

/** Additive writes (INSERT, CREATE) are left out on purpose. */
export const destructiveKeywords = (text: string, dialect: DialectSpec) => [
  ...new Set(keywordsRun(DESTRUCTIVE, text, dialect)),
]

// COMMENT counts only as a statement's first word: as a keyword it would make `SELECT comment` a write to `readsOnly`.
export const invalidatesCatalog = (text: string, dialect: DialectSpec) =>
  keywordsRun(DDL_KEYWORDS, text, dialect).length > 0 ||
  splitStatements(text, dialect).some(
    ({ tokens }) => tokens[0]?.text.toUpperCase() === 'COMMENT'
  )

export const writesData = (text: string, dialect: DialectSpec) =>
  keywordsRun(DATA_WRITE_KEYWORDS, text, dialect).length > 0

const READ_COMMANDS = new Set([
  'DESC',
  'DESCRIBE',
  'EXPLAIN',
  'SELECT',
  'SHOW',
  'TABLE',
  'VALUES',
  'WITH',
])

// SQL Server has no EXPLAIN, SHOW, DESCRIBE, TABLE or VALUES statement: a batch's first bare word runs as a procedure.
const SQL_SERVER_READ_COMMANDS = new Set(['SELECT', 'WITH'])

// No `FOR`/`ON` exemption: SQL Server's `SET NOCOUNT ON COMMIT` would pass as a referential action.
const WRITES_INSIDE_READ = new Set([
  ...DDL_KEYWORDS,
  ...DATA_WRITE_KEYWORDS,
  ...DYNAMIC_SQL_COMMANDS,
  'BACKUP',
  'COMMIT',
  'DBCC',
  'DENY',
  'GRANT',
  'INTO',
  'KILL',
  'RECONFIGURE',
  'RESTORE',
  'REVOKE',
  'ROLLBACK',
  'SHUTDOWN',
])

const SESSION_COMMANDS = new Set([
  'BEGIN',
  'DECLARE',
  'OPEN',
  'REVERT',
  'SAVE',
  'SET',
  'SETUSER',
  'USE',
  'WAITFOR',
])

const SIDE_EFFECT_FUNCTION =
  /^(?:DBLINK\w*|GET_LOCK|LO_EXPORT|OPEN(?:DATASOURCE|QUERY|ROWSET)|PG_(?:CANCEL|TERMINATE)_BACKEND|PG_(?:TRY_)?ADVISORY_LOCK(?:_SHARED)?|PG_FILE_(?:RENAME|UNLINK|WRITE))$/u

export const readsOnly = (text: string, dialect: DialectSpec) => {
  const { tokens } = tokenize(text, dialect)
  const [statement, ...rest] = statementsFromTokens(text, tokens, dialect)
  if (!statement || rest.length > 0) {
    return false
  }
  const words = statement.tokens
    // Most commands are not keywords in any dialect's list, so a bare word counts too.
    .filter(
      ({ kind, quoted }) =>
        kind === 'keyword' || (kind === 'identifier' && !quoted)
    )
    .map((token) => token.text.toUpperCase())
  return (
    (dialect.separatorFreeStatements
      ? SQL_SERVER_READ_COMMANDS
      : READ_COMMANDS
    ).has(statement.tokens[0]?.text.toUpperCase() ?? '') &&
    !words.some(
      (word, index) =>
        (WRITES_INSIDE_READ.has(word) && !showsDefinition(words, index)) ||
        (dialect.separatorFreeStatements && SESSION_COMMANDS.has(word)) ||
        (word === 'NEXT' &&
          words[index + 1] === 'VALUE' &&
          words[index + 2] === 'FOR')
    ) &&
    !statement.tokens.some(
      (token, index) =>
        isPunctuation(statement.tokens[index + 1], '(') &&
        SIDE_EFFECT_FUNCTION.test(identifierName(token).toUpperCase())
    ) &&
    // Postgres reads `U&"lo_\0065xport"` as `lo_export`, a name the check above never sees.
    !statement.tokens.some(
      (token) =>
        token.quoted &&
        text.slice(token.start - 2, token.start).toUpperCase() === 'U&'
    ) &&
    // MySQL and MariaDB run `/*! … */` and `/*M! … */` as SQL; the tokenizer reads them as comments.
    !tokens.some(
      (token) => token.kind === 'comment' && /^\/\*M?!/u.test(token.text)
    )
  )
}