Ctrl K
ComunidadGuías…

packages/sql/statements.test.ts

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

Archivo original ↗Preguntar a KiBot
import { describe, expect, it } from 'bun:test'

import { Dialect } from '@tamery/shared/enums/connection-type'

import { dialects } from './dialects'
import {
  endsTransaction,
  leavesTransactionOpen,
  splitStatements,
  statementAt,
  unwrapTransaction,
} from './statements'

const pg = dialects[Dialect.Postgres]
const mssql = dialects[Dialect.MSSQL]
const mysql = dialects[Dialect.MySQL]
const clickhouse = dialects[Dialect.ClickHouse]
const sqlite = dialects[Dialect.SQLite]
const oracle = dialects[Dialect.Oracle]
const texts = (sql: string, dialect = pg) =>
  splitStatements(sql, dialect).map((statement) => statement.text)

describe('splitStatements', () => {
  it('splits on semicolons and drops comments between statements', () => {
    expect(texts('SELECT 1; SELECT 2')).toEqual(['SELECT 1', 'SELECT 2'])
    expect(
      texts(`-- lead
SELECT *
FROM users; /* mid */
SELECT 2; -- trail`)
    ).toEqual(['SELECT *\nFROM users', 'SELECT 2'])
    expect(texts('-- only\n/* comments */')).toEqual([])
  })

  it('keeps semicolons inside strings, dollar bodies and BEGIN…END blocks', () => {
    expect(texts("SELECT 'a;b'; SELECT 2")).toEqual([
      "SELECT 'a;b'",
      'SELECT 2',
    ])
    const fn = `CREATE FUNCTION f() RETURNS int AS $$
BEGIN
  RETURN 1;
END;
$$ LANGUAGE plpgsql`
    expect(texts(`${fn};\nSELECT f()`)).toEqual([fn, 'SELECT f()'])
    const block = `CREATE PROCEDURE p AS
BEGIN
  SELECT CASE WHEN 1 = 1 THEN 'a' ELSE 'b' END;
  IF 1 = 1 BEGIN SELECT 1; END
END`
    expect(texts(`${block};\nSELECT 2`, mssql)).toEqual([block, 'SELECT 2'])
    const trigger = `CREATE TRIGGER t AFTER INSERT ON a FOR EACH ROW WHEN new.b > 1
BEGIN
  INSERT INTO log VALUES (new.id);
  UPDATE a SET c = CASE WHEN c IS NULL THEN 0 ELSE c END;
END`
    expect(texts(`${trigger};\nSELECT 2`, sqlite)).toEqual([
      trigger,
      'SELECT 2',
    ])
  })

  it('groups a transaction into one statement', () => {
    const tx = `BEGIN;
UPDATE users SET a = 1;
COMMIT`
    expect(texts(`${tx};\nSELECT 1`)).toEqual([tx, 'SELECT 1'])
    expect(texts('START TRANSACTION; DELETE FROM t; ROLLBACK;')).toEqual([
      'START TRANSACTION; DELETE FROM t; ROLLBACK',
    ])
  })

  it('takes a transaction group apart for the driver', () => {
    expect(
      unwrapTransaction(
        'BEGIN;\nUPDATE a SET x = 1;\nDELETE FROM b;\nCOMMIT',
        pg
      )
    ).toMatchObject({
      commit: true,
      statements: ['UPDATE a SET x = 1', 'DELETE FROM b'],
    })
    expect(
      unwrapTransaction('START TRANSACTION; DELETE FROM t; ROLLBACK', pg)
    ).toMatchObject({ commit: false, statements: ['DELETE FROM t'] })
    expect(unwrapTransaction('SELECT 1', pg)).toBeNull()
    expect(unwrapTransaction('BEGIN; SELECT 1', pg)).toBeNull()
  })

  it('keeps the isolation level and access mode the opener asks for', () => {
    expect(
      unwrapTransaction(
        'BEGIN ISOLATION LEVEL READ COMMITTED, READ ONLY; SELECT 1; COMMIT',
        pg
      )
    ).toMatchObject({
      accessMode: 'read only',
      isolationLevel: 'read committed',
    })
    expect(
      unwrapTransaction('START TRANSACTION READ WRITE; SELECT 1; COMMIT', mysql)
    ).toMatchObject({ accessMode: 'read write', isolationLevel: undefined })
  })

  it('reads an unreserved begin as a column, not a block', () => {
    expect(texts('SELECT begin, end FROM t; SELECT 2', mysql)).toEqual([
      'SELECT begin, end FROM t',
      'SELECT 2',
    ])
  })

  it('keeps a rollback to a savepoint inside its transaction', () => {
    const tx =
      'BEGIN; UPDATE a SET x=1; SAVEPOINT s; UPDATE b SET y=1; ROLLBACK TO SAVEPOINT s; UPDATE c SET z=1; COMMIT'
    expect(texts(`${tx};`)).toEqual([tx])
    expect(unwrapTransaction(`${tx};`, pg)).toMatchObject({
      commit: true,
      statements: [
        'UPDATE a SET x=1',
        'SAVEPOINT s',
        'UPDATE b SET y=1',
        'ROLLBACK TO SAVEPOINT s',
        'UPDATE c SET z=1',
      ],
    })
  })

  it('opens a transaction with every BEGIN form, not a block', () => {
    for (const opener of [
      'BEGIN WORK',
      'begin transaction',
      'BEGIN ISOLATION LEVEL SERIALIZABLE',
      'BEGIN READ ONLY',
      'BEGIN NOT DEFERRABLE',
    ]) {
      const tx = `${opener}; DELETE FROM t; COMMIT WORK`
      expect(texts(`${tx};\nSELECT 1`)).toEqual([tx, 'SELECT 1'])
    }
    const distributed = 'BEGIN DISTRIBUTED TRAN; DELETE FROM t; ROLLBACK WORK'
    expect(texts(`${distributed};\nSELECT 1`, mssql)).toEqual([
      distributed,
      'SELECT 1',
    ])
    for (const mode of ['DEFERRED', 'IMMEDIATE', 'EXCLUSIVE TRANSACTION']) {
      const tx = `BEGIN ${mode}; DELETE FROM t; COMMIT`
      expect(texts(`${tx};\nSELECT 1`, sqlite)).toEqual([tx, 'SELECT 1'])
      expect(unwrapTransaction(tx, sqlite)?.statements).toEqual([
        'DELETE FROM t',
      ])
    }
    const atomic = 'BEGIN NOT ATOMIC SELECT 1; SELECT 2; END'
    expect(texts(`${atomic};\nSELECT 3`, mysql)).toEqual([atomic, 'SELECT 3'])
  })

  it('closes a Postgres transaction with END or ABORT, not a block END', () => {
    expect(texts('BEGIN; DELETE FROM t; END;\nSELECT 1')).toEqual([
      'BEGIN; DELETE FROM t; END',
      'SELECT 1',
    ])
    expect(texts('BEGIN; DELETE FROM t; ABORT;\nSELECT 1')).toEqual([
      'BEGIN; DELETE FROM t; ABORT',
      'SELECT 1',
    ])
    expect(
      unwrapTransaction('BEGIN; DELETE FROM t; END TRANSACTION', pg)
    ).toMatchObject({ commit: true, statements: ['DELETE FROM t'] })
    expect(unwrapTransaction('BEGIN; DELETE FROM t; ABORT', pg)).toMatchObject({
      commit: false,
      statements: ['DELETE FROM t'],
    })
    const tx = 'BEGIN TRAN; IF 1 = 1 BEGIN ROLLBACK; END; COMMIT'
    expect(texts(`${tx};\nSELECT 1`, mssql)).toEqual([tx, 'SELECT 1'])
  })

  it('runs an unclosed transaction statement by statement', () => {
    expect(texts('BEGIN; SELECT 1; SELECT 2')).toEqual([
      'BEGIN',
      'SELECT 1',
      'SELECT 2',
    ])
    expect(texts('BEGIN; SELECT 1; COMMIT')).toEqual([
      'BEGIN; SELECT 1; COMMIT',
    ])
  })

  it('never groups on a dialect without transactions', () => {
    const tx = 'BEGIN TRANSACTION; SELECT 1; COMMIT'
    expect(texts(tx, clickhouse)).toEqual([
      'BEGIN TRANSACTION',
      'SELECT 1',
      'COMMIT',
    ])
    expect(unwrapTransaction(tx, clickhouse)).toBeNull()
  })

  it('tells a transaction left open from a block or a closed batch', () => {
    expect(leavesTransactionOpen('-- lead\nBEGIN', pg)).toBe(true)
    expect(
      leavesTransactionOpen('begin isolation level serializable', pg)
    ).toBe(true)
    expect(leavesTransactionOpen('START TRANSACTION; SELECT 1', mysql)).toBe(
      true
    )
    expect(leavesTransactionOpen('BEGIN TRAN', mssql)).toBe(true)
    expect(leavesTransactionOpen('BEGIN SELECT 1; END', mssql)).toBe(false)
    expect(leavesTransactionOpen('SELECT 1; BEGIN', pg)).toBe(false)
    expect(
      leavesTransactionOpen('BEGIN TRAN\nUPDATE t SET a = 1\nCOMMIT', mssql)
    ).toBe(false)
  })

  it('tells a statement ending the transaction from a savepoint rollback', () => {
    expect(endsTransaction('-- done\ncommit work', pg)).toBe(true)
    expect(endsTransaction('ROLLBACK', mysql)).toBe(true)
    expect(endsTransaction('END', pg)).toBe(true)
    expect(endsTransaction('ROLLBACK TO SAVEPOINT s1', pg)).toBe(false)
    expect(endsTransaction('CREATE TABLE t (id int)', mysql)).toBe(false)
  })

  it('splits SQL Server batches on GO', () => {
    expect(texts('SELECT 1\nGO\nSELECT 2', mssql)).toEqual([
      'SELECT 1',
      'SELECT 2',
    ])
    expect(texts('SELECT 1\nGO 2\nSELECT 2', mssql)).toEqual([
      'SELECT 1',
      'SELECT 2',
    ])
  })

  it('runs an Oracle PL/SQL unit to its slash line', () => {
    const procedure = `CREATE OR REPLACE PROCEDURE p AS
  total NUMBER;
BEGIN
  CASE WHEN total > 0 THEN total := 1; END CASE;
END;`
    expect(texts(`${procedure}\n/\nSELECT 1 FROM dual;`, oracle)).toEqual([
      procedure,
      'SELECT 1 FROM dual',
    ])
    expect(texts('DECLARE n NUMBER; BEGIN n := 1; END;', oracle)).toEqual([
      'DECLARE n NUMBER; BEGIN n := 1; END;',
    ])
    expect(
      texts('SELECT 4 / 2 FROM dual\n/\nSELECT 2 FROM dual', oracle)
    ).toEqual(['SELECT 4 / 2 FROM dual', 'SELECT 2 FROM dual'])
  })

  it('closes a MySQL CASE statement at END CASE', () => {
    const procedure =
      'CREATE PROCEDURE p() BEGIN CASE x WHEN 1 THEN SELECT 1; END CASE; END'
    expect(texts(`${procedure}; SELECT 2`, mysql)).toEqual([
      procedure,
      'SELECT 2',
    ])
  })

  it('keeps semicolons inside Oracle q-quoted strings', () => {
    for (const literal of ["q'[it's; fine]'", "Q'{a; b}'", "nq'!x; y!'"]) {
      expect(
        texts(`SELECT ${literal} FROM dual; SELECT 2 FROM dual`, oracle)
      ).toEqual([`SELECT ${literal} FROM dual`, 'SELECT 2 FROM dual'])
    }
    expect(texts("SELECT q'[a]' FROM t; SELECT 2", pg)).toEqual([
      "SELECT q'[a]' FROM t",
      'SELECT 2',
    ])
  })

  it('finds the statement at an offset', () => {
    const text = 'SELECT 1;\n\nSELECT 2;'
    const statements = splitStatements(text, pg)
    expect(statementAt(statements, 3, text)?.text).toBe('SELECT 1')
    expect(statementAt(statements, 9, text)?.text).toBe('SELECT 1')
    expect(statementAt(statements, 10, text)).toBeUndefined()
    expect(statementAt(statements, 11, text)?.text).toBe('SELECT 2')
    const line = 'SELECT 5; SELECT 6;'
    const onLine = splitStatements(line, pg)
    expect(statementAt(onLine, 9, line)?.text).toBe('SELECT 5')
    expect(statementAt(onLine, 12, line)?.text).toBe('SELECT 6')
    expect(statementAt(onLine, line.length, line)?.text).toBe('SELECT 6')
    const split = 'SELECT 1\n;\n\nSELECT 2;'
    expect(statementAt(splitStatements(split, pg), 10, split)?.text).toBe(
      'SELECT 1'
    )
  })
})