Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import Foundation

// A WHERE in a comment, a literal, a quoted identifier or a subquery is not the statement's own.
public enum SQLUnfilteredWrite: Sendable, Equatable {
case update
case delete

public static func writes(in sql: String, grammar: SQLLexicalGrammar) -> [SQLUnfilteredWrite] {
SQLStatementScanner.executableStatements(in: sql, grammar: grammar).flatMap { statement in
// On T-SQL a statement can begin inside another without a `;`.
SQLUnterminatedStatements.runnable(in: statement.sql, grammar: grammar).flatMap { text in
writes(inStatement: text as NSString, grammar: grammar)
}
}
}

private static let period = UInt16(UnicodeScalar(".").value)

private static let servedStatements: Set<String> = ["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE"]

// `:where` and `$where` are bind parameters on SQLite.
private static let parameterPrefixes: Set<UInt16> = [
UInt16(UnicodeScalar(":").value), UInt16(UnicodeScalar("$").value),
]

private static func writes(inStatement source: NSString, grammar: SQLLexicalGrammar) -> [SQLUnfilteredWrite] {
let tokens = TSQLTokens.read(source, grammar: grammar)
guard let start = tokens.firstIndex(where: { $0.kind != .open }), let first = tokens[start].word else {
return []
}
let depth = tokens[start].depth
var found: [SQLUnfilteredWrite] = []
let verb: Int?
switch first {
case "UPDATE", "DELETE":
verb = start
case "WITH":
// A CTE may be named `delete` or `merge`, and on PostgreSQL its body may itself write. A list that does
// not parse is searched whole, which can only find a write that is not there.
let list = commonTableExpressions(in: tokens, with: start)
for body in list?.bodies ?? [] {
found += writes(inStatement: source.substring(with: body) as NSString, grammar: grammar)
}
verb = tokens.indices.dropFirst(list?.main ?? start + 1).first { index in
tokens[index].depth == depth && servedStatements.contains(tokens[index].word ?? "")
}
case "ALTER":
// ClickHouse writes rows with `ALTER TABLE t UPDATE ... WHERE` and `ALTER TABLE t DELETE WHERE`.
guard tokens.indices.contains(start + 1), tokens[start + 1].word == "TABLE" else { return [] }
verb = tokens.indices.dropFirst(start + 2).first { index in
let word = tokens[index].word
return tokens[index].depth == depth && (word == "UPDATE" || word == "DELETE")
&& tokens[index - 1].word != "ON"
}
default:
verb = nil
}
if let verb, let write = write(at: verb, in: tokens), !hasOwnWhere(tokens, depth: depth, in: source) {
found.append(write)
}
return found
}

private static func commonTableExpressions(
in tokens: [TSQLTokens.Token],
with start: Int
) -> (bodies: [NSRange], main: Int)? {
let depth = tokens[start].depth
var index = start + 1
var bodies: [NSRange] = []
func word(_ expected: String) -> Bool {
guard tokens.indices.contains(index), tokens[index].word == expected else { return false }
index += 1
return true
}
func parentheses() -> NSRange? {
guard tokens.indices.contains(index), tokens[index].kind == .open,
let close = tokens.indices.dropFirst(index + 1).first(where: {
tokens[$0].kind == .close && tokens[$0].depth == depth
})
else { return nil }
let inside = tokens[index].location + 1
index = close + 1
return NSRange(location: inside, length: tokens[close].location - inside)
}
_ = word("RECURSIVE")
while tokens.indices.contains(index) {
index += 1
_ = parentheses()
guard word("AS") else { return nil }
_ = word("NOT")
_ = word("MATERIALIZED")
guard let body = parentheses() else { return nil }
bodies.append(body)
guard tokens.indices.contains(index), tokens[index].kind == .comma else { break }
index += 1
}
return tokens.indices.contains(index) ? (bodies, index) : nil
}

private static func write(at index: Int, in tokens: [TSQLTokens.Token]) -> SQLUnfilteredWrite? {
switch tokens[index].word {
case "UPDATE":
// T-SQL's `UPDATE STATISTICS t` writes no rows. A table named statistics is still updated with a SET.
let next = tokens.indices.contains(index + 1) ? tokens[index + 1].word : nil
let depth = tokens[index].depth
let setsColumns = tokens.dropFirst(index + 1).contains { $0.depth == depth && $0.word == "SET" }
return next == "STATISTICS" && !setsColumns ? nil : .update
case "DELETE":
return .delete
default:
return nil
}
}

private static func hasOwnWhere(_ tokens: [TSQLTokens.Token], depth: Int, in source: NSString) -> Bool {
tokens.indices.contains { index in
guard tokens[index].depth == depth, tokens[index].word == "WHERE" else { return false }
guard index > 0 else { return true }
let location = tokens[index].location
if location > 0, parameterPrefixes.contains(source.character(at: location - 1)) { return false }
// `t.where` is a column.
let previous = tokens[index - 1]
return previous.kind != .other || source.character(at: previous.location) != period
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import Foundation
import TableProSQLGrammar
import Testing

struct SQLUnfilteredWriteTests {
private static func grammar(_ typeId: String) -> SQLLexicalGrammar {
SQLLexicalReadings.resolve(databaseTypeId: typeId, declared: nil, session: nil).execution
}

private static let postgreSQL = grammar("PostgreSQL")
private static let sqlServer = grammar("SQL Server")
private static let clickHouse = grammar("ClickHouse")
private static let mySQL = grammar("MySQL")

private func first(_ sql: String, _ grammar: SQLLexicalGrammar = postgreSQL) -> SQLUnfilteredWrite? {
SQLUnfilteredWrite.writes(in: sql, grammar: grammar).first
}

@Test("An UPDATE or DELETE with no WHERE is found", arguments: [
("UPDATE t SET a = 1", SQLUnfilteredWrite.update),
("update \"t\" set \"a\" = $1", .update),
("DELETE FROM t", .delete),
("delete from \"s\".\"t\";", .delete),
("UPDATE t SET a = (SELECT b FROM u WHERE u.id = 1)", .update),
("DELETE FROM t WHERE_x = 1", .delete),
("WITH c AS (SELECT id FROM u WHERE id > 1) DELETE FROM t", .delete),
("UPDATE t SET a = t.where", .update),
])
func unfiltered(sql: String, expected: SQLUnfilteredWrite) {
#expect(first(sql) == expected)
}

@Test("A WHERE the engine never reads as code does not count", arguments: [
"UPDATE t SET a = 1 -- WHERE id = 1",
"UPDATE t SET a = 1 /* WHERE id = 1 */",
"UPDATE t SET a = ' WHERE id = 1 '",
"UPDATE t SET \" WHERE \" = 1",
"DELETE FROM t -- WHERE id = 1",
"DELETE FROM \"t WHERE id = 1\"",
])
func whereOutsideCode(sql: String) {
#expect(first(sql) != nil)
}

@Test("A statement with a WHERE of its own passes", arguments: [
"UPDATE t SET a = 1 WHERE id = 1",
"update t set a = 1\nwhere id = 1",
"DELETE FROM t WHERE(id = 1)",
"DELETE FROM \"t\" WHERE \"id\" = $1 OR \"id\" = $2",
"UPDATE t SET a = (SELECT 1) WHERE id = 1 RETURNING *",
"WITH c AS (SELECT 1) UPDATE t SET a = 1 WHERE id = 1",
"INSERT INTO t (a) VALUES (1)",
"SELECT * FROM t",
])
func filtered(sql: String) {
#expect(first(sql) == nil)
}

@Test("Every statement in the text is read")
func everyStatementIsRead() {
#expect(first("UPDATE t SET a = 1 WHERE id = 1; DELETE FROM t") == .delete)
#expect(first("DELETE FROM t WHERE id = 1; UPDATE t SET a = 1 WHERE id = 2") == nil)
#expect(SQLUnfilteredWrite.writes(in: "UPDATE t SET a = 1; DELETE FROM t", grammar: Self.postgreSQL)
== [.update, .delete])
}

@Test("A common table expression named like a verb is not the statement")
func commonTableExpressionNames() {
#expect(first("WITH delete AS (SELECT 1) SELECT * FROM delete") == nil)
#expect(first("WITH merge AS (SELECT 1) DELETE FROM items") == .delete)
#expect(first("WITH RECURSIVE update (n) AS (SELECT 1), b AS MATERIALIZED (SELECT 2) UPDATE t SET a = 1")
== .update)
#expect(first("WITH a AS NOT MATERIALIZED (SELECT 1) DELETE FROM t WHERE id IN (SELECT * FROM a)") == nil)
}

@Test("A common table expression that writes is read as the write it is")
func dataModifyingCommonTableExpression() {
#expect(first("WITH gone AS (DELETE FROM items RETURNING *) SELECT * FROM gone") == .delete)
#expect(first("WITH gone AS (DELETE FROM items WHERE id = 1 RETURNING *) SELECT * FROM gone") == nil)
#expect(first("WITH a AS (SELECT 1), b AS (UPDATE t SET c = 1 RETURNING c) SELECT * FROM b") == .update)
}

@Test("Only T-SQL's maintenance command is spelled UPDATE STATISTICS without a SET")
func tableNamedStatistics() {
#expect(first("UPDATE statistics SET value = 1") == .update)
#expect(first("UPDATE statistics SET value = 1 WHERE id = 1") == nil)
}

@Test("A bind parameter named where is not a WHERE")
func parameterNamedWhere() {
let sqlite = Self.grammar("SQLite")
#expect(first("DELETE FROM t RETURNING :where", sqlite) == .delete)
#expect(first("DELETE FROM t RETURNING $where", sqlite) == .delete)
#expect(first("DELETE FROM t WHERE id = :where", sqlite) == nil)
}

@Test("A statement in parentheses is read at its own depth")
func parenthesizedStatement() {
#expect(first("(DELETE FROM t)") == .delete)
#expect(first("(DELETE FROM t WHERE id = 1)") == nil)
}

@Test("On SQL Server a statement written without a terminator is read on its own")
func sqlServerUnterminatedStatement() {
#expect(first("UPDATE [t] SET [a] = 1 WHERE [id] = 1 DELETE FROM [t]", Self.sqlServer) == .delete)
#expect(first("DELETE FROM [t] UPDATE [t] SET [a] = 1 WHERE [id] = 1", Self.sqlServer) == .delete)
#expect(first("SET NOCOUNT OFF; UPDATE TOP (1) [t] SET [a] = ? WHERE [a] = ?", Self.sqlServer) == nil)
#expect(first("DELETE TOP (1) FROM [t] WHERE [a] IS NULL", Self.sqlServer) == nil)
#expect(first("UPDATE [t] SET [a] = 1 -- WHERE [id] = 1", Self.sqlServer) == .update)
#expect(first("UPDATE STATISTICS [t]", Self.sqlServer) == nil)
}

@Test("ClickHouse's ALTER TABLE mutations are read as the write they are")
func clickHouseMutations() {
#expect(first("ALTER TABLE `t` UPDATE `a` = ? WHERE `id` = ?", Self.clickHouse) == nil)
#expect(first("ALTER TABLE `t` DELETE WHERE `id` = ?", Self.clickHouse) == nil)
#expect(first("ALTER TABLE `t` UPDATE `a` = ?", Self.clickHouse) == .update)
#expect(first("ALTER TABLE `t` ON CLUSTER c DELETE", Self.clickHouse) == .delete)
#expect(first("ALTER TABLE `t` UPDATE `a` = '# WHERE'", Self.clickHouse) == .update)
}

@Test("A table change that names UPDATE or DELETE as a referential action is not a write")
func referentialActionsAreNotWrites() {
#expect(first("ALTER TABLE t ADD FOREIGN KEY (a) REFERENCES u (id) ON DELETE CASCADE", Self.mySQL) == nil)
#expect(first("ALTER TABLE t MODIFY c TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", Self.mySQL) == nil)
}

@Test("A MySQL comment is closed where MySQL closes it")
func mySQLHashComment() {
#expect(first("UPDATE `t` SET `a` = 1 # WHERE `id` = 1", Self.mySQL) == .update)
#expect(first("UPDATE `t` SET `a` = 1 WHERE `id` = 1", Self.mySQL) == nil)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import TableProSpannerCore
import Testing

struct SpannerRowEditSQLRowMatchTests {
private let columns = ["tenant", "id", "name"]
private let originalRow: [SpannerCell] = [.text("acme"), .text("7"), .text("a")]

private func statements(
_ changes: [SpannerRowChange],
columns: [String]? = nil,
primaryKey: [String] = ["tenant", "id"],
deletedRows: Set<Int> = []
) -> [SpannerRenderedStatement]? {
SpannerRowEditSQL.statements(
schema: "",
table: "t",
columns: columns ?? self.columns,
primaryKey: primaryKey,
changes: changes,
insertedRows: [:],
deletedRows: deletedRows,
insertedRowIndices: [],
dialect: .postgreSQL
)
}

private func rename(row: [SpannerCell]? = nil) -> SpannerRowChange {
SpannerRowChange(
rowIndex: 0,
kind: .update,
cellChanges: [SpannerRowChange.CellChange(column: "name", value: .text("b"))],
originalRow: row ?? originalRow
)
}

private func deletion(row: [SpannerCell]? = nil) -> SpannerRowChange {
SpannerRowChange(rowIndex: 1, kind: .delete, originalRow: row ?? originalRow)
}

@Test("An update and a delete name the row by every key column")
func writesNameTheRowByItsKey() {
#expect(statements([rename(), deletion()], deletedRows: [1]) == [
SpannerRenderedStatement(
sql: #"UPDATE "t" SET "name" = ? WHERE "tenant" = ? AND "id" = ?"#,
parameters: [.text("b"), .text("acme"), .text("7")]
),
SpannerRenderedStatement(
sql: #"DELETE FROM "t" WHERE "tenant" = ? AND "id" = ?"#,
parameters: [.text("acme"), .text("7")]
),
])
}

@Test("A table with no key writes nothing rather than a statement that could reach other rows")
func keylessTableIsRefused() {
#expect(statements([rename()], primaryKey: []) == nil)
#expect(statements([deletion()], primaryKey: [], deletedRows: [1]) == nil)
}

@Test("A result that leaves out a key column writes nothing")
func missingKeyColumnIsRefused() {
let partial = ["id", "name"]
let row: [SpannerCell] = [.text("7"), .text("a")]
#expect(statements([rename(row: row)], columns: partial) == nil)
#expect(statements([deletion(row: row)], columns: partial, deletedRows: [1]) == nil)
}

@Test("A change with no row read back writes nothing")
func changeWithoutOriginalRowIsRefused() {
let change = SpannerRowChange(
rowIndex: 0,
kind: .update,
cellChanges: [SpannerRowChange.CellChange(column: "name", value: .text("b"))]
)
#expect(statements([change]) == nil)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,52 @@ struct ClickHouseDMLStatementTests {
#expect(!sql.contains("ALTER TABLE"))
}

@Test("An update and a delete match the row on every column it was read with", arguments: [["id"], []])
func writesMatchTheWholeRow(primaryKeyColumns: [String]) {
let original: [PluginCellValue] = [.text("1"), .null]
let statements = driver.generateStatements(
table: table,
columns: columns,
primaryKeyColumns: primaryKeyColumns,
changes: [
PluginRowChange(
rowIndex: 0,
type: .update,
cellChanges: [(columnIndex: 1, columnName: "name", oldValue: .null, newValue: .text("Bob"))],
originalRow: original
),
PluginRowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: original),
],
insertedRowData: [:],
deletedRowIndices: [1],
insertedRowIndices: []
)

#expect(statements?.map { $0.statement } == [
"ALTER TABLE `users` UPDATE `name` = ? WHERE `id` = ? AND `name` IS NULL",
"ALTER TABLE `users` DELETE WHERE `id` = ? AND `name` IS NULL",
])
#expect(statements?.map { $0.parameters } == [[.text("Bob"), .text("1")], [.text("1")]])
}

@Test("A change with no row to match writes nothing rather than an unfiltered mutation")
func changeWithoutOriginalRowWritesNothing() {
let statements = driver.generateStatements(
table: table,
columns: columns,
primaryKeyColumns: ["id"],
changes: [
change(.update, cells: [(columnIndex: 1, columnName: "name", oldValue: .text("a"), newValue: .text("b"))]),
PluginRowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: nil),
],
insertedRowData: [:],
deletedRowIndices: [1],
insertedRowIndices: []
)

#expect(statements == nil)
}

/// A row the caller did not mark deleted is skipped, so a stale change cannot delete anything.
@Test("A delete not listed in deletedRowIndices produces nothing")
func unlistedDeleteIsSkipped() {
Expand Down
Loading
Loading