From 421c01cde3fbfdadf81054be73fa97cd5a2f51da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= <26674308+J2TeamNNL@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:49:55 +0700 Subject: [PATCH 1/8] feat(data-grid): edit rows of updatable views keyed on the base table's key PostgreSQL, MySQL, MariaDB and SQL Server views the server can write through, and that show every primary key column of their one base table, are editable in the grid. Other views stay read-only and the grid says why. --- .../TableProMSSQLCore/MSSQLViewSources.swift | 99 +++++ .../MSSQLViewSourcesTests.swift | 64 +++ .../PluginViewRowEditingTests.swift | 84 ++++ .../MSSQLPluginDriver+Schema.swift | 20 + .../MySQLPluginDriver+Views.swift | 35 ++ .../MySQLViewRowEditing.swift | 233 +++++++++++ .../MySQLViewRowEditingTests.swift | 111 ++++++ .../PostgreSQLPluginDriver+Views.swift | 33 ++ .../PostgreSQLViewRowEditing.swift | 166 ++++++++ .../PostgreSQLViewRowEditingTests.swift | 98 +++++ .../PluginDatabaseDriver.swift | 9 +- .../PluginViewRowEditing.swift | 64 +++ .../QueryExecutionCoordinator+Helpers.swift | 18 +- ...QueryExecutionCoordinator+Parameters.swift | 5 +- .../Core/Database/DatabaseDriver.swift | 4 + .../Core/Plugins/PluginDriverAdapter.swift | 4 + .../Core/Services/Query/QueryExecutor.swift | 39 +- .../TablePro/Models/Query/QueryTabState.swift | 10 +- .../TablePro/Models/Query/ResultOrigin.swift | 43 +- .../TablePro/Resources/Localizable.xcstrings | 12 + ...ContentCoordinator+TableRowsMutation.swift | 1 + .../Views/Main/MainContentCoordinator.swift | 4 +- .../Query/ParseSchemaMetadataTests.swift | 23 ++ .../Models/Query/ResultEditabilityTests.swift | 41 +- .../Models/Query/TabObjectKindTests.swift | 18 + .../scripts/probes/check-view-row-editing.sh | 373 ++++++++++++++++++ 26 files changed, 1588 insertions(+), 23 deletions(-) create mode 100644 apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginViewRowEditingTests.swift create mode 100644 apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift create mode 100644 apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift create mode 100644 apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift create mode 100644 apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift create mode 100644 apple/Plugins/PostgreSQLDriverTests/PostgreSQLViewRowEditingTests.swift create mode 100644 apple/Plugins/TableProPluginKit/PluginViewRowEditing.swift create mode 100755 apple/scripts/probes/check-view-row-editing.sh diff --git a/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift b/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift new file mode 100644 index 0000000000..37aa2c8dd3 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift @@ -0,0 +1,99 @@ +import Foundation + +/// What SQL Server says about writing through one view. `INFORMATION_SCHEMA.VIEWS.IS_UPDATABLE` +/// is always NO, so both answers come from `sys.dm_exec_describe_first_result_set`, which compiles +/// a statement without running it: no trigger fires and no session setting changes. +public enum MSSQLViewSources { + /// "Cannot update the view ... because it contains aggregates, or a DISTINCT or GROUP BY clause", + /// and the like. The server raises them while it compiles a DELETE through the view. + public static func compileErrorsQuery(schema: String, view: String) -> String { + let probe = "DELETE FROM \(MSSQLSchemaQueries.bracketed(schema: schema, table: view)) WHERE 1 = 0" + return """ + SELECT error_number FROM sys.dm_exec_describe_first_result_set(\(MSSQLStringLiteral.quoted(probe)), NULL, 0) + WHERE error_number IS NOT NULL + """ + } + + /// Browse mode 1 names the base table and column behind each view column, NULL for a computed + /// one, and adds hidden key columns the view does not show. A view may read another database, + /// so the database comes along; a column from a linked server is left without a source. + public static func columnSourcesQuery(schema: String, view: String) -> String { + let probe = "SELECT * FROM \(MSSQLSchemaQueries.bracketed(schema: schema, table: view))" + return """ + SELECT name, COALESCE(source_database, DB_NAME()), source_schema, source_table, source_column + FROM sys.dm_exec_describe_first_result_set(\(MSSQLStringLiteral.quoted(probe)), NULL, 1) + WHERE is_hidden = 0 AND source_server IS NULL + ORDER BY column_ordinal + """ + } + + /// Read from the base table's own database: the same name in the current one can be another table. + public static func primaryKeyQuery(_ table: BaseTable) -> String { + let database = "[\(MSSQLSchemaQueries.escapeBracket(table.database))]" + let object = MSSQLStringLiteral.quoted( + "\(database).\(MSSQLSchemaQueries.bracketed(schema: table.schema, table: table.name))" + ) + return """ + SELECT c.name + FROM \(database).sys.indexes i + JOIN \(database).sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id + JOIN \(database).sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id + WHERE i.object_id = OBJECT_ID(\(object)) AND i.is_primary_key = 1 + ORDER BY ic.key_ordinal + """ + } + + /// 11501 only says the batch failed to compile and comes with the real error. + public static func isUpdatable(compileErrors: [Int]) -> Bool { + Set(compileErrors).subtracting([joinsTablesError, 11_501]).isEmpty + } + + /// 4405: the modification would touch more than one base table. A join showing only one + /// table's columns raises it too, and a DELETE through it always fails. + public static func baseTableCount(_ summary: Summary, compileErrors: [Int]) -> Int { + compileErrors.contains(joinsTablesError) ? max(2, summary.baseTables.count) : summary.baseTables.count + } + + private static let joinsTablesError = 4_405 + + public struct BaseTable: Hashable, Sendable { + public let database: String + public let schema: String + public let name: String + + public init(database: String, schema: String, name: String) { + self.database = database + self.schema = schema + self.name = name + } + } + + public struct Origin: Equatable, Sendable { + public let viewColumn: String + public let baseColumn: String + } + + public struct Summary: Equatable, Sendable { + /// Every base table a shown column comes from, in the order the columns name them. + public let baseTables: [BaseTable] + public let origins: [Origin] + } + + /// Rows of `columnSourcesQuery`: name, source database, schema, table and column. + public static func summarize(rows: [[String?]]) -> Summary { + var baseTables: [BaseTable] = [] + var columns: [(viewColumn: String, table: BaseTable, column: String)] = [] + for row in rows { + guard row.count >= 5, let name = row[0], let database = row[1], + let schema = row[2], let table = row[3], let column = row[4] + else { continue } + let base = BaseTable(database: database, schema: schema, name: table) + if !baseTables.contains(base) { baseTables.append(base) } + columns.append((name, base, column)) + } + let origins = baseTables.count == 1 + ? columns.map { Origin(viewColumn: $0.viewColumn, baseColumn: $0.column) } + : [] + return Summary(baseTables: baseTables, origins: origins) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift b/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift new file mode 100644 index 0000000000..4ef4d2170d --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift @@ -0,0 +1,64 @@ +import Foundation +@testable import TableProMSSQLCore +import Testing + +/// Rows are what SQL Server 2022 returns from `sys.dm_exec_describe_first_result_set` for the views named. +struct MSSQLViewSourcesTests { + @Test("A view over one table maps each shown column to its base column, skipping computed ones") + func oneTable() { + let summary = MSSQLViewSources.summarize(rows: [ + ["key_id", "shop", "dbo", "t", "id"], + ["name", "shop", "dbo", "t", "name"], + ["q2", "shop", nil, nil, nil], + ]) + #expect(summary.baseTables == [.init(database: "shop", schema: "dbo", name: "t")]) + #expect(summary.origins.map { $0.viewColumn } == ["key_id", "name"]) + #expect(summary.origins.map { $0.baseColumn } == ["id", "name"]) + } + + @Test("A join view names both tables and maps no column") + func join() { + let summary = MSSQLViewSources.summarize(rows: [ + ["id", "shop", "dbo", "t", "id"], + ["tid", "shop", "dbo", "u", "tid"], + ]) + #expect(summary.baseTables.count == 2) + #expect(summary.origins.isEmpty) + } + + @Test("The same table name in two databases is two tables") + func sameNameInTwoDatabases() { + let summary = MSSQLViewSources.summarize(rows: [ + ["id", "shop", "dbo", "t", "id"], + ["code", "archive", "dbo", "t", "code"], + ]) + #expect(summary.baseTables.count == 2) + } + + @Test("A join counts as several tables even when it shows one table's columns") + func joinShowingOneSide() { + let summary = MSSQLViewSources.summarize(rows: [["id", "shop", "dbo", "t", "id"]]) + #expect(MSSQLViewSources.baseTableCount(summary, compileErrors: []) == 1) + #expect(MSSQLViewSources.baseTableCount(summary, compileErrors: [4_405, 11_501]) == 2) + } + + @Test("Only errors other than the multi-table one make a view not updatable") + func compileErrors() { + #expect(MSSQLViewSources.isUpdatable(compileErrors: [])) + #expect(MSSQLViewSources.isUpdatable(compileErrors: [4_405, 11_501])) + #expect(!MSSQLViewSources.isUpdatable(compileErrors: [4_403, 11_501])) + } + + @Test("The probes bracket the names and carry them as one Unicode literal") + func queries() { + let errors = MSSQLViewSources.compileErrorsQuery(schema: "dbo", view: "o'v]x") + #expect(errors.contains("N'DELETE FROM [dbo].[o''v]]x] WHERE 1 = 0'")) + let sources = MSSQLViewSources.columnSourcesQuery(schema: "dbo", view: "v") + #expect(sources.contains("N'SELECT * FROM [dbo].[v]', NULL, 1")) + #expect(sources.contains("is_hidden = 0 AND source_server IS NULL")) + let key = MSSQLViewSources.primaryKeyQuery(.init(database: "arch]ive", schema: "dbo", name: "t")) + #expect(key.contains("OBJECT_ID(N'[arch]]ive].[dbo].[t]')")) + #expect(key.contains("FROM [arch]]ive].sys.indexes i")) + #expect(key.contains("i.is_primary_key = 1")) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginViewRowEditingTests.swift b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginViewRowEditingTests.swift new file mode 100644 index 0000000000..1a7873693f --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginViewRowEditingTests.swift @@ -0,0 +1,84 @@ +import Foundation +import TableProPluginKit +import Testing + +struct PluginViewRowEditingTests { + private let simpleOrigins = [ + PluginViewColumnOrigin(viewColumn: "id", baseColumn: "id"), + PluginViewColumnOrigin(viewColumn: "name", baseColumn: "name"), + ] + + @Test("An updatable view over one table is keyed on the view columns that show the table's key") + func updatableViewIsEditable() { + let editing = PluginViewRowEditing.resolve( + isUpdatable: true, baseTableCount: 1, baseKeyColumns: ["id"], origins: simpleOrigins + ) + #expect(editing == .editable(keyColumns: ["id"])) + } + + @Test("A renamed key column keys the edit by its view name") + func renamedKeyUsesViewName() { + let origins = [ + PluginViewColumnOrigin(viewColumn: "order_id", baseColumn: "id"), + PluginViewColumnOrigin(viewColumn: "line", baseColumn: "line_no"), + ] + let editing = PluginViewRowEditing.resolve( + isUpdatable: true, baseTableCount: 1, baseKeyColumns: ["id", "line_no"], origins: origins + ) + #expect(editing == .editable(keyColumns: ["order_id", "line"])) + #expect(editing.keyColumns == ["order_id", "line"]) + } + + @Test("A view column named like the key but showing another column is not the key") + func swappedNamesAreNotTheKey() { + let origins = [ + PluginViewColumnOrigin(viewColumn: "id", baseColumn: "name"), + PluginViewColumnOrigin(viewColumn: "name", baseColumn: "id"), + ] + let editing = PluginViewRowEditing.resolve( + isUpdatable: true, baseTableCount: 1, baseKeyColumns: ["id"], origins: origins + ) + #expect(editing == .editable(keyColumns: ["name"])) + } + + @Test("The server's answer wins over everything else") + func notUpdatableStaysReadOnly() { + let editing = PluginViewRowEditing.resolve( + isUpdatable: false, baseTableCount: 1, baseKeyColumns: ["id"], origins: simpleOrigins + ) + #expect(editing == .readOnly(.notUpdatable)) + #expect(editing.keyColumns == nil) + } + + @Test("A view with no column from any table is not updatable") + func noBaseTableIsNotUpdatable() { + let editing = PluginViewRowEditing.resolve( + isUpdatable: true, baseTableCount: 0, baseKeyColumns: [], origins: [] + ) + #expect(editing == .readOnly(.notUpdatable)) + } + + @Test("A view that reads several tables stays read-only") + func severalTablesStayReadOnly() { + let editing = PluginViewRowEditing.resolve( + isUpdatable: true, baseTableCount: 2, baseKeyColumns: ["id"], origins: simpleOrigins + ) + #expect(editing == .readOnly(.readsSeveralTables)) + } + + @Test("A view over a table without a primary key stays read-only") + func keylessTableStaysReadOnly() { + let editing = PluginViewRowEditing.resolve( + isUpdatable: true, baseTableCount: 1, baseKeyColumns: [], origins: simpleOrigins + ) + #expect(editing == .readOnly(.baseTableHasNoKey)) + } + + @Test("A view missing one column of a composite key stays read-only") + func partialKeyStaysReadOnly() { + let editing = PluginViewRowEditing.resolve( + isUpdatable: true, baseTableCount: 1, baseKeyColumns: ["id", "version"], origins: simpleOrigins + ) + #expect(editing == .readOnly(.keyNotExposed)) + } +} diff --git a/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift b/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift index 81c4b74fed..5f750a05be 100644 --- a/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -58,6 +58,26 @@ extension MSSQLPluginDriver { } } + func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? { + let resolvedSchema = effectiveSchema(schema) + let errors = try await execute(query: MSSQLViewSources.compileErrorsQuery(schema: resolvedSchema, view: view)) + let compileErrors = errors.rows.compactMap { $0[safe: 0]?.asText.flatMap(Int.init) } + let sources = try await execute(query: MSSQLViewSources.columnSourcesQuery(schema: resolvedSchema, view: view)) + let summary = MSSQLViewSources.summarize(rows: sources.rows.map { $0.map(\.asText) }) + + var baseKeyColumns: [String] = [] + if summary.baseTables.count == 1, let baseTable = summary.baseTables.first { + let keys = try await execute(query: MSSQLViewSources.primaryKeyQuery(baseTable)) + baseKeyColumns = keys.rows.compactMap { $0[safe: 0]?.asText } + } + return PluginViewRowEditing.resolve( + isUpdatable: MSSQLViewSources.isUpdatable(compileErrors: compileErrors), + baseTableCount: MSSQLViewSources.baseTableCount(summary, compileErrors: compileErrors), + baseKeyColumns: baseKeyColumns, + origins: summary.origins.map { PluginViewColumnOrigin(viewColumn: $0.viewColumn, baseColumn: $0.baseColumn) } + ) + } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { /// Bracket-escaped for the identifier and literal-escaped for the string it sits in: /// SQL Server allows both `]` and `'` in an identifier. diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift new file mode 100644 index 0000000000..af75efae47 --- /dev/null +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift @@ -0,0 +1,35 @@ +// +// MySQLPluginDriver+Views.swift +// MySQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension MySQLPluginDriver { + /// MySQL and MariaDB only: TiDB, OceanBase and Databend store view definitions in other forms. + func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? { + switch flavor { + case .mysql, .mariadb: break + default: return nil + } + let escape = escapeStringLiteral + let viewResult = try await execute( + ownStatement: MySQLViewRowEditing.viewQuery(name: view, database: schema, escape: escape) + ) + guard let row = viewResult.rows.first else { return nil } + let isUpdatable = row[safe: 0]?.asText?.uppercased() == "YES" + let definition = (row[safe: 1]?.asText).flatMap(MySQLViewRowEditing.parse(definition:)) + guard isUpdatable, let baseTable = definition?.baseTable else { + return MySQLViewRowEditing.resolve(isUpdatable: isUpdatable, definition: definition, baseKeyColumns: []) + } + let keyResult = try await execute( + ownStatement: MySQLViewRowEditing.primaryKeyQuery(table: baseTable, viewDatabase: schema, escape: escape) + ) + return MySQLViewRowEditing.resolve( + isUpdatable: isUpdatable, + definition: definition, + baseKeyColumns: keyResult.rows.compactMap { $0[safe: 0]?.asText } + ) + } +} diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift b/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift new file mode 100644 index 0000000000..b9330b14d0 --- /dev/null +++ b/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift @@ -0,0 +1,233 @@ +// +// MySQLViewRowEditing.swift +// MySQLDriverPlugin +// +// Works out whether the grid may write rows through a MySQL or MariaDB view. The server answers +// whether the view is updatable; the definition it stores names every column fully qualified, +// as in "select `db`.`t`.`id` AS `key_id` from `db`.`t`", which says which base column each view +// column shows. Pure, so it is testable without a server. +// + +import Foundation +import TableProPluginKit + +enum MySQLViewRowEditing { + struct BaseTable: Equatable { + let database: String? + let name: String + } + + struct Definition: Equatable { + /// Nil when the FROM clause is anything but one table: a join, a list or a derived table. + let baseTable: BaseTable? + let origins: [PluginViewColumnOrigin] + } + + static func viewQuery(name: String, database: String?, escape: (String) -> String) -> String { + """ + SELECT IS_UPDATABLE, VIEW_DEFINITION FROM information_schema.VIEWS \ + WHERE TABLE_SCHEMA = \(schemaExpression(database, escape: escape)) AND TABLE_NAME = '\(escape(name))' + """ + } + + static func primaryKeyQuery(table: BaseTable, viewDatabase: String?, escape: (String) -> String) -> String { + """ + SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE \ + WHERE TABLE_SCHEMA = \(schemaExpression(table.database ?? viewDatabase, escape: escape)) \ + AND TABLE_NAME = '\(escape(table.name))' AND CONSTRAINT_NAME = 'PRIMARY' \ + ORDER BY ORDINAL_POSITION + """ + } + + private static func schemaExpression(_ database: String?, escape: (String) -> String) -> String { + database.map { "'\(escape($0))'" } ?? "DATABASE()" + } + + /// A nil definition is one the server withheld or that is not a SELECT. + static func resolve(isUpdatable: Bool, definition: Definition?, baseKeyColumns: [String]) -> PluginViewRowEditing { + guard let definition else { return .readOnly(.notUpdatable) } + return PluginViewRowEditing.resolve( + isUpdatable: isUpdatable, + baseTableCount: definition.baseTable == nil ? 2 : 1, + baseKeyColumns: baseKeyColumns, + origins: definition.origins + ) + } + + static func parse(definition text: String) -> Definition? { + let tokens = Token.tokenize(text) + guard let first = tokens.first, first.isWord("select") else { return nil } + let fromIndex = tokens.firstIndex { $0.depth == 0 && $0.isWord("from") } ?? tokens.endIndex + let baseTable = singleTable(in: Array(tokens[fromIndex...].dropFirst())) + let alias = baseTable?.alias + + var origins: [PluginViewColumnOrigin] = [] + for item in split(Array(tokens[1..= 3, + item[item.count - 2].isWord("as"), + let viewColumn = item.last?.identifier, + let reference = columnReference(Array(item.dropLast(2))), + let table = baseTable?.table + else { continue } + let qualifier = reference.qualifier + let matches: Bool + switch qualifier.count { + case 2: matches = alias == nil && qualifier[0] == table.database && qualifier[1] == table.name + case 1: matches = qualifier[0] == (alias ?? table.name) + default: matches = alias == nil + } + if matches { + origins.append(PluginViewColumnOrigin(viewColumn: viewColumn, baseColumn: reference.column)) + } + } + return Definition(baseTable: baseTable?.table, origins: origins) + } + + private static let clauseKeywords: Set = [ + "where", "group", "having", "order", "limit", "window", "union", "for", "lock", "into", + ] + + private static func singleTable(in tokens: [Token]) -> (table: BaseTable, alias: String?)? { + let clause = Array(tokens.prefix { !($0.depth == 0 && clauseKeywords.contains($0.text.lowercased())) }) + var parts: [String] = [] + var index = 0 + while index < clause.count, let name = clause[index].identifier { + parts.append(name) + guard index + 1 < clause.count, clause[index + 1].text == "." else { + index += 1 + break + } + index += 2 + } + let rest = clause[index...] + guard (1...2).contains(parts.count), rest.count <= 1 else { return nil } + let alias: String? + if let aliasToken = rest.first { + guard let name = aliasToken.identifier else { return nil } + alias = name + } else { + alias = nil + } + let table = parts.count == 2 + ? BaseTable(database: parts[0], name: parts[1]) + : BaseTable(database: nil, name: parts[0]) + return (table, alias) + } + + private static func columnReference(_ tokens: [Token]) -> (qualifier: [String], column: String)? { + guard tokens.count % 2 == 1, tokens.count <= 5 else { return nil } + var names: [String] = [] + for (offset, token) in tokens.enumerated() { + if offset % 2 == 1 { + guard token.text == "." else { return nil } + } else { + guard let name = token.identifier else { return nil } + names.append(name) + } + } + guard let column = names.popLast() else { return nil } + return (names, column) + } + + private static func split(_ tokens: [Token], separator: String) -> [[Token]] { + var items: [[Token]] = [[]] + for token in tokens { + if token.depth == 0, token.text == separator, !token.isQuoted { + items.append([]) + } else { + items[items.count - 1].append(token) + } + } + return items.filter { !$0.isEmpty } + } +} + +private struct Token { + let text: String + let depth: Int + let isQuoted: Bool + + var identifier: String? { + if isQuoted { return text } + guard let first = text.unicodeScalars.first, + CharacterSet.letters.contains(first) || first == "_" else { return nil } + return text + } + + func isWord(_ word: String) -> Bool { + !isQuoted && text.caseInsensitiveCompare(word) == .orderedSame + } + + /// Backquoted identifiers are kept as identifiers with their doubled backquotes undone. A + /// string literal becomes one opaque token, so a comma or keyword inside it never counts. + static func tokenize(_ text: String) -> [Token] { + var tokens: [Token] = [] + var depth = 0 + let characters = Array(text) + var index = 0 + + while index < characters.count { + let character = characters[index] + switch character { + case "`": + var name = "" + index += 1 + while index < characters.count { + if characters[index] == "`" { + if index + 1 < characters.count, characters[index + 1] == "`" { + name.append("`") + index += 2 + continue + } + break + } + name.append(characters[index]) + index += 1 + } + tokens.append(Token(text: name, depth: depth, isQuoted: true)) + index += 1 + case "'", "\"": + index += 1 + while index < characters.count { + if characters[index] == "\\" { + index += 2 + continue + } + if characters[index] == character { + if index + 1 < characters.count, characters[index + 1] == character { + index += 2 + continue + } + break + } + index += 1 + } + tokens.append(Token(text: "''", depth: depth, isQuoted: false)) + index += 1 + case "(": + tokens.append(Token(text: "(", depth: depth, isQuoted: false)) + depth += 1 + index += 1 + case ")": + depth -= 1 + tokens.append(Token(text: ")", depth: depth, isQuoted: false)) + index += 1 + case _ where character.isWhitespace: + index += 1 + case _ where character.isLetter || character.isNumber || character == "_" || character == "$": + var word = "" + while index < characters.count, + characters[index].isLetter || characters[index].isNumber + || characters[index] == "_" || characters[index] == "$" { + word.append(characters[index]) + index += 1 + } + tokens.append(Token(text: word, depth: depth, isQuoted: false)) + default: + tokens.append(Token(text: String(character), depth: depth, isQuoted: false)) + index += 1 + } + } + return tokens + } +} diff --git a/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift b/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift new file mode 100644 index 0000000000..747172c5bd --- /dev/null +++ b/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift @@ -0,0 +1,111 @@ +// +// MySQLViewRowEditingTests.swift +// TableProTests +// +// The definitions below are what MySQL 8.4 and MariaDB 11 store in +// information_schema.VIEWS.VIEW_DEFINITION for the views named. +// + +import Foundation +import TableProPluginKit +import Testing + +struct MySQLViewRowEditingTests { + private func parse(_ text: String) throws -> MySQLViewRowEditing.Definition { + try #require(MySQLViewRowEditing.parse(definition: text)) + } + + @Test("Every column of a SELECT * view maps to its base column") + func simpleView() throws { + let definition = try parse( + "select `vt`.`t`.`id` AS `id`,`vt`.`t`.`name` AS `name`,`vt`.`t`.`qty` AS `qty` from `vt`.`t`" + ) + #expect(definition.baseTable == .init(database: "vt", name: "t")) + #expect(definition.origins == [ + PluginViewColumnOrigin(viewColumn: "id", baseColumn: "id"), + PluginViewColumnOrigin(viewColumn: "name", baseColumn: "name"), + PluginViewColumnOrigin(viewColumn: "qty", baseColumn: "qty"), + ]) + #expect(MySQLViewRowEditing.resolve(isUpdatable: true, definition: definition, baseKeyColumns: ["id"]) + == .editable(keyColumns: ["id"])) + } + + @Test("Swapped column names map by what each column shows, not by its name") + func swappedNames() throws { + let definition = try parse("select `vt`.`t`.`name` AS `id`,`vt`.`t`.`id` AS `name` from `vt`.`t`") + #expect(MySQLViewRowEditing.resolve(isUpdatable: true, definition: definition, baseKeyColumns: ["id"]) + == .editable(keyColumns: ["name"])) + } + + @Test("An aliased table, a computed column, a quoted name and a sub-select in WHERE (MySQL spelling)") + func aliasedTableMySQL() throws { + let definition = try parse(""" + select `x`.`id` AS `id`,(`x`.`qty` + 1) AS `q2`,`x`.`name` AS `we``ird, name` from `vt`.`t` `x` \ + where `x`.`id` in (select `vt`.`u`.`tid` from `vt`.`u`) order by `x`.`name` + """) + #expect(definition.baseTable == .init(database: "vt", name: "t")) + #expect(definition.origins == [ + PluginViewColumnOrigin(viewColumn: "id", baseColumn: "id"), + PluginViewColumnOrigin(viewColumn: "we`ird, name", baseColumn: "name"), + ]) + } + + @Test("The same view in MariaDB's spelling, without brackets around the expression") + func aliasedTableMariaDB() throws { + let definition = try parse(""" + select `x`.`id` AS `id`,`x`.`qty` + 1 AS `q2`,`x`.`name` AS `we``ird, name` from `vt`.`t` `x` \ + where `x`.`id` in (select `vt`.`u`.`tid` from `vt`.`u`) order by `x`.`name` + """) + #expect(definition.origins.map { $0.viewColumn } == ["id", "we`ird, name"]) + } + + @Test("A join is not one table, even when every column comes from one side") + func joinIsSeveralTables() throws { + let definition = try parse(""" + select `vt`.`t`.`id` AS `id`,`vt`.`t`.`name` AS `name`,`vt`.`t`.`qty` AS `qty` \ + from (`vt`.`t` join `vt`.`u` on((`vt`.`u`.`tid` = `vt`.`t`.`id`))) + """) + #expect(definition.baseTable == nil) + #expect(MySQLViewRowEditing.resolve(isUpdatable: true, definition: definition, baseKeyColumns: []) + == .readOnly(.readsSeveralTables)) + } + + @Test("A string literal holding a comma or FROM does not split the select list") + func literalIsOpaque() throws { + let definition = try parse( + "select 'a, from b' AS `label`,`vt`.`t`.`id` AS `id` from `vt`.`t` where (`vt`.`t`.`name` <> 'x''y')" + ) + #expect(definition.origins == [PluginViewColumnOrigin(viewColumn: "id", baseColumn: "id")]) + #expect(definition.baseTable == .init(database: "vt", name: "t")) + } + + @Test("A view the server reports as not updatable, or whose definition is withheld, stays read-only") + func notUpdatable() throws { + let definition = try parse("select distinct `vt`.`t`.`id` AS `id` from `vt`.`t`") + #expect(MySQLViewRowEditing.resolve(isUpdatable: false, definition: definition, baseKeyColumns: ["id"]) + == .readOnly(.notUpdatable)) + #expect(MySQLViewRowEditing.parse(definition: "") == nil) + #expect(MySQLViewRowEditing.resolve(isUpdatable: true, definition: nil, baseKeyColumns: ["id"]) + == .readOnly(.notUpdatable)) + } + + @Test("A view that leaves out the key stays read-only") + func keyNotExposed() throws { + let definition = try parse("select `vt`.`t`.`name` AS `name`,`vt`.`t`.`qty` AS `qty` from `vt`.`t`") + #expect(MySQLViewRowEditing.resolve(isUpdatable: true, definition: definition, baseKeyColumns: ["id"]) + == .readOnly(.keyNotExposed)) + } + + @Test("The catalog queries escape names and fall back to the current database") + func queries() { + let escape: (String) -> String = { $0.replacingOccurrences(of: "'", with: "''") } + let view = MySQLViewRowEditing.viewQuery(name: "o'v", database: nil, escape: escape) + #expect(view.contains("TABLE_SCHEMA = DATABASE()")) + #expect(view.contains("TABLE_NAME = 'o''v'")) + let key = MySQLViewRowEditing.primaryKeyQuery( + table: .init(database: nil, name: "t"), viewDatabase: "shop", escape: escape + ) + #expect(key.contains("TABLE_SCHEMA = 'shop'")) + #expect(key.contains("CONSTRAINT_NAME = 'PRIMARY'")) + } +} diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Views.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Views.swift index 9d29295ac9..594859f490 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Views.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Views.swift @@ -22,6 +22,39 @@ extension PostgreSQLPluginDriver { return PostgreSQLViewDefinition.statement(name: view, schema: resolvedSchema, row: catalogRow) } + func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? { + let resolvedSchema = schema ?? core.currentSchema + let viewResult = try await catalogRead(PostgreSQLViewRowEditing.viewQuery(name: view, schema: resolvedSchema)) + guard let row = viewResult.rows.first, let viewOID = row[safe: 0]?.asText else { return nil } + let isUpdatable = row[safe: 1]?.asText == "true" + let origins = PostgreSQLViewRowEditing.targetOrigins(nodeTree: row[safe: 2]?.asText ?? "") + guard isUpdatable, let baseTableOID = origins.first?.tableOID else { + return PostgreSQLViewRowEditing.resolve( + isUpdatable: isUpdatable, origins: origins, viewColumnNames: [:], baseKeyColumnNumbers: [] + ) + } + + let detail = try await catalogRead( + PostgreSQLViewRowEditing.columnsAndKeyQuery(viewOID: viewOID, baseTableOID: baseTableOID) + ) + var viewColumnNames: [Int: String] = [:] + var baseKeyColumnNumbers: [Int] = [] + for detailRow in detail.rows { + guard let number = detailRow[safe: 1]?.asText.flatMap(Int.init) else { continue } + switch detailRow[safe: 0]?.asText { + case "v": viewColumnNames[number] = detailRow[safe: 2]?.asText + case "k": baseKeyColumnNumbers.append(number) + default: break + } + } + return PostgreSQLViewRowEditing.resolve( + isUpdatable: isUpdatable, + origins: origins, + viewColumnNames: viewColumnNames, + baseKeyColumnNumbers: baseKeyColumnNumbers + ) + } + func objectCommentStatement(name: String, objectType: String, schema: String?, comment: String?) -> String? { PostgreSQLRelationSQL.commentStatement( name: name, diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift new file mode 100644 index 0000000000..9a2729a174 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift @@ -0,0 +1,166 @@ +// +// PostgreSQLViewRowEditing.swift +// PostgreSQLDriverPlugin +// +// Works out whether the grid may write rows through a view. The server answers whether the view +// is updatable; the view's stored query says which base column each view column shows. Pure, so +// it is testable without a server. +// + +import Foundation +import TableProPluginKit + +enum PostgreSQLViewRowEditing { + /// The base column one view column shows, as `pg_rewrite` stores it: `resorigtbl` and + /// `resorigcol` are the table's OID and attribute number, zero for a computed column. + struct TargetOrigin: Equatable { + let viewAttributeNumber: Int + let tableOID: String + let columnNumber: Int + } + + /// Bits 4 (UPDATE) and 16 (DELETE) of `pg_relation_is_updatable`, the test + /// `information_schema.views.is_updatable` applies, read directly so a view the user may not + /// see in `information_schema` still gets an answer. + static func viewQuery(name: String, schema: String) -> String { + """ + SELECT + c.oid::text, + (pg_catalog.pg_relation_is_updatable(c.oid, false) & 20) = 20, + r.ev_action::text + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN pg_catalog.pg_rewrite r ON r.ev_class = c.oid AND r.rulename = '_RETURN' + WHERE c.relkind = 'v' + AND n.nspname = \(PostgreSQLObjectQueries.quoteLiteral(schema)) + AND c.relname = \(PostgreSQLObjectQueries.quoteLiteral(name)) + """ + } + + /// The view's column names by number, then the base table's primary key column numbers in key + /// order, told apart by the first column. + static func columnsAndKeyQuery(viewOID: String, baseTableOID: String) -> String { + """ + SELECT 'v', a.attnum::text, a.attname::text, 0 + FROM pg_catalog.pg_attribute a + WHERE a.attrelid = \(PostgreSQLObjectQueries.quoteLiteral(viewOID))::oid + AND a.attnum > 0 AND NOT a.attisdropped + UNION ALL + SELECT 'k', k.attnum::text, NULL, k.ord + FROM pg_catalog.pg_index i + CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) + WHERE i.indrelid = \(PostgreSQLObjectQueries.quoteLiteral(baseTableOID))::oid + AND i.indisprimary + ORDER BY 1, 4 + """ + } + + static func resolve( + isUpdatable: Bool, + origins: [TargetOrigin], + viewColumnNames: [Int: String], + baseKeyColumnNumbers: [Int] + ) -> PluginViewRowEditing { + let baseTables = Set(origins.map(\.tableOID)) + let columnOrigins = origins.compactMap { origin -> PluginViewColumnOrigin? in + guard let name = viewColumnNames[origin.viewAttributeNumber] else { return nil } + return PluginViewColumnOrigin(viewColumn: name, baseColumn: String(origin.columnNumber)) + } + return PluginViewRowEditing.resolve( + isUpdatable: isUpdatable, + baseTableCount: baseTables.count, + baseKeyColumns: baseKeyColumnNumbers.map(String.init), + origins: columnOrigins + ) + } + + /// The origins of the outermost query's target list. A sub-select in the view's WHERE clause + /// stores a target list of its own earlier in the text, so this follows the nesting rather + /// than matching the first `:targetList`. + static func targetOrigins(nodeTree: String) -> [TargetOrigin] { + let tokens = NodeTreeToken.tokenize(nodeTree) + guard let queryIndex = tokens.firstIndex(where: { $0.text == "QUERY" }) else { return [] } + let queryDepth = tokens[queryIndex].depth + guard let listIndex = tokens[queryIndex...].firstIndex(where: { + $0.depth == queryDepth && $0.text == ":targetList" + }) else { return [] } + + let entryDepth = queryDepth + 2 + var origins: [TargetOrigin] = [] + var fields: [String: String] = [:] + var index = tokens.index(after: listIndex) + guard index < tokens.count, tokens[index].text == "(" else { return [] } + index += 1 + + while index < tokens.count, tokens[index].depth > queryDepth { + let token = tokens[index] + if token.text == "}", token.depth == entryDepth - 1 { + if let origin = origin(from: fields) { origins.append(origin) } + fields = [:] + } else if token.depth == entryDepth, token.text.hasPrefix(":"), index + 1 < tokens.count { + let value = tokens[index + 1] + if value.depth == entryDepth, !value.isBracket { + fields[token.text] = value.text + } + } + index += 1 + } + return origins + } + + private static func origin(from fields: [String: String]) -> TargetOrigin? { + guard fields[":resjunk"] != "true", + let resno = fields[":resno"].flatMap(Int.init), + let table = fields[":resorigtbl"], table != "0", + let column = fields[":resorigcol"].flatMap(Int.init), column > 0 + else { return nil } + return TargetOrigin(viewAttributeNumber: resno, tableOID: table, columnNumber: column) + } +} + +/// One token of a `pg_node_tree` text, with the number of brackets open before it. A closing +/// bracket carries the depth of its opening one. +private struct NodeTreeToken { + let text: String + let depth: Int + + var isBracket: Bool { ["{", "}", "(", ")"].contains(text) } + + static func tokenize(_ text: String) -> [NodeTreeToken] { + var tokens: [NodeTreeToken] = [] + var depth = 0 + var word = "" + var escaped = false + + func flush() { + if !word.isEmpty { tokens.append(NodeTreeToken(text: word, depth: depth)) } + word = "" + } + + for character in text { + if escaped { + word.append(character) + escaped = false + continue + } + switch character { + case "\\": + escaped = true + case "{", "(": + flush() + tokens.append(NodeTreeToken(text: String(character), depth: depth)) + depth += 1 + case "}", ")": + flush() + depth -= 1 + tokens.append(NodeTreeToken(text: String(character), depth: depth)) + case " ", "\n", "\t": + flush() + default: + word.append(character) + } + } + flush() + return tokens + } +} diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLViewRowEditingTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLViewRowEditingTests.swift new file mode 100644 index 0000000000..7f5927b908 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLViewRowEditingTests.swift @@ -0,0 +1,98 @@ +// +// PostgreSQLViewRowEditingTests.swift +// TableProTests +// +// The node trees below are what PostgreSQL 17 stores in pg_rewrite.ev_action, trimmed of the +// fields the parser never reads. +// + +import Foundation +import TableProPluginKit +import Testing + +struct PostgreSQLViewRowEditingTests { + /// `CREATE VIEW v AS SELECT id AS key_id, name FROM t WHERE id IN (SELECT tid FROM u) OR true`: + /// the sub-select's target list comes first in the text and names another table. + private let viewWithSubselect = """ + ({QUERY :commandType 1 :querySource 0 :rtable ({RANGETBLENTRY :alias <> :eref {ALIAS :aliasname t \ + :colnames ("id" "name" "qty")} :rtekind 0 :relid 16388}) :jointree {FROMEXPR :fromlist ({RANGETBLREF \ + :rtindex 1}) :quals {BOOLEXPR :boolop or :args ({SUBLINK :subLinkType 2 :subselect {QUERY :commandType 1 \ + :rtable ({RANGETBLENTRY :relid 16395}) :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 1} \ + :resno 1 :resname tid :ressortgroupref 0 :resorigtbl 16395 :resorigcol 1 :resjunk false}) :override 0} \ + :location -1} {CONST :consttype 16 :constvalue 1 [ 1 0 0 0 0 0 0 0 ]}) :location -1}} :targetList \ + ({TARGETENTRY :expr {VAR :varno 1 :varattno 1 :vartype 23} :resno 1 :resname key_id :ressortgroupref 0 \ + :resorigtbl 16388 :resorigcol 1 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 :varattno 2 :vartype 25} \ + :resno 2 :resname name :ressortgroupref 0 :resorigtbl 16388 :resorigcol 2 :resjunk false}) :override 0}) + """ + + @Test("Only the outermost target list counts, not a sub-select's") + func readsOuterTargetList() { + let origins = PostgreSQLViewRowEditing.targetOrigins(nodeTree: viewWithSubselect) + #expect(origins == [ + .init(viewAttributeNumber: 1, tableOID: "16388", columnNumber: 1), + .init(viewAttributeNumber: 2, tableOID: "16388", columnNumber: 2), + ]) + } + + @Test("A computed column has no origin") + func computedColumnHasNoOrigin() { + let tree = """ + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {OPEXPR :opno 551 :args ({VAR :varno 1 \ + :varattno 3} {CONST :consttype 23 :constvalue 4 [ 1 0 0 0 0 0 0 0 ]})} :resno 1 :resname q2 \ + :ressortgroupref 0 :resorigtbl 0 :resorigcol 0 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 \ + :varattno 1} :resno 2 :resname id :ressortgroupref 0 :resorigtbl 16388 :resorigcol 1 :resjunk false}) \ + :override 0}) + """ + #expect(PostgreSQLViewRowEditing.targetOrigins(nodeTree: tree) == [ + .init(viewAttributeNumber: 2, tableOID: "16388", columnNumber: 1), + ]) + } + + @Test("An escaped bracket inside a column name does not end the entry") + func escapedBracketInName() { + let tree = """ + ({QUERY :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 1} :resno 1 :resname odd\\)\\}\\ name \ + :ressortgroupref 0 :resorigtbl 77 :resorigcol 1 :resjunk false}) :override 0}) + """ + #expect(PostgreSQLViewRowEditing.targetOrigins(nodeTree: tree) == [ + .init(viewAttributeNumber: 1, tableOID: "77", columnNumber: 1), + ]) + } + + @Test("Text that is not a query tree yields no origins") + func malformedTreeYieldsNothing() { + #expect(PostgreSQLViewRowEditing.targetOrigins(nodeTree: "").isEmpty) + #expect(PostgreSQLViewRowEditing.targetOrigins(nodeTree: "({QUERY :targetList <>})").isEmpty) + } + + @Test("The key is mapped through attribute numbers, so a renamed key column still keys the edit") + func resolvesRenamedKey() { + let origins = PostgreSQLViewRowEditing.targetOrigins(nodeTree: viewWithSubselect) + let editing = PostgreSQLViewRowEditing.resolve( + isUpdatable: true, + origins: origins, + viewColumnNames: [1: "key_id", 2: "name"], + baseKeyColumnNumbers: [1] + ) + #expect(editing == .editable(keyColumns: ["key_id"])) + } + + @Test("A view that hides the key column stays read-only") + func hiddenKeyStaysReadOnly() { + let origins = [PostgreSQLViewRowEditing.TargetOrigin(viewAttributeNumber: 1, tableOID: "16388", columnNumber: 2)] + let editing = PostgreSQLViewRowEditing.resolve( + isUpdatable: true, origins: origins, viewColumnNames: [1: "name"], baseKeyColumnNumbers: [1] + ) + #expect(editing == .readOnly(.keyNotExposed)) + } + + @Test("The catalog queries quote the names they are given") + func queriesQuoteNames() { + let query = PostgreSQLViewRowEditing.viewQuery(name: "o'v", schema: "s") + #expect(query.contains("c.relname = 'o''v'")) + #expect(query.contains("pg_relation_is_updatable(c.oid, false) & 20) = 20")) + let detail = PostgreSQLViewRowEditing.columnsAndKeyQuery(viewOID: "10", baseTableOID: "20") + #expect(detail.contains("a.attrelid = '10'::oid")) + #expect(detail.contains("i.indrelid = '20'::oid")) + } +} diff --git a/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index ca8846ef83..074250baa0 100644 --- a/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -646,10 +646,11 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { /// Whether this view can be refreshed without blocking its readers. Return nil where the engine /// has no such refresh, so the option is not offered at all. - func concurrentRefreshAvailability( - materializedView: String, - schema: String? - ) async throws -> PluginConcurrentRefreshAvailability? + func concurrentRefreshAvailability(materializedView: String, schema: String?) async throws -> PluginConcurrentRefreshAvailability? + + /// Whether the grid may write rows through this view, keyed on its base table's primary key. + /// Return nil where the engine does not check, so its views stay read-only. + func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? // Nil where the engine has no sequences, which keeps the sequence commands off the menu. func fetchSequenceDetails(sequence: String, schema: String?) async throws -> PluginSequenceDetails? diff --git a/apple/Plugins/TableProPluginKit/PluginViewRowEditing.swift b/apple/Plugins/TableProPluginKit/PluginViewRowEditing.swift new file mode 100644 index 0000000000..b52ee13ed3 --- /dev/null +++ b/apple/Plugins/TableProPluginKit/PluginViewRowEditing.swift @@ -0,0 +1,64 @@ +// +// PluginViewRowEditing.swift +// TableProPluginKit +// + +import Foundation + +/// Whether the data grid may write rows through one view, and which view columns identify a row. +/// +/// An edit through a view is keyed on its base table's primary key: without every key column in +/// the view, an `UPDATE` or `DELETE` could match more rows than the one the user changed. +public enum PluginViewRowEditing: Sendable, Equatable { + case editable(keyColumns: [String]) + case readOnly(PluginViewReadOnlyReason) + + public var keyColumns: [String]? { + if case .editable(let keyColumns) = self { return keyColumns } + return nil + } + + /// The rule every engine shares once it knows the server's verdict, how many tables the view + /// reads, its base table's key, and which base column each view column shows. + public static func resolve( + isUpdatable: Bool, + baseTableCount: Int, + baseKeyColumns: [String], + origins: [PluginViewColumnOrigin] + ) -> PluginViewRowEditing { + guard isUpdatable, baseTableCount > 0 else { return .readOnly(.notUpdatable) } + guard baseTableCount == 1 else { return .readOnly(.readsSeveralTables) } + guard !baseKeyColumns.isEmpty else { return .readOnly(.baseTableHasNoKey) } + + var keyColumns: [String] = [] + for baseColumn in baseKeyColumns { + guard let origin = origins.first(where: { $0.baseColumn == baseColumn }) else { + return .readOnly(.keyNotExposed) + } + keyColumns.append(origin.viewColumn) + } + return .editable(keyColumns: keyColumns) + } +} + +public enum PluginViewReadOnlyReason: String, Sendable, Equatable { + case notUpdatable + case readsSeveralTables + case baseTableHasNoKey + case keyNotExposed +} + +/// One view column that shows a base table column unchanged. A computed column has no origin. +public struct PluginViewColumnOrigin: Sendable, Equatable { + public let viewColumn: String + public let baseColumn: String + + public init(viewColumn: String, baseColumn: String) { + self.viewColumn = viewColumn + self.baseColumn = baseColumn + } +} + +public extension PluginDatabaseDriver { + func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? { nil } +} diff --git a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 7ed1294821..8fdc7b6d42 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -323,7 +323,12 @@ extension QueryExecutionCoordinator { definitionChanged: definitionChanged ) - parent.tabManager.mutate(at: idx) { $0.tableContext.primaryKeyColumns = resolvedPKs } + parent.tabManager.mutate(at: idx) { tab in + tab.tableContext.primaryKeyColumns = resolvedPKs + if let viewRowEditing = metadata?.viewRowEditing { + tab.tableContext.viewRowEditing = viewRowEditing + } + } captureOrigin( tabIndex: idx, tabId: tabId, @@ -511,7 +516,8 @@ extension QueryExecutionCoordinator { isEditable: isEditable, isView: context.isView, objectType: context.objectType, - keysResolved: true + keysResolved: true, + viewRowEditing: context.viewRowEditing ) } @@ -567,6 +573,10 @@ extension QueryExecutionCoordinator { resultSet.origin?.primaryKeyColumns = parsed.primaryKeyColumns resultSet.origin?.keysResolved = true } + // Phase 2 always read the schema afresh, so a nil answer replaces a stale one. + if resultSet.origin?.isView == true { + resultSet.origin?.viewRowEditing = parsed.viewRowEditing + } } private func applyEnumValues( @@ -615,6 +625,10 @@ extension QueryExecutionCoordinator { tab.display.activeResultSet?.origin?.primaryKeyColumns = parsed.primaryKeyColumns tab.display.activeResultSet?.origin?.keysResolved = true } + if tab.tableContext.resolvedObjectKind() == .view { + tab.tableContext.viewRowEditing = parsed.viewRowEditing + tab.display.activeResultSet?.origin?.viewRowEditing = parsed.viewRowEditing + } if let approxCount = parsed.approximateRowCount, approxCount > 0, !tab.filterState.narrowsRows(browseSearchIsSupported: usesBrowseSearch) { tab.pagination.applyDerivedRowCount(approxCount, isApproximate: true) diff --git a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index dc863a5f69..2e782ae328 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -158,6 +158,7 @@ extension QueryExecutionCoordinator { primaryKeyColumns: tab.tableContext.primaryKeyColumns ) + let readsView = tab.tableContext.resolvedObjectKind() == .view let boundValues = BoundParameterValues(values: parameters) let failureOutput = ServerOutputBox() let grammar = parent.lexicalGrammar @@ -165,7 +166,9 @@ extension QueryExecutionCoordinator { guard let self else { return } if let tableName, !tableName.isEmpty { parent.prefetchForeignKeys(scope: scope) } - let schemaTask = QueryExecutor.schemaFetch(tableName: needsMetadataFetch ? tableName : nil, scope: scope) + let schemaTask = QueryExecutor.schemaFetch( + tableName: needsMetadataFetch ? tableName : nil, isView: readsView, scope: scope + ) do { let (fetchResult, tableEdits) = try await DatabaseManager.shared.withScopedDriver( diff --git a/apple/TablePro/Core/Database/DatabaseDriver.swift b/apple/TablePro/Core/Database/DatabaseDriver.swift index d812cc62f7..2fbdf9ef46 100644 --- a/apple/TablePro/Core/Database/DatabaseDriver.swift +++ b/apple/TablePro/Core/Database/DatabaseDriver.swift @@ -409,6 +409,8 @@ protocol DatabaseDriver: AnyObject, Sendable { schema: String? ) async throws -> PluginConcurrentRefreshAvailability? + func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? + // MARK: - Table Options /// Nil where the engine has no table options. @@ -912,6 +914,8 @@ extension DatabaseDriver { nil } + func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? { nil } + func fetchTableOptions(tableName: String) async throws -> TableOptions? { nil } func fetchTableOptionsCatalog() async throws -> TableOptionsCatalog? { nil } diff --git a/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift b/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift index dd6b0e175a..ba361ff89d 100644 --- a/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -1239,6 +1239,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor try await pluginDriver.concurrentRefreshAvailability(materializedView: materializedView, schema: schema) } + func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? { + try await pluginDriver.fetchViewRowEditing(view: view, schema: schema ?? pluginDriver.currentSchema) + } + // MARK: - Table Options func fetchTableOptions(tableName: String) async throws -> TableOptions? { diff --git a/apple/TablePro/Core/Services/Query/QueryExecutor.swift b/apple/TablePro/Core/Services/Query/QueryExecutor.swift index e8a042f2c1..d7b12c79e4 100644 --- a/apple/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/apple/TablePro/Core/Services/Query/QueryExecutor.swift @@ -35,6 +35,7 @@ struct FetchedTableSchema { let columns: [ColumnInfo] let foreignKeys: [ForeignKeyInfo]? let approximateRowCount: Int? + var viewRowEditing: PluginViewRowEditing? } struct ParsedSchemaMetadata { @@ -57,6 +58,7 @@ struct ParsedSchemaMetadata { let isAuthoritative: Bool var rowMatchPolicy: RowMatchPolicy = .none var columnDeclaredTypes: [String: String] = [:] + var viewRowEditing: PluginViewRowEditing? /// The metadata a tab already holds, captured at the moment the cache decision is made. /// @@ -244,12 +246,16 @@ final class QueryExecutor { // MARK: - Schema fetch + parse /// The schema read that runs beside a load's rows, when the load needs one. - static func schemaFetch(tableName: String?, scope: DatabaseScope) -> Task? { + static func schemaFetch(tableName: String?, isView: Bool, scope: DatabaseScope) -> Task? { guard let tableName else { return nil } - return Task { try await fetchTableSchema(scope: scope, tableName: tableName) } + return Task { try await fetchTableSchema(scope: scope, tableName: tableName, isView: isView) } } - static func fetchTableSchema(scope: DatabaseScope, tableName: String) async throws -> FetchedTableSchema { + static func fetchTableSchema( + scope: DatabaseScope, + tableName: String, + isView: Bool = false + ) async throws -> FetchedTableSchema { queryExecutorLog.info( "[fk] schema fetch start table=\(tableName, privacy: .private(mask: .hash)) db=\(scope.database, privacy: .public) schema=\(scope.schema ?? "default", privacy: .public)" ) @@ -261,10 +267,30 @@ final class QueryExecutor { return (columns, approximateRowCount) } let foreignKeys = await fetchForeignKeys(scope: scope, tableName: tableName) + let viewRowEditing = isView ? await fetchViewRowEditing(scope: scope, viewName: tableName) : nil queryExecutorLog.info( "[fk] schema fetch done table=\(tableName, privacy: .private(mask: .hash)) columns=\(columns.count) fks=\(foreignKeys.map { String($0.count) } ?? "failed", privacy: .public)" ) - return FetchedTableSchema(columns: columns, foreignKeys: foreignKeys, approximateRowCount: approximateRowCount) + return FetchedTableSchema( + columns: columns, + foreignKeys: foreignKeys, + approximateRowCount: approximateRowCount, + viewRowEditing: viewRowEditing + ) + } + + // A failed check leaves the view read-only, as it was before views could be edited. + private static func fetchViewRowEditing(scope: DatabaseScope, viewName: String) async -> PluginViewRowEditing? { + do { + return try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + try await driver.fetchViewRowEditing(view: viewName, schema: scope.schema) + } + } catch { + queryExecutorLog.error( + "View row editing check failed for \(viewName, privacy: .private(mask: .hash)): \(error.publicLogShape, privacy: .public)" + ) + return nil + } } private static func fetchForeignKeys(scope: DatabaseScope, tableName: String) async -> [ForeignKeyInfo]? { @@ -319,7 +345,7 @@ final class QueryExecutor { columnDefaults: defaults, columnForeignKeys: fks, columnNullable: nullable, - primaryKeyColumns: schema.columns.filter { $0.isPrimaryKey }.map(\.name), + primaryKeyColumns: schema.viewRowEditing?.keyColumns ?? schema.columns.filter { $0.isPrimaryKey }.map(\.name), generatedColumns: Set( schema.columns .filter { $0.isGenerated || $0.identityKind == .always } @@ -334,7 +360,8 @@ final class QueryExecutor { excludedColumns: columns(in: schema.columns, typedAnyOf: rowMatchExcludedTypePrefixes), textColumns: columns(in: schema.columns, typedAnyOf: rowMatchTextTypePrefixes) ), - columnDeclaredTypes: declaredTypes + columnDeclaredTypes: declaredTypes, + viewRowEditing: schema.viewRowEditing ) } diff --git a/apple/TablePro/Models/Query/QueryTabState.swift b/apple/TablePro/Models/Query/QueryTabState.swift index 731d95864a..485b532d51 100644 --- a/apple/TablePro/Models/Query/QueryTabState.swift +++ b/apple/TablePro/Models/Query/QueryTabState.swift @@ -533,12 +533,20 @@ struct TabTableContext: Equatable { // Browse only: a staged row edit would land on whatever table the name reaches once the temporary one is dropped. var isTemporary = false + // Nil until the driver has answered, and for an engine that never does. + var viewRowEditing: PluginViewRowEditing? + func resolvedObjectKind() -> TableInfo.TableType { objectType ?? (isView ? .view : .table) } var allowsRowEditing: Bool { - !isView && !isTemporary && resolvedObjectKind().allowsRowEditing + guard !isTemporary else { return false } + if resolvedObjectKind() == .view { + guard let keys = viewRowEditing?.keyColumns else { return false } + return !keys.isEmpty && keys == primaryKeyColumns + } + return !isView && resolvedObjectKind().allowsRowEditing } var primaryKeyColumn: String? { primaryKeyColumns.first } diff --git a/apple/TablePro/Models/Query/ResultOrigin.swift b/apple/TablePro/Models/Query/ResultOrigin.swift index cdf986a821..0bc98a2945 100644 --- a/apple/TablePro/Models/Query/ResultOrigin.swift +++ b/apple/TablePro/Models/Query/ResultOrigin.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit /// Where one result set's rows came from, captured when its statement ran. /// @@ -29,6 +30,9 @@ struct ResultOrigin: Equatable { /// first is safe to write to, so the two cannot share a representation. var keysResolved: Bool + /// The driver's answer for a view, nil for a table or before the answer arrives. + var viewRowEditing: PluginViewRowEditing? + init( tableName: String? = nil, schemaName: String? = nil, @@ -37,7 +41,8 @@ struct ResultOrigin: Equatable { isEditable: Bool = false, isView: Bool = false, objectType: TableInfo.TableType? = nil, - keysResolved: Bool = false + keysResolved: Bool = false, + viewRowEditing: PluginViewRowEditing? = nil ) { self.tableName = tableName self.schemaName = schemaName @@ -47,6 +52,7 @@ struct ResultOrigin: Equatable { self.isView = isView self.objectType = objectType self.keysResolved = keysResolved + self.viewRowEditing = viewRowEditing } } @@ -54,7 +60,7 @@ struct ResultOrigin: Equatable { /// every case carries the sentence shown to the user. enum ResultEditRefusal: Equatable { case unknownTable - case view + case view(PluginViewReadOnlyReason?) case databaseMoved case keysUnresolved @@ -62,14 +68,31 @@ enum ResultEditRefusal: Equatable { switch self { case .unknownTable: return String(localized: "These rows cannot be edited because TablePro cannot tell which table they came from.") - case .view: - return String(localized: "These rows come from a view, which cannot be edited.") + case .view(let reason): + return Self.viewMessage(reason) case .databaseMoved: return String(localized: "These rows came from another database than the one this tab is now using.") case .keysUnresolved: return String(localized: "TablePro has not looked up this table's key columns, so it cannot tell which row an edit would change.") } } + + private static func viewMessage(_ reason: PluginViewReadOnlyReason?) -> String { + switch reason { + case .notUpdatable: + return String(localized: "These rows come from a view the server reports as not updatable.") + case .readsSeveralTables: + return String(localized: "These rows come from a view that reads more than one table, so an edit cannot target one row.") + case .baseTableHasNoKey: + return String(localized: "These rows come from a view whose table has no primary key, so an edit could change more than one row.") + case .keyNotExposed: + return String(localized: "These rows come from a view that does not show every primary key column of its table, so an edit could change more than one row.") + case nil: + return String(localized: "These rows come from a view, which cannot be edited.") + @unknown default: + return String(localized: "These rows come from a view, which cannot be edited.") + } + } } /// The single decision about whether a result's rows may be written back. @@ -79,7 +102,10 @@ enum ResultEditRefusal: Equatable { enum ResultEditability { static func refusal(for origin: ResultOrigin?, currentDatabaseName: String) -> ResultEditRefusal? { guard let origin else { return .unknownTable } - if origin.isView { return .view } + if origin.isView, !writesThroughView(origin) { + if case .readOnly(let reason) = origin.viewRowEditing { return .view(reason) } + return .view(nil) + } guard origin.isEditable, let tableName = origin.tableName, !tableName.isEmpty else { return .unknownTable } @@ -91,4 +117,11 @@ enum ResultEditability { } return nil } + + /// The keys the edit will use must be the ones the driver named, or a stale answer would key + /// the write on whatever the tab last held. + private static func writesThroughView(_ origin: ResultOrigin) -> Bool { + guard (origin.objectType ?? .view) == .view, let keys = origin.viewRowEditing?.keyColumns else { return false } + return !keys.isEmpty && keys == origin.primaryKeyColumns + } } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 4b999ea5d9..0b9a9c5841 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -193779,6 +193779,18 @@ } } } + }, + "These rows come from a view that does not show every primary key column of its table, so an edit could change more than one row." : { + + }, + "These rows come from a view that reads more than one table, so an edit cannot target one row." : { + + }, + "These rows come from a view the server reports as not updatable." : { + + }, + "These rows come from a view whose table has no primary key, so an edit could change more than one row." : { + }, "These rows come from a view, which cannot be edited." : { "localizations" : { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift index 869d72a70b..b6fc23496b 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift @@ -165,6 +165,7 @@ extension MainContentCoordinator { tab.tableContext.isEditable = origin?.isEditable ?? false tab.tableContext.isView = origin?.isView ?? false tab.tableContext.objectType = origin?.objectType + tab.tableContext.viewRowEditing = origin?.viewRowEditing tab.schemaVersion += 1 } let tab = tabManager.tabs[tabIdx] diff --git a/apple/TablePro/Views/Main/MainContentCoordinator.swift b/apple/TablePro/Views/Main/MainContentCoordinator.swift index f00eefa556..57992d1939 100644 --- a/apple/TablePro/Views/Main/MainContentCoordinator.swift +++ b/apple/TablePro/Views/Main/MainContentCoordinator.swift @@ -1201,7 +1201,7 @@ final class MainContentCoordinator: ObservableObject { } return } - let isTableTab = tab.tabType == .table + let (isTableTab, readsView) = (tab.tabType == .table, tab.tableContext.resolvedObjectKind() == .view) let (grammar, lengthColumns) = (lexicalGrammar, unloadedValueLengthColumns(for: tab)) let failureOutput = ServerOutputBox() @@ -1213,7 +1213,7 @@ final class MainContentCoordinator: ObservableObject { } if let tableName, !tableName.isEmpty { prefetchForeignKeys(scope: scope) } - let schemaTask = QueryExecutor.schemaFetch(tableName: needsMetadataFetch ? tableName : nil, scope: scope) + let schemaTask = QueryExecutor.schemaFetch(tableName: needsMetadataFetch ? tableName : nil, isView: readsView, scope: scope) let fetchBeganAt = ContinuousClock.now do { diff --git a/apple/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift b/apple/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift index 821e8b1b42..9388ca8043 100644 --- a/apple/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift +++ b/apple/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit import Testing @testable import TablePro @@ -25,6 +26,28 @@ struct ParseSchemaMetadataTests { #expect(parsed.columnComments == ["id": "Primary key", "name": "Display name"]) } + @Test("An editable view is keyed on the view columns its driver named, which carry no key of their own") + func editableViewSuppliesKeys() { + var schema = FetchedTableSchema( + columns: [column("key_id", comment: nil), column("name", comment: nil)], + foreignKeys: nil, + approximateRowCount: nil + ) + schema.viewRowEditing = .editable(keyColumns: ["key_id"]) + let parsed = QueryExecutor.parseSchemaMetadata(schema) + #expect(parsed.primaryKeyColumns == ["key_id"]) + #expect(parsed.viewRowEditing == .editable(keyColumns: ["key_id"])) + } + + @Test("A read-only view keeps no key and carries its reason") + func readOnlyViewHasNoKeys() { + var schema = FetchedTableSchema(columns: [column("name", comment: nil)], foreignKeys: nil, approximateRowCount: nil) + schema.viewRowEditing = .readOnly(.readsSeveralTables) + let parsed = QueryExecutor.parseSchemaMetadata(schema) + #expect(parsed.primaryKeyColumns.isEmpty) + #expect(parsed.viewRowEditing == .readOnly(.readsSeveralTables)) + } + @Test("Excludes nil and empty comments") func excludesNilAndEmpty() { let schema = FetchedTableSchema( diff --git a/apple/TableProTests/Models/Query/ResultEditabilityTests.swift b/apple/TableProTests/Models/Query/ResultEditabilityTests.swift index e4674a15c6..0bad09bd0f 100644 --- a/apple/TableProTests/Models/Query/ResultEditabilityTests.swift +++ b/apple/TableProTests/Models/Query/ResultEditabilityTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro @@ -50,7 +51,40 @@ struct ResultEditabilityTests { @Test("A view refuses, and says so rather than reporting an unknown table") func viewRefuses() { - #expect(ResultEditability.refusal(for: origin(isView: true), currentDatabaseName: "app") == .view) + #expect(ResultEditability.refusal(for: origin(isView: true), currentDatabaseName: "app") == .view(nil)) + } + + @Test("An updatable view that exposes its table's key is written like a table") + func editableViewIsEditable() { + var view = origin(isView: true) + view.objectType = .view + view.viewRowEditing = .editable(keyColumns: ["id"]) + #expect(ResultEditability.refusal(for: view, currentDatabaseName: "app") == nil) + } + + @Test("A view refuses when the keys the edit would use are not the driver's") + func viewWithStaleKeysRefuses() { + var view = origin(isView: true) + view.viewRowEditing = .editable(keyColumns: ["id"]) + view.primaryKeyColumns = [] + #expect(ResultEditability.refusal(for: view, currentDatabaseName: "app") == .view(nil)) + view.primaryKeyColumns = ["code"] + #expect(ResultEditability.refusal(for: view, currentDatabaseName: "app") == .view(nil)) + } + + @Test("A read-only view carries the driver's reason into the refusal") + func readOnlyViewSaysWhy() { + var view = origin(isView: true) + view.viewRowEditing = .readOnly(.keyNotExposed) + #expect(ResultEditability.refusal(for: view, currentDatabaseName: "app") == .view(.keyNotExposed)) + } + + @Test("A materialized view never takes a view's editing answer") + func materializedViewStaysReadOnly() { + var matview = origin(isView: true) + matview.objectType = .materializedView + matview.viewRowEditing = .editable(keyColumns: ["id"]) + #expect(ResultEditability.refusal(for: matview, currentDatabaseName: "app") == .view(nil)) } @Test("A result captured against another database refuses once the tab has moved") @@ -84,7 +118,10 @@ struct ResultEditabilityTests { @Test("Every refusal carries a sentence for the user") func everyRefusalExplainsItself() { - for refusal in [ResultEditRefusal.unknownTable, .view, .databaseMoved, .keysUnresolved] { + let viewRefusals: [ResultEditRefusal] = [ + .view(nil), .view(.notUpdatable), .view(.readsSeveralTables), .view(.baseTableHasNoKey), .view(.keyNotExposed), + ] + for refusal in [ResultEditRefusal.unknownTable, .databaseMoved, .keysUnresolved] + viewRefusals { #expect(!refusal.message.isEmpty) } } diff --git a/apple/TableProTests/Models/Query/TabObjectKindTests.swift b/apple/TableProTests/Models/Query/TabObjectKindTests.swift index ab288637c7..2859e316f6 100644 --- a/apple/TableProTests/Models/Query/TabObjectKindTests.swift +++ b/apple/TableProTests/Models/Query/TabObjectKindTests.swift @@ -51,6 +51,24 @@ struct TabObjectKindTests { #expect(!TabTableContext(isView: true, objectType: nil).allowsRowEditing) } + @Test("A view allows row edits only once its driver keyed it on the base table") + func viewFollowsItsEditingAnswer() { + var view = TabTableContext(isView: true, objectType: .view) + #expect(!view.allowsRowEditing) + view.viewRowEditing = .readOnly(.notUpdatable) + #expect(!view.allowsRowEditing) + view.viewRowEditing = .editable(keyColumns: ["id"]) + #expect(!view.allowsRowEditing, "the tab does not key its edits on the driver's columns yet") + view.primaryKeyColumns = ["id"] + #expect(view.allowsRowEditing) + view.isTemporary = true + #expect(!view.allowsRowEditing) + + var matview = TabTableContext(isView: true, objectType: .materializedView) + matview.viewRowEditing = .editable(keyColumns: ["id"]) + #expect(!matview.allowsRowEditing) + } + @Test("A writable kind, or no kind at all, keeps the rows writable") func writableKindsKeepRowEditing() { #expect(TabTableContext(isView: false, objectType: .table).allowsRowEditing) diff --git a/apple/scripts/probes/check-view-row-editing.sh b/apple/scripts/probes/check-view-row-editing.sh new file mode 100755 index 0000000000..bce31e74ef --- /dev/null +++ b/apple/scripts/probes/check-view-row-editing.sh @@ -0,0 +1,373 @@ +#!/usr/bin/env bash +# +# Check which views the data grid lets a user edit, and that an edit keyed the way the grid keys it +# changes exactly the row it names. +# +# The drivers decide from the server's catalog: PostgreSQL's pg_relation_is_updatable and the +# view's stored query, MySQL and MariaDB's information_schema.VIEWS, SQL Server's +# sys.dm_exec_describe_first_result_set. This builds one table and a view of every shape that +# matters (all columns, a renamed key, swapped names, no key, a join, a join showing one table's +# columns, DISTINCT, a keyless table, and on SQL Server a table in another database whose name the +# current one reuses with another key), +# runs the plugin's real queries and parsers from its own source files, compares each answer with +# the expected one, then writes through every view the answer calls editable: an UPDATE and a +# DELETE keyed on the columns it named, and an INSERT, each checked against the base table. +# +# Usage: +# scripts/probes/check-view-row-editing.sh [host] [port] [user] [password] +# +# Needs psql, mysql or mariadb, or FreeTDS's tsql, and a server the user may create a database on. +# Throwaway servers that match the defaults: +# docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=probe postgres:17 +# docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=probe mysql:8.4 +# docker run -d -p 3306:3306 -e MARIADB_ROOT_PASSWORD=probe mariadb:11 +# docker run -d -p 1433:1433 -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD=Probe_pw1234 mcr.microsoft.com/mssql/server:2022-latest +# Exits non-zero on a wrong answer or a wrong write, 3 when a prerequisite is missing. + +set -uo pipefail + +ENGINE="${1:-}" +case "$ENGINE" in + postgresql) DEFAULT_PORT=5432 DEFAULT_USER=postgres DEFAULT_PASSWORD=probe ;; + mysql | mariadb) DEFAULT_PORT=3306 DEFAULT_USER=root DEFAULT_PASSWORD=probe ;; + mssql) DEFAULT_PORT=1433 DEFAULT_USER=sa DEFAULT_PASSWORD=Probe_pw1234 ;; + *) + echo "usage: $0 [host] [port] [user] [password]" >&2 + exit 3 + ;; +esac +HOST="${2:-127.0.0.1}" +PORT="${3:-$DEFAULT_PORT}" +USER_NAME="${4:-$DEFAULT_USER}" +PASSWORD="${5:-$DEFAULT_PASSWORD}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DATABASE="tablepro_views_$$_$RANDOM" +WORK="$(mktemp -d)" +CREATED=0 +FAILURES=0 + +# One statement per call; prints rows with fields joined by a unit separator and NULL as empty. +sql() { + local database="$1" statement="$2" + case "$ENGINE" in + postgresql) + PGPASSWORD="$PASSWORD" psql -h "$HOST" -p "$PORT" -U "$USER_NAME" -d "$database" \ + -X -q -At -F $'\x1f' -v ON_ERROR_STOP=1 -c "$statement" 2>&1 + ;; + mysql | mariadb) + MYSQL_PWD="$PASSWORD" "$MYSQL_CLIENT" -h "$HOST" -P "$PORT" -u "$USER_NAME" --protocol=TCP \ + -N -B --raw ${database:+-D "$database"} -e "$statement" 2>&1 \ + | grep -v '^WARNING: option --ssl-verify-server-cert' \ + | sed -e $'s/\t/\x1f/g' -e $'s/\x1fNULL\x1f/\x1f\x1f/g' -e $'s/\x1fNULL$/\x1f/' -e $'s/^NULL\x1f/\x1f/' + ;; + mssql) + printf 'SET NOCOUNT ON\ngo\n%s\ngo\n' "$statement" \ + | tsql -H "$HOST" -p "$PORT" -U "$USER_NAME" -P "$PASSWORD" -D "$database" -o fhq -t $'\x1f' 2>&1 \ + | sed -e $'s/\x1fNULL\x1f/\x1f\x1f/g' -e $'s/\x1fNULL$/\x1f/' -e $'s/^NULL\x1f/\x1f/' + ;; + esac +} + +admin_database() { + case "$ENGINE" in + postgresql) echo postgres ;; + mysql | mariadb) echo "" ;; + mssql) echo master ;; + esac +} + +cleanup() { + if [ "$CREATED" -eq 1 ]; then + case "$ENGINE" in + mssql) + local database + for database in "$DATABASE" "${DATABASE}_other"; do + sql master "IF DB_ID('$database') IS NOT NULL BEGIN ALTER DATABASE [$database] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [$database] END" > /dev/null + done + ;; + *) sql "$(admin_database)" "DROP DATABASE $DATABASE" > /dev/null ;; + esac + fi + rm -rf "$WORK" +} +trap cleanup EXIT + +case "$ENGINE" in + postgresql) command -v psql > /dev/null || { echo "psql not found (brew install libpq)" >&2; exit 3; } ;; + mysql | mariadb) + MYSQL_CLIENT="$(command -v mysql || command -v mariadb)" || { echo "mysql not found" >&2; exit 3; } + ;; + mssql) command -v tsql > /dev/null || { echo "tsql not found (brew install freetds)" >&2; exit 3; } ;; +esac +if [ -z "${DEVELOPER_DIR:-}" ]; then + DEVELOPER_DIR="$(xcode-select -p)" + case "$DEVELOPER_DIR" in + *CommandLineTools*) DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer ;; + esac +fi +export DEVELOPER_DIR + +if ! sql "$(admin_database)" "SELECT 1" | grep -qx '1'; then + echo "no $ENGINE server at $HOST:$PORT as $USER_NAME" >&2 + exit 3 +fi + +# The resolver reads the plugin's own files, so a change to a parser or a query is what this runs. +mkdir -p "$WORK/Sources/ViewEditing" +cat > "$WORK/Package.swift" << EOF +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "ViewEditing", + platforms: [.macOS(.v14)], + dependencies: [.package(path: "$ROOT/Packages/TableProCore")], + targets: [ + .executableTarget( + name: "ViewEditing", + dependencies: [ + .product(name: "TableProPluginKit", package: "TableProCore"), + .product(name: "TableProMSSQLCore", package: "TableProCore"), + ] + ) + ] +) +EOF +ln -s "$ROOT/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift" "$WORK/Sources/ViewEditing/" +ln -s "$ROOT/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift" "$WORK/Sources/ViewEditing/" +cat > "$WORK/Sources/ViewEditing/main.swift" << 'SWIFT' +import Foundation +import TableProMSSQLCore +import TableProPluginKit + +// The driver's literal quoting, minus the backslash and NUL cases no fixture name has. +enum PostgreSQLObjectQueries { + static func quoteLiteral(_ value: String) -> String { "'\(value.replacingOccurrences(of: "'", with: "''"))'" } +} + +let separator: Character = "\u{1F}" + +func rows(_ text: String) -> [[String?]] { + text.split(separator: "\n", omittingEmptySubsequences: true).map { line in + line.split(separator: separator, omittingEmptySubsequences: false).map { $0.isEmpty ? nil : String($0) } + } +} + +// Runs one statement through the probe's own client, which reads it from the environment. +func query(_ statement: String) -> [[String?]] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = ["-c", "\"$PROBE_SQL\" \"$PROBE_DATABASE\" \"$1\"", "probe", statement] + let pipe = Pipe() + process.standardOutput = pipe + try? process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return rows(String(decoding: data, as: UTF8.self)) +} + +func describe(_ editing: PluginViewRowEditing) -> String { + switch editing { + case .editable(let keys): return "editable:" + keys.joined(separator: ",") + case .readOnly(let reason): return "readonly:" + reason.rawValue + @unknown default: return "unknown" + } +} + +func postgreSQL(view: String, schema: String) -> PluginViewRowEditing? { + guard let row = query(PostgreSQLViewRowEditing.viewQuery(name: view, schema: schema)).first, + let viewOID = row[0] else { return nil } + let isUpdatable = row[1] == "t" + let origins = PostgreSQLViewRowEditing.targetOrigins(nodeTree: row[2] ?? "") + var names: [Int: String] = [:] + var keys: [Int] = [] + if isUpdatable, let base = origins.first?.tableOID { + for detail in query(PostgreSQLViewRowEditing.columnsAndKeyQuery(viewOID: viewOID, baseTableOID: base)) { + guard let number = detail[1].flatMap(Int.init) else { continue } + if detail[0] == "v" { names[number] = detail[2] } else { keys.append(number) } + } + } + return PostgreSQLViewRowEditing.resolve( + isUpdatable: isUpdatable, origins: origins, viewColumnNames: names, baseKeyColumnNumbers: keys + ) +} + +func mySQL(view: String, database: String) -> PluginViewRowEditing? { + let escape: (String) -> String = { $0.replacingOccurrences(of: "'", with: "''") } + guard let row = query(MySQLViewRowEditing.viewQuery(name: view, database: database, escape: escape)).first + else { return nil } + let isUpdatable = row[0]?.uppercased() == "YES" + let definition = row[1].flatMap(MySQLViewRowEditing.parse(definition:)) + var keys: [String] = [] + if isUpdatable, let base = definition?.baseTable { + keys = query(MySQLViewRowEditing.primaryKeyQuery(table: base, viewDatabase: database, escape: escape)) + .compactMap { $0[0] } + } + return MySQLViewRowEditing.resolve(isUpdatable: isUpdatable, definition: definition, baseKeyColumns: keys) +} + +func msSQL(view: String, schema: String) -> PluginViewRowEditing { + let errors = query(MSSQLViewSources.compileErrorsQuery(schema: schema, view: view)).compactMap { $0[0].flatMap(Int.init) } + let summary = MSSQLViewSources.summarize(rows: query(MSSQLViewSources.columnSourcesQuery(schema: schema, view: view))) + var keys: [String] = [] + if summary.baseTables.count == 1, let base = summary.baseTables.first { + keys = query(MSSQLViewSources.primaryKeyQuery(base)).compactMap { $0[0] } + } + return PluginViewRowEditing.resolve( + isUpdatable: MSSQLViewSources.isUpdatable(compileErrors: errors), + baseTableCount: MSSQLViewSources.baseTableCount(summary, compileErrors: errors), + baseKeyColumns: keys, + origins: summary.origins.map { PluginViewColumnOrigin(viewColumn: $0.viewColumn, baseColumn: $0.baseColumn) } + ) +} + +let arguments = CommandLine.arguments +let engine = arguments[1], container = arguments[2], view = arguments[3] +let editing: PluginViewRowEditing? +switch engine { +case "postgresql": editing = postgreSQL(view: view, schema: container) +case "mssql": editing = msSQL(view: view, schema: container) +default: editing = mySQL(view: view, database: container) +} +print(editing.map(describe) ?? "nil") +SWIFT +swift build --package-path "$WORK" > "$WORK/build.log" 2>&1 || { + echo "the resolver failed to build:" >&2 + grep -E 'error:' "$WORK/build.log" | sort -u | head -20 >&2 + exit 3 +} +RESOLVER="$(swift build --package-path "$WORK" --show-bin-path)/ViewEditing" + +case "$ENGINE" in + mssql) out="$(sql master "CREATE DATABASE [$DATABASE]")" ;; + *) out="$(sql "$(admin_database)" "CREATE DATABASE $DATABASE")" ;; +esac +if [ -n "$out" ]; then + echo "could not create $DATABASE: $out" >&2 + exit 3 +fi +CREATED=1 + +case "$ENGINE" in + postgresql) CONTAINER=public Q_OPEN='"' Q_CLOSE='"' ;; + mysql | mariadb) CONTAINER="$DATABASE" Q_OPEN='`' Q_CLOSE='`' ;; + mssql) CONTAINER=dbo Q_OPEN='[' Q_CLOSE=']' ;; +esac +q() { printf '%s%s%s' "$Q_OPEN" "$1" "$Q_CLOSE"; } + +# One statement per line: SQL Server needs CREATE VIEW to open its own batch. +FIXTURE="CREATE TABLE t (id int PRIMARY KEY, name varchar(20), qty int) +CREATE TABLE u (tid int) +CREATE TABLE k (name varchar(20), qty int) +INSERT INTO t (id, name, qty) VALUES (1, 'a', 1) +INSERT INTO t (id, name, qty) VALUES (2, 'b', 2) +INSERT INTO u (tid) VALUES (1) +CREATE VIEW v_simple AS SELECT id, name, qty FROM t +CREATE VIEW v_renamed AS SELECT id AS key_id, name, qty + 1 AS q2 FROM t +CREATE VIEW v_swap AS SELECT name AS id, id AS name FROM t +CREATE VIEW v_nokey AS SELECT name, qty FROM t +CREATE VIEW v_join AS SELECT t.id, t.name, u.tid FROM t JOIN u ON u.tid = t.id +CREATE VIEW v_join_one AS SELECT t.id, t.name FROM t JOIN u ON u.tid = t.id +CREATE VIEW v_distinct AS SELECT DISTINCT id, name FROM t +CREATE VIEW v_keyless AS SELECT name, qty FROM k" +while IFS= read -r line; do + out="$(sql "$DATABASE" "$line")" + if [ -n "$out" ]; then + echo "fixture failed: $line: $out" >&2 + exit 3 + fi +done <<< "$FIXTURE" + +# The other database's t2 is keyed on id; the current one's t2 is keyed on code, which repeats over there. +if [ "$ENGINE" = mssql ]; then + OTHER_FIXTURE="CREATE DATABASE [${DATABASE}_other] +CREATE TABLE [${DATABASE}_other].dbo.t2 (code int NOT NULL, id int PRIMARY KEY) +INSERT INTO [${DATABASE}_other].dbo.t2 (code, id) VALUES (7, 1), (7, 2) +CREATE TABLE dbo.t2 (code int PRIMARY KEY, id int) +CREATE VIEW v_otherdb AS SELECT code, id FROM [${DATABASE}_other].dbo.t2" + while IFS= read -r line; do + out="$(sql "$DATABASE" "$line")" + if [ -n "$out" ]; then + echo "fixture failed: $line: $out" >&2 + exit 3 + fi + done <<< "$OTHER_FIXTURE" +fi + +case "$ENGINE" in + postgresql) JOIN_ANSWER=readonly:notUpdatable ;; + *) JOIN_ANSWER=readonly:readsSeveralTables ;; +esac +EXPECTED="v_simple editable:id +v_renamed editable:key_id +v_swap editable:name +v_nokey readonly:keyNotExposed +v_join $JOIN_ANSWER +v_join_one $JOIN_ANSWER +v_distinct readonly:notUpdatable +v_keyless readonly:baseTableHasNoKey" +[ "$ENGINE" = mssql ] && EXPECTED="$EXPECTED +v_otherdb editable:id" + +export PROBE_SQL="$WORK/sql.sh" PROBE_DATABASE="$DATABASE" +{ + echo '#!/usr/bin/env bash' + declare -p ENGINE HOST PORT USER_NAME PASSWORD + [ -n "${MYSQL_CLIENT:-}" ] && declare -p MYSQL_CLIENT + declare -f sql + echo 'sql "$1" "$2"' +} > "$PROBE_SQL" +chmod +x "$PROBE_SQL" + +echo "Checking view row editing on $ENGINE at $HOST:$PORT" +while read -r view expected; do + actual="$("$RESOLVER" "$ENGINE" "$CONTAINER" "$view")" + if [ "$actual" = "$expected" ]; then + echo " ok $view: $actual" + else + echo " FAIL $view: expected $expected, got $actual" + FAILURES=$((FAILURES + 1)) + fi +done <<< "$EXPECTED" + +# A write keyed the way the grid keys it. Each check reads the base table, never the view. +expect_value() { + local label="$1" statement="$2" expected="$3" + local actual + actual="$(sql "$DATABASE" "$statement")" + if [ "$actual" = "$expected" ]; then + echo " ok $label" + else + echo " FAIL $label: expected '$expected', got '$actual'" + FAILURES=$((FAILURES + 1)) + fi +} +write() { + local label="$1" statement="$2" + local out + out="$(sql "$DATABASE" "$statement")" + if [ -n "$out" ]; then + echo " FAIL $label: $out" + FAILURES=$((FAILURES + 1)) + fi +} + +echo "Writing through the editable views" +write "update v_renamed" "UPDATE $(q v_renamed) SET $(q name) = 'renamed' WHERE $(q key_id) = 2" +expect_value "update v_renamed changed row 2 only" "SELECT name FROM t ORDER BY id" $'a\nrenamed' +write "update v_swap" "UPDATE $(q v_swap) SET $(q id) = 'swapped' WHERE $(q name) = 1" +expect_value "update v_swap changed row 1 only" "SELECT name FROM t ORDER BY id" $'swapped\nrenamed' +write "insert v_simple" "INSERT INTO $(q v_simple) ($(q id), $(q name), $(q qty)) VALUES (3, 'new', 3)" +expect_value "insert v_simple added row 3" "SELECT COUNT(*) FROM t WHERE id = 3 AND name = 'new'" "1" +write "delete v_simple" "DELETE FROM $(q v_simple) WHERE $(q id) = 3" +expect_value "delete v_simple removed row 3 only" "SELECT COUNT(*) FROM t" "2" +if [ "$ENGINE" = mssql ]; then + write "update v_otherdb" "UPDATE [v_otherdb] SET [code] = 8 WHERE [id] = 2" + expect_value "update v_otherdb changed row 2 only" "SELECT code FROM [${DATABASE}_other].dbo.t2 ORDER BY id" $'7\n8' +fi + +if [ "$FAILURES" -gt 0 ]; then + echo "$FAILURES check(s) failed" + exit 1 +fi +echo "All checks passed" From a46dfb6d90dcc58a1d3a8c3cd232bffc5aebfee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= <26674308+J2TeamNNL@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:50:16 +0700 Subject: [PATCH 2/8] docs(changelog): note editable updatable views --- apple/changelog.d/3961.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3961.added.md diff --git a/apple/changelog.d/3961.added.md b/apple/changelog.d/3961.added.md new file mode 100644 index 0000000000..7daeee053b --- /dev/null +++ b/apple/changelog.d/3961.added.md @@ -0,0 +1 @@ +Rows of updatable PostgreSQL, MySQL, MariaDB and SQL Server views that show their table's primary key are editable. From acc104ac1106624f46a4b3cdd73cb2d2fc7ca781 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 1 Nov 2026 06:42:24 +0700 Subject: [PATCH 3/8] refactor(data-grid): answer each view with its write target, columns and operations --- .../TableProMSSQLCore/MSSQLViewSources.swift | 388 +++++++++--- .../MSSQLViewSourcesTests.swift | 454 ++++++++++++-- .../PluginViewRowEditingTests.swift | 168 +++-- .../MSSQLPluginDriver+Schema.swift | 89 ++- .../MySQLPluginDriver+Views.swift | 48 +- .../MySQLViewRowEditing.swift | 509 ++++++++++----- .../MySQLViewRowEditingTests.swift | 526 +++++++++++++--- .../PostgreSQLPluginDriver+Views.swift | 58 +- .../PostgreSQLViewRowEditing.swift | 285 ++++++--- .../PostgreSQLViewRowEditingTests.swift | 584 ++++++++++++++++-- .../PluginDatabaseDriver.swift | 159 +---- .../TableProPluginKit/PluginSQLFilter.swift | 150 +++++ .../PluginViewRowEditing.swift | 175 +++++- .../ChangeTracking/DataChangeManager.swift | 6 +- .../QueryExecutionCoordinator+Helpers.swift | 18 +- ...QueryExecutionCoordinator+Parameters.swift | 5 +- .../RowEditingCoordinator+SaveChanges.swift | 27 +- .../Coordinators/RowEditingCoordinator.swift | 10 +- .../Core/DataWrite/CascadeDeletePlan.swift | 13 + .../Core/DataWrite/DataWritePlan.swift | 16 + .../DataWrite/Rewind/RewindExecutor.swift | 3 +- .../Core/DataWrite/Rewind/RewindPlanner.swift | 9 +- .../Core/DataWrite/Rewind/RewindRecord.swift | 15 +- .../Core/DataWrite/Rewind/RewindRefusal.swift | 7 + .../DataWrite/RowWriteOperationBuilder.swift | 11 +- ...inSplitViewController+MenuValidation.swift | 10 +- .../Core/Services/Query/QueryExecutor.swift | 46 +- .../TablePro/Models/Query/QueryTabState.swift | 25 +- .../TablePro/Models/Query/ResultOrigin.swift | 44 +- .../TablePro/Resources/Localizable.xcstrings | 12 - ...ntentCoordinator+DatabaseObjectTools.swift | 25 +- .../MainContentCoordinator+GridModels.swift | 7 +- ...MainContentCoordinator+ResultEditing.swift | 28 +- ...MainContentCoordinator+RowOperations.swift | 2 +- .../MainContentCoordinator+SQLPreview.swift | 14 +- ...MainContentCoordinator+SchemaLoading.swift | 11 + .../Main/MainContentCommandActions.swift | 12 +- .../Views/Main/MainContentCoordinator.swift | 6 +- .../DataWrite/CascadeDeletePlanTests.swift | 41 ++ .../Core/DataWrite/RewindPlannerTests.swift | 51 +- .../RowWriteOperationBuilderTests.swift | 14 +- .../Core/Menu/MainMenuBuilderTests.swift | 6 +- .../MenuContentModeParityTests.swift | 3 +- .../Query/ParseSchemaMetadataTests.swift | 39 +- .../TableProTests/Helpers/TestFixtures.swift | 18 + .../Models/Query/ResultEditabilityTests.swift | 16 +- .../Models/Query/TabObjectKindTests.swift | 20 +- .../Main/UpdatableViewRowWriteTests.swift | 127 ++++ 48 files changed, 3374 insertions(+), 936 deletions(-) create mode 100644 apple/Plugins/TableProPluginKit/PluginSQLFilter.swift create mode 100644 apple/TableProTests/Views/Main/UpdatableViewRowWriteTests.swift diff --git a/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift b/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift index 37aa2c8dd3..671cbce3ae 100644 --- a/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift +++ b/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift @@ -1,99 +1,343 @@ import Foundation -/// What SQL Server says about writing through one view. `INFORMATION_SCHEMA.VIEWS.IS_UPDATABLE` -/// is always NO, so both answers come from `sys.dm_exec_describe_first_result_set`, which compiles -/// a statement without running it: no trigger fires and no session setting changes. +// INFORMATION_SCHEMA says NO for every view; sys.dm_exec_describe_first_result_set compiles without running. public enum MSSQLViewSources { - /// "Cannot update the view ... because it contains aggregates, or a DISTINCT or GROUP BY clause", - /// and the like. The server raises them while it compiles a DELETE through the view. - public static func compileErrorsQuery(schema: String, view: String) -> String { - let probe = "DELETE FROM \(MSSQLSchemaQueries.bracketed(schema: schema, table: view)) WHERE 1 = 0" - return """ - SELECT error_number FROM sys.dm_exec_describe_first_result_set(\(MSSQLStringLiteral.quoted(probe)), NULL, 0) - WHERE error_number IS NOT NULL - """ + public enum Refusal: Equatable, Sendable { + case notUpdatable + case writesRunTriggers + case readsSeveralTables + case baseTableHidden } - /// Browse mode 1 names the base table and column behind each view column, NULL for a computed - /// one, and adds hidden key columns the view does not show. A view may read another database, - /// so the database comes along; a column from a linked server is left without a source. - public static func columnSourcesQuery(schema: String, view: String) -> String { - let probe = "SELECT * FROM \(MSSQLSchemaQueries.bracketed(schema: schema, table: view))" - return """ - SELECT name, COALESCE(source_database, DB_NAME()), source_schema, source_table, source_column - FROM sys.dm_exec_describe_first_result_set(\(MSSQLStringLiteral.quoted(probe)), NULL, 1) - WHERE is_hidden = 0 AND source_server IS NULL - ORDER BY column_ordinal - """ + public enum Operation: String, CaseIterable, Sendable { + case update + case insert + case delete } - /// Read from the base table's own database: the same name in the current one can be another table. - public static func primaryKeyQuery(_ table: BaseTable) -> String { - let database = "[\(MSSQLSchemaQueries.escapeBracket(table.database))]" - let object = MSSQLStringLiteral.quoted( - "\(database).\(MSSQLSchemaQueries.bracketed(schema: table.schema, table: table.name))" - ) - return """ - SELECT c.name - FROM \(database).sys.indexes i - JOIN \(database).sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id - JOIN \(database).sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id - WHERE i.object_id = OBJECT_ID(\(object)) AND i.is_primary_key = 1 - ORDER BY ic.key_ordinal - """ - } + public struct BaseTable: Hashable, Sendable { + // Set for a table on a linked server. + public let server: String? + public let database: String? + public let schema: String + public let name: String - /// 11501 only says the batch failed to compile and comes with the real error. - public static func isUpdatable(compileErrors: [Int]) -> Bool { - Set(compileErrors).subtracting([joinsTablesError, 11_501]).isEmpty + public init(server: String? = nil, database: String?, schema: String, name: String) { + self.server = server + self.database = database + self.schema = schema + self.name = name + } } - /// 4405: the modification would touch more than one base table. A join showing only one - /// table's columns raises it too, and a DELETE through it always fails. - public static func baseTableCount(_ summary: Summary, compileErrors: [Int]) -> Int { - compileErrors.contains(joinsTablesError) ? max(2, summary.baseTables.count) : summary.baseTables.count - } + public struct ViewColumn: Equatable, Sendable { + public let name: String + public let baseColumn: String? + public let acceptsWrites: Bool - private static let joinsTablesError = 4_405 + public init(name: String, baseColumn: String?, acceptsWrites: Bool) { + self.name = name + self.baseColumn = baseColumn + self.acceptsWrites = acceptsWrites + } + } - public struct BaseTable: Hashable, Sendable { - public let database: String - public let schema: String + public struct BaseColumn: Equatable, Sendable { public let name: String + public let isNullable: Bool + // Nil also for a default whose text needs VIEW DEFINITION to read. + public let defaultValue: String? + public let hasDefault: Bool + public let isIdentity: Bool + public let isComputed: Bool + // A rowversion or a GENERATED ALWAYS period column. + public let isServerGenerated: Bool - public init(database: String, schema: String, name: String) { - self.database = database - self.schema = schema + public init( + name: String, + isNullable: Bool, + defaultValue: String?, + hasDefault: Bool, + isIdentity: Bool, + isComputed: Bool, + isServerGenerated: Bool + ) { self.name = name + self.isNullable = isNullable + self.defaultValue = defaultValue + self.hasDefault = hasDefault + self.isIdentity = isIdentity + self.isComputed = isComputed + self.isServerGenerated = isServerGenerated + } + + // A default the user cannot read still fills the column when an INSERT leaves it out. + public var assignsValue: Bool { + isIdentity || isServerGenerated || (hasDefault && defaultValue == nil) + } + + public var acceptsWrites: Bool { + !isComputed && !isIdentity && !isServerGenerated + } + } + + static let maxViewDepth = 8 + + // MARK: - The view itself + + // sys.sql_expression_dependencies needs VIEW DEFINITION; sys.sql_dependencies does not but drops recreated views. + // A schema-bound view has one sys.sql_expression_dependencies row per column; minor id 0 is the object. + public static func viewFactsQuery(schema: String, view: String) -> String { + let object = MSSQLStringLiteral.quoted(MSSQLSchemaQueries.bracketed(schema: schema, table: view)) + return """ + DECLARE @view int = OBJECT_ID(\(object)); + DECLARE @edges TABLE (referencing_id int NOT NULL, referenced_id int NOT NULL); + IF EXISTS (SELECT 1 FROM sys.views WHERE object_id = @view) + BEGIN + INSERT INTO @edges (referencing_id, referenced_id) + SELECT DISTINCT d.object_id, d.referenced_major_id + FROM sys.sql_dependencies d + JOIN sys.views r ON r.object_id = d.referenced_major_id; + IF HAS_PERMS_BY_NAME(N'sys.sql_expression_dependencies', 'OBJECT', 'SELECT') = 1 + INSERT INTO @edges (referencing_id, referenced_id) + SELECT d.referencing_id, d.referenced_id + FROM sys.sql_expression_dependencies d + JOIN sys.views r ON r.object_id = d.referenced_id + WHERE d.referenced_minor_id = 0 + AND NOT EXISTS ( + SELECT 1 + FROM @edges e + WHERE e.referencing_id = d.referencing_id AND e.referenced_id = d.referenced_id + ); + END; + WITH chain AS ( + SELECT v.object_id, 0 AS depth + FROM sys.views v + WHERE v.object_id = @view + UNION ALL + SELECT e.referenced_id, c.depth + 1 + FROM chain c + JOIN @edges e ON e.referencing_id = c.object_id + WHERE c.depth < \(maxViewDepth) + ) + SELECT + v.has_opaque_metadata, + CASE WHEN EXISTS ( + SELECT 1 + FROM chain c + JOIN sys.triggers tr ON tr.parent_id = c.object_id + WHERE tr.is_instead_of_trigger = 1 AND tr.is_disabled = 0 + ) THEN 1 ELSE 0 END AS runs_instead_of_trigger + FROM sys.views v + WHERE v.object_id = @view + """ + } + + public struct ViewFacts: Equatable, Sendable { + public let hasOpaqueMetadata: Bool + public let runsInsteadOfTrigger: Bool + + public init(hasOpaqueMetadata: Bool, runsInsteadOfTrigger: Bool) { + self.hasOpaqueMetadata = hasOpaqueMetadata + self.runsInsteadOfTrigger = runsInsteadOfTrigger + } + + // Nil when no view of that name is visible to the user. + public init?(rows: [[String?]]) { + guard let row = rows.first else { return nil } + self.init(hasOpaqueMetadata: row.cell(0) == "1", runsInsteadOfTrigger: row.cell(1) == "1") + } + + public var refusal: Refusal? { + // WITH VIEW_METADATA makes browse mode name the view in place of its base table. + if hasOpaqueMetadata { return .baseTableHidden } + if runsInsteadOfTrigger { return .writesRunTriggers } + return nil } } - public struct Origin: Equatable, Sendable { - public let viewColumn: String - public let baseColumn: String + // MARK: - Browse mode + + // Mode 1 is FOR BROWSE: base table and column per column, plus hidden key and rowversion rows of every table. + public static func browseQuery(schema: String, view: String) -> String { + let select = "SELECT * FROM \(MSSQLSchemaQueries.bracketed(schema: schema, table: view))" + return """ + SELECT name, is_hidden, source_server, source_database, source_schema, source_table, source_column, + is_updateable + FROM sys.dm_exec_describe_first_result_set(\(MSSQLStringLiteral.quoted(select)), NULL, 1) + ORDER BY column_ordinal + """ + } + + public enum Source: Equatable, Sendable { + case table(BaseTable) + case refused(Refusal) } - public struct Summary: Equatable, Sendable { - /// Every base table a shown column comes from, in the order the columns name them. + public struct BrowseFacts: Equatable, Sendable { + public let columns: [ViewColumn] public let baseTables: [BaseTable] - public let origins: [Origin] + // View columns the probes write, one per base column, in view order. + public let writableColumns: [String] + + public init(rows: [[String?]]) { + var columns: [ViewColumn] = [] + var baseTables: [BaseTable] = [] + var writableColumns: [String] = [] + var probedBaseColumns: Set = [] + for row in rows { + // A view that no longer compiles yields error rows with no name. + guard let name = row.cell(0) else { continue } + let server = row.cell(2) + if let schema = row.cell(4), let table = row.cell(5) { + let base = BaseTable(server: server, database: row.cell(3), schema: schema, name: table) + if !baseTables.contains(base) { baseTables.append(base) } + } + guard row.cell(1) != "1" else { continue } + let baseColumn = server == nil ? row.cell(6) : nil + let acceptsWrites = row.cell(7) == "1" + columns.append(ViewColumn(name: name, baseColumn: baseColumn, acceptsWrites: acceptsWrites)) + if acceptsWrites, let baseColumn, probedBaseColumns.insert(baseColumn).inserted { + writableColumns.append(name) + } + } + self.columns = columns + self.baseTables = baseTables + self.writableColumns = writableColumns + } + + public var source: Source { + guard baseTables.count == 1, let table = baseTables.first else { + return .refused(baseTables.isEmpty ? .notUpdatable : .readsSeveralTables) + } + // Its key and columns live in another server's catalog. + return table.server == nil ? .table(table) : .refused(.baseTableHidden) + } } - /// Rows of `columnSourcesQuery`: name, source database, schema, table and column. - public static func summarize(rows: [[String?]]) -> Summary { - var baseTables: [BaseTable] = [] - var columns: [(viewColumn: String, table: BaseTable, column: String)] = [] - for row in rows { - guard row.count >= 5, let name = row[0], let database = row[1], - let schema = row[2], let table = row[3], let column = row[4] + // MARK: - Operations + + public static func probesQuery(schema: String, view: String, writableColumns: [String]) -> String { + let target = MSSQLSchemaQueries.bracketed(schema: schema, table: view) + var probes: [(Operation, String)] = [(.delete, "DELETE FROM \(target) WHERE 1 = 0")] + if let first = writableColumns.first.map(bracketedColumn) { + let list = writableColumns.map(bracketedColumn).joined(separator: ", ") + probes.append((.update, "UPDATE \(target) SET \(first) = \(first) WHERE 1 = 0")) + probes.append((.insert, "INSERT INTO \(target) (\(list)) SELECT \(list) FROM \(target) WHERE 1 = 0")) + } + let values = probes + .map { "('\($0.0.rawValue)', \(MSSQLStringLiteral.quoted($0.1)))" } + .joined(separator: ",\n ") + return """ + SELECT p.operation, r.error_number + FROM (VALUES + \(values) + ) AS p(operation, statement) + CROSS APPLY sys.dm_exec_describe_first_result_set(p.statement, NULL, 0) r + WHERE r.error_number IS NOT NULL + """ + } + + public static func allowedOperations(writableColumns: [String], probeRows: [[String?]]) -> Set { + var allowed: Set = writableColumns.isEmpty ? [.delete] : Set(Operation.allCases) + for row in probeRows { + guard let operation = row.cell(0).flatMap(Operation.init(rawValue:)), + let number = row.cell(1).flatMap({ Int($0) }), + structuralErrors.contains(number) else { continue } - let base = BaseTable(database: database, schema: schema, name: table) - if !baseTables.contains(base) { baseTables.append(base) } - columns.append((name, base, column)) + allowed.remove(operation) } - let origins = baseTables.count == 1 - ? columns.map { Origin(viewColumn: $0.viewColumn, baseColumn: $0.column) } - : [] - return Summary(baseTables: baseTables, origins: origins) + return allowed + } + + // 11501 marks any compile error. 229 and 230 come with 11529 and wait for the save, as on a table. + static let structuralErrors: Set = [ + 208, // invalid object name + 264, // a column assigned twice + 271, // computed column + 272, // timestamp column + 4_403, // aggregate, DISTINCT, GROUP BY, PIVOT + 4_405, // the statement touches several base tables + 4_406, // derived or constant column + 4_426, // UNION + 8_102, // identity column + 11_501, // the batch has compile errors + 13_537, // GENERATED ALWAYS column + ] + + // MARK: - Base table + + // Read in the base table's own database. sys.tables skips a view that browse mode stopped at. + public static func baseColumnsQuery(_ table: BaseTable) -> String { + let catalog = table.database.map { "[\(MSSQLSchemaQueries.escapeBracket($0))]." } ?? "" + let object = MSSQLStringLiteral.quoted( + catalog + MSSQLSchemaQueries.bracketed(schema: table.schema, table: table.name) + ) + return """ + SELECT + c.name, + c.is_nullable, + d.definition, + CASE WHEN c.default_object_id <> 0 THEN 1 ELSE 0 END AS has_default, + c.is_identity, + c.is_computed, + CASE + WHEN TYPE_NAME(c.system_type_id) = 'timestamp' THEN 1 + WHEN c.generated_always_type > 0 THEN 1 + ELSE 0 + END AS is_server_generated, + ic.key_ordinal + FROM \(catalog)sys.columns c + JOIN \(catalog)sys.tables t ON t.object_id = c.object_id + LEFT JOIN \(catalog)sys.default_constraints d ON d.object_id = c.default_object_id + LEFT JOIN \(catalog)sys.indexes i ON i.object_id = c.object_id AND i.is_primary_key = 1 + LEFT JOIN \(catalog)sys.index_columns ic + ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.column_id = c.column_id + WHERE c.object_id = OBJECT_ID(\(object)) + ORDER BY c.column_id + """ + } + + public struct BaseFacts: Equatable, Sendable { + public let keyColumns: [String] + public let columns: [BaseColumn] + + public init(keyColumns: [String], columns: [BaseColumn]) { + self.keyColumns = keyColumns + self.columns = columns + } + + // Nil when no row comes back: OBJECT_ID is NULL for a table the user has no permission on. + public init?(rows: [[String?]]) { + var columns: [BaseColumn] = [] + var keyed: [(ordinal: Int, name: String)] = [] + for row in rows { + guard let name = row.cell(0) else { continue } + columns.append(BaseColumn( + name: name, + isNullable: row.cell(1) == "1", + defaultValue: row.cell(2), + hasDefault: row.cell(3) == "1", + isIdentity: row.cell(4) == "1", + isComputed: row.cell(5) == "1", + isServerGenerated: row.cell(6) == "1" + )) + if let ordinal = row.cell(7).flatMap({ Int($0) }) { + keyed.append((ordinal, name)) + } + } + guard !columns.isEmpty else { return nil } + self.init(keyColumns: keyed.sorted { $0.ordinal < $1.ordinal }.map(\.name), columns: columns) + } + } + + private static func bracketedColumn(_ column: String) -> String { + "[\(MSSQLSchemaQueries.escapeBracket(column))]" + } +} + +private extension Array where Element == String? { + func cell(_ index: Int) -> String? { + indices.contains(index) ? self[index] : nil } } diff --git a/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift b/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift index 4ef4d2170d..85d6e36f77 100644 --- a/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift @@ -2,63 +2,413 @@ import Foundation @testable import TableProMSSQLCore import Testing -/// Rows are what SQL Server 2022 returns from `sys.dm_exec_describe_first_result_set` for the views named. +// Every row below is what SQL Server 2022 (16.0.4305.5) returned for the query and view named beside it. +private enum Rows { + // v: SELECT id, name, qty, req, qty + 1 AS q2, comp, ident FROM t; t also has a rowversion rv. + static let browseV: [[String?]] = [ + ["id", "0", nil, "implc", "dbo", "t", "id", "1"], + ["name", "0", nil, "implc", "dbo", "t", "name", "1"], + ["qty", "0", nil, "implc", "dbo", "t", "qty", "1"], + ["req", "0", nil, "implc", "dbo", "t", "req", "1"], + ["q2", "0", nil, nil, nil, nil, nil, "0"], + ["comp", "0", nil, "implc", "dbo", "t", "comp", "0"], + ["ident", "0", nil, "implc", "dbo", "t", "ident", "0"], + ["rv", "1", nil, "implc", "dbo", "t", "rv", "0"], + ] + + // t (id PK, name NULL, qty NOT NULL DEFAULT 0, req NOT NULL, comp AS qty * 2, ident IDENTITY, rv rowversion) + static let baseT: [[String?]] = [ + ["id", "0", nil, "0", "0", "0", "0", "1"], + ["name", "1", nil, "0", "0", "0", "0", nil], + ["qty", "0", "((0))", "1", "0", "0", "0", nil], + ["req", "0", nil, "0", "0", "0", "0", nil], + ["comp", "1", nil, "0", "0", "1", "0", nil], + ["ident", "0", nil, "0", "1", "0", "0", nil], + ["rv", "0", nil, "0", "0", "0", "1", nil], + ] + + // v_join: SELECT t.id, t.name, t2.note FROM t JOIN t2 ON t2.t_id = t.id + static let browseJoin: [[String?]] = [ + ["id", "0", nil, "implc", "dbo", "t", "id", "1"], + ["name", "0", nil, "implc", "dbo", "t", "name", "1"], + ["note", "0", nil, "implc", "dbo", "t2", "note", "1"], + ["rv", "1", nil, "implc", "dbo", "t", "rv", "0"], + ["id", "1", nil, "implc", "dbo", "t2", "id", "1"], + ] + + // v_join_one: SELECT t.id, t.name FROM t JOIN t2 ON t2.t_id = t.id; only the hidden key names t2. + static let browseJoinShowingOneTable: [[String?]] = [ + ["id", "0", nil, "implc", "dbo", "t", "id", "1"], + ["name", "0", nil, "implc", "dbo", "t", "name", "1"], + ["rv", "1", nil, "implc", "dbo", "t", "rv", "0"], + ["id", "1", nil, "implc", "dbo", "t2", "id", "1"], + ] + + // v_ck_partial: SELECT a, c FROM ck, where ck has PRIMARY KEY (b, a). + static let browseKeyPartlyShown: [[String?]] = [ + ["a", "0", nil, "implc", "dbo", "ck", "a", "1"], + ["c", "0", nil, "implc", "dbo", "ck", "c", "1"], + ["b", "1", nil, "implc", "dbo", "ck", "b", "1"], + ] + + static let baseCompositeKey: [[String?]] = [ + ["a", "0", nil, "0", "0", "0", "0", "2"], + ["b", "0", nil, "0", "0", "0", "0", "1"], + ["c", "1", nil, "0", "0", "0", "0", nil], + ] + + // v_agg: SELECT name, count(*) AS n FROM t GROUP BY name + static let browseAggregate: [[String?]] = [ + ["name", "0", nil, "implc", "dbo", "t", "name", "1"], + ["n", "0", nil, nil, nil, nil, nil, "1"], + ] + + // v_union: SELECT id, name FROM t UNION ALL SELECT id, name FROM t + static let browseUnion: [[String?]] = [ + ["id", "0", nil, "implc", "dbo", "v_union", "id", "1"], + ["name", "0", nil, "implc", "dbo", "v_union", "name", "1"], + ] + + // v_twice: SELECT id, name, name AS name2, req FROM t + static let browseColumnShownTwice: [[String?]] = [ + ["id", "0", nil, "implc", "dbo", "t", "id", "1"], + ["name", "0", nil, "implc", "dbo", "t", "name", "1"], + ["name2", "0", nil, "implc", "dbo", "t", "name", "1"], + ["req", "0", nil, "implc", "dbo", "t", "req", "1"], + ["rv", "1", nil, "implc", "dbo", "t", "rv", "0"], + ] + + // v_cross: SELECT id, code, note FROM implc_arch.dbo.t + static let browseOtherDatabase: [[String?]] = [ + ["id", "0", nil, "implc_arch", "dbo", "t", "id", "1"], + ["code", "0", nil, "implc_arch", "dbo", "t", "code", "1"], + ["note", "0", nil, "implc_arch", "dbo", "t", "note", "1"], + ] + + // v_linked: SELECT id, code, note FROM implc_loop.implc_arch.dbo.t over a linked server. + static let browseLinkedServer: [[String?]] = [ + ["id", "0", "implc_loop", "implc_arch", "dbo", "t", "id", "1"], + ["code", "0", "implc_loop", "implc_arch", "dbo", "t", "code", "1"], + ["note", "0", "implc_loop", "implc_arch", "dbo", "t", "note", "1"], + ] + + // v_broken: its base table was dropped; the describe answers errors 208, 4413 and 11501. + static let browseBroken: [[String?]] = [ + [nil, nil, nil, nil, nil, nil, nil, nil], + [nil, nil, nil, nil, nil, nil, nil, nil], + [nil, nil, nil, nil, nil, nil, nil, nil], + ] + + // tmp: a system-versioned table with GENERATED ALWAYS period columns vf and vt. + static let baseTemporal: [[String?]] = [ + ["id", "0", nil, "0", "0", "0", "0", "1"], + ["name", "0", nil, "0", "0", "0", "0", nil], + ["vf", "0", nil, "0", "0", "0", "1", nil], + ["vt", "0", nil, "0", "0", "0", "1", nil], + ] + + // heap: no primary key. + static let baseHeap: [[String?]] = [ + ["a", "1", nil, "0", "0", "0", "0", nil], + ["b", "1", nil, "0", "0", "0", "0", nil], + ] + + // td.created NOT NULL DEFAULT SYSDATETIME(), read by sa and by a db_datareader without VIEW DEFINITION. + static let baseDefaultReadable: [[String?]] = [ + ["id", "0", nil, "0", "0", "0", "0", "1"], + ["name", "1", nil, "0", "0", "0", "0", nil], + ["created", "0", "(sysdatetime())", "1", "0", "0", "0", nil], + ] + + static let baseDefaultHidden: [[String?]] = [ + ["id", "0", nil, "0", "0", "0", "0", "1"], + ["name", "1", nil, "0", "0", "0", "0", nil], + ["created", "0", nil, "1", "0", "0", "0", nil], + ] +} + +private func table(_ database: String, _ name: String, server: String? = nil) -> MSSQLViewSources.BaseTable { + MSSQLViewSources.BaseTable(server: server, database: database, schema: "dbo", name: name) +} + struct MSSQLViewSourcesTests { - @Test("A view over one table maps each shown column to its base column, skipping computed ones") - func oneTable() { - let summary = MSSQLViewSources.summarize(rows: [ - ["key_id", "shop", "dbo", "t", "id"], - ["name", "shop", "dbo", "t", "name"], - ["q2", "shop", nil, nil, nil], - ]) - #expect(summary.baseTables == [.init(database: "shop", schema: "dbo", name: "t")]) - #expect(summary.origins.map { $0.viewColumn } == ["key_id", "name"]) - #expect(summary.origins.map { $0.baseColumn } == ["id", "name"]) + @Test("A view created WITH VIEW_METADATA hides its base table") + func viewMetadataHidesBase() throws { + let facts = try #require(MSSQLViewSources.ViewFacts(rows: [["1", "0"]])) + #expect(facts.refusal == .baseTableHidden) } - @Test("A join view names both tables and maps no column") - func join() { - let summary = MSSQLViewSources.summarize(rows: [ - ["id", "shop", "dbo", "t", "id"], - ["tid", "shop", "dbo", "u", "tid"], - ]) - #expect(summary.baseTables.count == 2) - #expect(summary.origins.isEmpty) + @Test("An enabled INSTEAD OF trigger on the view or a view under it makes writes run triggers") + func insteadOfTrigger() throws { + let facts = try #require(MSSQLViewSources.ViewFacts(rows: [["0", "1"]])) + #expect(facts.refusal == .writesRunTriggers) + } + + @Test("A plain view passes the catalog check, and a name that is no view answers nothing") + func plainViewAndMissingView() throws { + let facts = try #require(MSSQLViewSources.ViewFacts(rows: [["0", "0"]])) + #expect(facts.refusal == nil) + #expect(MSSQLViewSources.ViewFacts(rows: []) == nil) } - @Test("The same table name in two databases is two tables") - func sameNameInTwoDatabases() { - let summary = MSSQLViewSources.summarize(rows: [ - ["id", "shop", "dbo", "t", "id"], - ["code", "archive", "dbo", "t", "code"], + @Test("Browse mode maps shown columns to base columns and leaves computed ones without one") + func browseOneTable() { + let browse = MSSQLViewSources.BrowseFacts(rows: Rows.browseV) + #expect(browse.source == .table(table("implc", "t"))) + #expect(browse.columns == [ + .init(name: "id", baseColumn: "id", acceptsWrites: true), + .init(name: "name", baseColumn: "name", acceptsWrites: true), + .init(name: "qty", baseColumn: "qty", acceptsWrites: true), + .init(name: "req", baseColumn: "req", acceptsWrites: true), + .init(name: "q2", baseColumn: nil, acceptsWrites: false), + .init(name: "comp", baseColumn: "comp", acceptsWrites: false), + .init(name: "ident", baseColumn: "ident", acceptsWrites: false), ]) - #expect(summary.baseTables.count == 2) - } - - @Test("A join counts as several tables even when it shows one table's columns") - func joinShowingOneSide() { - let summary = MSSQLViewSources.summarize(rows: [["id", "shop", "dbo", "t", "id"]]) - #expect(MSSQLViewSources.baseTableCount(summary, compileErrors: []) == 1) - #expect(MSSQLViewSources.baseTableCount(summary, compileErrors: [4_405, 11_501]) == 2) - } - - @Test("Only errors other than the multi-table one make a view not updatable") - func compileErrors() { - #expect(MSSQLViewSources.isUpdatable(compileErrors: [])) - #expect(MSSQLViewSources.isUpdatable(compileErrors: [4_405, 11_501])) - #expect(!MSSQLViewSources.isUpdatable(compileErrors: [4_403, 11_501])) - } - - @Test("The probes bracket the names and carry them as one Unicode literal") - func queries() { - let errors = MSSQLViewSources.compileErrorsQuery(schema: "dbo", view: "o'v]x") - #expect(errors.contains("N'DELETE FROM [dbo].[o''v]]x] WHERE 1 = 0'")) - let sources = MSSQLViewSources.columnSourcesQuery(schema: "dbo", view: "v") - #expect(sources.contains("N'SELECT * FROM [dbo].[v]', NULL, 1")) - #expect(sources.contains("is_hidden = 0 AND source_server IS NULL")) - let key = MSSQLViewSources.primaryKeyQuery(.init(database: "arch]ive", schema: "dbo", name: "t")) - #expect(key.contains("OBJECT_ID(N'[arch]]ive].[dbo].[t]')")) - #expect(key.contains("FROM [arch]]ive].sys.indexes i")) - #expect(key.contains("i.is_primary_key = 1")) + #expect(browse.writableColumns == ["id", "name", "qty", "req"]) + } + + @Test("A join reads several tables") + func browseJoin() { + #expect(MSSQLViewSources.BrowseFacts(rows: Rows.browseJoin).source == .refused(.readsSeveralTables)) + } + + @Test("A hidden key column from another table counts that table too") + func browseHiddenKeyOfAnotherTable() { + let browse = MSSQLViewSources.BrowseFacts(rows: Rows.browseJoinShowingOneTable) + #expect(browse.source == .refused(.readsSeveralTables)) + #expect(browse.columns.map { $0.name } == ["id", "name"]) + } + + @Test("A hidden key column of the same table is not shown and keeps one base table") + func browseHiddenKeyOfSameTable() { + let browse = MSSQLViewSources.BrowseFacts(rows: Rows.browseKeyPartlyShown) + #expect(browse.source == .table(table("implc", "ck"))) + #expect(browse.columns.map { $0.name } == ["a", "c"]) + } + + @Test("A column with no source is never probed, even when browse mode calls it updatable") + func browseAggregate() { + let browse = MSSQLViewSources.BrowseFacts(rows: Rows.browseAggregate) + #expect(browse.columns.last == MSSQLViewSources.ViewColumn(name: "n", baseColumn: nil, acceptsWrites: true)) + #expect(browse.writableColumns == ["name"]) + } + + @Test("A second column over one base column is not probed") + func browseColumnShownTwice() { + let browse = MSSQLViewSources.BrowseFacts(rows: Rows.browseColumnShownTwice) + #expect(browse.writableColumns == ["id", "name", "req"]) + #expect(browse.columns.map { $0.name } == ["id", "name", "name2", "req"]) + } + + @Test("A base table in another database keeps that database") + func browseOtherDatabase() { + let browse = MSSQLViewSources.BrowseFacts(rows: Rows.browseOtherDatabase) + #expect(browse.source == .table(table("implc_arch", "t"))) + } + + @Test("A base table on a linked server is hidden and its columns map to no base column") + func browseLinkedServer() { + let browse = MSSQLViewSources.BrowseFacts(rows: Rows.browseLinkedServer) + #expect(browse.source == .refused(.baseTableHidden)) + #expect(browse.columns.allSatisfy { $0.baseColumn == nil }) + #expect(browse.writableColumns.isEmpty) + } + + @Test("A view whose describe fails reads no table") + func browseBroken() { + let browse = MSSQLViewSources.BrowseFacts(rows: Rows.browseBroken) + #expect(browse.source == .refused(.notUpdatable)) + #expect(browse.columns.isEmpty) + } + + @Test("A UNION view names itself, so its probes decide") + func browseUnionThenProbes() { + let browse = MSSQLViewSources.BrowseFacts(rows: Rows.browseUnion) + #expect(browse.source == .table(table("implc", "v_union"))) + let operations = MSSQLViewSources.allowedOperations( + writableColumns: browse.writableColumns, + probeRows: [ + ["delete", "4426"], ["delete", "11501"], + ["update", "4406"], ["update", "11501"], + ["insert", "4406"], ["insert", "11501"], + ] + ) + #expect(operations.isEmpty) + } + + @Test("No probe error allows every operation") + func probesClean() { + let operations = MSSQLViewSources.allowedOperations(writableColumns: ["id"], probeRows: []) + #expect(operations == [.update, .insert, .delete]) + } + + @Test("An aggregate view refuses every operation") + func probesAggregate() { + let operations = MSSQLViewSources.allowedOperations( + writableColumns: ["name"], + probeRows: [ + ["delete", "4403"], ["delete", "11501"], + ["update", "4403"], ["update", "11501"], + ["insert", "4403"], ["insert", "11501"], + ] + ) + #expect(operations.isEmpty) + } + + @Test("A join refuses only the DELETE") + func probesJoinDelete() { + let operations = MSSQLViewSources.allowedOperations( + writableColumns: ["id", "name"], + probeRows: [["delete", "4405"], ["delete", "11501"]] + ) + #expect(operations == [.update, .insert]) + } + + @Test( + "A column-level compile error removes the operation", + arguments: [271, 272, 264, 4_406, 8_102, 13_537] + ) + func probesColumnError(errorNumber: Int) { + let operations = MSSQLViewSources.allowedOperations( + writableColumns: ["c"], + probeRows: [["update", String(errorNumber)], ["update", "11501"]] + ) + #expect(operations == [.insert, .delete]) + } + + @Test("A compile error not listed still refuses through 11501") + func probesUnlistedCompileError() { + let operations = MSSQLViewSources.allowedOperations( + writableColumns: ["id"], + probeRows: [["delete", "4413"], ["delete", "11501"]] + ) + #expect(operations == [.update, .insert]) + } + + @Test("Missing permissions are left to the save, as for a table") + func probesPermissionDenied() { + let noDeleteOrInsert = MSSQLViewSources.allowedOperations( + writableColumns: ["id", "name", "qty", "req"], + probeRows: [["delete", "229"], ["delete", "11529"], ["insert", "229"], ["insert", "11529"]] + ) + #expect(noDeleteOrInsert == [.update, .insert, .delete]) + let columnGrant = MSSQLViewSources.allowedOperations( + writableColumns: ["id", "name"], + probeRows: [["update", "230"], ["update", "11529"]] + ) + #expect(columnGrant == [.update, .insert, .delete]) + } + + @Test("A base table trigger that uses a temp table blocks no operation") + func probesTriggerTempTable() { + let operations = MSSQLViewSources.allowedOperations( + writableColumns: ["id"], + probeRows: [["update", "11526"], ["delete", "11526"], ["insert", "11526"]] + ) + #expect(operations == [.update, .insert, .delete]) + } + + @Test("With no writable column only DELETE is probed and allowed") + func probesWithoutWritableColumn() { + #expect(MSSQLViewSources.allowedOperations(writableColumns: [], probeRows: []) == [.delete]) + let query = MSSQLViewSources.probesQuery(schema: "dbo", view: "v", writableColumns: []) + #expect(query.contains("('delete', N'DELETE FROM [dbo].[v] WHERE 1 = 0')")) + #expect(!query.contains("UPDATE")) + #expect(!query.contains("INSERT")) + } + + @Test("Base columns carry the key in key order, defaults, and what the server fills") + func baseColumns() throws { + let base = try #require(MSSQLViewSources.BaseFacts(rows: Rows.baseT)) + #expect(base.keyColumns == ["id"]) + let byName = Dictionary(uniqueKeysWithValues: base.columns.map { ($0.name, $0) }) + let qty = try #require(byName["qty"]) + #expect(qty.defaultValue == "((0))") + #expect(!qty.assignsValue && qty.acceptsWrites) + let comp = try #require(byName["comp"]) + #expect(!comp.acceptsWrites && !comp.assignsValue) + let ident = try #require(byName["ident"]) + #expect(ident.assignsValue && !ident.acceptsWrites) + let rowversion = try #require(byName["rv"]) + #expect(rowversion.assignsValue && !rowversion.acceptsWrites) + let req = try #require(byName["req"]) + #expect(!req.isNullable && req.defaultValue == nil && !req.assignsValue && req.acceptsWrites) + } + + @Test("A composite key follows key_ordinal, not column order") + func baseCompositeKey() throws { + let base = try #require(MSSQLViewSources.BaseFacts(rows: Rows.baseCompositeKey)) + #expect(base.keyColumns == ["b", "a"]) + } + + @Test("A table without a primary key has no key columns") + func baseHeap() throws { + let base = try #require(MSSQLViewSources.BaseFacts(rows: Rows.baseHeap)) + #expect(base.keyColumns.isEmpty) + #expect(base.columns.count == 2) + } + + @Test("GENERATED ALWAYS period columns are filled by the server and refuse writes") + func baseTemporal() throws { + let base = try #require(MSSQLViewSources.BaseFacts(rows: Rows.baseTemporal)) + let period = base.columns.filter { $0.name == "vf" || $0.name == "vt" } + #expect(period.count == 2) + #expect(period.allSatisfy { $0.assignsValue && !$0.acceptsWrites }) + } + + @Test("A default whose text needs VIEW DEFINITION still fills the column") + func baseHiddenDefault() throws { + let readable = try #require(MSSQLViewSources.BaseFacts(rows: Rows.baseDefaultReadable)) + let shown = try #require(readable.columns.last) + #expect(shown.defaultValue == "(sysdatetime())" && !shown.assignsValue) + let hidden = try #require(MSSQLViewSources.BaseFacts(rows: Rows.baseDefaultHidden)) + let unread = try #require(hidden.columns.last) + #expect(unread.defaultValue == nil && unread.hasDefault) + #expect(unread.assignsValue && unread.acceptsWrites) + } + + @Test("No base row means the user cannot see the base table") + func baseHidden() { + #expect(MSSQLViewSources.BaseFacts(rows: []) == nil) + } + + @Test("The catalog query brackets the name and carries it as one Unicode literal") + func viewFactsQueryQuoting() { + let query = MSSQLViewSources.viewFactsQuery(schema: "s'q]x", view: "o'v]x") + #expect(query.contains("DECLARE @view int = OBJECT_ID(N'[s''q]]x].[o''v]]x]');")) + #expect(query.contains("tr.is_instead_of_trigger = 1 AND tr.is_disabled = 0")) + #expect(query.contains("WHERE c.depth < 8")) + #expect(query.contains("IF HAS_PERMS_BY_NAME(N'sys.sql_expression_dependencies', 'OBJECT', 'SELECT') = 1")) + } + + @Test("The browse query describes SELECT * in mode 1") + func browseQueryQuoting() { + let query = MSSQLViewSources.browseQuery(schema: "s'q]x", view: "o'v]x") + #expect(query.contains("sys.dm_exec_describe_first_result_set(N'SELECT * FROM [s''q]]x].[o''v]]x]', NULL, 1)")) + } + + @Test("The probes bracket every name and escape it inside the literal") + func probesQueryQuoting() { + let query = MSSQLViewSources.probesQuery(schema: "s'q]x", view: "o'v]x", writableColumns: ["i'd", "n]m"]) + #expect(query.contains("('delete', N'DELETE FROM [s''q]]x].[o''v]]x] WHERE 1 = 0')")) + #expect(query.contains("('update', N'UPDATE [s''q]]x].[o''v]]x] SET [i''d] = [i''d] WHERE 1 = 0')")) + #expect(query.contains( + "('insert', N'INSERT INTO [s''q]]x].[o''v]]x] ([i''d], [n]]m]) " + + "SELECT [i''d], [n]]m] FROM [s''q]]x].[o''v]]x] WHERE 1 = 0')" + )) + #expect(query.contains("sys.dm_exec_describe_first_result_set(p.statement, NULL, 0)")) + } + + @Test("The base query reads the base table's own database") + func baseColumnsQueryQuoting() { + let query = MSSQLViewSources.baseColumnsQuery( + .init(database: "arch]ive", schema: "s'q]x", name: "t'b]l") + ) + #expect(query.contains("FROM [arch]]ive].sys.columns c")) + #expect(query.contains("JOIN [arch]]ive].sys.tables t")) + #expect(query.contains("WHERE c.object_id = OBJECT_ID(N'[arch]]ive].[s''q]]x].[t''b]]l]')")) + let current = MSSQLViewSources.baseColumnsQuery(.init(database: nil, schema: "dbo", name: "t")) + #expect(current.contains("FROM sys.columns c")) + #expect(current.contains("OBJECT_ID(N'[dbo].[t]')")) } } diff --git a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginViewRowEditingTests.swift b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginViewRowEditingTests.swift index 1a7873693f..b377bc544e 100644 --- a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginViewRowEditingTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginViewRowEditingTests.swift @@ -3,82 +3,136 @@ import TableProPluginKit import Testing struct PluginViewRowEditingTests { - private let simpleOrigins = [ - PluginViewColumnOrigin(viewColumn: "id", baseColumn: "id"), - PluginViewColumnOrigin(viewColumn: "name", baseColumn: "name"), - ] - - @Test("An updatable view over one table is keyed on the view columns that show the table's key") - func updatableViewIsEditable() { - let editing = PluginViewRowEditing.resolve( - isUpdatable: true, baseTableCount: 1, baseKeyColumns: ["id"], origins: simpleOrigins + private let table = PluginViewBaseTable(schema: "public", name: "t") + + private func column(_ name: String, _ base: String?, writable: Bool = true) -> PluginViewColumn { + PluginViewColumn(name: name, baseColumn: base, acceptsWrites: writable) + } + + private func base( + _ name: String, + nullable: Bool = true, + defaultValue: String? = nil, + assigns: Bool = false, + writable: Bool = true + ) -> PluginViewBaseColumn { + PluginViewBaseColumn( + name: name, isNullable: nullable, defaultValue: defaultValue, assignsValue: assigns, acceptsWrites: writable + ) + } + + private func resolve( + operations: PluginViewWriteOperations = .all, + columns: [PluginViewColumn], + keys: [String]? = ["id"], + baseColumns: [PluginViewBaseColumn]? = nil + ) -> PluginViewRowEditing { + PluginViewRowEditing.resolve( + operations: operations, baseTable: table, columns: columns, baseKeyColumns: keys, baseColumns: baseColumns ) - #expect(editing == .editable(keyColumns: ["id"])) } - @Test("A renamed key column keys the edit by its view name") - func renamedKeyUsesViewName() { - let origins = [ - PluginViewColumnOrigin(viewColumn: "order_id", baseColumn: "id"), - PluginViewColumnOrigin(viewColumn: "line", baseColumn: "line_no"), - ] - let editing = PluginViewRowEditing.resolve( - isUpdatable: true, baseTableCount: 1, baseKeyColumns: ["id", "line_no"], origins: origins + @Test("A view over one table that shows its key is keyed on the view columns showing it") + func renamedKeyUsesViewNames() throws { + let answer = resolve( + columns: [column("order_id", "id"), column("line", "line_no"), column("label", "name")], + keys: ["id", "line_no"], + baseColumns: [base("id", nullable: false), base("line_no", nullable: false), base("name")] ) - #expect(editing == .editable(keyColumns: ["order_id", "line"])) - #expect(editing.keyColumns == ["order_id", "line"]) + let target = try #require(answer.writeTarget) + #expect(target.keyColumns == ["order_id", "line"]) + #expect(target.baseKeyColumns == ["id", "line_no"]) + #expect(target.baseColumnNames == ["order_id": "id", "line": "line_no", "label": "name"]) + #expect(target.operations == .all) + #expect(target.readOnlyColumns.isEmpty) } @Test("A view column named like the key but showing another column is not the key") func swappedNamesAreNotTheKey() { - let origins = [ - PluginViewColumnOrigin(viewColumn: "id", baseColumn: "name"), - PluginViewColumnOrigin(viewColumn: "name", baseColumn: "id"), - ] - let editing = PluginViewRowEditing.resolve( - isUpdatable: true, baseTableCount: 1, baseKeyColumns: ["id"], origins: origins - ) - #expect(editing == .editable(keyColumns: ["name"])) + let answer = resolve(columns: [column("id", "name"), column("name", "code")]) + #expect(answer == .readOnly(.keyNotExposed)) } - @Test("The server's answer wins over everything else") - func notUpdatableStaysReadOnly() { - let editing = PluginViewRowEditing.resolve( - isUpdatable: false, baseTableCount: 1, baseKeyColumns: ["id"], origins: simpleOrigins - ) - #expect(editing == .readOnly(.notUpdatable)) - #expect(editing.keyColumns == nil) + @Test("Without UPDATE the view is not updatable, whatever else the server allows") + func updateIsRequired() { + #expect(resolve(operations: [.insert, .delete], columns: [column("id", "id")]) == .readOnly(.notUpdatable)) } - @Test("A view with no column from any table is not updatable") - func noBaseTableIsNotUpdatable() { - let editing = PluginViewRowEditing.resolve( - isUpdatable: true, baseTableCount: 0, baseKeyColumns: [], origins: [] - ) - #expect(editing == .readOnly(.notUpdatable)) + @Test("A key the user cannot read hides the table, an empty one means it has no key") + func baseKeyReasons() { + #expect(resolve(columns: [column("id", "id")], keys: nil) == .readOnly(.baseTableHidden)) + #expect(resolve(columns: [column("id", "id")], keys: []) == .readOnly(.baseTableHasNoKey)) } - @Test("A view that reads several tables stays read-only") - func severalTablesStayReadOnly() { - let editing = PluginViewRowEditing.resolve( - isUpdatable: true, baseTableCount: 2, baseKeyColumns: ["id"], origins: simpleOrigins + @Test("Computed, server-refused and server-owned columns are read-only") + func readOnlyColumns() throws { + let answer = resolve( + columns: [ + column("id", "id"), column("q2", nil, writable: false), column("gen", "gen"), + column("ident", "ident"), column("frozen", "name", writable: false), + ], + baseColumns: [ + base("id", nullable: false), base("gen", writable: false), + base("ident", nullable: false, assigns: true, writable: false), base("name"), + ] ) - #expect(editing == .readOnly(.readsSeveralTables)) + let target = try #require(answer.writeTarget) + #expect(target.readOnlyColumns == ["q2", "gen", "ident", "frozen"]) + #expect(target.baseColumns["ident"]?.assignsValue == true) + #expect(target.baseColumns["q2"] == nil) } - @Test("A view over a table without a primary key stays read-only") - func keylessTableStaysReadOnly() { - let editing = PluginViewRowEditing.resolve( - isUpdatable: true, baseTableCount: 1, baseKeyColumns: [], origins: simpleOrigins - ) - #expect(editing == .readOnly(.baseTableHasNoKey)) + @Test("A base column shown twice is written through its first showing only") + func duplicateShowingIsReadOnly() throws { + let answer = resolve(columns: [column("id", "id"), column("id_again", "id")], baseColumns: [base("id")]) + let target = try #require(answer.writeTarget) + #expect(target.keyColumns == ["id"]) + #expect(target.readOnlyColumns == ["id_again"]) } - @Test("A view missing one column of a composite key stays read-only") - func partialKeyStaysReadOnly() { - let editing = PluginViewRowEditing.resolve( - isUpdatable: true, baseTableCount: 1, baseKeyColumns: ["id", "version"], origins: simpleOrigins + @Test("Insert needs every required base column shown as a writable view column") + func insertNeedsRequiredColumns() throws { + let required = base("code", nullable: false) + let shown = resolve( + columns: [column("id", "id"), column("code", "code")], + baseColumns: [base("id", nullable: false, defaultValue: "nextval('t_id_seq')"), required] ) - #expect(editing == .readOnly(.keyNotExposed)) + #expect(try #require(shown.writeTarget).operations == .all) + + let hidden = resolve( + columns: [column("id", "id")], + baseColumns: [base("id", nullable: false, assigns: true), required] + ) + #expect(try #require(hidden.writeTarget).operations == [.update, .delete]) + + let refused = resolve( + columns: [column("id", "id"), column("code", "code", writable: false)], + baseColumns: [base("id", nullable: false, assigns: true), required] + ) + #expect(try #require(refused.writeTarget).operations == [.update, .delete]) + } + + @Test("Base columns the user cannot read keep the edit and the delete but drop the insert") + func unreadableBaseColumnsDropInsert() throws { + let answer = resolve(columns: [column("id", "id"), column("q2", nil, writable: false)], baseColumns: nil) + let target = try #require(answer.writeTarget) + #expect(target.operations == [.update, .delete]) + #expect(target.readOnlyColumns == ["q2"]) + #expect(target.baseColumns.isEmpty) + } + + @Test("A NOT NULL column with a default, an identity or a generated value is not required on insert") + func requiredOnInsert() { + #expect(base("a", nullable: false).isRequiredOnInsert) + #expect(!base("b", nullable: false, defaultValue: "0").isRequiredOnInsert) + #expect(!base("c", nullable: false, assigns: true).isRequiredOnInsert) + #expect(!base("d", nullable: false, writable: false).isRequiredOnInsert) + #expect(!base("e").isRequiredOnInsert) + } + + @Test("The server's operations narrow what the grid offers") + func serverOperationsCarryThrough() throws { + let answer = resolve(operations: [.update], columns: [column("id", "id")], baseColumns: [base("id")]) + #expect(try #require(answer.writeTarget).operations == [.update]) } } diff --git a/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift b/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift index 5f750a05be..fbddcc066d 100644 --- a/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -59,25 +59,62 @@ extension MSSQLPluginDriver { } func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? { - let resolvedSchema = effectiveSchema(schema) - let errors = try await execute(query: MSSQLViewSources.compileErrorsQuery(schema: resolvedSchema, view: view)) - let compileErrors = errors.rows.compactMap { $0[safe: 0]?.asText.flatMap(Int.init) } - let sources = try await execute(query: MSSQLViewSources.columnSourcesQuery(schema: resolvedSchema, view: view)) - let summary = MSSQLViewSources.summarize(rows: sources.rows.map { $0.map(\.asText) }) - - var baseKeyColumns: [String] = [] - if summary.baseTables.count == 1, let baseTable = summary.baseTables.first { - let keys = try await execute(query: MSSQLViewSources.primaryKeyQuery(baseTable)) - baseKeyColumns = keys.rows.compactMap { $0[safe: 0]?.asText } + let schema = effectiveSchema(schema) + let factRows = try await viewCatalogRows(MSSQLViewSources.viewFactsQuery(schema: schema, view: view)) + guard let facts = MSSQLViewSources.ViewFacts(rows: factRows) else { return nil } + if let refusal = facts.refusal { return .readOnly(PluginViewReadOnlyReason(refusal)) } + + let browse = MSSQLViewSources.BrowseFacts( + rows: try await viewCatalogRows(MSSQLViewSources.browseQuery(schema: schema, view: view)) + ) + let baseTable: MSSQLViewSources.BaseTable + switch browse.source { + case .refused(let refusal): + return .readOnly(PluginViewReadOnlyReason(refusal)) + case .table(let table): + baseTable = table } + + let probeRows = try await viewCatalogRows(MSSQLViewSources.probesQuery( + schema: schema, + view: view, + writableColumns: browse.writableColumns + )) + let operations = MSSQLViewSources.allowedOperations( + writableColumns: browse.writableColumns, + probeRows: probeRows + ) + let base = MSSQLViewSources.BaseFacts( + rows: try await viewCatalogRows(MSSQLViewSources.baseColumnsQuery(baseTable)) + ) + return PluginViewRowEditing.resolve( - isUpdatable: MSSQLViewSources.isUpdatable(compileErrors: compileErrors), - baseTableCount: MSSQLViewSources.baseTableCount(summary, compileErrors: compileErrors), - baseKeyColumns: baseKeyColumns, - origins: summary.origins.map { PluginViewColumnOrigin(viewColumn: $0.viewColumn, baseColumn: $0.baseColumn) } + operations: PluginViewWriteOperations(operations), + baseTable: PluginViewBaseTable( + database: baseTable.database, + schema: baseTable.schema, + name: baseTable.name + ), + columns: browse.columns.map { + PluginViewColumn(name: $0.name, baseColumn: $0.baseColumn, acceptsWrites: $0.acceptsWrites) + }, + baseKeyColumns: base?.keyColumns, + baseColumns: base?.columns.map { + PluginViewBaseColumn( + name: $0.name, + isNullable: $0.isNullable, + defaultValue: $0.defaultValue, + assignsValue: $0.assignsValue, + acceptsWrites: $0.acceptsWrites + ) + } ) } + private func viewCatalogRows(_ query: String) async throws -> [[String?]] { + try await execute(query: query).rows.map { $0.map(\.asText) } + } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { /// Bracket-escaped for the identifier and literal-escaped for the string it sits in: /// SQL Server allows both `]` and `'` in an identifier. @@ -473,3 +510,27 @@ extension MSSQLColumnRow { ) } } + +private extension PluginViewReadOnlyReason { + init(_ refusal: MSSQLViewSources.Refusal) { + switch refusal { + case .notUpdatable: self = .notUpdatable + case .writesRunTriggers: self = .writesRunTriggers + case .readsSeveralTables: self = .readsSeveralTables + case .baseTableHidden: self = .baseTableHidden + } + } +} + +private extension PluginViewWriteOperations { + init(_ operations: Set) { + self = [] + for operation in operations { + switch operation { + case .update: insert(.update) + case .insert: insert(.insert) + case .delete: insert(.delete) + } + } + } +} diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift index af75efae47..2f877fb88b 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift @@ -7,29 +7,45 @@ import Foundation import TableProPluginKit extension MySQLPluginDriver { - /// MySQL and MariaDB only: TiDB, OceanBase and Databend store view definitions in other forms. + // TiDB, OceanBase and Databend store view definitions in other forms. func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? { switch flavor { case .mysql, .mariadb: break default: return nil } let escape = escapeStringLiteral - let viewResult = try await execute( - ownStatement: MySQLViewRowEditing.viewQuery(name: view, database: schema, escape: escape) - ) - guard let row = viewResult.rows.first else { return nil } - let isUpdatable = row[safe: 0]?.asText?.uppercased() == "YES" - let definition = (row[safe: 1]?.asText).flatMap(MySQLViewRowEditing.parse(definition:)) - guard isUpdatable, let baseTable = definition?.baseTable else { - return MySQLViewRowEditing.resolve(isUpdatable: isUpdatable, definition: definition, baseKeyColumns: []) + let topRows = try await catalogRows(MySQLViewRowEditing.viewQuery(database: schema, name: view, escape: escape)) + guard let top = MySQLViewRowEditing.view(rows: topRows) else { return nil } + + var levels: [MySQLViewRowEditing.Level] = [] + var next: MySQLViewRowEditing.View? = top + while let current = next { + guard levels.count < MySQLViewRowEditing.maximumDepth else { return .readOnly(.notUpdatable) } + let columnRows = try await catalogRows(MySQLViewRowEditing.columnsQuery(current.relation, escape: escape)) + switch try MySQLViewRowEditing.step(view: current, columnRows: columnRows) { + case .readOnly(let reason): + return .readOnly(reason) + case .reads(let level): + levels.append(level) + let innerRows = try await catalogRows( + MySQLViewRowEditing.viewQuery(database: level.base.database, name: level.base.name, escape: escape) + ) + next = MySQLViewRowEditing.view(rows: innerRows) + } } - let keyResult = try await execute( - ownStatement: MySQLViewRowEditing.primaryKeyQuery(table: baseTable, viewDatabase: schema, escape: escape) - ) - return MySQLViewRowEditing.resolve( - isUpdatable: isUpdatable, - definition: definition, - baseKeyColumns: keyResult.rows.compactMap { $0[safe: 0]?.asText } + + guard let base = levels.last?.base else { return .readOnly(.notUpdatable) } + let keyRows = try await catalogRows(MySQLViewRowEditing.primaryKeyQuery(base, escape: escape)) + let baseColumnRows = try await catalogRows(MySQLViewRowEditing.columnsQuery(base, escape: escape)) + return MySQLViewRowEditing.answer( + levels: levels, + keyRows: keyRows, + columnRows: baseColumnRows, + quotesDefaults: catalogQuotesDefaults ) } + + private func catalogRows(_ statement: String) async throws -> [[String?]] { + try await execute(ownStatement: statement).rows.map { $0.map(\.asText) } + } } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift b/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift index b9330b14d0..cb9207fb42 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift @@ -2,232 +2,433 @@ // MySQLViewRowEditing.swift // MySQLDriverPlugin // -// Works out whether the grid may write rows through a MySQL or MariaDB view. The server answers -// whether the view is updatable; the definition it stores names every column fully qualified, -// as in "select `db`.`t`.`id` AS `key_id` from `db`.`t`", which says which base column each view -// column shows. Pure, so it is testable without a server. -// import Foundation import TableProPluginKit enum MySQLViewRowEditing { - struct BaseTable: Equatable { - let database: String? + static let maximumDepth = 8 + + struct Relation: Hashable { + let database: String let name: String } - struct Definition: Equatable { - /// Nil when the FROM clause is anything but one table: a join, a list or a derived table. - let baseTable: BaseTable? - let origins: [PluginViewColumnOrigin] + struct View: Equatable { + let relation: Relation + let isUpdatable: Bool + let definition: String } - static func viewQuery(name: String, database: String?, escape: (String) -> String) -> String { - """ - SELECT IS_UPDATABLE, VIEW_DEFINITION FROM information_schema.VIEWS \ - WHERE TABLE_SCHEMA = \(schemaExpression(database, escape: escape)) AND TABLE_NAME = '\(escape(name))' - """ + struct Level: Equatable { + let base: Relation + let columns: [String] + // Per view column, the column of `base` it shows; nil for a computed one. + let sources: [String?] + + func source(of column: String) -> String? { + guard let index = columns.firstIndex(where: { MySQLViewRowEditing.sameName($0, column) }) else { + return nil + } + return sources[index] + } + } + + enum Step: Equatable { + case readOnly(PluginViewReadOnlyReason) + case reads(Level) + } + + enum Select: Equatable { + case oneTable(Relation, sources: [String?]) + case severalTables + case noTable + } + + struct ColumnCountMismatch: Error, Equatable { + let view: Relation + let items: Int + let columns: Int + } + + struct Token: Equatable { + enum Kind: Equatable { + case word + case quoted + case literal + case symbol + } + + let kind: Kind + let text: String + let depth: Int + + func isWord(_ word: String) -> Bool { + kind == .word && text.lowercased() == word + } + + func isWord(in words: Set) -> Bool { + kind == .word && words.contains(text.lowercased()) + } + + func isSymbol(_ symbol: String) -> Bool { + kind == .symbol && text == symbol + } + + var identifier: String? { + switch kind { + case .quoted: + return text + case .word: + guard let first = text.unicodeScalars.first, !(48...57).contains(first.value) else { return nil } + return text + case .literal, .symbol: + return nil + } + } + } + + static func viewQuery(database: String?, name: String, escape: (String) -> String) -> String { + let schema = database.map { "'\(escape($0))'" } ?? "DATABASE()" + return """ + SELECT TABLE_SCHEMA, TABLE_NAME, IS_UPDATABLE, VIEW_DEFINITION FROM information_schema.VIEWS \ + WHERE TABLE_SCHEMA = \(schema) AND TABLE_NAME = '\(escape(name))' + """ } - static func primaryKeyQuery(table: BaseTable, viewDatabase: String?, escape: (String) -> String) -> String { + static func columnsQuery(_ relation: Relation, escape: (String) -> String) -> String { """ - SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE \ - WHERE TABLE_SCHEMA = \(schemaExpression(table.database ?? viewDatabase, escape: escape)) \ - AND TABLE_NAME = '\(escape(table.name))' AND CONSTRAINT_NAME = 'PRIMARY' \ + SELECT COLUMN_NAME, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_TYPE FROM information_schema.COLUMNS \ + WHERE TABLE_SCHEMA = '\(escape(relation.database))' AND TABLE_NAME = '\(escape(relation.name))' \ ORDER BY ORDINAL_POSITION """ } - private static func schemaExpression(_ database: String?, escape: (String) -> String) -> String { - database.map { "'\(escape($0))'" } ?? "DATABASE()" + static func primaryKeyQuery(_ relation: Relation, escape: (String) -> String) -> String { + """ + SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE \ + WHERE TABLE_SCHEMA = '\(escape(relation.database))' AND TABLE_NAME = '\(escape(relation.name))' \ + AND CONSTRAINT_NAME = 'PRIMARY' ORDER BY ORDINAL_POSITION + """ } - /// A nil definition is one the server withheld or that is not a SELECT. - static func resolve(isUpdatable: Bool, definition: Definition?, baseKeyColumns: [String]) -> PluginViewRowEditing { - guard let definition else { return .readOnly(.notUpdatable) } - return PluginViewRowEditing.resolve( - isUpdatable: isUpdatable, - baseTableCount: definition.baseTable == nil ? 2 : 1, - baseKeyColumns: baseKeyColumns, - origins: definition.origins + static func view(rows: [[String?]]) -> View? { + guard let row = rows.first, let database = cell(row, 0), let name = cell(row, 1) else { return nil } + return View( + relation: Relation(database: database, name: name), + isUpdatable: cell(row, 2)?.uppercased() == "YES", + definition: cell(row, 3) ?? "" ) } - static func parse(definition text: String) -> Definition? { - let tokens = Token.tokenize(text) - guard let first = tokens.first, first.isWord("select") else { return nil } - let fromIndex = tokens.firstIndex { $0.depth == 0 && $0.isWord("from") } ?? tokens.endIndex - let baseTable = singleTable(in: Array(tokens[fromIndex...].dropFirst())) - let alias = baseTable?.alias - - var origins: [PluginViewColumnOrigin] = [] - for item in split(Array(tokens[1..= 3, - item[item.count - 2].isWord("as"), - let viewColumn = item.last?.identifier, - let reference = columnReference(Array(item.dropLast(2))), - let table = baseTable?.table - else { continue } - let qualifier = reference.qualifier - let matches: Bool - switch qualifier.count { - case 2: matches = alias == nil && qualifier[0] == table.database && qualifier[1] == table.name - case 1: matches = qualifier[0] == (alias ?? table.name) - default: matches = alias == nil - } - if matches { - origins.append(PluginViewColumnOrigin(viewColumn: viewColumn, baseColumn: reference.column)) + static func step(view: View, columnRows: [[String?]]) throws -> Step { + guard view.isUpdatable else { return .readOnly(.notUpdatable) } + // The server sends an empty definition to a user without the SHOW VIEW privilege. + guard !view.definition.isEmpty else { return .readOnly(.baseTableHidden) } + switch select(definition: view.definition) { + case .severalTables: + return .readOnly(.readsSeveralTables) + case .noTable: + return .readOnly(.notUpdatable) + case .oneTable(let base, let sources): + // By position: MySQL keeps a CREATE VIEW column list apart from the AS aliases it stores. + let columns = columnRows.compactMap { cell($0, 0) } + guard sources.count == columns.count else { + throw ColumnCountMismatch(view: view.relation, items: sources.count, columns: columns.count) } + return .reads(Level(base: base, columns: columns, sources: sources)) } - return Definition(baseTable: baseTable?.table, origins: origins) } - private static let clauseKeywords: Set = [ - "where", "group", "having", "order", "limit", "window", "union", "for", "lock", "into", - ] + static func baseColumn(row: [String?], quotesDefaults: Bool) -> PluginViewBaseColumn? { + guard let name = cell(row, 0) else { return nil } + let isNullable = cell(row, 1)?.uppercased() == "YES" + let extra = cell(row, 3) + let catalogDefault: MySQLCatalogDefault = quotesDefaults ? .quoted(cell(row, 2)) : .bare(cell(row, 2)) + return PluginViewBaseColumn( + name: name, + isNullable: isNullable, + defaultValue: mysqlColumnDefault( + catalogDefault, + extra: extra, + dataType: cell(row, 4) ?? "", + isNullable: isNullable + ), + assignsValue: mysqlIdentityKind(extra: extra) != nil, + acceptsWrites: !mysqlColumnIsGenerated(extra: extra) + ) + } - private static func singleTable(in tokens: [Token]) -> (table: BaseTable, alias: String?)? { - let clause = Array(tokens.prefix { !($0.depth == 0 && clauseKeywords.contains($0.text.lowercased())) }) - var parts: [String] = [] - var index = 0 - while index < clause.count, let name = clause[index].identifier { - parts.append(name) - guard index + 1 < clause.count, clause[index + 1].text == "." else { - index += 1 - break - } - index += 2 + static func answer( + levels: [Level], + keyRows: [[String?]], + columnRows: [[String?]], + quotesDefaults: Bool + ) -> PluginViewRowEditing { + guard let top = levels.first, let base = levels.last?.base else { return .readOnly(.notUpdatable) } + let baseColumns = columnRows.compactMap { baseColumn(row: $0, quotesDefaults: quotesDefaults) } + let isVisible = !baseColumns.isEmpty + + var sources = top.sources + for level in levels.dropFirst() { + sources = sources.map { $0.flatMap(level.source(of:)) } } - let rest = clause[index...] - guard (1...2).contains(parts.count), rest.count <= 1 else { return nil } - let alias: String? - if let aliasToken = rest.first { - guard let name = aliasToken.identifier else { return nil } - alias = name - } else { - alias = nil + if isVisible { + sources = sources.map { source in + source.flatMap { name in baseColumns.first { sameName($0.name, name) }?.name } + } } - let table = parts.count == 2 - ? BaseTable(database: parts[0], name: parts[1]) - : BaseTable(database: nil, name: parts[0]) - return (table, alias) - } - private static func columnReference(_ tokens: [Token]) -> (qualifier: [String], column: String)? { - guard tokens.count % 2 == 1, tokens.count <= 5 else { return nil } - var names: [String] = [] - for (offset, token) in tokens.enumerated() { - if offset % 2 == 1 { - guard token.text == "." else { return nil } - } else { - guard let name = token.identifier else { return nil } - names.append(name) - } + var operations: PluginViewWriteOperations = [.update, .delete] + // MySQL refuses an INSERT (1471) once a view column is not a base column or shows one twice. + let shown = sources.compactMap { $0?.lowercased() } + if shown.count == sources.count, Set(shown).count == shown.count { + operations.insert(.insert) } - guard let column = names.popLast() else { return nil } - return (names, column) + + return PluginViewRowEditing.resolve( + operations: operations, + baseTable: PluginViewBaseTable(database: base.database, name: base.name), + columns: zip(top.columns, sources).map { + PluginViewColumn(name: $0, baseColumn: $1, acceptsWrites: $1 != nil) + }, + baseKeyColumns: isVisible ? keyRows.compactMap { cell($0, 0) } : nil, + baseColumns: isVisible ? baseColumns : nil + ) } - private static func split(_ tokens: [Token], separator: String) -> [[Token]] { - var items: [[Token]] = [[]] - for token in tokens { - if token.depth == 0, token.text == separator, !token.isQuoted { - items.append([]) - } else { - items[items.count - 1].append(token) + static func select(definition: String) -> Select { + var tokens = unwrapped(tokenize(definition)) + if tokens.first?.isWord("with") == true { + guard let main = tokens.firstIndex(where: { $0.depth == 0 && $0.isWord("select") }) else { + return .noTable } + tokens = Array(tokens[main...]) } - return items.filter { !$0.isEmpty } - } -} + guard tokens.first?.isWord("select") == true else { return .noTable } -private struct Token { - let text: String - let depth: Int - let isQuoted: Bool + var listStart = 1 + while listStart < tokens.count, tokens[listStart].isWord(in: selectModifiers) { + listStart += 1 + } + guard let from = tokens[listStart...].firstIndex(where: { $0.depth == 0 && $0.isWord("from") }) else { + return .noTable + } - var identifier: String? { - if isQuoted { return text } - guard let first = text.unicodeScalars.first, - CharacterSet.letters.contains(first) || first == "_" else { return nil } - return text - } + let base: Relation + let alias: String? + switch fromClause(tokens[(from + 1)...]) { + case .severalTables: + return .severalTables + case .noTable: + return .noTable + case .table(let relation, let tableAlias): + base = relation + alias = tableAlias + } - func isWord(_ word: String) -> Bool { - !isQuoted && text.caseInsensitiveCompare(word) == .orderedSame + let sources = items(tokens[listStart.. String? in + guard let reference = columnReference(item), + namesBase(reference.qualifier, base: base, alias: alias) + else { return nil } + return reference.column + } + return .oneTable(base, sources: sources) } - /// Backquoted identifiers are kept as identifiers with their doubled backquotes undone. A - /// string literal becomes one opaque token, so a comma or keyword inside it never counts. + // Scalar by scalar: a backquote followed by a combining mark is one Character but still opens a name. static func tokenize(_ text: String) -> [Token] { + let scalars = Array(text.unicodeScalars) var tokens: [Token] = [] var depth = 0 - let characters = Array(text) var index = 0 - while index < characters.count { - let character = characters[index] - switch character { + while index < scalars.count { + let scalar = scalars[index] + switch scalar { case "`": - var name = "" + var name = String.UnicodeScalarView() index += 1 - while index < characters.count { - if characters[index] == "`" { - if index + 1 < characters.count, characters[index + 1] == "`" { - name.append("`") - index += 2 - continue - } - break + while index < scalars.count { + if scalars[index] == "`" { + guard index + 1 < scalars.count, scalars[index + 1] == "`" else { break } + index += 1 } - name.append(characters[index]) + name.append(scalars[index]) index += 1 } - tokens.append(Token(text: name, depth: depth, isQuoted: true)) index += 1 + tokens.append(Token(kind: .quoted, text: String(name), depth: depth)) case "'", "\"": + let start = index index += 1 - while index < characters.count { - if characters[index] == "\\" { + while index < scalars.count { + if scalars[index] == "\\" { index += 2 continue } - if characters[index] == character { - if index + 1 < characters.count, characters[index + 1] == character { - index += 2 - continue - } - break + if scalars[index] == scalar { + guard index + 1 < scalars.count, scalars[index + 1] == scalar else { break } + index += 1 } index += 1 } - tokens.append(Token(text: "''", depth: depth, isQuoted: false)) - index += 1 + index = min(index + 1, scalars.count) + let literal = String(String.UnicodeScalarView(scalars[start.. = [ + "all", "distinct", "distinctrow", "high_priority", "straight_join", "sql_small_result", + "sql_big_result", "sql_buffer_result", "sql_cache", "sql_no_cache", "sql_calc_found_rows", + ] + + private static let clauseEnds: Set = [ + "where", "group", "having", "order", "limit", "window", "union", "except", "intersect", + "for", "lock", "into", "procedure", + ] + + private enum FromClause { + case table(Relation, alias: String?) + case severalTables + case noTable + } + + private static func fromClause(_ tokens: ArraySlice) -> FromClause { + let clause = Array(tokens.prefix { !($0.depth == 0 && $0.isWord(in: clauseEnds)) }) + if let reference = tableReference(clause) { return reference } + return joinsTables(clause) ? .severalTables : .noTable + } + + private static func tableReference(_ clause: [Token]) -> FromClause? { + var names: [String] = [] + var index = 0 + while index < clause.count, names.count < 3, let name = clause[index].identifier { + names.append(name) + index += 1 + guard index < clause.count, clause[index].isSymbol(".") else { break } + index += 1 + } + guard (1...2).contains(names.count) else { return nil } + if index < clause.count, clause[index].isWord("as") { index += 1 } + var alias: String? + if index < clause.count { + guard let name = clause[index].identifier else { return nil } + alias = name + index += 1 + } + guard index == clause.count else { return nil } + // The server qualifies every table it stores, so a bare name is a common table expression. + guard names.count == 2 else { return .noTable } + return .table(Relation(database: names[0], name: names[1]), alias: alias) + } + + private static func joinsTables(_ clause: [Token]) -> Bool { + var subqueryDepths: [Int] = [] + var previous: Token? + for token in clause { + defer { previous = token } + if let open = previous, open.isSymbol("("), token.isWord("select") || token.isWord("with") { + subqueryDepths.append(open.depth) + } + if token.isSymbol(")"), subqueryDepths.last == token.depth { + subqueryDepths.removeLast() + continue + } + guard subqueryDepths.isEmpty else { continue } + if token.isWord("join") || token.isWord("straight_join") { return true } + if token.depth == 0, token.isSymbol(",") { return true } + } + return false + } + + private static func items(_ tokens: ArraySlice) -> [[Token]] { + var items: [[Token]] = [[]] + for token in tokens { + if token.depth == 0, token.isSymbol(",") { + items.append([]) + } else { + items[items.count - 1].append(token) + } + } + return items + } + + private static func columnReference(_ item: [Token]) -> (qualifier: [String], column: String)? { + var tokens = item[...] + if tokens.count >= 3, tokens[tokens.endIndex - 2].isWord("as"), tokens.last?.identifier != nil { + tokens = tokens.dropLast(2) + } + guard tokens.count % 2 == 1, tokens.count <= 5 else { return nil } + var names: [String] = [] + for (offset, token) in tokens.enumerated() { + if offset.isMultiple(of: 2) { + guard let name = token.identifier else { return nil } + names.append(name) + } else if !token.isSymbol(".") { + return nil + } + } + guard let column = names.popLast() else { return nil } + return (names, column) + } + + // MariaDB leaves the database out of a reference to an inner view; MySQL writes `db`.`alias` + // for an aliased one. The server qualifies every column, so a bare name is a keyword. + private static func namesBase(_ qualifier: [String], base: Relation, alias: String?) -> Bool { + let table = alias ?? base.name + switch qualifier.count { + case 1: + return sameName(qualifier[0], table) + case 2: + return sameName(qualifier[0], base.database) && sameName(qualifier[1], table) + default: + return false + } + } + + private static func unwrapped(_ tokens: [Token]) -> [Token] { + var tokens = tokens + while tokens.count >= 2, tokens[0].isSymbol("("), + tokens.dropFirst().firstIndex(where: { $0.depth == 0 && $0.isSymbol(")") }) == tokens.count - 1 { + tokens = tokens.dropFirst().dropLast().map { Token(kind: $0.kind, text: $0.text, depth: $0.depth - 1) } + } + return tokens + } + + private static func isWordScalar(_ scalar: Unicode.Scalar) -> Bool { + guard scalar.isASCII else { return !scalar.properties.isWhitespace } + return scalar.properties.isAlphabetic || (48...57).contains(scalar.value) || scalar == "_" || scalar == "$" + } + + private static func sameName(_ lhs: String, _ rhs: String) -> Bool { + lhs.caseInsensitiveCompare(rhs) == .orderedSame + } + + private static func cell(_ row: [String?], _ index: Int) -> String? { + row.indices.contains(index) ? row[index] : nil + } } diff --git a/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift b/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift index 747172c5bd..1727f6e6a4 100644 --- a/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift +++ b/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift @@ -1,111 +1,463 @@ // // MySQLViewRowEditingTests.swift -// TableProTests -// -// The definitions below are what MySQL 8.4 and MariaDB 11 store in -// information_schema.VIEWS.VIEW_DEFINITION for the views named. +// MySQLDriverTests // import Foundation import TableProPluginKit import Testing +// Definitions and catalog rows as MySQL 8.4.13 and MariaDB 11.8.9 store them for the views named. +private enum Fixture { + static let table = MySQLViewRowEditing.Relation(database: "impb", name: "t") + + static let mysqlV = """ + select `impb`.`t`.`id` AS `id`,`impb`.`t`.`name` AS `name`,`impb`.`t`.`qty` AS `qty`,\ + (`impb`.`t`.`qty` + 1) AS `q2`,`impb`.`t`.`gen` AS `gen`,`impb`.`t`.`req` AS `req` from `impb`.`t` + """ + static let mariaDBV = """ + select `impb`.`t`.`id` AS `id`,`impb`.`t`.`name` AS `name`,`impb`.`t`.`qty` AS `qty`,\ + `impb`.`t`.`qty` + 1 AS `q2`,`impb`.`t`.`gen` AS `gen`,`impb`.`t`.`req` AS `req` from `impb`.`t` + """ + static let vColumns = ["id", "name", "qty", "q2", "gen", "req"] + + // CREATE VIEW v_cols (name, id, qty) AS SELECT id, name, qty FROM t + static let mysqlColumnList = """ + select `impb`.`t`.`id` AS `id`,`impb`.`t`.`name` AS `name`,`impb`.`t`.`qty` AS `qty` from `impb`.`t` + """ + static let mariaDBColumnList = """ + select `impb`.`t`.`id` AS `name`,`impb`.`t`.`name` AS `id`,`impb`.`t`.`qty` AS `qty` from `impb`.`t` + """ + + static let rename = """ + select `impb`.`t`.`qty` AS `amount`,`impb`.`t`.`id` AS `key_id`,`impb`.`t`.`name` AS `Label`,\ + `impb`.`t`.`req` AS `req` from `impb`.`t` + """ + + static let base = """ + select `impb`.`t`.`id` AS `id`,`impb`.`t`.`name` AS `name`,`impb`.`t`.`qty` AS `qty`,\ + `impb`.`t`.`gen` AS `gen`,`impb`.`t`.`vgen` AS `vgen`,`impb`.`t`.`req` AS `req`,`impb`.`t`.`ts` AS `ts`,\ + `impb`.`t`.`ex` AS `ex`,`impb`.`t`.`dn` AS `dn`,`impb`.`t`.`nd` AS `nd`,`impb`.`t`.`s` AS `s`,\ + `impb`.`t`.`nn` AS `nn` from `impb`.`t` + """ + static let baseColumns = ["id", "name", "qty", "gen", "vgen", "req", "ts", "ex", "dn", "nd", "s", "nn"] + + static let mysqlNested = """ + select `impb`.`v_base`.`id` AS `id`,`impb`.`v_base`.`name` AS `name`,`impb`.`v_base`.`req` AS `req` \ + from `impb`.`v_base` + """ + static let mariaDBNested = """ + select `v_base`.`id` AS `id`,`v_base`.`name` AS `name`,`v_base`.`req` AS `req` from `impb`.`v_base` + """ + + // CREATE VIEW tcv AS SELECT id AS ` AS q,t.id AS name from t where x`, name, qty FROM tc + static let combiningMark = "select `impb`.`tc`.`id` AS `\u{301} AS q,t.id AS name from t where x`," + + "`impb`.`tc`.`name` AS `name`,`impb`.`tc`.`qty` AS `qty` from `impb`.`tc`" + + // COLUMN_NAME, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_TYPE of `t`, ordered by ORDINAL_POSITION. + static let mysqlTableRows: [[String?]] = [ + ["id", "NO", nil, "auto_increment", "int"], + ["name", "YES", nil, "", "varchar(20)"], + ["qty", "NO", "0", "", "int"], + ["gen", "YES", nil, "STORED GENERATED", "int"], + ["vgen", "YES", nil, "VIRTUAL GENERATED", "int"], + ["req", "NO", nil, "", "varchar(5)"], + ["ts", "YES", "CURRENT_TIMESTAMP", "DEFAULT_GENERATED", "timestamp"], + ["ex", "YES", "uuid()", "DEFAULT_GENERATED", "varchar(40)"], + ["dn", "YES", nil, "", "varchar(5)"], + ["nd", "YES", nil, "", "varchar(5)"], + ["s", "YES", "abc", "", "varchar(5)"], + ["nn", "NO", "x", "", "varchar(5)"], + ] + static let mariaDBTableRows: [[String?]] = [ + ["id", "NO", nil, "auto_increment", "int(11)"], + ["name", "YES", "NULL", "", "varchar(20)"], + ["qty", "NO", "0", "", "int(11)"], + ["gen", "YES", "NULL", "STORED GENERATED", "int(11)"], + ["vgen", "YES", "NULL", "VIRTUAL GENERATED", "int(11)"], + ["req", "NO", nil, "", "varchar(5)"], + ["ts", "YES", "current_timestamp()", "", "timestamp"], + ["ex", "YES", "uuid()", "", "varchar(40)"], + ["dn", "YES", "NULL", "", "varchar(5)"], + ["nd", "YES", "NULL", "", "varchar(5)"], + ["s", "YES", "'abc'", "", "varchar(5)"], + ["nn", "NO", "'x'", "", "varchar(5)"], + ] + static let keyRows: [[String?]] = [["id"]] +} + +private struct UnexpectedStep: Error { + let step: MySQLViewRowEditing.Step +} + struct MySQLViewRowEditingTests { - private func parse(_ text: String) throws -> MySQLViewRowEditing.Definition { - try #require(MySQLViewRowEditing.parse(definition: text)) + private func rows(_ names: [String]) -> [[String?]] { + names.map { [$0] } } - @Test("Every column of a SELECT * view maps to its base column") - func simpleView() throws { - let definition = try parse( - "select `vt`.`t`.`id` AS `id`,`vt`.`t`.`name` AS `name`,`vt`.`t`.`qty` AS `qty` from `vt`.`t`" + private func level(_ definition: String, columns: [String]) throws -> MySQLViewRowEditing.Level { + let view = MySQLViewRowEditing.View( + relation: .init(database: "impb", name: "v"), + isUpdatable: true, + definition: definition + ) + let step = try MySQLViewRowEditing.step(view: view, columnRows: rows(columns)) + guard case .reads(let level) = step else { throw UnexpectedStep(step: step) } + return level + } + + private func answer( + _ levels: [MySQLViewRowEditing.Level], + keys: [[String?]] = Fixture.keyRows, + tableRows: [[String?]] = Fixture.mysqlTableRows, + quotesDefaults: Bool = false + ) -> PluginViewRowEditing { + MySQLViewRowEditing.answer(levels: levels, keyRows: keys, columnRows: tableRows, quotesDefaults: quotesDefaults) + } + + @Test("A backquote followed by a combining mark still opens a quoted name") + func combiningMarkAfterBackquote() { + let tokens = MySQLViewRowEditing.tokenize("`\u{301} AS q,t.id`,`x`") + let kinds = tokens.map { $0.kind } + #expect(kinds == [.quoted, .symbol, .quoted]) + #expect(tokens.first?.text == "\u{301} AS q,t.id") + } + + @Test("Doubled backquotes are undone, literals stay opaque and parentheses carry depth") + func tokenizer() { + let tokens = MySQLViewRowEditing.tokenize(#"`wei``rd, from` 'it\'s, `x` from' 'b\\c' (f(`a`))"#) + let kinds = tokens.map { $0.kind } + let depths = tokens.map { $0.depth } + #expect(kinds == [.quoted, .literal, .literal, .symbol, .word, .symbol, .quoted, .symbol, .symbol]) + #expect(depths == [0, 0, 0, 0, 1, 1, 2, 1, 0]) + #expect(tokens.first?.text == "wei`rd, from") + #expect(tokens.dropFirst().first?.text == #"'it\'s, `x` from'"#) + } + + @Test("A combining mark in a stored name maps the view to its real table and columns") + func combiningMarkDefinition() { + #expect(MySQLViewRowEditing.select(definition: Fixture.combiningMark) == .oneTable( + .init(database: "impb", name: "tc"), + sources: ["id", "name", "qty"] + )) + } + + @Test("Plain column references map to the base, a computed item maps to nothing") + func computedColumn() { + let expected = MySQLViewRowEditing.Select.oneTable( + Fixture.table, + sources: ["id", "name", "qty", nil, "gen", "req"] + ) + #expect(MySQLViewRowEditing.select(definition: Fixture.mysqlV) == expected) + #expect(MySQLViewRowEditing.select(definition: Fixture.mariaDBV) == expected) + } + + @Test("Literals, functions and subqueries in the select list are computed") + func computedItems() { + let definition = #""" + select `impb`.`t`.`id` AS `id`,'it\'s, `x` from' AS `a`,ucase(`impb`.`t`.`name`) AS `u`,\ + (select count(0) from `impb`.`t2`) AS `c`,`impb`.`t`.`req` AS `wei``rd, from` from `impb`.`t` + """# + #expect(MySQLViewRowEditing.select(definition: definition) == .oneTable( + Fixture.table, + sources: ["id", nil, nil, nil, "req"] + )) + } + + @Test("An aliased table, a select modifier, unquoted names and a subquery in WHERE") + func tableForms() { + let forms = [ + "select `x`.`id` AS `id`,`x`.`name` AS `name` from `impb`.`t` `x`", + "select straight_join `impb`.`t`.`id` AS `id`,`impb`.`t`.`name` AS `name` from `impb`.`t`", + "select impb.t.id AS id,impb.t.`name` AS `name` from impb.t", + "(select `impb`.`t`.`id` AS `id`,`impb`.`t`.`name` AS `name` from `impb`.`t`)", + """ + select `impb`.`t`.`id` AS `id`,`impb`.`t`.`name` AS `name` from `impb`.`t` where \ + (`impb`.`t`.`qty` > (select count(0) from `impb`.`t2` where (`impb`.`t2`.`t_id` = `impb`.`t`.`id`))) + """, + """ + with `c` as (select `impb`.`t2`.`id` AS `id` from `impb`.`t2`) select `impb`.`t`.`id` AS `id`,\ + `impb`.`t`.`name` AS `name` from `impb`.`t` where `impb`.`t`.`id` in (select `c`.`id` from `c`) + """, + ] + for form in forms { + #expect(MySQLViewRowEditing.select(definition: form) == .oneTable(Fixture.table, sources: ["id", "name"])) + } + } + + @Test("A join, MySQL's comma join and a table function read several tables") + func severalTables() { + let forms = [ + """ + select `impb`.`t`.`id` AS `id`,`impb`.`t2`.`note` AS `note` from (`impb`.`t` join `impb`.`t2` \ + on((`impb`.`t2`.`t_id` = `impb`.`t`.`id`))) + """, + """ + select `impb`.`t`.`id` AS `id`,`impb`.`t`.`name` AS `name` from `impb`.`t` join `impb`.`t2` \ + where (`impb`.`t2`.`t_id` = `impb`.`t`.`id`) + """, + """ + select `impb`.`t`.`id` AS `id`,`j`.`x` AS `x` from `impb`.`t` join json_table('[1,2]', '$[*]' \ + columns (`x` int path '$')) `j` + """, + ] + for form in forms { + #expect(MySQLViewRowEditing.select(definition: form) == .severalTables) + } + } + + @Test("A derived table, a common table expression or no FROM reads no base table") + func noTable() { + let forms = [ + """ + select `d`.`id` AS `id`,`d`.`name` AS `name` from (select `impb`.`t`.`id` AS `id`,\ + `impb`.`t`.`name` AS `name` from `impb`.`t`) `d` + """, + """ + with c as (select `impb`.`t`.`id` AS `id`,`impb`.`t`.`name` AS `name` from `impb`.`t`)\ + select `c`.`id` AS `id`,`c`.`name` AS `name` from `c` + """, + "select 1 AS `one`", + ] + for form in forms { + #expect(MySQLViewRowEditing.select(definition: form) == .noTable) + } + } + + @Test("Each server's reference to an inner view names it as the base") + func nestedForms() { + let inner = MySQLViewRowEditing.Relation(database: "impb", name: "v_base") + let expected = MySQLViewRowEditing.Select.oneTable(inner, sources: ["id", "name", "req"]) + #expect(MySQLViewRowEditing.select(definition: Fixture.mysqlNested) == expected) + #expect(MySQLViewRowEditing.select(definition: Fixture.mariaDBNested) == expected) + + let rename = MySQLViewRowEditing.Relation(database: "impb", name: "v_rename") + let aliased = MySQLViewRowEditing.Select.oneTable(rename, sources: ["key_id", "req"]) + let mysqlAliased = """ + select `impb`.`r`.`key_id` AS `key_id`,`impb`.`r`.`req` AS `req` from `impb`.`v_rename` `r` + """ + let mariaDBAliased = "select `r`.`key_id` AS `key_id`,`r`.`req` AS `req` from `impb`.`v_rename` `r`" + #expect(MySQLViewRowEditing.select(definition: mysqlAliased) == aliased) + #expect(MySQLViewRowEditing.select(definition: mariaDBAliased) == aliased) + } + + @Test("The server's answer comes first, then a hidden definition, then the FROM clause") + func readOnlySteps() throws { + let relation = MySQLViewRowEditing.Relation(database: "impb", name: "v") + let notUpdatable = MySQLViewRowEditing.View(relation: relation, isUpdatable: false, definition: Fixture.mysqlV) + #expect(try MySQLViewRowEditing.step(view: notUpdatable, columnRows: []) == .readOnly(.notUpdatable)) + + let hidden = MySQLViewRowEditing.View(relation: relation, isUpdatable: true, definition: "") + #expect(try MySQLViewRowEditing.step(view: hidden, columnRows: []) == .readOnly(.baseTableHidden)) + + let derived = MySQLViewRowEditing.View( + relation: relation, + isUpdatable: true, + definition: "select `d`.`id` AS `id` from (select `impb`.`t`.`id` AS `id` from `impb`.`t`) `d`" + ) + #expect(try MySQLViewRowEditing.step(view: derived, columnRows: rows(["id"])) == .readOnly(.notUpdatable)) + + let join = MySQLViewRowEditing.View( + relation: relation, + isUpdatable: true, + definition: "select `impb`.`t`.`id` AS `id` from `impb`.`t` join `impb`.`t2`" + ) + #expect(try MySQLViewRowEditing.step(view: join, columnRows: rows(["id"])) == .readOnly(.readsSeveralTables)) + } + + @Test("A select list that does not match the view's columns fails the check") + func columnCountMismatch() { + let view = MySQLViewRowEditing.View( + relation: .init(database: "impb", name: "v"), + isUpdatable: true, + definition: Fixture.mysqlV ) - #expect(definition.baseTable == .init(database: "vt", name: "t")) - #expect(definition.origins == [ - PluginViewColumnOrigin(viewColumn: "id", baseColumn: "id"), - PluginViewColumnOrigin(viewColumn: "name", baseColumn: "name"), - PluginViewColumnOrigin(viewColumn: "qty", baseColumn: "qty"), - ]) - #expect(MySQLViewRowEditing.resolve(isUpdatable: true, definition: definition, baseKeyColumns: ["id"]) - == .editable(keyColumns: ["id"])) - } - - @Test("Swapped column names map by what each column shows, not by its name") - func swappedNames() throws { - let definition = try parse("select `vt`.`t`.`name` AS `id`,`vt`.`t`.`id` AS `name` from `vt`.`t`") - #expect(MySQLViewRowEditing.resolve(isUpdatable: true, definition: definition, baseKeyColumns: ["id"]) - == .editable(keyColumns: ["name"])) - } - - @Test("An aliased table, a computed column, a quoted name and a sub-select in WHERE (MySQL spelling)") - func aliasedTableMySQL() throws { - let definition = try parse(""" - select `x`.`id` AS `id`,(`x`.`qty` + 1) AS `q2`,`x`.`name` AS `we``ird, name` from `vt`.`t` `x` \ - where `x`.`id` in (select `vt`.`u`.`tid` from `vt`.`u`) order by `x`.`name` - """) - #expect(definition.baseTable == .init(database: "vt", name: "t")) - #expect(definition.origins == [ - PluginViewColumnOrigin(viewColumn: "id", baseColumn: "id"), - PluginViewColumnOrigin(viewColumn: "we`ird, name", baseColumn: "name"), - ]) - } - - @Test("The same view in MariaDB's spelling, without brackets around the expression") - func aliasedTableMariaDB() throws { - let definition = try parse(""" - select `x`.`id` AS `id`,`x`.`qty` + 1 AS `q2`,`x`.`name` AS `we``ird, name` from `vt`.`t` `x` \ - where `x`.`id` in (select `vt`.`u`.`tid` from `vt`.`u`) order by `x`.`name` - """) - #expect(definition.origins.map { $0.viewColumn } == ["id", "we`ird, name"]) - } - - @Test("A join is not one table, even when every column comes from one side") - func joinIsSeveralTables() throws { - let definition = try parse(""" - select `vt`.`t`.`id` AS `id`,`vt`.`t`.`name` AS `name`,`vt`.`t`.`qty` AS `qty` \ - from (`vt`.`t` join `vt`.`u` on((`vt`.`u`.`tid` = `vt`.`t`.`id`))) - """) - #expect(definition.baseTable == nil) - #expect(MySQLViewRowEditing.resolve(isUpdatable: true, definition: definition, baseKeyColumns: []) - == .readOnly(.readsSeveralTables)) - } - - @Test("A string literal holding a comma or FROM does not split the select list") - func literalIsOpaque() throws { - let definition = try parse( - "select 'a, from b' AS `label`,`vt`.`t`.`id` AS `id` from `vt`.`t` where (`vt`.`t`.`name` <> 'x''y')" + #expect(throws: MySQLViewRowEditing.ColumnCountMismatch(view: view.relation, items: 6, columns: 2)) { + try MySQLViewRowEditing.step(view: view, columnRows: [["id"], ["name"]]) + } + } + + @Test("A VIEWS row becomes the view; no row means the name is not a view") + func viewRow() { + #expect(MySQLViewRowEditing.view(rows: []) == nil) + #expect(MySQLViewRowEditing.view(rows: [["impb", "v", "YES", ""]]) == MySQLViewRowEditing.View( + relation: .init(database: "impb", name: "v"), + isUpdatable: true, + definition: "" + )) + #expect(MySQLViewRowEditing.view(rows: [["impb", "v", "NO", nil]])?.isUpdatable == false) + } + + @Test("Edits key on the base table's key, computed and generated columns stay read-only") + func simpleView() throws { + let result = answer([try level(Fixture.mysqlV, columns: Fixture.vColumns)]) + let target = try #require(result.writeTarget) + #expect(target.baseTable == PluginViewBaseTable(database: "impb", name: "t")) + #expect(target.keyColumns == ["id"]) + #expect(target.readOnlyColumns == ["q2", "gen"]) + #expect(target.operations == [.update, .delete]) + } + + @Test("Columns map by position, never by the alias MySQL stores beside a column list") + func columnList() throws { + let mysql = answer([try level(Fixture.mysqlColumnList, columns: ["name", "id", "qty"])]) + let mariaDB = answer([try level(Fixture.mariaDBColumnList, columns: ["name", "id", "qty"])]) + for result in [mysql, mariaDB] { + let target = try #require(result.writeTarget) + #expect(target.keyColumns == ["name"]) + #expect(target.baseColumnNames == ["name": "id", "id": "name", "qty": "qty"]) + } + + let list = answer([try level(Fixture.mysqlColumnList, columns: ["a", "b", "qty"])]) + #expect(list.writeTarget?.keyColumns == ["a"]) + } + + @Test("A renamed key is the view's name for it") + func renamedKey() throws { + let result = answer([try level(Fixture.rename, columns: ["amount", "key_id", "Label", "req"])]) + #expect(result.writeTarget?.keyColumns == ["key_id"]) + #expect(result.writeTarget?.operations == [.update, .insert, .delete]) + } + + @Test("A view that leaves out the key, or over a table without one, stays read-only") + func missingKey() throws { + let noKey = "select `impb`.`t`.`name` AS `name`,`impb`.`t`.`qty` AS `qty` from `impb`.`t`" + #expect(answer([try level(noKey, columns: ["name", "qty"])]) == .readOnly(.keyNotExposed)) + let keyless = answer([try level(Fixture.mysqlV, columns: Fixture.vColumns)], keys: []) + #expect(keyless == .readOnly(.baseTableHasNoKey)) + } + + @Test("A composite key comes back in the key's order, under the view's names") + func compositeKey() throws { + let definition = """ + select `impb`.`ck`.`a` AS `a`,`impb`.`ck`.`b` AS `b`,`impb`.`ck`.`c` AS `c` from `impb`.`ck` + """ + let tableRows: [[String?]] = [ + ["a", "NO", nil, "", "int"], + ["b", "NO", nil, "", "int"], + ["c", "YES", nil, "", "text"], + ] + let result = answer( + [try level(definition, columns: ["a", "b", "c"])], + keys: [["b"], ["a"]], + tableRows: tableRows ) - #expect(definition.origins == [PluginViewColumnOrigin(viewColumn: "id", baseColumn: "id")]) - #expect(definition.baseTable == .init(database: "vt", name: "t")) + #expect(result.writeTarget?.keyColumns == ["b", "a"]) + } + + @Test("A base table the user cannot read hides the key") + func hiddenBase() throws { + let result = answer([try level(Fixture.mysqlV, columns: Fixture.vColumns)], keys: [], tableRows: []) + #expect(result == .readOnly(.baseTableHidden)) + } + + @Test("A view over a view resolves to the table underneath, on both servers") + func nestedView() throws { + for nested in [Fixture.mysqlNested, Fixture.mariaDBNested] { + let levels = [ + try level(nested, columns: ["id", "name", "req"]), + try level(Fixture.base, columns: Fixture.baseColumns), + ] + let target = try #require(answer(levels).writeTarget) + #expect(target.baseTable == PluginViewBaseTable(database: "impb", name: "t")) + #expect(target.keyColumns == ["id"]) + #expect(target.operations == [.update, .insert, .delete]) + } } - @Test("A view the server reports as not updatable, or whose definition is withheld, stays read-only") - func notUpdatable() throws { - let definition = try parse("select distinct `vt`.`t`.`id` AS `id` from `vt`.`t`") - #expect(MySQLViewRowEditing.resolve(isUpdatable: false, definition: definition, baseKeyColumns: ["id"]) - == .readOnly(.notUpdatable)) - #expect(MySQLViewRowEditing.parse(definition: "") == nil) - #expect(MySQLViewRowEditing.resolve(isUpdatable: true, definition: nil, baseKeyColumns: ["id"]) - == .readOnly(.notUpdatable)) + @Test("Renames compose through three levels") + func threeLevels() throws { + let top = "select `impb`.`v_over_rename`.`key_id` AS `k3`,`impb`.`v_over_rename`.`lbl` AS `lbl` " + + "from `impb`.`v_over_rename`" + let middle = "select `v_rename`.`key_id` AS `key_id`,`v_rename`.`Label` AS `lbl`,`v_rename`.`req` AS `req` " + + "from `impb`.`v_rename`" + let levels = [ + try level(top, columns: ["k3", "lbl"]), + try level(middle, columns: ["key_id", "lbl", "req"]), + try level(Fixture.rename, columns: ["amount", "key_id", "Label", "req"]), + ] + let target = try #require(answer(levels).writeTarget) + #expect(target.keyColumns == ["k3"]) + #expect(target.baseColumnNames == ["k3": "id", "lbl": "name"]) + // `req` is NOT NULL with no default and not shown. + #expect(!target.operations.contains(.insert)) } - @Test("A view that leaves out the key stays read-only") - func keyNotExposed() throws { - let definition = try parse("select `vt`.`t`.`name` AS `name`,`vt`.`t`.`qty` AS `qty` from `vt`.`t`") - #expect(MySQLViewRowEditing.resolve(isUpdatable: true, definition: definition, baseKeyColumns: ["id"]) - == .readOnly(.keyNotExposed)) + @Test("Add Row needs every view column to be a base column shown once", arguments: [ + ("select `impb`.`t`.`id` AS `id`,`impb`.`t`.`id` AS `id2`,`impb`.`t`.`req` AS `req` from `impb`.`t`", false), + ("select `impb`.`t`.`id` AS `id`,'x' AS `k`,`impb`.`t`.`req` AS `req` from `impb`.`t`", false), + ("select `impb`.`t`.`id` AS `id`,`impb`.`t`.`name` AS `name`,`impb`.`t`.`req` AS `req` from `impb`.`t`", true), + ]) + func insertRule(definition: String, allowsInsert: Bool) throws { + let result = answer([try level(definition, columns: ["id", "x", "req"])]) + #expect(result.writeTarget?.operations.contains(.insert) == allowsInsert) } - @Test("The catalog queries escape names and fall back to the current database") - func queries() { + @Test("An inner view's computed column the outer view does not show leaves Add Row on, as MySQL does") + func insertThroughNestedViews() throws { + let middle = """ + select `impb`.`v_base`.`id` AS `id`,`impb`.`v_base`.`name` AS `name`,`impb`.`v_base`.`req` AS `req`,\ + (`impb`.`v_base`.`qty` * 3) AS `q3` from `impb`.`v_base` + """ + let middleLevel = try level(middle, columns: ["id", "name", "req", "q3"]) + let baseLevel = try level(Fixture.base, columns: Fixture.baseColumns) + + let hidesComputed = "select `impb`.`v_mid`.`id` AS `id`,`impb`.`v_mid`.`name` AS `name`," + + "`impb`.`v_mid`.`req` AS `req` from `impb`.`v_mid`" + let top = try level(hidesComputed, columns: ["id", "name", "req"]) + #expect(answer([top, middleLevel, baseLevel]).writeTarget?.operations == [.update, .insert, .delete]) + + let showsComputed = "select `impb`.`v_mid`.`id` AS `id`,`impb`.`v_mid`.`q3` AS `q3`," + + "`impb`.`v_mid`.`req` AS `req` from `impb`.`v_mid`" + let computedTop = try level(showsComputed, columns: ["id", "q3", "req"]) + let target = try #require(answer([computedTop, middleLevel, baseLevel]).writeTarget) + #expect(target.readOnlyColumns == ["q3"]) + #expect(target.operations == [.update, .delete]) + } + + @Test("A view hiding a NOT NULL column without a default allows no Add Row") + func hiddenRequiredColumn() throws { + let definition = """ + select `impb`.`t`.`id` AS `id`,`impb`.`t`.`name` AS `name`,`impb`.`t`.`qty` AS `qty` from `impb`.`t` + """ + let result = answer([try level(definition, columns: ["id", "name", "qty"])]) + #expect(result.writeTarget?.operations == [.update, .delete]) + } + + @Test("Base columns read the same from MySQL's bare catalog and MariaDB's quoted one") + func baseColumns() throws { + for (rows, quotes) in [(Fixture.mysqlTableRows, false), (Fixture.mariaDBTableRows, true)] { + let columns = rows.compactMap { MySQLViewRowEditing.baseColumn(row: $0, quotesDefaults: quotes) } + let byName = Dictionary(uniqueKeysWithValues: columns.map { ($0.name, $0) }) + let id = try #require(byName["id"]) + #expect(id.assignsValue && id.acceptsWrites && !id.isRequiredOnInsert) + #expect(byName["gen"]?.acceptsWrites == false) + #expect(byName["vgen"]?.acceptsWrites == false) + #expect(byName["ts"]?.acceptsWrites == true) + #expect(byName["req"]?.isRequiredOnInsert == true) + #expect(byName["req"]?.defaultValue == nil) + #expect(byName["name"]?.defaultValue == "NULL") + #expect(byName["qty"]?.defaultValue == "0") + #expect(byName["s"]?.defaultValue == "'abc'") + #expect(byName["nn"]?.isRequiredOnInsert == false) + let required = columns.filter { $0.isRequiredOnInsert }.map { $0.name } + #expect(required == ["req"]) + } + let mysqlExpression = MySQLViewRowEditing.baseColumn(row: Fixture.mysqlTableRows[7], quotesDefaults: false) + let mariaDBExpression = MySQLViewRowEditing.baseColumn(row: Fixture.mariaDBTableRows[7], quotesDefaults: true) + #expect(mysqlExpression?.defaultValue == "(uuid())") + #expect(mariaDBExpression?.defaultValue == "uuid()") + } + + @Test("The catalog statements escape names and fall back to the current database") + func statements() { let escape: (String) -> String = { $0.replacingOccurrences(of: "'", with: "''") } - let view = MySQLViewRowEditing.viewQuery(name: "o'v", database: nil, escape: escape) + let view = MySQLViewRowEditing.viewQuery(database: nil, name: "o'v", escape: escape) #expect(view.contains("TABLE_SCHEMA = DATABASE()")) #expect(view.contains("TABLE_NAME = 'o''v'")) - let key = MySQLViewRowEditing.primaryKeyQuery( - table: .init(database: nil, name: "t"), viewDatabase: "shop", escape: escape - ) - #expect(key.contains("TABLE_SCHEMA = 'shop'")) - #expect(key.contains("CONSTRAINT_NAME = 'PRIMARY'")) + let relation = MySQLViewRowEditing.Relation(database: "sh'op", name: "t") + #expect(MySQLViewRowEditing.viewQuery(database: "sh'op", name: "t", escape: escape).contains("'sh''op'")) + #expect(MySQLViewRowEditing.primaryKeyQuery(relation, escape: escape).contains("CONSTRAINT_NAME = 'PRIMARY'")) + #expect(MySQLViewRowEditing.columnsQuery(relation, escape: escape).hasSuffix("ORDER BY ORDINAL_POSITION")) } } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Views.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Views.swift index 594859f490..507e146d7c 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Views.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Views.swift @@ -5,11 +5,10 @@ import Foundation import TableProPluginKit +import TableProPostgreSQLCore extension PostgreSQLPluginDriver { - /// `pg_views` and `pg_matviews` return the query alone, so the definition this used to build - /// from them lost the view's options and check option, and read the body under the connection's - /// own search path. The statement is now rebuilt from the catalog with every name qualified. + // Rebuilt from the catalog with every name qualified: pg_views lost the options and the check option. func fetchViewDefinition(view: String, schema: String?) async throws -> String { let resolvedSchema = schema ?? core.currentSchema let query = PostgreSQLViewDefinition.catalogQuery(name: view, schema: resolvedSchema) @@ -23,36 +22,33 @@ extension PostgreSQLPluginDriver { } func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? { - let resolvedSchema = schema ?? core.currentSchema - let viewResult = try await catalogRead(PostgreSQLViewRowEditing.viewQuery(name: view, schema: resolvedSchema)) - guard let row = viewResult.rows.first, let viewOID = row[safe: 0]?.asText else { return nil } - let isUpdatable = row[safe: 1]?.asText == "true" - let origins = PostgreSQLViewRowEditing.targetOrigins(nodeTree: row[safe: 2]?.asText ?? "") - guard isUpdatable, let baseTableOID = origins.first?.tableOID else { - return PostgreSQLViewRowEditing.resolve( - isUpdatable: isUpdatable, origins: origins, viewColumnNames: [:], baseKeyColumnNumbers: [] - ) - } - - let detail = try await catalogRead( - PostgreSQLViewRowEditing.columnsAndKeyQuery(viewOID: viewOID, baseTableOID: baseTableOID) - ) - var viewColumnNames: [Int: String] = [:] - var baseKeyColumnNumbers: [Int] = [] - for detailRow in detail.rows { - guard let number = detailRow[safe: 1]?.asText.flatMap(Int.init) else { continue } - switch detailRow[safe: 0]?.asText { - case "v": viewColumnNames[number] = detailRow[safe: 2]?.asText - case "k": baseKeyColumnNumbers.append(number) - default: break + let capabilities = catalogCapabilities + guard capabilities.serverVersion >= PostgreSQLViewRowEditing.minimumServerVersion else { return nil } + var chain: [PostgreSQLViewRowEditing.Relation] = [] + var query = PostgreSQLViewRowEditing.relationQuery(name: view, schema: schema ?? core.currentSchema) + while true { + let row = try await catalogRead(query).rows.first?.map(\.asText) + switch PostgreSQLViewRowEditing.step(&chain, row: row) { + case .notAView: + return nil + case .readOnly(let reason): + return .readOnly(reason) + case .readRelation(let oid): + query = PostgreSQLViewRowEditing.relationQuery(oid: oid) + case .readColumns(let viewOID, let baseOID, let baseSchema): + let viewColumns = try await catalogRead(PostgreSQLViewRowEditing.viewColumnsQuery(viewOID: viewOID)) + // Relative, so a default reads as it does in the base table's own tab. + let baseColumns = try await catalogRead( + PostgreSQLViewRowEditing.baseColumnsQuery(baseOID: baseOID, capabilities: capabilities), + namesRelativeTo: baseSchema + ) + return PostgreSQLViewRowEditing.resolve( + chain: chain, + viewColumns: viewColumns.rows.map { $0.map(\.asText) }, + baseColumns: baseColumns.rows.map { $0.map(\.asText) } + ) } } - return PostgreSQLViewRowEditing.resolve( - isUpdatable: isUpdatable, - origins: origins, - viewColumnNames: viewColumnNames, - baseKeyColumnNumbers: baseKeyColumnNumbers - ) } func objectCommentStatement(name: String, objectType: String, schema: String?, comment: String?) -> String? { diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift index 9a2729a174..bcb6e441ca 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift @@ -1,101 +1,222 @@ -// -// PostgreSQLViewRowEditing.swift -// PostgreSQLDriverPlugin -// -// Works out whether the grid may write rows through a view. The server answers whether the view -// is updatable; the view's stored query says which base column each view column shows. Pure, so -// it is testable without a server. -// - import Foundation import TableProPluginKit +import TableProPostgreSQLCore enum PostgreSQLViewRowEditing { - /// The base column one view column shows, as `pg_rewrite` stores it: `resorigtbl` and - /// `resorigcol` are the table's OID and attribute number, zero for a computed column. - struct TargetOrigin: Equatable { - let viewAttributeNumber: Int - let tableOID: String - let columnNumber: Int - } - - /// Bits 4 (UPDATE) and 16 (DELETE) of `pg_relation_is_updatable`, the test - /// `information_schema.views.is_updatable` applies, read directly so a view the user may not - /// see in `information_schema` still gets an answer. - static func viewQuery(name: String, schema: String) -> String { + static let maxViewDepth = 8 + // pg_relation_is_updatable is 9.3, unnest WITH ORDINALITY is 9.4. + static let minimumServerVersion: Int32 = 90_400 + + struct ColumnOrigin: Hashable { + let relationOID: String + let attributeNumber: Int + } + + struct Relation: Equatable { + let oid: String + let kind: String + let schema: String + let name: String + let events: Int + let hasRules: Bool + let hasInsteadTrigger: Bool + // Keyed by the view's attribute number; a computed column has no entry. + let origins: [Int: ColumnOrigin] + + var isView: Bool { kind == "v" } + + init?(row: [String?]) { + guard let oid = cell(row, 0), let kind = cell(row, 1), let schema = cell(row, 2), let name = cell(row, 3) + else { return nil } + self.oid = oid + self.kind = kind + self.schema = schema + self.name = name + events = cell(row, 4).flatMap(Int.init) ?? 0 + hasRules = PostgreSQLCatalogBoolean.isTrue(cell(row, 5)) + hasInsteadTrigger = PostgreSQLCatalogBoolean.isTrue(cell(row, 6)) + origins = kind == "v" ? targetOrigins(nodeTree: cell(row, 7) ?? "") : [:] + } + } + + enum Step: Equatable { + case notAView + case readOnly(PluginViewReadOnlyReason) + case readRelation(oid: String) + case readColumns(viewOID: String, baseOID: String, baseSchema: String) + } + + static func relationQuery(name: String, schema: String) -> String { + relationQuery(matching: """ + n.nspname = \(PostgreSQLObjectQueries.quoteLiteral(schema)) \ + AND c.relname = \(PostgreSQLObjectQueries.quoteLiteral(name)) + """) + } + + static func relationQuery(oid: String) -> String { + relationQuery(matching: "c.oid = \(PostgreSQLObjectQueries.quoteLiteral(oid))::oid") + } + + // Every INSTEAD OF trigger counts: one marked disabled still answers UPDATE 1 and changes nothing. + private static func relationQuery(matching condition: String) -> String { """ SELECT c.oid::text, - (pg_catalog.pg_relation_is_updatable(c.oid, false) & 20) = 20, - r.ev_action::text + c.relkind::text, + n.nspname::text, + c.relname::text, + pg_catalog.pg_relation_is_updatable(c.oid, false)::text, + EXISTS ( + SELECT 1 FROM pg_catalog.pg_rewrite r + WHERE r.ev_class = c.oid AND r.rulename <> '_RETURN' + ), + EXISTS ( + SELECT 1 FROM pg_catalog.pg_trigger tg + WHERE tg.tgrelid = c.oid AND (tg.tgtype & 64) <> 0 + ), + rt.ev_action::text FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - LEFT JOIN pg_catalog.pg_rewrite r ON r.ev_class = c.oid AND r.rulename = '_RETURN' - WHERE c.relkind = 'v' - AND n.nspname = \(PostgreSQLObjectQueries.quoteLiteral(schema)) - AND c.relname = \(PostgreSQLObjectQueries.quoteLiteral(name)) + LEFT JOIN pg_catalog.pg_rewrite rt ON rt.ev_class = c.oid AND rt.rulename = '_RETURN' + WHERE \(condition) """ } - /// The view's column names by number, then the base table's primary key column numbers in key - /// order, told apart by the first column. - static func columnsAndKeyQuery(viewOID: String, baseTableOID: String) -> String { + static func viewColumnsQuery(viewOID: String) -> String { """ - SELECT 'v', a.attnum::text, a.attname::text, 0 + SELECT + a.attnum::text, + a.attname::text, + pg_catalog.pg_column_is_updatable(a.attrelid, a.attnum, false) FROM pg_catalog.pg_attribute a WHERE a.attrelid = \(PostgreSQLObjectQueries.quoteLiteral(viewOID))::oid - AND a.attnum > 0 AND NOT a.attisdropped - UNION ALL - SELECT 'k', k.attnum::text, NULL, k.ord - FROM pg_catalog.pg_index i - CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) - WHERE i.indrelid = \(PostgreSQLObjectQueries.quoteLiteral(baseTableOID))::oid - AND i.indisprimary - ORDER BY 1, 4 + AND a.attnum > 0 + AND NOT a.attisdropped + ORDER BY a.attnum """ } + static func baseColumnsQuery(baseOID: String, capabilities: PostgreSQLCapabilities) -> String { + let oid = "\(PostgreSQLObjectQueries.quoteLiteral(baseOID))::oid" + let identity = capabilities.hasIdentityColumns ? "a.attidentity::text" : "''" + let generated = capabilities.hasGeneratedColumns ? "a.attgenerated::text" : "''" + return """ + SELECT + a.attnum::text, + a.attname::text, + a.attnotnull, + pg_catalog.pg_get_expr(d.adbin, d.adrelid), + \(identity), + \(generated), + k.ord::text + FROM pg_catalog.pg_attribute a + LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum + LEFT JOIN ( + SELECT key.attnum, key.ord + FROM pg_catalog.pg_index i + CROSS JOIN LATERAL pg_catalog.unnest(i.indkey) WITH ORDINALITY AS key(attnum, ord) + WHERE i.indrelid = \(oid) AND i.indisprimary + ) k ON k.attnum = a.attnum + WHERE a.attrelid = \(oid) + AND a.attnum > 0 + AND NOT a.attisdropped + ORDER BY a.attnum + """ + } + + static func step(_ chain: inout [Relation], row: [String?]?) -> Step { + guard let relation = row.flatMap(Relation.init(row:)) else { + return chain.isEmpty ? .notAView : .readOnly(.notUpdatable) + } + guard relation.isView else { + guard let outer = chain.first else { return .notAView } + chain.append(relation) + guard operations(events: outer.events).contains(.update) else { return .readOnly(.notUpdatable) } + return .readColumns(viewOID: outer.oid, baseOID: relation.oid, baseSchema: relation.schema) + } + chain.append(relation) + if relation.hasRules || relation.hasInsteadTrigger { return .readOnly(.writesRunTriggers) } + let bases = Set(relation.origins.values.map(\.relationOID)) + guard bases.count <= 1 else { return .readOnly(.readsSeveralTables) } + guard let base = bases.first, chain.count <= maxViewDepth else { return .readOnly(.notUpdatable) } + return .readRelation(oid: base) + } + + // Bits are 1 << CmdType: UPDATE 2, INSERT 3, DELETE 4. + static func operations(events: Int) -> PluginViewWriteOperations { + var operations: PluginViewWriteOperations = [] + if events & 4 != 0 { operations.insert(.update) } + if events & 8 != 0 { operations.insert(.insert) } + if events & 16 != 0 { operations.insert(.delete) } + return operations + } + + static func baseAttributeNumbers(_ chain: [Relation]) -> [Int: Int] { + let views = chain.prefix(while: \.isView) + guard let outer = views.first else { return [:] } + return views.dropFirst().reduce(outer.origins.mapValues(\.attributeNumber)) { numbers, view in + numbers.compactMapValues { view.origins[$0]?.attributeNumber } + } + } + static func resolve( - isUpdatable: Bool, - origins: [TargetOrigin], - viewColumnNames: [Int: String], - baseKeyColumnNumbers: [Int] + chain: [Relation], + viewColumns viewRows: [[String?]], + baseColumns baseRows: [[String?]] ) -> PluginViewRowEditing { - let baseTables = Set(origins.map(\.tableOID)) - let columnOrigins = origins.compactMap { origin -> PluginViewColumnOrigin? in - guard let name = viewColumnNames[origin.viewAttributeNumber] else { return nil } - return PluginViewColumnOrigin(viewColumn: name, baseColumn: String(origin.columnNumber)) + let attributes = baseRows.compactMap(BaseAttribute.init(row:)) + guard let outer = chain.first, let base = chain.last, !base.isView, !attributes.isEmpty else { + return .readOnly(.notUpdatable) } + // A target entry with no view attribute means the tree was misread, so no mapping is trusted. + let viewNumbers = Set(viewRows.compactMap { cell($0, 0).flatMap(Int.init) }) + guard Set(outer.origins.keys).isSubset(of: viewNumbers) else { return .readOnly(.notUpdatable) } + + var baseNames: [Int: String] = [:] + for attribute in attributes { baseNames[attribute.number] = attribute.column.name } + let baseNumbers = baseAttributeNumbers(chain) + let columns = viewRows.compactMap { row -> PluginViewColumn? in + guard let number = cell(row, 0).flatMap(Int.init), let name = cell(row, 1) else { return nil } + return PluginViewColumn( + name: name, + baseColumn: baseNumbers[number].flatMap { baseNames[$0] }, + acceptsWrites: PostgreSQLCatalogBoolean.isTrue(cell(row, 2)) + ) + } + let keyColumns = attributes + .compactMap { attribute in attribute.keyPosition.map { (position: $0, name: attribute.column.name) } } + .sorted { $0.position < $1.position } + .map(\.name) + return PluginViewRowEditing.resolve( - isUpdatable: isUpdatable, - baseTableCount: baseTables.count, - baseKeyColumns: baseKeyColumnNumbers.map(String.init), - origins: columnOrigins + operations: operations(events: outer.events), + baseTable: PluginViewBaseTable(schema: base.schema, name: base.name), + columns: columns, + baseKeyColumns: keyColumns, + baseColumns: attributes.map(\.column) ) } - /// The origins of the outermost query's target list. A sub-select in the view's WHERE clause - /// stores a target list of its own earlier in the text, so this follows the nesting rather - /// than matching the first `:targetList`. - static func targetOrigins(nodeTree: String) -> [TargetOrigin] { + // A WHERE sub-select stores its own target list first, so only the outermost query's list is read. + static func targetOrigins(nodeTree: String) -> [Int: ColumnOrigin] { let tokens = NodeTreeToken.tokenize(nodeTree) - guard let queryIndex = tokens.firstIndex(where: { $0.text == "QUERY" }) else { return [] } + guard let queryIndex = tokens.firstIndex(where: { $0.text == "QUERY" }) else { return [:] } let queryDepth = tokens[queryIndex].depth guard let listIndex = tokens[queryIndex...].firstIndex(where: { $0.depth == queryDepth && $0.text == ":targetList" - }) else { return [] } + }) else { return [:] } let entryDepth = queryDepth + 2 - var origins: [TargetOrigin] = [] + var origins: [Int: ColumnOrigin] = [:] var fields: [String: String] = [:] var index = tokens.index(after: listIndex) - guard index < tokens.count, tokens[index].text == "(" else { return [] } + guard index < tokens.count, tokens[index].text == "(" else { return [:] } index += 1 while index < tokens.count, tokens[index].depth > queryDepth { let token = tokens[index] if token.text == "}", token.depth == entryDepth - 1 { - if let origin = origin(from: fields) { origins.append(origin) } + if let entry = origin(from: fields) { origins[entry.number] = entry.origin } fields = [:] } else if token.depth == entryDepth, token.text.hasPrefix(":"), index + 1 < tokens.count { let value = tokens[index + 1] @@ -108,18 +229,43 @@ enum PostgreSQLViewRowEditing { return origins } - private static func origin(from fields: [String: String]) -> TargetOrigin? { + private static func origin(from fields: [String: String]) -> (number: Int, origin: ColumnOrigin)? { guard fields[":resjunk"] != "true", - let resno = fields[":resno"].flatMap(Int.init), + let number = fields[":resno"].flatMap(Int.init), let table = fields[":resorigtbl"], table != "0", let column = fields[":resorigcol"].flatMap(Int.init), column > 0 else { return nil } - return TargetOrigin(viewAttributeNumber: resno, tableOID: table, columnNumber: column) + return (number, ColumnOrigin(relationOID: table, attributeNumber: column)) } + + private struct BaseAttribute { + let number: Int + let column: PluginViewBaseColumn + let keyPosition: Int? + + init?(row: [String?]) { + guard let number = cell(row, 0).flatMap(Int.init), let name = cell(row, 1) else { return nil } + let identity = cell(row, 4) ?? "" + let generated = cell(row, 5) ?? "" + self.number = number + // pg_attrdef holds a generated column's expression too, which is no default. + column = PluginViewBaseColumn( + name: name, + isNullable: !PostgreSQLCatalogBoolean.isTrue(cell(row, 2)), + defaultValue: generated.isEmpty ? cell(row, 3) : nil, + assignsValue: identity == "a" || identity == "d", + acceptsWrites: generated.isEmpty && identity != "a" + ) + keyPosition = cell(row, 6).flatMap(Int.init) + } + } +} + +private func cell(_ row: [String?], _ index: Int) -> String? { + row[safe: index] ?? nil } -/// One token of a `pg_node_tree` text, with the number of brackets open before it. A closing -/// bracket carries the depth of its opening one. +// depth counts the brackets open before a token; a closing bracket carries its opening one's depth. private struct NodeTreeToken { let text: String let depth: Int @@ -129,15 +275,16 @@ private struct NodeTreeToken { static func tokenize(_ text: String) -> [NodeTreeToken] { var tokens: [NodeTreeToken] = [] var depth = 0 - var word = "" + var word = String.UnicodeScalarView() var escaped = false func flush() { - if !word.isEmpty { tokens.append(NodeTreeToken(text: word, depth: depth)) } - word = "" + if !word.isEmpty { tokens.append(NodeTreeToken(text: String(word), depth: depth)) } + word = String.UnicodeScalarView() } - for character in text { + // Scalars, not Characters: a combining mark after a space or bracket would fuse the two into one Character. + for character in text.unicodeScalars { if escaped { word.append(character) escaped = false diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLViewRowEditingTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLViewRowEditingTests.swift index 7f5927b908..ce115845f6 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLViewRowEditingTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLViewRowEditingTests.swift @@ -1,61 +1,46 @@ -// -// PostgreSQLViewRowEditingTests.swift -// TableProTests -// -// The node trees below are what PostgreSQL 17 stores in pg_rewrite.ev_action, trimmed of the -// fields the parser never reads. -// - import Foundation import TableProPluginKit +import TableProPostgreSQLCore import Testing struct PostgreSQLViewRowEditingTests { - /// `CREATE VIEW v AS SELECT id AS key_id, name FROM t WHERE id IN (SELECT tid FROM u) OR true`: - /// the sub-select's target list comes first in the text and names another table. - private let viewWithSubselect = """ - ({QUERY :commandType 1 :querySource 0 :rtable ({RANGETBLENTRY :alias <> :eref {ALIAS :aliasname t \ - :colnames ("id" "name" "qty")} :rtekind 0 :relid 16388}) :jointree {FROMEXPR :fromlist ({RANGETBLREF \ - :rtindex 1}) :quals {BOOLEXPR :boolop or :args ({SUBLINK :subLinkType 2 :subselect {QUERY :commandType 1 \ - :rtable ({RANGETBLENTRY :relid 16395}) :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 1} \ - :resno 1 :resname tid :ressortgroupref 0 :resorigtbl 16395 :resorigcol 1 :resjunk false}) :override 0} \ - :location -1} {CONST :consttype 16 :constvalue 1 [ 1 0 0 0 0 0 0 0 ]}) :location -1}} :targetList \ - ({TARGETENTRY :expr {VAR :varno 1 :varattno 1 :vartype 23} :resno 1 :resname key_id :ressortgroupref 0 \ - :resorigtbl 16388 :resorigcol 1 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 :varattno 2 :vartype 25} \ - :resno 2 :resname name :ressortgroupref 0 :resorigtbl 16388 :resorigcol 2 :resjunk false}) :override 0}) - """ - - @Test("Only the outermost target list counts, not a sub-select's") + @Test("Only the outermost target list counts, not a WHERE sub-select's") func readsOuterTargetList() { - let origins = PostgreSQLViewRowEditing.targetOrigins(nodeTree: viewWithSubselect) - #expect(origins == [ - .init(viewAttributeNumber: 1, tableOID: "16388", columnNumber: 1), - .init(viewAttributeNumber: 2, tableOID: "16388", columnNumber: 2), + #expect(PostgreSQLViewRowEditing.targetOrigins(nodeTree: Tree.whereSubselect) == [ + 1: .init(relationOID: OID.t, attributeNumber: 1), + 2: .init(relationOID: OID.t, attributeNumber: 2), ]) } + @Test("A junk entry is skipped even when it carries an origin") + func skipsJunkEntries() { + let expected: [Int: PostgreSQLViewRowEditing.ColumnOrigin] = [ + 1: .init(relationOID: OID.t, attributeNumber: 2), + 2: .init(relationOID: OID.t, attributeNumber: 1), + ] + #expect(PostgreSQLViewRowEditing.targetOrigins(nodeTree: Tree.sorted) == expected) + // The server writes no origin on a junk entry; this one gets one so only :resjunk keeps it out. + let junkWithOrigin = Tree.sorted.replacingOccurrences( + of: ":resorigtbl 0 :resorigcol 0 :resjunk true", + with: ":resorigtbl \(OID.t) :resorigcol 3 :resjunk true" + ) + #expect(junkWithOrigin != Tree.sorted) + #expect(PostgreSQLViewRowEditing.targetOrigins(nodeTree: junkWithOrigin) == expected) + } + @Test("A computed column has no origin") func computedColumnHasNoOrigin() { - let tree = """ - ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {OPEXPR :opno 551 :args ({VAR :varno 1 \ - :varattno 3} {CONST :consttype 23 :constvalue 4 [ 1 0 0 0 0 0 0 0 ]})} :resno 1 :resname q2 \ - :ressortgroupref 0 :resorigtbl 0 :resorigcol 0 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 \ - :varattno 1} :resno 2 :resname id :ressortgroupref 0 :resorigtbl 16388 :resorigcol 1 :resjunk false}) \ - :override 0}) - """ - #expect(PostgreSQLViewRowEditing.targetOrigins(nodeTree: tree) == [ - .init(viewAttributeNumber: 2, tableOID: "16388", columnNumber: 1), + #expect(PostgreSQLViewRowEditing.targetOrigins(nodeTree: Tree.innerCalc) == [ + 1: .init(relationOID: OID.t, attributeNumber: 1), + 3: .init(relationOID: OID.t, attributeNumber: 2), ]) } - @Test("An escaped bracket inside a column name does not end the entry") - func escapedBracketInName() { - let tree = """ - ({QUERY :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 1} :resno 1 :resname odd\\)\\}\\ name \ - :ressortgroupref 0 :resorigtbl 77 :resorigcol 1 :resjunk false}) :override 0}) - """ - #expect(PostgreSQLViewRowEditing.targetOrigins(nodeTree: tree) == [ - .init(viewAttributeNumber: 1, tableOID: "77", columnNumber: 1), + @Test("Escaped brackets and spaces in a column name do not end the entry") + func escapedNames() { + #expect(PostgreSQLViewRowEditing.targetOrigins(nodeTree: Tree.overOdd) == [ + 1: .init(relationOID: OID.vOdd, attributeNumber: 1), + 2: .init(relationOID: OID.vOdd, attributeNumber: 2), ]) } @@ -65,34 +50,505 @@ struct PostgreSQLViewRowEditingTests { #expect(PostgreSQLViewRowEditing.targetOrigins(nodeTree: "({QUERY :targetList <>})").isEmpty) } - @Test("The key is mapped through attribute numbers, so a renamed key column still keys the edit") - func resolvesRenamedKey() { - let origins = PostgreSQLViewRowEditing.targetOrigins(nodeTree: viewWithSubselect) + @Test("A table, or a name with no relation, is not a view") + func notAView() { + #expect(walk(Rows.t).step == .notAView) + #expect(walk(nil).step == .notAView) + } + + @Test( + "A rule or an INSTEAD OF trigger makes the view read-only", + arguments: [Rows.ruleSimple, Rows.ruleAggregate, Rows.ruleConditional, Rows.insteadOfTrigger] + ) + func rulesAndTriggers(row: [String?]) { + #expect(walk(row, through: [Rows.t]).step == .readOnly(.writesRunTriggers)) + } + + @Test("A trigger on a view one level down makes the outer view read-only") + func triggerOnInnerView() { + let walked = walk(Rows.overTrigger, through: [Rows.insteadOfTrigger, Rows.t]) + #expect(walked.step == .readOnly(.writesRunTriggers)) + #expect(walked.chain.map(\.oid) == [OID.vOverTrig, OID.vTrigSimple]) + } + + @Test("A view reading two tables is read-only for that reason") + func join() { + #expect(walk(Rows.join, through: [Rows.t]).step == .readOnly(.readsSeveralTables)) + } + + @Test("An aggregate reads one table but the server refuses writes") + func aggregate() { + #expect(walk(Rows.aggregate, through: [Rows.t]).step == .readOnly(.notUpdatable)) + } + + @Test("A view with no column from a relation is not updatable") + func noBaseRelation() { + #expect(walk(Rows.union, through: [Rows.t]).step == .readOnly(.notUpdatable)) + } + + @Test("Eight views over a table are walked, a ninth view is not") + func depthLimit() { + func chain(views: Int) -> [[String?]] { + (1...views).map { level in + let next = level == views ? OID.t : "9000\(level + 1)" + return Rows.relation("9000\(level)", "v", "v\(level)", tree: Tree.passThrough(to: next)) + } + } + let eight = chain(views: 8) + #expect(walk(eight[0], through: eight + [Rows.t]).step == .readColumns( + viewOID: "90001", + baseOID: OID.t, + baseSchema: "public" + )) + let nine = chain(views: 9) + #expect(walk(nine[0], through: nine + [Rows.t]).step == .readOnly(.notUpdatable)) + } + + @Test("A relation that is gone by the next read leaves the view read-only") + func relationGoneMidWalk() { + #expect(walk(Rows.v, through: []).step == .readOnly(.notUpdatable)) + } + + @Test("Operations come from the update, insert and delete bits") + func operationBits() { + #expect(PostgreSQLViewRowEditing.operations(events: 28) == .all) + #expect(PostgreSQLViewRowEditing.operations(events: 20) == [.update, .delete]) + #expect(PostgreSQLViewRowEditing.operations(events: 4) == .update) + #expect(PostgreSQLViewRowEditing.operations(events: 0).isEmpty) + } + + @Test("Computed, generated and always-identity columns are read-only; the rest edit through the key") + func simpleView() throws { + let walked = walk(Rows.v, through: [Rows.t]) + #expect(walked.step == .readColumns(viewOID: OID.v, baseOID: OID.t, baseSchema: "public")) let editing = PostgreSQLViewRowEditing.resolve( - isUpdatable: true, - origins: origins, - viewColumnNames: [1: "key_id", 2: "name"], - baseKeyColumnNumbers: [1] + chain: walked.chain, + viewColumns: Rows.vColumns, + baseColumns: Rows.tColumns ) - #expect(editing == .editable(keyColumns: ["key_id"])) + let target = try #require(editing.writeTarget) + #expect(target.baseTable == PluginViewBaseTable(schema: "public", name: "t")) + #expect(target.keyColumns == ["id"]) + #expect(target.readOnlyColumns == ["q2", "gen", "ident"]) + #expect(target.operations == .all) + #expect(target.baseColumnNames["q2"] == nil) } - @Test("A view that hides the key column stays read-only") - func hiddenKeyStaysReadOnly() { - let origins = [PostgreSQLViewRowEditing.TargetOrigin(viewAttributeNumber: 1, tableOID: "16388", columnNumber: 2)] + @Test("Base defaults, identity and generated columns reach the target as the catalog says") + func baseColumnFacts() throws { let editing = PostgreSQLViewRowEditing.resolve( - isUpdatable: true, origins: origins, viewColumnNames: [1: "name"], baseKeyColumnNumbers: [1] + chain: walk(Rows.v, through: [Rows.t]).chain, + viewColumns: Rows.vColumns, + baseColumns: Rows.tColumns + ) + let columns = try #require(editing.writeTarget).baseColumns + #expect(columns["ser"]?.defaultValue == "nextval('t_ser_seq'::regclass)") + #expect(columns["ser"]?.isRequiredOnInsert == false) + #expect(columns["gen"]?.defaultValue == nil) + #expect(columns["gen"]?.acceptsWrites == false) + #expect(columns["ident"]?.assignsValue == true) + #expect(columns["ident"]?.acceptsWrites == false) + #expect(columns["byd"]?.assignsValue == true) + #expect(columns["byd"]?.acceptsWrites == true) + #expect(columns["req"]?.isRequiredOnInsert == true) + } + + @Test("Hiding a NOT NULL column with no default drops Add Row") + func hiddenRequiredColumnDropsInsert() throws { + let editing = PostgreSQLViewRowEditing.resolve( + chain: walk(Rows.hideRequired, through: [Rows.t]).chain, + viewColumns: Rows.hideRequiredColumns, + baseColumns: Rows.tColumns + ) + let target = try #require(editing.writeTarget) + #expect(target.operations == [.update, .delete]) + #expect(target.keyColumns == ["id"]) + } + + @Test("A renamed key column keys the edit by its view name") + func renamedKey() throws { + let editing = PostgreSQLViewRowEditing.resolve( + chain: walk(Rows.rename, through: [Rows.t]).chain, + viewColumns: Rows.renameColumns, + baseColumns: Rows.tColumns + ) + let target = try #require(editing.writeTarget) + #expect(target.keyColumns == ["key_id"]) + #expect(target.baseKeyColumns == ["id"]) + #expect(target.baseColumnNames == ["label": "name", "key_id": "id", "amount": "qty", "req": "req"]) + } + + @Test("Column numbers compose through each view down to the base table") + func composedMap() throws { + let nested = walk(Rows.nested, through: [Rows.rename, Rows.t]) + #expect(PostgreSQLViewRowEditing.baseAttributeNumbers(nested.chain) == [1: 1, 2: 2]) + + let walked = walk(Rows.nested2, through: [Rows.nested, Rows.rename, Rows.t]) + #expect(walked.chain.map(\.oid) == [OID.vNested2, OID.vNested, OID.vRename, OID.t]) + #expect(walked.step == .readColumns(viewOID: OID.vNested2, baseOID: OID.t, baseSchema: "public")) + #expect(PostgreSQLViewRowEditing.baseAttributeNumbers(walked.chain) == [1: 2, 2: 1]) + + let editing = PostgreSQLViewRowEditing.resolve( + chain: walked.chain, + viewColumns: Rows.nested2Columns, + baseColumns: Rows.tColumns + ) + let target = try #require(editing.writeTarget) + #expect(target.baseTable == PluginViewBaseTable(schema: "public", name: "t")) + #expect(target.keyColumns == ["k"]) + #expect(target.baseColumnNames == ["heading": "name", "k": "id"]) + #expect(target.operations == [.update, .delete]) + } + + @Test("A view that hides the key stays read-only") + func hiddenKey() { + let editing = PostgreSQLViewRowEditing.resolve( + chain: walk(Rows.noKey, through: [Rows.t]).chain, + viewColumns: Rows.noKeyColumns, + baseColumns: Rows.tColumns ) #expect(editing == .readOnly(.keyNotExposed)) } - @Test("The catalog queries quote the names they are given") - func queriesQuoteNames() { - let query = PostgreSQLViewRowEditing.viewQuery(name: "o'v", schema: "s") - #expect(query.contains("c.relname = 'o''v'")) - #expect(query.contains("pg_relation_is_updatable(c.oid, false) & 20) = 20")) - let detail = PostgreSQLViewRowEditing.columnsAndKeyQuery(viewOID: "10", baseTableOID: "20") - #expect(detail.contains("a.attrelid = '10'::oid")) - #expect(detail.contains("i.indrelid = '20'::oid")) + @Test("A base table with no primary key stays read-only") + func baseWithoutPrimaryKey() { + let editing = PostgreSQLViewRowEditing.resolve( + chain: walk(Rows.noPrimaryKey, through: [Rows.noPrimaryKeyTable]).chain, + viewColumns: Rows.noPrimaryKeyColumns, + baseColumns: Rows.noPrimaryKeyTableColumns + ) + #expect(editing == .readOnly(.baseTableHasNoKey)) + } + + @Test("A composite key keeps the primary key's own order") + func compositeKeyOrder() throws { + let editing = PostgreSQLViewRowEditing.resolve( + chain: walk(Rows.compositeKey, through: [Rows.compositeKeyTable]).chain, + viewColumns: Rows.compositeKeyColumns, + baseColumns: Rows.compositeKeyTableColumns + ) + #expect(try #require(editing.writeTarget).keyColumns == ["b", "a"]) + } + + @Test("A target entry with no view attribute leaves the view read-only") + func misreadTreeIsRefused() { + let editing = PostgreSQLViewRowEditing.resolve( + chain: walk(Rows.v, through: [Rows.t]).chain, + viewColumns: Array(Rows.vColumns.dropLast()), + baseColumns: Rows.tColumns + ) + #expect(editing == .readOnly(.notUpdatable)) + } + + @Test("The catalog queries quote what they are given") + func queriesQuote() { + let byName = PostgreSQLViewRowEditing.relationQuery(name: "o'v", schema: "s") + #expect(byName.contains("n.nspname = 's' AND c.relname = 'o''v'")) + #expect(PostgreSQLViewRowEditing.relationQuery(oid: "16733").contains("c.oid = '16733'::oid")) + #expect(PostgreSQLViewRowEditing.viewColumnsQuery(viewOID: "16761").contains("a.attrelid = '16761'::oid")) + let modern = PostgreSQLViewRowEditing.baseColumnsQuery( + baseOID: "16733", + capabilities: PostgreSQLCapabilities(serverVersion: 170_000) + ) + #expect(modern.contains("i.indrelid = '16733'::oid")) + #expect(modern.contains("a.attidentity") && modern.contains("a.attgenerated")) + } + + @Test("Servers without identity or generated columns read neither") + func legacyBaseColumns() { + let query = PostgreSQLViewRowEditing.baseColumnsQuery( + baseOID: "16733", + capabilities: PostgreSQLCapabilities(serverVersion: 90_600) + ) + #expect(!query.contains("attidentity")) + #expect(!query.contains("attgenerated")) + } +} + +private func walk( + _ first: [String?]?, + through relations: [[String?]] = [] +) -> (chain: [PostgreSQLViewRowEditing.Relation], step: PostgreSQLViewRowEditing.Step) { + var chain: [PostgreSQLViewRowEditing.Relation] = [] + var step = PostgreSQLViewRowEditing.step(&chain, row: first) + while case .readRelation(let oid) = step { + step = PostgreSQLViewRowEditing.step(&chain, row: relations.first { $0.first == oid }) + } + return (chain, step) +} + +private enum OID { + static let t = "16733" + static let ck = "16754" + static let nopk = "16749" + static let v = "16761" + static let vRename = "16765" + static let vNested = "16835" + static let vNested2 = "16839" + static let vTrigSimple = "16820" + static let vOverTrig = "16851" + static let vOdd = "16875" +} + +// PostgreSQL 17.11 output for t(id int PRIMARY KEY, name text NOT NULL, qty int, +// gen int GENERATED ALWAYS AS (qty * 2) STORED, ident int GENERATED ALWAYS AS IDENTITY, +// byd int GENERATED BY DEFAULT AS IDENTITY, ser serial, req text NOT NULL), ck(a, b, c, PRIMARY KEY (b, a)), +// nopk(a, b) and the views named on each tree. +private enum Rows { + static func relation( + _ oid: String, + _ kind: String, + _ name: String, + events: String = "28", + rules: Bool = false, + trigger: Bool = false, + tree: String? = nil + ) -> [String?] { + [oid, kind, "public", name, events, rules ? "true" : "false", trigger ? "true" : "false", tree] + } + + static let t = relation(OID.t, "r", "t") + static let compositeKeyTable = relation(OID.ck, "r", "ck") + static let noPrimaryKeyTable = relation(OID.nopk, "r", "nopk") + static let v = relation(OID.v, "v", "v", tree: Tree.v) + static let rename = relation(OID.vRename, "v", "v_rename", tree: Tree.rename) + static let nested = relation(OID.vNested, "v", "v_nested", tree: Tree.nested) + static let nested2 = relation(OID.vNested2, "v", "v_nested2", tree: Tree.nested2) + static let ruleSimple = relation("16797", "v", "v_rule_simple", rules: true, tree: Tree.idName) + static let ruleAggregate = relation("16802", "v", "v_rule_agg", events: "20", rules: true, tree: Tree.agg) + static let ruleConditional = relation("16808", "v", "v_rule_cond", rules: true, tree: Tree.idName) + static let insteadOfTrigger = relation(OID.vTrigSimple, "v", "v_trig_simple", trigger: true, tree: Tree.idName) + static let overTrigger = relation(OID.vOverTrig, "v", "v_over_trig", tree: Tree.overTrig) + static let aggregate = relation("16781", "v", "v_agg", events: "0", tree: Tree.agg) + static let join = relation("16777", "v", "v_join", events: "0", tree: Tree.join) + static let union = relation("16896", "v", "v_union", events: "0", tree: Tree.union) + static let noKey = relation("16769", "v", "v_nokey", tree: Tree.noKey) + static let hideRequired = relation("16773", "v", "v_hide_req", tree: Tree.hideReq) + static let compositeKey = relation("16789", "v", "v_ck", tree: Tree.compositeKey) + static let noPrimaryKey = relation("16793", "v", "v_nopk", tree: Tree.noPrimaryKey) + + static let tColumns: [[String?]] = [ + ["1", "id", "true", nil, "", "", "1"], + ["2", "name", "true", nil, "", "", nil], + ["3", "qty", "false", nil, "", "", nil], + ["4", "gen", "false", "(qty * 2)", "", "s", nil], + ["5", "ident", "true", nil, "a", "", nil], + ["6", "byd", "true", nil, "d", "", nil], + ["7", "ser", "true", "nextval('t_ser_seq'::regclass)", "", "", nil], + ["8", "req", "true", nil, "", "", nil], + ] + static let compositeKeyTableColumns: [[String?]] = [ + ["1", "a", "true", nil, "", "", "2"], + ["2", "b", "true", nil, "", "", "1"], + ["3", "c", "false", nil, "", "", nil], + ] + static let noPrimaryKeyTableColumns: [[String?]] = [ + ["1", "a", "false", nil, "", "", nil], + ["2", "b", "false", nil, "", "", nil], + ] + + static let vColumns: [[String?]] = [ + ["1", "id", "true"], + ["2", "name", "true"], + ["3", "qty", "true"], + ["4", "q2", "false"], + ["5", "gen", "true"], + ["6", "ident", "true"], + ["7", "byd", "true"], + ["8", "ser", "true"], + ["9", "req", "true"], + ] + static let renameColumns: [[String?]] = [ + ["1", "label", "true"], ["2", "key_id", "true"], ["3", "amount", "true"], ["4", "req", "true"], + ] + static let nested2Columns: [[String?]] = [["1", "heading", "true"], ["2", "k", "true"]] + static let noKeyColumns: [[String?]] = [["1", "name", "true"], ["2", "qty", "true"], ["3", "req", "true"]] + static let hideRequiredColumns: [[String?]] = [["1", "id", "true"], ["2", "name", "true"], ["3", "qty", "true"]] + static let compositeKeyColumns: [[String?]] = [["1", "c", "true"], ["2", "a", "true"], ["3", "b", "true"]] + static let noPrimaryKeyColumns: [[String?]] = [["1", "a", "true"], ["2", "b", "true"]] +} + +// pg_rewrite.ev_action of each view's _RETURN rule, without the fields the parser never reads. +private enum Tree { + static func passThrough(to oid: String) -> String { + """ + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 1 :vartype 23 \ + :varnullingrels (b)} :resno 1 :resname id :resorigtbl \(oid) :resorigcol 1 :resjunk false}) :override 0}) + """ } + + // CREATE VIEW v_where_sub AS SELECT id AS key_id, name FROM t WHERE id IN (SELECT t_id FROM t2) OR true + static let whereSubselect = """ + ({QUERY :commandType 1 :rtable ({RANGETBLENTRY :alias <> :eref {ALIAS :aliasname t :colnames ("id" "name" \ + "qty" "gen" "ident" "byd" "ser" "req")} :rtekind 0 :relid 16733}) :jointree {FROMEXPR :fromlist \ + ({RANGETBLREF :rtindex 1}) :quals {BOOLEXPR :boolop or :args ({SUBLINK :subLinkType 2 :subLinkId 0 :testexpr \ + {OPEXPR :opno 96 :opfuncid 65 :opresulttype 16 :opretset false :opcollid 0 :inputcollid 0 :args ({VAR :varno \ + 1 :varattno 1 :vartype 23 :varnullingrels (b)} {PARAM :paramkind 2 :paramid 1 :paramtype 23 :paramtypmod -1 \ + :paramcollid 0 :location -1}) :location -1} :operName ("=") :subselect {QUERY :commandType 1 :rtable \ + ({RANGETBLENTRY :alias <> :eref {ALIAS :aliasname t2 :colnames ("id" "t_id" "x")} :rtekind 0 :relid 16742}) \ + :jointree {FROMEXPR :fromlist ({RANGETBLREF :rtindex 1}) :quals <>} :targetList ({TARGETENTRY :expr {VAR \ + :varno 1 :varattno 2 :vartype 23 :varnullingrels (b)} :resno 1 :resname t_id :resorigtbl 16742 :resorigcol 2 \ + :resjunk false}) :override 0 :sortClause <>} :location -1} {CONST :consttype 16 :consttypmod -1 :constcollid \ + 0 :constlen 1 :constbyval true :constisnull false :location -1 :constvalue 1 [ 1 0 0 0 0 0 0 0 ]}) :location \ + -1}} :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 1 :vartype 23 :varnullingrels (b)} :resno 1 \ + :resname key_id :resorigtbl 16733 :resorigcol 1 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 :varattno 2 \ + :vartype 25 :varnullingrels (b)} :resno 2 :resname name :resorigtbl 16733 :resorigcol 2 :resjunk false}) \ + :override 0 :sortClause <>}) + """ + + // CREATE VIEW v_sorted AS SELECT name, id FROM t ORDER BY qty + static let sorted = """ + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 2 :vartype 25 :varnullingrels \ + (b)} :resno 1 :resname name :resorigtbl 16733 :resorigcol 2 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 \ + :varattno 1 :vartype 23 :varnullingrels (b)} :resno 2 :resname id :resorigtbl 16733 :resorigcol 1 :resjunk \ + false} {TARGETENTRY :expr {VAR :varno 1 :varattno 3 :vartype 23 :varnullingrels (b)} :resno 3 :resname <> \ + :ressortgroupref 1 :resorigtbl 0 :resorigcol 0 :resjunk true}) :override 0}) + """ + + // CREATE VIEW v_inner_calc AS SELECT id, qty + 1 AS q2, name FROM t + static let innerCalc = """ + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 1 :vartype 23 :varnullingrels \ + (b)} :resno 1 :resname id :resorigtbl 16733 :resorigcol 1 :resjunk false} {TARGETENTRY :expr {OPEXPR :opno \ + 551 :opfuncid 177 :opresulttype 23 :opretset false :opcollid 0 :inputcollid 0 :args ({VAR :varno 1 :varattno \ + 3 :vartype 23 :varnullingrels (b)} {CONST :consttype 23 :consttypmod -1 :constcollid 0 :constlen 4 \ + :constbyval true :constisnull false :location -1 :constvalue 4 [ 1 0 0 0 0 0 0 0 ]}) :location -1} :resno 2 \ + :resname q2 :resorigtbl 0 :resorigcol 0 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 :varattno 2 \ + :vartype 25 :varnullingrels (b)} :resno 3 :resname name :resorigtbl 16733 :resorigcol 2 :resjunk false}) \ + :override 0}) + """ + + // CREATE VIEW v_over_odd AS SELECT "odd) }{name" AS "a""b(c", "two words" FROM v_odd + static let overOdd = #""" + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 1 :vartype 23 \# + :varnullingrels (b)} :resno 1 :resname a"b\(c :resorigtbl 16875 :resorigcol 1 :resjunk false} {TARGETENTRY \# + :expr {VAR :varno 1 :varattno 2 :vartype 25 :varnullingrels (b)} :resno 2 :resname two\ words :resorigtbl \# + 16875 :resorigcol 2 :resjunk false}) :override 0}) + """# + + // CREATE VIEW v AS SELECT id, name, qty, qty + 1 AS q2, gen, ident, byd, ser, req FROM t + static let v = """ + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 1 :vartype 23 :varnullingrels \ + (b)} :resno 1 :resname id :resorigtbl 16733 :resorigcol 1 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 \ + :varattno 2 :vartype 25 :varnullingrels (b)} :resno 2 :resname name :resorigtbl 16733 :resorigcol 2 :resjunk \ + false} {TARGETENTRY :expr {VAR :varno 1 :varattno 3 :vartype 23 :varnullingrels (b)} :resno 3 :resname qty \ + :resorigtbl 16733 :resorigcol 3 :resjunk false} {TARGETENTRY :expr {OPEXPR :opno 551 :opfuncid 177 \ + :opresulttype 23 :opretset false :opcollid 0 :inputcollid 0 :args ({VAR :varno 1 :varattno 3 :vartype 23 \ + :varnullingrels (b)} {CONST :consttype 23 :consttypmod -1 :constcollid 0 :constlen 4 :constbyval true \ + :constisnull false :location -1 :constvalue 4 [ 1 0 0 0 0 0 0 0 ]}) :location -1} :resno 4 :resname q2 \ + :resorigtbl 0 :resorigcol 0 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 :varattno 4 :vartype 23 \ + :varnullingrels (b)} :resno 5 :resname gen :resorigtbl 16733 :resorigcol 4 :resjunk false} {TARGETENTRY \ + :expr {VAR :varno 1 :varattno 5 :vartype 23 :varnullingrels (b)} :resno 6 :resname ident :resorigtbl 16733 \ + :resorigcol 5 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 :varattno 6 :vartype 23 :varnullingrels (b)} \ + :resno 7 :resname byd :resorigtbl 16733 :resorigcol 6 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 \ + :varattno 7 :vartype 23 :varnullingrels (b)} :resno 8 :resname ser :resorigtbl 16733 :resorigcol 7 :resjunk \ + false} {TARGETENTRY :expr {VAR :varno 1 :varattno 8 :vartype 25 :varnullingrels (b)} :resno 9 :resname req \ + :resorigtbl 16733 :resorigcol 8 :resjunk false}) :override 0}) + """ + + // CREATE VIEW v_rename AS SELECT name AS label, id AS key_id, qty AS amount, req FROM t + static let rename = """ + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 2 :vartype 25 :varnullingrels \ + (b)} :resno 1 :resname label :resorigtbl 16733 :resorigcol 2 :resjunk false} {TARGETENTRY :expr {VAR :varno \ + 1 :varattno 1 :vartype 23 :varnullingrels (b)} :resno 2 :resname key_id :resorigtbl 16733 :resorigcol 1 \ + :resjunk false} {TARGETENTRY :expr {VAR :varno 1 :varattno 3 :vartype 23 :varnullingrels (b)} :resno 3 \ + :resname amount :resorigtbl 16733 :resorigcol 3 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 :varattno 8 \ + :vartype 25 :varnullingrels (b)} :resno 4 :resname req :resorigtbl 16733 :resorigcol 8 :resjunk false}) \ + :override 0}) + """ + + // CREATE VIEW v_nested AS SELECT key_id, label AS title FROM v_rename + static let nested = """ + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 2 :vartype 23 :varnullingrels \ + (b)} :resno 1 :resname key_id :resorigtbl 16765 :resorigcol 2 :resjunk false} {TARGETENTRY :expr {VAR :varno \ + 1 :varattno 1 :vartype 25 :varnullingrels (b)} :resno 2 :resname title :resorigtbl 16765 :resorigcol 1 \ + :resjunk false}) :override 0}) + """ + + // CREATE VIEW v_nested2 AS SELECT title AS heading, key_id AS k FROM v_nested + static let nested2 = """ + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 2 :vartype 25 :varnullingrels \ + (b)} :resno 1 :resname heading :resorigtbl 16835 :resorigcol 2 :resjunk false} {TARGETENTRY :expr {VAR \ + :varno 1 :varattno 1 :vartype 23 :varnullingrels (b)} :resno 2 :resname k :resorigtbl 16835 :resorigcol 1 \ + :resjunk false}) :override 0}) + """ + + // v_rule_simple, v_rule_cond and v_trig_simple: SELECT id, name FROM t + static let idName = """ + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 1 :vartype 23 :varnullingrels \ + (b)} :resno 1 :resname id :resorigtbl 16733 :resorigcol 1 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 \ + :varattno 2 :vartype 25 :varnullingrels (b)} :resno 2 :resname name :resorigtbl 16733 :resorigcol 2 :resjunk \ + false}) :override 0}) + """ + + // CREATE VIEW v_over_trig AS SELECT id, name FROM v_trig_simple + static let overTrig = """ + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 1 :vartype 23 :varnullingrels \ + (b)} :resno 1 :resname id :resorigtbl 16820 :resorigcol 1 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 \ + :varattno 2 :vartype 25 :varnullingrels (b)} :resno 2 :resname name :resorigtbl 16820 :resorigcol 2 :resjunk \ + false}) :override 0}) + """ + + // v_agg and v_rule_agg: SELECT name, count(*) AS n FROM t GROUP BY name + static let agg = """ + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 2 :vartype 25 :varnullingrels \ + (b)} :resno 1 :resname name :ressortgroupref 1 :resorigtbl 16733 :resorigcol 2 :resjunk false} {TARGETENTRY \ + :expr {AGGREF :aggfnoid 2803 :aggtype 20 :aggcollid 0 :inputcollid 0 :aggtranstype 0 :aggargtypes <> \ + :aggdirectargs <> :args <> :aggorder <> :aggdistinct <> :aggfilter <> :aggstar true :aggvariadic false \ + :aggkind n :aggpresorted false :agglevelsup 0 :aggsplit 0 :aggno -1 :aggtransno -1 :location -1} :resno 2 \ + :resname n :resorigtbl 0 :resorigcol 0 :resjunk false}) :override 0 :groupClause ({SORTGROUPCLAUSE \ + :tleSortGroupRef 1 :eqop 98 :sortop 664 :nulls_first false :hashable true})}) + """ + + // CREATE VIEW v_join AS SELECT t.id, t.name, t2.x FROM t JOIN t2 ON t2.t_id = t.id + static let join = """ + ({QUERY :commandType 1 :rteperminfos ({RTEPERMISSIONINFO :relid 16733 :inh true :requiredPerms 2 \ + :checkAsUser 0 :selectedCols (b 8 9) :insertedCols (b) :updatedCols (b)} {RTEPERMISSIONINFO :relid 16742 \ + :inh true :requiredPerms 2 :checkAsUser 0 :selectedCols (b 9 10) :insertedCols (b) :updatedCols (b)}) \ + :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 1 :vartype 23 :varnullingrels (b)} :resno 1 :resname \ + id :resorigtbl 16733 :resorigcol 1 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 :varattno 2 :vartype 25 \ + :varnullingrels (b)} :resno 2 :resname name :resorigtbl 16733 :resorigcol 2 :resjunk false} {TARGETENTRY \ + :expr {VAR :varno 2 :varattno 3 :vartype 25 :varnullingrels (b)} :resno 3 :resname x :resorigtbl 16742 \ + :resorigcol 3 :resjunk false}) :override 0}) + """ + + // CREATE VIEW v_union AS SELECT id FROM t UNION SELECT id FROM t2 + static let union = """ + ({QUERY :commandType 1 :rteperminfos <> :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 1 :vartype \ + 23 :varnullingrels (b)} :resno 1 :resname id :resorigtbl 0 :resorigcol 0 :resjunk false}) :override 0}) + """ + + // CREATE VIEW v_nokey AS SELECT name, qty, req FROM t + static let noKey = """ + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 2 :vartype 25 :varnullingrels \ + (b)} :resno 1 :resname name :resorigtbl 16733 :resorigcol 2 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 \ + :varattno 3 :vartype 23 :varnullingrels (b)} :resno 2 :resname qty :resorigtbl 16733 :resorigcol 3 :resjunk \ + false} {TARGETENTRY :expr {VAR :varno 1 :varattno 8 :vartype 25 :varnullingrels (b)} :resno 3 :resname req \ + :resorigtbl 16733 :resorigcol 8 :resjunk false}) :override 0}) + """ + + // CREATE VIEW v_hide_req AS SELECT id, name, qty FROM t + static let hideReq = """ + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 1 :vartype 23 :varnullingrels \ + (b)} :resno 1 :resname id :resorigtbl 16733 :resorigcol 1 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 \ + :varattno 2 :vartype 25 :varnullingrels (b)} :resno 2 :resname name :resorigtbl 16733 :resorigcol 2 :resjunk \ + false} {TARGETENTRY :expr {VAR :varno 1 :varattno 3 :vartype 23 :varnullingrels (b)} :resno 3 :resname qty \ + :resorigtbl 16733 :resorigcol 3 :resjunk false}) :override 0}) + """ + + // CREATE VIEW v_ck AS SELECT c, a, b FROM ck + static let compositeKey = """ + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 3 :vartype 25 :varnullingrels \ + (b)} :resno 1 :resname c :resorigtbl 16754 :resorigcol 3 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 \ + :varattno 1 :vartype 23 :varnullingrels (b)} :resno 2 :resname a :resorigtbl 16754 :resorigcol 1 :resjunk \ + false} {TARGETENTRY :expr {VAR :varno 1 :varattno 2 :vartype 23 :varnullingrels (b)} :resno 3 :resname b \ + :resorigtbl 16754 :resorigcol 2 :resjunk false}) :override 0}) + """ + + // CREATE VIEW v_nopk AS SELECT a, b FROM nopk + static let noPrimaryKey = """ + ({QUERY :commandType 1 :targetList ({TARGETENTRY :expr {VAR :varno 1 :varattno 1 :vartype 23 :varnullingrels \ + (b)} :resno 1 :resname a :resorigtbl 16749 :resorigcol 1 :resjunk false} {TARGETENTRY :expr {VAR :varno 1 \ + :varattno 2 :vartype 25 :varnullingrels (b)} :resno 2 :resname b :resorigtbl 16749 :resorigcol 2 :resjunk \ + false}) :override 0}) + """ } diff --git a/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 074250baa0..c934c00981 100644 --- a/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -646,10 +646,12 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { /// Whether this view can be refreshed without blocking its readers. Return nil where the engine /// has no such refresh, so the option is not offered at all. - func concurrentRefreshAvailability(materializedView: String, schema: String?) async throws -> PluginConcurrentRefreshAvailability? + func concurrentRefreshAvailability( + materializedView: String, + schema: String? + ) async throws -> PluginConcurrentRefreshAvailability? - /// Whether the grid may write rows through this view, keyed on its base table's primary key. - /// Return nil where the engine does not check, so its views stay read-only. + // Nil where the engine does not check, so its views stay read-only. func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? // Nil where the engine has no sequences, which keeps the sequence commands off the menu. @@ -1370,6 +1372,8 @@ public extension PluginDatabaseDriver { nil } + func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? { nil } + func fetchSequenceDetails(sequence: String, schema: String?) async throws -> PluginSequenceDetails? { nil } func sequenceNextValueStatement( @@ -1728,152 +1732,3 @@ public extension PluginDatabaseDriver { ) } } - -public enum PluginSQLFilter { - public static func escapeForLike(_ text: String) -> String { - text - .replacingOccurrences(of: "\\", with: "\\\\", options: .literal) - .replacingOccurrences(of: "%", with: "\\%", options: .literal) - .replacingOccurrences(of: "_", with: "\\_", options: .literal) - .replacingOccurrences(of: "'", with: "''", options: .literal) - } - - public static func buildOrderByClause( - sortColumns: [(columnIndex: Int, ascending: Bool)], - columns: [String], - quoteIdentifier: (String) -> String - ) -> String? { - let parts = sortColumns.compactMap { sortCol -> String? in - guard sortCol.columnIndex >= 0, sortCol.columnIndex < columns.count else { return nil } - let direction = sortCol.ascending ? "ASC" : "DESC" - return "\(quoteIdentifier(columns[sortCol.columnIndex])) \(direction)" - } - guard !parts.isEmpty else { return nil } - return "ORDER BY " + parts.joined(separator: ", ") - } - - public static func buildWhereClause( - filters: [(column: String, op: String, value: String)], - logicMode: String, - quoteIdentifier: (String) -> String, - escapeValue: (String) -> String, - regexCondition: (_ quotedColumn: String, _ value: String) -> String? - ) -> String { - let conditions = filters.compactMap { filter in - buildFilterCondition( - column: filter.column, - op: filter.op, - value: filter.value, - quoteIdentifier: quoteIdentifier, - escapeValue: escapeValue, - regexCondition: regexCondition - ) - } - guard !conditions.isEmpty else { return "" } - let separator = logicMode == "and" ? " AND " : " OR " - return conditions.joined(separator: separator) - } - - public static func buildFilterCondition( - column: String, - op: String, - value: String, - quoteIdentifier: (String) -> String, - escapeValue: (String) -> String, - regexCondition: (_ quotedColumn: String, _ value: String) -> String? - ) -> String? { - let quoted = quoteIdentifier(column) - switch op { - case "=": return "\(quoted) = \(escapeValue(value))" - case "!=": return "\(quoted) != \(escapeValue(value))" - case ">": return "\(quoted) > \(escapeValue(value))" - case ">=": return "\(quoted) >= \(escapeValue(value))" - case "<": return "\(quoted) < \(escapeValue(value))" - case "<=": return "\(quoted) <= \(escapeValue(value))" - case "IS NULL": return "\(quoted) IS NULL" - case "IS NOT NULL": return "\(quoted) IS NOT NULL" - case "IS EMPTY": return "(\(quoted) IS NULL OR \(quoted) = '')" - case "IS NOT EMPTY": return "(\(quoted) IS NOT NULL AND \(quoted) != '')" - case "CONTAINS": - return "\(quoted) LIKE '%\(escapeForLike(value))%' ESCAPE '\\'" - case "NOT CONTAINS": - return "\(quoted) NOT LIKE '%\(escapeForLike(value))%' ESCAPE '\\'" - case "STARTS WITH": - return "\(quoted) LIKE '\(escapeForLike(value))%' ESCAPE '\\'" - case "ENDS WITH": - return "\(quoted) LIKE '%\(escapeForLike(value))' ESCAPE '\\'" - case "IN": - let values = value.split(separator: ",") - .map { escapeValue($0.trimmingCharacters(in: .whitespaces)) } - .joined(separator: ", ") - return values.isEmpty ? nil : "\(quoted) IN (\(values))" - case "NOT IN": - let values = value.split(separator: ",") - .map { escapeValue($0.trimmingCharacters(in: .whitespaces)) } - .joined(separator: ", ") - return values.isEmpty ? nil : "\(quoted) NOT IN (\(values))" - case "BETWEEN": - let parts = value.split(separator: ",", maxSplits: 1) - guard parts.count == 2 else { return nil } - let v1 = escapeValue(parts[0].trimmingCharacters(in: .whitespaces)) - let v2 = escapeValue(parts[1].trimmingCharacters(in: .whitespaces)) - return "\(quoted) BETWEEN \(v1) AND \(v2)" - case "REGEX": - return regexCondition(quoted, value) - default: return nil - } - } - - public static func buildWhereClause( - filters: [(column: String, op: String, value: String)], - logicMode: String, - columnKinds: [String: PluginColumnKind], - quoteIdentifier: (String) -> String, - escapeTypedValue: (_ value: String, _ kind: PluginColumnKind?) -> String, - regexCondition: (_ quotedColumn: String, _ value: String) -> String? - ) -> String { - let conditions = filters.compactMap { filter in - buildFilterCondition( - column: filter.column, - op: filter.op, - value: filter.value, - kind: columnKinds[filter.column], - quoteIdentifier: quoteIdentifier, - escapeTypedValue: escapeTypedValue, - regexCondition: regexCondition - ) - } - guard !conditions.isEmpty else { return "" } - let separator = logicMode == "and" ? " AND " : " OR " - return conditions.joined(separator: separator) - } - - public static func buildFilterCondition( - column: String, - op: String, - value: String, - kind: PluginColumnKind?, - quoteIdentifier: (String) -> String, - escapeTypedValue: (_ value: String, _ kind: PluginColumnKind?) -> String, - regexCondition: (_ quotedColumn: String, _ value: String) -> String? - ) -> String? { - let quoted = quoteIdentifier(column) - switch op { - case "IS EMPTY": - guard PluginSQLLiteral.supportsEmptyStringComparison(kind) else { return "\(quoted) IS NULL" } - return "(\(quoted) IS NULL OR \(quoted) = '')" - case "IS NOT EMPTY": - guard PluginSQLLiteral.supportsEmptyStringComparison(kind) else { return "\(quoted) IS NOT NULL" } - return "(\(quoted) IS NOT NULL AND \(quoted) != '')" - default: - return buildFilterCondition( - column: column, - op: op, - value: value, - quoteIdentifier: quoteIdentifier, - escapeValue: { escapeTypedValue($0, kind) }, - regexCondition: regexCondition - ) - } - } -} diff --git a/apple/Plugins/TableProPluginKit/PluginSQLFilter.swift b/apple/Plugins/TableProPluginKit/PluginSQLFilter.swift new file mode 100644 index 0000000000..0de3c10d0d --- /dev/null +++ b/apple/Plugins/TableProPluginKit/PluginSQLFilter.swift @@ -0,0 +1,150 @@ +import Foundation + +public enum PluginSQLFilter { + public static func escapeForLike(_ text: String) -> String { + text + .replacingOccurrences(of: "\\", with: "\\\\", options: .literal) + .replacingOccurrences(of: "%", with: "\\%", options: .literal) + .replacingOccurrences(of: "_", with: "\\_", options: .literal) + .replacingOccurrences(of: "'", with: "''", options: .literal) + } + + public static func buildOrderByClause( + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + quoteIdentifier: (String) -> String + ) -> String? { + let parts = sortColumns.compactMap { sortCol -> String? in + guard sortCol.columnIndex >= 0, sortCol.columnIndex < columns.count else { return nil } + let direction = sortCol.ascending ? "ASC" : "DESC" + return "\(quoteIdentifier(columns[sortCol.columnIndex])) \(direction)" + } + guard !parts.isEmpty else { return nil } + return "ORDER BY " + parts.joined(separator: ", ") + } + + public static func buildWhereClause( + filters: [(column: String, op: String, value: String)], + logicMode: String, + quoteIdentifier: (String) -> String, + escapeValue: (String) -> String, + regexCondition: (_ quotedColumn: String, _ value: String) -> String? + ) -> String { + let conditions = filters.compactMap { filter in + buildFilterCondition( + column: filter.column, + op: filter.op, + value: filter.value, + quoteIdentifier: quoteIdentifier, + escapeValue: escapeValue, + regexCondition: regexCondition + ) + } + guard !conditions.isEmpty else { return "" } + let separator = logicMode == "and" ? " AND " : " OR " + return conditions.joined(separator: separator) + } + + public static func buildFilterCondition( + column: String, + op: String, + value: String, + quoteIdentifier: (String) -> String, + escapeValue: (String) -> String, + regexCondition: (_ quotedColumn: String, _ value: String) -> String? + ) -> String? { + let quoted = quoteIdentifier(column) + switch op { + case "=": return "\(quoted) = \(escapeValue(value))" + case "!=": return "\(quoted) != \(escapeValue(value))" + case ">": return "\(quoted) > \(escapeValue(value))" + case ">=": return "\(quoted) >= \(escapeValue(value))" + case "<": return "\(quoted) < \(escapeValue(value))" + case "<=": return "\(quoted) <= \(escapeValue(value))" + case "IS NULL": return "\(quoted) IS NULL" + case "IS NOT NULL": return "\(quoted) IS NOT NULL" + case "IS EMPTY": return "(\(quoted) IS NULL OR \(quoted) = '')" + case "IS NOT EMPTY": return "(\(quoted) IS NOT NULL AND \(quoted) != '')" + case "CONTAINS": + return "\(quoted) LIKE '%\(escapeForLike(value))%' ESCAPE '\\'" + case "NOT CONTAINS": + return "\(quoted) NOT LIKE '%\(escapeForLike(value))%' ESCAPE '\\'" + case "STARTS WITH": + return "\(quoted) LIKE '\(escapeForLike(value))%' ESCAPE '\\'" + case "ENDS WITH": + return "\(quoted) LIKE '%\(escapeForLike(value))' ESCAPE '\\'" + case "IN": + let values = value.split(separator: ",") + .map { escapeValue($0.trimmingCharacters(in: .whitespaces)) } + .joined(separator: ", ") + return values.isEmpty ? nil : "\(quoted) IN (\(values))" + case "NOT IN": + let values = value.split(separator: ",") + .map { escapeValue($0.trimmingCharacters(in: .whitespaces)) } + .joined(separator: ", ") + return values.isEmpty ? nil : "\(quoted) NOT IN (\(values))" + case "BETWEEN": + let parts = value.split(separator: ",", maxSplits: 1) + guard parts.count == 2 else { return nil } + let v1 = escapeValue(parts[0].trimmingCharacters(in: .whitespaces)) + let v2 = escapeValue(parts[1].trimmingCharacters(in: .whitespaces)) + return "\(quoted) BETWEEN \(v1) AND \(v2)" + case "REGEX": + return regexCondition(quoted, value) + default: return nil + } + } + + public static func buildWhereClause( + filters: [(column: String, op: String, value: String)], + logicMode: String, + columnKinds: [String: PluginColumnKind], + quoteIdentifier: (String) -> String, + escapeTypedValue: (_ value: String, _ kind: PluginColumnKind?) -> String, + regexCondition: (_ quotedColumn: String, _ value: String) -> String? + ) -> String { + let conditions = filters.compactMap { filter in + buildFilterCondition( + column: filter.column, + op: filter.op, + value: filter.value, + kind: columnKinds[filter.column], + quoteIdentifier: quoteIdentifier, + escapeTypedValue: escapeTypedValue, + regexCondition: regexCondition + ) + } + guard !conditions.isEmpty else { return "" } + let separator = logicMode == "and" ? " AND " : " OR " + return conditions.joined(separator: separator) + } + + public static func buildFilterCondition( + column: String, + op: String, + value: String, + kind: PluginColumnKind?, + quoteIdentifier: (String) -> String, + escapeTypedValue: (_ value: String, _ kind: PluginColumnKind?) -> String, + regexCondition: (_ quotedColumn: String, _ value: String) -> String? + ) -> String? { + let quoted = quoteIdentifier(column) + switch op { + case "IS EMPTY": + guard PluginSQLLiteral.supportsEmptyStringComparison(kind) else { return "\(quoted) IS NULL" } + return "(\(quoted) IS NULL OR \(quoted) = '')" + case "IS NOT EMPTY": + guard PluginSQLLiteral.supportsEmptyStringComparison(kind) else { return "\(quoted) IS NOT NULL" } + return "(\(quoted) IS NOT NULL AND \(quoted) != '')" + default: + return buildFilterCondition( + column: column, + op: op, + value: value, + quoteIdentifier: quoteIdentifier, + escapeValue: { escapeTypedValue($0, kind) }, + regexCondition: regexCondition + ) + } + } +} diff --git a/apple/Plugins/TableProPluginKit/PluginViewRowEditing.swift b/apple/Plugins/TableProPluginKit/PluginViewRowEditing.swift index b52ee13ed3..5e50db0137 100644 --- a/apple/Plugins/TableProPluginKit/PluginViewRowEditing.swift +++ b/apple/Plugins/TableProPluginKit/PluginViewRowEditing.swift @@ -1,64 +1,175 @@ -// -// PluginViewRowEditing.swift -// TableProPluginKit -// - import Foundation -/// Whether the data grid may write rows through one view, and which view columns identify a row. -/// -/// An edit through a view is keyed on its base table's primary key: without every key column in -/// the view, an `UPDATE` or `DELETE` could match more rows than the one the user changed. public enum PluginViewRowEditing: Sendable, Equatable { - case editable(keyColumns: [String]) + case editable(PluginViewWriteTarget) case readOnly(PluginViewReadOnlyReason) - public var keyColumns: [String]? { - if case .editable(let keyColumns) = self { return keyColumns } + public var writeTarget: PluginViewWriteTarget? { + if case .editable(let target) = self { return target } return nil } - /// The rule every engine shares once it knows the server's verdict, how many tables the view - /// reads, its base table's key, and which base column each view column shows. + // Engines return early for what only they can see; the rest of the answer is the same everywhere. public static func resolve( - isUpdatable: Bool, - baseTableCount: Int, - baseKeyColumns: [String], - origins: [PluginViewColumnOrigin] + operations: PluginViewWriteOperations, + baseTable: PluginViewBaseTable, + columns: [PluginViewColumn], + baseKeyColumns: [String]?, + baseColumns: [PluginViewBaseColumn]? ) -> PluginViewRowEditing { - guard isUpdatable, baseTableCount > 0 else { return .readOnly(.notUpdatable) } - guard baseTableCount == 1 else { return .readOnly(.readsSeveralTables) } + guard operations.contains(.update) else { return .readOnly(.notUpdatable) } + guard let baseKeyColumns else { return .readOnly(.baseTableHidden) } guard !baseKeyColumns.isEmpty else { return .readOnly(.baseTableHasNoKey) } var keyColumns: [String] = [] - for baseColumn in baseKeyColumns { - guard let origin = origins.first(where: { $0.baseColumn == baseColumn }) else { + for baseKey in baseKeyColumns { + guard let shown = columns.first(where: { $0.baseColumn == baseKey }) else { return .readOnly(.keyNotExposed) } - keyColumns.append(origin.viewColumn) + keyColumns.append(shown.name) + } + + var baseByName: [String: PluginViewBaseColumn] = [:] + for column in baseColumns ?? [] where baseByName[column.name] == nil { + baseByName[column.name] = column + } + + var readOnlyColumns: Set = [] + var baseColumnNames: [String: String] = [:] + var shownBaseColumns: [String: PluginViewBaseColumn] = [:] + var assignedBaseColumns: Set = [] + for column in columns { + guard let baseName = column.baseColumn else { + readOnlyColumns.insert(column.name) + continue + } + baseColumnNames[column.name] = baseName + let base = baseByName[baseName] + shownBaseColumns[column.name] = base + // A second view column over one base column would assign it twice in one statement. + let isFirstShowing = assignedBaseColumns.insert(baseName).inserted + if !column.acceptsWrites || base?.acceptsWrites == false || !isFirstShowing { + readOnlyColumns.insert(column.name) + } } - return .editable(keyColumns: keyColumns) + + var allowed = operations + if let baseColumns { + let filled = Set(columns.filter { !readOnlyColumns.contains($0.name) }.compactMap(\.baseColumn)) + if baseColumns.contains(where: { $0.isRequiredOnInsert && !filled.contains($0.name) }) { + allowed.remove(.insert) + } + } else { + allowed.remove(.insert) + } + + return .editable(PluginViewWriteTarget( + baseTable: baseTable, + keyColumns: keyColumns, + readOnlyColumns: readOnlyColumns, + operations: allowed, + baseColumnNames: baseColumnNames, + baseColumns: shownBaseColumns + )) } } public enum PluginViewReadOnlyReason: String, Sendable, Equatable { case notUpdatable + case writesRunTriggers case readsSeveralTables + case baseTableHidden case baseTableHasNoKey case keyNotExposed } -/// One view column that shows a base table column unchanged. A computed column has no origin. -public struct PluginViewColumnOrigin: Sendable, Equatable { - public let viewColumn: String - public let baseColumn: String +public struct PluginViewWriteOperations: OptionSet, Sendable, Hashable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let update = PluginViewWriteOperations(rawValue: 1 << 0) + public static let insert = PluginViewWriteOperations(rawValue: 1 << 1) + public static let delete = PluginViewWriteOperations(rawValue: 1 << 2) + public static let all: PluginViewWriteOperations = [.update, .insert, .delete] +} + +public struct PluginViewWriteTarget: Sendable, Equatable { + public let baseTable: PluginViewBaseTable + public let keyColumns: [String] + public let readOnlyColumns: Set + public let operations: PluginViewWriteOperations + public let baseColumnNames: [String: String] + // Only for base columns the user may read; empty when the server hides them. + public let baseColumns: [String: PluginViewBaseColumn] + + public init( + baseTable: PluginViewBaseTable, + keyColumns: [String], + readOnlyColumns: Set, + operations: PluginViewWriteOperations, + baseColumnNames: [String: String], + baseColumns: [String: PluginViewBaseColumn] + ) { + self.baseTable = baseTable + self.keyColumns = keyColumns + self.readOnlyColumns = readOnlyColumns + self.operations = operations + self.baseColumnNames = baseColumnNames + self.baseColumns = baseColumns + } + + public var baseKeyColumns: [String] { + keyColumns.map { baseColumnNames[$0] ?? $0 } + } +} + +public struct PluginViewBaseTable: Sendable, Hashable { + // Nil means the view's own database or schema. + public let database: String? + public let schema: String? + public let name: String - public init(viewColumn: String, baseColumn: String) { - self.viewColumn = viewColumn + public init(database: String? = nil, schema: String? = nil, name: String) { + self.database = database + self.schema = schema + self.name = name + } +} + +public struct PluginViewColumn: Sendable, Equatable { + public let name: String + // Nil for a computed column. + public let baseColumn: String? + public let acceptsWrites: Bool + + public init(name: String, baseColumn: String?, acceptsWrites: Bool) { + self.name = name self.baseColumn = baseColumn + self.acceptsWrites = acceptsWrites } } -public extension PluginDatabaseDriver { - func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? { nil } +public struct PluginViewBaseColumn: Sendable, Equatable { + public let name: String + public let isNullable: Bool + public let defaultValue: String? + // An identity, auto increment or rowversion column: the server fills it when an INSERT leaves it out. + public let assignsValue: Bool + // False for a generated column and for an identity the server refuses an explicit value for. + public let acceptsWrites: Bool + + public init(name: String, isNullable: Bool, defaultValue: String?, assignsValue: Bool, acceptsWrites: Bool) { + self.name = name + self.isNullable = isNullable + self.defaultValue = defaultValue + self.assignsValue = assignsValue + self.acceptsWrites = acceptsWrites + } + + public var isRequiredOnInsert: Bool { + acceptsWrites && !isNullable && defaultValue == nil && !assignsValue + } } diff --git a/apple/TablePro/Core/ChangeTracking/DataChangeManager.swift b/apple/TablePro/Core/ChangeTracking/DataChangeManager.swift index d01f9c93c9..a58bee4a96 100644 --- a/apple/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/apple/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -733,7 +733,8 @@ final class DataChangeManager: ObservableObject, ChangeManaging { database: String, schema: String?, containsTableOperation: Bool, - rowsWithUnloadedValues: Set = [] + rowsWithUnloadedValues: Set = [], + writesThroughView: Bool = false ) throws -> RowWriteBuild { let factory = try statementFactory() let operations = RowWriteOperationBuilder.operations( @@ -746,7 +747,8 @@ final class DataChangeManager: ObservableObject, ChangeManaging { primaryKeyColumns: primaryKeyColumns, generatedColumns: generatedColumns, containsTableOperation: containsTableOperation, - rowsWithUnloadedValues: rowsWithUnloadedValues + rowsWithUnloadedValues: rowsWithUnloadedValues, + writesThroughView: writesThroughView ) let steps = try writeSteps( diff --git a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 8fdc7b6d42..b984726674 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -325,9 +325,9 @@ extension QueryExecutionCoordinator { parent.tabManager.mutate(at: idx) { tab in tab.tableContext.primaryKeyColumns = resolvedPKs - if let viewRowEditing = metadata?.viewRowEditing { - tab.tableContext.viewRowEditing = viewRowEditing - } + // Cached and inline metadata carry no answer, so the last one stands unless the relation changed. + let kept = tableName == previousTableName ? tab.tableContext.viewRowEditing : nil + tab.tableContext.adoptViewAnswer(metadata?.viewRowEditing ?? kept, tabType: tab.tabType) } captureOrigin( tabIndex: idx, @@ -574,9 +574,7 @@ extension QueryExecutionCoordinator { resultSet.origin?.keysResolved = true } // Phase 2 always read the schema afresh, so a nil answer replaces a stale one. - if resultSet.origin?.isView == true { - resultSet.origin?.viewRowEditing = parsed.viewRowEditing - } + resultSet.origin?.viewRowEditing = parsed.viewRowEditing } private func applyEnumValues( @@ -625,10 +623,10 @@ extension QueryExecutionCoordinator { tab.display.activeResultSet?.origin?.primaryKeyColumns = parsed.primaryKeyColumns tab.display.activeResultSet?.origin?.keysResolved = true } - if tab.tableContext.resolvedObjectKind() == .view { - tab.tableContext.viewRowEditing = parsed.viewRowEditing - tab.display.activeResultSet?.origin?.viewRowEditing = parsed.viewRowEditing - } + tab.tableContext.adoptViewAnswer(parsed.viewRowEditing, tabType: tab.tabType) + tab.display.activeResultSet?.origin?.viewRowEditing = parsed.viewRowEditing + tab.display.activeResultSet?.origin?.isView = tab.tableContext.isView + tab.display.activeResultSet?.origin?.objectType = tab.tableContext.objectType if let approxCount = parsed.approximateRowCount, approxCount > 0, !tab.filterState.narrowsRows(browseSearchIsSupported: usesBrowseSearch) { tab.pagination.applyDerivedRowCount(approxCount, isApproximate: true) diff --git a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index 2e782ae328..057151b078 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -165,9 +165,8 @@ extension QueryExecutionCoordinator { let parameterizedTask = Task { [weak self, parent] in guard let self else { return } - if let tableName, !tableName.isEmpty { parent.prefetchForeignKeys(scope: scope) } - let schemaTask = QueryExecutor.schemaFetch( - tableName: needsMetadataFetch ? tableName : nil, isView: readsView, scope: scope + let schemaTask = parent.startSchemaFetch( + tableName, uncached: needsMetadataFetch, isView: readsView, scope: scope ) do { diff --git a/apple/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift b/apple/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift index 578fec7ed8..b7176b9f64 100644 --- a/apple/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift +++ b/apple/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift @@ -124,7 +124,7 @@ extension RowEditingCoordinator { } } if case .showRows(let line) = choice { - showCascadeRows(line, scope: plan.scope) + showCascadeRows(line, scope: plan.cascadeScope ?? plan.scope) } return } @@ -378,8 +378,7 @@ extension RowEditingCoordinator { parent.reloadActiveResult() } - /// Every table this save wrote rows to or truncated, so a tab showing one in this window or - /// another stops showing the rows as they were. The saving tab reloads itself and is left out. + // The saving tab reloads itself and is left out. private func announceWrittenTables( plan: DataWritePlan, truncatedTables: Set, @@ -390,6 +389,13 @@ extension RowEditingCoordinator { var written = Set(plan.steps.filter { $0.kind == .rowWrite }.compactMap { step in step.tableName.map { DataWriteTarget(database: plan.scope.database, schema: plan.scope.schema, table: $0) } }) + if let base = plan.viewTarget?.baseTable, plan.steps.contains(where: { $0.kind == .rowWrite }) { + written.insert(DataWriteTarget( + database: base.database ?? plan.scope.database, + schema: base.schema ?? plan.scope.schema, + table: base.name + )) + } for ref in truncatedTables { guard let scope = adoption.objectScope(for: ref, connectionId: connectionId) else { continue } written.insert(DataWriteTarget(database: scope.database, schema: scope.schema, table: ref.table.name)) @@ -576,6 +582,7 @@ extension RowEditingCoordinator { let databaseType = connection.type let connectionId = connection.id let capturedAt = Date() + let writtenThroughView: Bool? = plan.viewTarget == nil ? nil : true Task(priority: .utility) { var historyId: UUID? @@ -593,22 +600,22 @@ extension RowEditingCoordinator { generatedColumns: generatedColumns, identityColumns: identityColumns, columnTypeNames: columnTypeNames, - operations: operations + operations: operations, + writtenThroughView: writtenThroughView ) ) } } private func previewCascade(of plan: DataWritePlan) async -> CascadeDeletePreview? { - guard let root = CascadeDeleteRoot(operations: plan.rowOperations), - var environment = CascadeDeletePreviewer.Environment.live( - scope: plan.scope, - databaseType: plan.databaseType - ) + guard var root = CascadeDeleteRoot(operations: plan.rowOperations) else { return nil } + guard let scope = plan.cascadeScope else { return CascadeDeletePreview(outcome: .unavailable) } + if let target = plan.viewTarget { root = root.onBaseTable(of: target) } + guard var environment = CascadeDeletePreviewer.Environment.live(scope: scope, databaseType: plan.databaseType) else { return nil } // The only prologue a save carries turns foreign key checks off for "Ignore foreign key checks". environment.checksTurnedOffBySave = !plan.prologue.isEmpty - return await CascadeDeletePreviewer.preview(of: root, in: plan.scope, environment: environment) + return await CascadeDeletePreviewer.preview(of: root, in: scope, environment: environment) } // The save was cancelled first, so the staged deletes stay for the user to save after looking. diff --git a/apple/TablePro/Core/Coordinators/RowEditingCoordinator.swift b/apple/TablePro/Core/Coordinators/RowEditingCoordinator.swift index 6d1197fa6b..a3baa8c780 100644 --- a/apple/TablePro/Core/Coordinators/RowEditingCoordinator.swift +++ b/apple/TablePro/Core/Coordinators/RowEditingCoordinator.swift @@ -36,7 +36,7 @@ final class RowEditingCoordinator: ObservableObject { // MARK: - Row Operations func addNewRow() { - guard parent.canEditActiveResult, + guard parent.canEditActiveResult(.insert), let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex, tab.tableContext.tableName != nil else { return } @@ -62,7 +62,7 @@ final class RowEditingCoordinator: ObservableObject { } func deleteRows(_ rowIDs: [RowID]) { - guard parent.canEditActiveResult, + guard parent.canEditActiveResult(.delete), let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex, !rowIDs.isEmpty else { return } @@ -101,7 +101,7 @@ final class RowEditingCoordinator: ObservableObject { } func undoRowDeletions(_ rowIDs: [RowID]) { - guard parent.canEditActiveResult, let tabId = parent.tabManager.selectedTabId else { return } + guard parent.canEditActiveResult(.delete), let tabId = parent.tabManager.selectedTabId else { return } let deleted = rowIDs.filter { parent.changeManager.isRowDeleted($0) } guard !deleted.isEmpty else { return } for rowID in deleted { @@ -111,7 +111,7 @@ final class RowEditingCoordinator: ObservableObject { } func duplicateRow(_ rowID: RowID) { - guard parent.canEditActiveResult, + guard parent.canEditActiveResult(.insert), let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex, tab.tableContext.tableName != nil else { return } @@ -433,7 +433,7 @@ final class RowEditingCoordinator: ObservableObject { } func pasteRows() { - guard parent.canEditActiveResult, + guard parent.canEditActiveResult(.insert), let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex, tab.tabType == .table else { return } diff --git a/apple/TablePro/Core/DataWrite/CascadeDeletePlan.swift b/apple/TablePro/Core/DataWrite/CascadeDeletePlan.swift index 068ed07d65..74637d6695 100644 --- a/apple/TablePro/Core/DataWrite/CascadeDeletePlan.swift +++ b/apple/TablePro/Core/DataWrite/CascadeDeletePlan.swift @@ -144,6 +144,19 @@ struct CascadeDeleteRoot: Equatable, Sendable { ) } + // Rows deleted through a view are its table's rows, and only a table is referenced by a foreign key. + func onBaseTable(of target: PluginViewWriteTarget) -> CascadeDeleteRoot { + let shown = columns.indices.filter { target.baseColumnNames[columns[$0]] != nil } + return CascadeDeleteRoot( + table: target.baseTable.name, + columns: shown.compactMap { target.baseColumnNames[columns[$0]] }, + primaryKeyColumns: target.baseKeyColumns, + rows: rows.map { row in shown.compactMap { row.indices.contains($0) ? row[$0] : nil } }, + isComplete: isComplete, + editedColumns: Set(editedColumns.compactMap { target.baseColumnNames[$0] }) + ) + } + // An edit to a key column of this table changes which rows the delete reaches. func editsKeyColumns(of keys: [ReferencingKey], tableNames: CascadeTableNames) -> Bool { let name = tableNames.key(table) diff --git a/apple/TablePro/Core/DataWrite/DataWritePlan.swift b/apple/TablePro/Core/DataWrite/DataWritePlan.swift index 82ba2e0178..46fb456f25 100644 --- a/apple/TablePro/Core/DataWrite/DataWritePlan.swift +++ b/apple/TablePro/Core/DataWrite/DataWritePlan.swift @@ -220,6 +220,9 @@ struct DataWritePlan: Sendable { /// SQL Server's `IDENTITY_INSERT` is not, since every restoring INSERT then fails, so the run stops first. let prologueIsRequired: Bool + // Rows written through a view land in its table, which cascade preview, rewind and other tabs reason about. + var viewTarget: PluginViewWriteTarget? + init( scope: DatabaseScope, databaseType: DatabaseType, @@ -270,3 +273,16 @@ struct DataWritePlan: Sendable { displayStatements.joined(separator: "\n") } } + +extension DataWritePlan { + // Nil when the view's table lives in another database, which the cascade preview cannot follow. + var cascadeScope: DatabaseScope? { + guard let base = viewTarget?.baseTable else { return scope } + guard base.database == nil || base.database == scope.database else { return nil } + return DatabaseScope( + connectionId: scope.connectionId, + database: scope.database, + schema: base.schema ?? scope.schema + ) + } +} diff --git a/apple/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift b/apple/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift index 65316509b3..d0bda522c2 100644 --- a/apple/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift +++ b/apple/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift @@ -141,7 +141,8 @@ struct RewindExecutor { generatedColumns: plan.record.generatedColumns, identityColumns: plan.record.identityColumns, columnTypeNames: plan.record.columnTypeNames, - operations: operations + operations: operations, + writtenThroughView: plan.record.writtenThroughView ) ) } diff --git a/apple/TablePro/Core/DataWrite/Rewind/RewindPlanner.swift b/apple/TablePro/Core/DataWrite/Rewind/RewindPlanner.swift index f9c678ea8b..d4b5c33b6d 100644 --- a/apple/TablePro/Core/DataWrite/Rewind/RewindPlanner.swift +++ b/apple/TablePro/Core/DataWrite/Rewind/RewindPlanner.swift @@ -43,7 +43,7 @@ struct RewindPlanner { var rows: [RewindRowPlan] = [] var restorable: [RowWriteOperation] = [] for operation in record.operations { - if let refusal = operation.refusal { + if let refusal = record.refusal(of: operation) { rows.append( RewindRowPlan( id: UUID(), operation: operation, @@ -85,10 +85,12 @@ struct RewindPlanner { // MARK: - Classification + private var readsThroughView: Bool { record.writtenThroughView == true } + private func classify(_ operation: RowWriteOperation, current: RewindCurrentRow?) -> RewindRowOutcome { switch operation.kind { case .update: - guard let current else { return .rowMissing } + guard let current else { return readsThroughView ? .notReversible(.rowLeftView) : .rowMissing } guard let postImage = operation.postImage, let preImage = operation.preImage else { return .notReversible(.serverComputedValue) } @@ -103,7 +105,8 @@ struct RewindPlanner { if current != nil { return .rowAlreadyPresent } return .willRestore case .insert: - guard let current else { return .alreadyRestored } + // Read back through a view, a row its filter no longer shows is still in the table. + guard let current else { return readsThroughView ? .notReversible(.rowLeftView) : .alreadyRestored } guard let postImage = operation.postImage else { return .notReversible(.serverAssignedKey) } guard matches(current, postImage, absent: operation.postImageAbsentColumns, on: operation) else { return .changedSinceSave diff --git a/apple/TablePro/Core/DataWrite/Rewind/RewindRecord.swift b/apple/TablePro/Core/DataWrite/Rewind/RewindRecord.swift index 2b3548f420..0011ef766f 100644 --- a/apple/TablePro/Core/DataWrite/Rewind/RewindRecord.swift +++ b/apple/TablePro/Core/DataWrite/Rewind/RewindRecord.swift @@ -29,19 +29,28 @@ struct RewindRecord: Codable, Sendable, Identifiable, Equatable { /// column's type. Nil in a record saved before it was kept. var columnTypeNames: [String: String]? let operations: [RowWriteOperation] + // Nil in a record saved before views could be edited, and in one that wrote a table. + var writtenThroughView: Bool? var reversibleOperations: [RowWriteOperation] { - operations.filter(\.isReversible) + operations.filter { refusal(of: $0) == nil } + } + + // A deleted row comes back through the view with only the columns it shows. The refusal is + // decided here, not stored in the operation, so builds that predate the case still read the record. + func refusal(of operation: RowWriteOperation) -> RewindRefusal? { + if writtenThroughView == true, operation.kind == .delete { return .deletedThroughView } + return operation.refusal } var isReversible: Bool { !reversibleOperations.isEmpty } - /// The single reason nothing here can be taken back, when every row agrees on one. + // Only when every row agrees on one. var blanketRefusal: RewindRefusal? { guard !operations.isEmpty, reversibleOperations.isEmpty else { return nil } - let reasons = Set(operations.compactMap(\.refusal)) + let reasons = Set(operations.compactMap(refusal(of:))) return reasons.count == 1 ? reasons.first : nil } diff --git a/apple/TablePro/Core/DataWrite/Rewind/RewindRefusal.swift b/apple/TablePro/Core/DataWrite/Rewind/RewindRefusal.swift index b1c0d4315d..26a7b54909 100644 --- a/apple/TablePro/Core/DataWrite/Rewind/RewindRefusal.swift +++ b/apple/TablePro/Core/DataWrite/Rewind/RewindRefusal.swift @@ -30,6 +30,9 @@ enum RewindRefusal: String, Codable, Sendable, Equatable, CaseIterable { /// The key is binary or otherwise cannot be written as a filter, so the row cannot be read /// back and checked before writing to it. case keyNotComparable + // Never stored: RewindRecord.refusal(of:) and the planner derive these, so older builds still decode the record. + case deletedThroughView + case rowLeftView var explanation: String { switch self { @@ -49,6 +52,10 @@ enum RewindRefusal: String, Codable, Sendable, Equatable, CaseIterable { return String(localized: "The table is no longer reachable.") case .keyNotComparable: return String(localized: "This row's key cannot be matched, so it cannot be checked before writing.") + case .deletedThroughView: + return String(localized: "The row was deleted through a view, which may not show every column of its table.") + case .rowLeftView: + return String(localized: "The row no longer shows through the view, so it cannot be checked before writing.") } } } diff --git a/apple/TablePro/Core/DataWrite/RowWriteOperationBuilder.swift b/apple/TablePro/Core/DataWrite/RowWriteOperationBuilder.swift index f5c8ab7eb2..98560f3228 100644 --- a/apple/TablePro/Core/DataWrite/RowWriteOperationBuilder.swift +++ b/apple/TablePro/Core/DataWrite/RowWriteOperationBuilder.swift @@ -30,7 +30,8 @@ enum RowWriteOperationBuilder { primaryKeyColumns: [String], generatedColumns: Set, containsTableOperation: Bool, - rowsWithUnloadedValues: Set = [] + rowsWithUnloadedValues: Set = [], + writesThroughView: Bool = false ) -> [RowWriteOperation] { changes.compactMap { change in switch change.type { @@ -46,7 +47,8 @@ enum RowWriteOperationBuilder { change, target: target, columns: columns, primaryKeyColumns: primaryKeyColumns, containsTableOperation: containsTableOperation, - hasUnloadedValues: rowsWithUnloadedValues.contains(change.rowID) + hasUnloadedValues: rowsWithUnloadedValues.contains(change.rowID), + throughView: writesThroughView ) case .insert: guard insertedRowIDs.contains(change.rowID) else { return nil } @@ -121,10 +123,13 @@ enum RowWriteOperationBuilder { columns: [String], primaryKeyColumns: [String], containsTableOperation: Bool, - hasUnloadedValues: Bool + hasUnloadedValues: Bool, + throughView: Bool ) -> RowWriteOperation { let refusal: RewindRefusal? = { if containsTableOperation { return .destructiveTableOperation } + // A case every shipped build can read, so one that predates the view flag still refuses. + if throughView { return .identityNotPreservable } guard let originalRow = change.originalRow else { return .noPrimaryKey } if primaryKeyColumns.isEmpty { return .noPrimaryKey } // A value the page left on the server was larger than the inline limit and never read. diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index f480747647..17f61b809a 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -33,7 +33,8 @@ struct MenuValidationContext: Equatable { var isQueryTab = false var hasResultRows = false var canExportDataDictionary = false - var isCurrentTabEditable = false + var canInsertRows = false + var canDeleteRows = false // Add and Duplicate Row stage DEFAULT from the table schema; result metadata alone stages NULL the server refuses for identity. var isCurrentTabSchemaResolved = false var canRestorePreviousValues = false @@ -63,7 +64,7 @@ struct MenuValidationContext: Equatable { // An engine with no drop DDL for the object must not offer Delete, or the app invents SQL it cannot run. var canDropSelectedTables = false // Without the row check Delete stayed lit with no selection and fell through to the sidebar's drop path, doing nothing. - var canDeleteSelectedRows: Bool { isCurrentTabEditable && hasRowSelection } + var canDeleteSelectedRows: Bool { canDeleteRows && hasRowSelection } // AppKit sends the key equivalent anyway, so enabling over a handler that bails at its first guard swallows Cmd+V silently. var canPasteRows = false var canCloseOtherTabs = false @@ -230,7 +231,7 @@ extension MainSplitViewController: NSMenuItemValidation { return context.isConnected && context.isQueryTab case #selector(addRow(_:)), #selector(duplicateRow(_:)): - return context.isConnected && context.isCurrentTabEditable && !context.isReadOnly + return context.isConnected && context.canInsertRows && !context.isReadOnly && context.isCurrentTabSchemaResolved case #selector(restorePreviousValues(_:)): return context.isConnected && context.canRestorePreviousValues && !context.isReadOnly @@ -593,7 +594,8 @@ extension MainSplitViewController: NSMenuItemValidation { isQueryTab: actions.isQueryTab, hasResultRows: actions.hasResultRows, canExportDataDictionary: actions.canExportDataDictionary, - isCurrentTabEditable: actions.isCurrentTabEditable, + canInsertRows: actions.currentTabAllows(.insert), + canDeleteRows: actions.currentTabAllows(.delete), isCurrentTabSchemaResolved: actions.isCurrentTabSchemaResolved, canRestorePreviousValues: actions.canRestorePreviousValues, canInsertDocument: actions.canInsertDocument, diff --git a/apple/TablePro/Core/Services/Query/QueryExecutor.swift b/apple/TablePro/Core/Services/Query/QueryExecutor.swift index d7b12c79e4..814efa74d4 100644 --- a/apple/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/apple/TablePro/Core/Services/Query/QueryExecutor.swift @@ -254,20 +254,24 @@ final class QueryExecutor { static func fetchTableSchema( scope: DatabaseScope, tableName: String, - isView: Bool = false + isView: Bool ) async throws -> FetchedTableSchema { queryExecutorLog.info( "[fk] schema fetch start table=\(tableName, privacy: .private(mask: .hash)) db=\(scope.database, privacy: .public) schema=\(scope.schema ?? "default", privacy: .public)" ) - let (columns, approximateRowCount) = try await DatabaseManager.shared.withMetadataDriver( + let (columns, approximateRowCount, viewRowEditing) = try await DatabaseManager.shared.withMetadataDriver( scope: scope ) { driver in let columns = try await driver.fetchColumns(table: tableName, schema: scope.schema) let approximateRowCount = try? await driver.fetchApproximateRowCount(table: tableName, schema: scope.schema) - return (columns, approximateRowCount) + // A view has no key of its own; a tab that does not know its kind asks whenever none shows. + let asksView = isView || !columns.contains { $0.isPrimaryKey } + let viewRowEditing = asksView + ? await fetchViewRowEditing(viewName: tableName, scope: scope, on: driver) + : nil + return (columns, approximateRowCount, viewRowEditing) } let foreignKeys = await fetchForeignKeys(scope: scope, tableName: tableName) - let viewRowEditing = isView ? await fetchViewRowEditing(scope: scope, viewName: tableName) : nil queryExecutorLog.info( "[fk] schema fetch done table=\(tableName, privacy: .private(mask: .hash)) columns=\(columns.count) fks=\(foreignKeys.map { String($0.count) } ?? "failed", privacy: .public)" ) @@ -279,12 +283,14 @@ final class QueryExecutor { ) } - // A failed check leaves the view read-only, as it was before views could be edited. - private static func fetchViewRowEditing(scope: DatabaseScope, viewName: String) async -> PluginViewRowEditing? { + // A failed check leaves the view read-only, as every view was before the check existed. + nonisolated private static func fetchViewRowEditing( + viewName: String, + scope: DatabaseScope, + on driver: DatabaseDriver + ) async -> PluginViewRowEditing? { do { - return try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in - try await driver.fetchViewRowEditing(view: viewName, schema: scope.schema) - } + return try await driver.fetchViewRowEditing(view: viewName, schema: scope.schema) } catch { queryExecutorLog.error( "View row editing check failed for \(viewName, privacy: .private(mask: .hash)): \(error.publicLogShape, privacy: .public)" @@ -329,6 +335,10 @@ final class QueryExecutor { } fks = byColumn } + let viewTarget = schema.viewRowEditing?.writeTarget + if let viewTarget { + overlayBaseColumns(of: viewTarget, defaults: &defaults, nullable: &nullable, identity: &identity) + } var enumValues: [String: [String]] = [:] var comments: [String: String] = [:] for col in schema.columns { @@ -345,12 +355,12 @@ final class QueryExecutor { columnDefaults: defaults, columnForeignKeys: fks, columnNullable: nullable, - primaryKeyColumns: schema.viewRowEditing?.keyColumns ?? schema.columns.filter { $0.isPrimaryKey }.map(\.name), + primaryKeyColumns: viewTarget?.keyColumns ?? schema.columns.filter { $0.isPrimaryKey }.map(\.name), generatedColumns: Set( schema.columns .filter { $0.isGenerated || $0.identityKind == .always } .map(\.name) - ), + ).union(viewTarget?.readOnlyColumns ?? []), columnIdentity: identity, approximateRowCount: schema.approximateRowCount, columnEnumValues: enumValues, @@ -365,6 +375,20 @@ final class QueryExecutor { ) } + // A view's own catalog entry knows nothing of the defaults, identity and nullability of the table it writes. + private static func overlayBaseColumns( + of target: PluginViewWriteTarget, + defaults: inout [String: String?], + nullable: inout [String: Bool], + identity: inout [String: IdentityKind] + ) { + for (viewColumn, base) in target.baseColumns { + if (defaults[viewColumn] ?? nil) == nil, let value = base.defaultValue { defaults[viewColumn] = value } + nullable[viewColumn] = base.isNullable + if base.assignsValue { identity[viewColumn] = base.acceptsWrites ? .byDefault : .always } + } + } + /// Both halves of a `RowMatchPolicy` are the columns whose declared type starts with one of the /// given prefixes, so they share one resolver. static func columns(in columns: [ColumnInfo], typedAnyOf typePrefixes: [String]) -> Set { diff --git a/apple/TablePro/Models/Query/QueryTabState.swift b/apple/TablePro/Models/Query/QueryTabState.swift index 485b532d51..bde7902c20 100644 --- a/apple/TablePro/Models/Query/QueryTabState.swift +++ b/apple/TablePro/Models/Query/QueryTabState.swift @@ -533,7 +533,7 @@ struct TabTableContext: Equatable { // Browse only: a staged row edit would land on whatever table the name reaches once the temporary one is dropped. var isTemporary = false - // Nil until the driver has answered, and for an engine that never does. + // Nil until the driver answers, and on an engine that never does. var viewRowEditing: PluginViewRowEditing? func resolvedObjectKind() -> TableInfo.TableType { @@ -542,13 +542,28 @@ struct TabTableContext: Equatable { var allowsRowEditing: Bool { guard !isTemporary else { return false } - if resolvedObjectKind() == .view { - guard let keys = viewRowEditing?.keyColumns else { return false } - return !keys.isEmpty && keys == primaryKeyColumns - } + if resolvedObjectKind() == .view { return viewEditRefusal == nil } return !isView && resolvedObjectKind().allowsRowEditing } + var viewEditRefusal: ResultEditRefusal? { + guard resolvedObjectKind() == .view else { return nil } + return ResultEditRefusal.forView(answer: viewRowEditing, keyedOn: primaryKeyColumns) + } + + var viewWriteTarget: PluginViewWriteTarget? { + guard resolvedObjectKind() == .view else { return nil } + return viewRowEditing?.writeTarget(keyedOn: primaryKeyColumns) + } + + // A driver answers only for a view, so a table tab opened without a kind (a deep link, a script) learns it here. + mutating func adoptViewAnswer(_ answer: PluginViewRowEditing?, tabType: TabType) { + viewRowEditing = answer + guard answer != nil, tabType == .table, resolvedObjectKind() == .table else { return } + objectType = .view + isView = true + } + var primaryKeyColumn: String? { primaryKeyColumns.first } // An empty name means the tab follows the window's browse cursor. diff --git a/apple/TablePro/Models/Query/ResultOrigin.swift b/apple/TablePro/Models/Query/ResultOrigin.swift index 0bc98a2945..827fd18d70 100644 --- a/apple/TablePro/Models/Query/ResultOrigin.swift +++ b/apple/TablePro/Models/Query/ResultOrigin.swift @@ -30,7 +30,7 @@ struct ResultOrigin: Equatable { /// first is safe to write to, so the two cannot share a representation. var keysResolved: Bool - /// The driver's answer for a view, nil for a table or before the answer arrives. + // Nil for a table, before the driver answers, and on an engine that never does. var viewRowEditing: PluginViewRowEditing? init( @@ -80,9 +80,13 @@ enum ResultEditRefusal: Equatable { private static func viewMessage(_ reason: PluginViewReadOnlyReason?) -> String { switch reason { case .notUpdatable: - return String(localized: "These rows come from a view the server reports as not updatable.") + return String(localized: "These rows come from a view the server cannot write through.") + case .writesRunTriggers: + return String(localized: "These rows come from a view whose writes run rules or triggers, so an edit may not change the row shown.") case .readsSeveralTables: return String(localized: "These rows come from a view that reads more than one table, so an edit cannot target one row.") + case .baseTableHidden: + return String(localized: "These rows come from a view whose table or key is hidden from this connection, so an edit cannot target one row.") case .baseTableHasNoKey: return String(localized: "These rows come from a view whose table has no primary key, so an edit could change more than one row.") case .keyNotExposed: @@ -93,18 +97,33 @@ enum ResultEditRefusal: Equatable { return String(localized: "These rows come from a view, which cannot be edited.") } } + + // A stale answer must not key a write on whatever keys the tab holds now. + static func forView(answer: PluginViewRowEditing?, keyedOn primaryKeyColumns: [String]) -> ResultEditRefusal? { + if answer?.writeTarget(keyedOn: primaryKeyColumns) != nil { return nil } + if case .readOnly(let reason)? = answer { return .view(reason) } + return .view(nil) + } } -/// The single decision about whether a result's rows may be written back. -/// -/// It is pure so the rule can be tested without a coordinator, and it is fail-closed: a result -/// with no captured origin refuses rather than borrowing the tab's current table. +extension PluginViewRowEditing { + func writeTarget(keyedOn primaryKeyColumns: [String]) -> PluginViewWriteTarget? { + guard let target = writeTarget, !target.keyColumns.isEmpty, target.keyColumns == primaryKeyColumns else { + return nil + } + return target + } +} + +// Fail-closed: a result with no captured origin refuses rather than borrowing the tab's current table. enum ResultEditability { static func refusal(for origin: ResultOrigin?, currentDatabaseName: String) -> ResultEditRefusal? { guard let origin else { return .unknownTable } - if origin.isView, !writesThroughView(origin) { - if case .readOnly(let reason) = origin.viewRowEditing { return .view(reason) } - return .view(nil) + // A query tab learns its relation is a view only from the driver's answer. + if origin.isView || origin.viewRowEditing != nil { + guard (origin.objectType ?? .view) == .view else { return .view(nil) } + let refusal = ResultEditRefusal.forView(answer: origin.viewRowEditing, keyedOn: origin.primaryKeyColumns) + if let refusal { return refusal } } guard origin.isEditable, let tableName = origin.tableName, !tableName.isEmpty else { return .unknownTable @@ -117,11 +136,4 @@ enum ResultEditability { } return nil } - - /// The keys the edit will use must be the ones the driver named, or a stale answer would key - /// the write on whatever the tab last held. - private static func writesThroughView(_ origin: ResultOrigin) -> Bool { - guard (origin.objectType ?? .view) == .view, let keys = origin.viewRowEditing?.keyColumns else { return false } - return !keys.isEmpty && keys == origin.primaryKeyColumns - } } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 0b9a9c5841..4b999ea5d9 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -193779,18 +193779,6 @@ } } } - }, - "These rows come from a view that does not show every primary key column of its table, so an edit could change more than one row." : { - - }, - "These rows come from a view that reads more than one table, so an edit cannot target one row." : { - - }, - "These rows come from a view the server reports as not updatable." : { - - }, - "These rows come from a view whose table has no primary key, so an edit could change more than one row." : { - }, "These rows come from a view, which cannot be edited." : { "localizations" : { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseObjectTools.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseObjectTools.swift index bb89330201..59b0c1d16d 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseObjectTools.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseObjectTools.swift @@ -125,13 +125,18 @@ extension MainContentCoordinator { // MARK: - Object Changes - /// Brings every tab showing the changed object up to date, and no other tab. - /// - /// A rows or structure change goes through `refreshTableTabs`, which reloads the selected tab only - /// when nothing of the user's is in the way, leaves every other tab marked to reload when it is - /// next shown, and fetches the structure of each again. A rows change needs that too: a MongoDB - /// collection's columns are sampled from its documents, and a materialized view's refresh changes - /// what its **Indexes** tab reports. + private func viewTabIds(writingThrough change: DatabaseObjectChange) -> Set { + Set(tabManager.tabs.filter { tab in + guard tab.tabType == .table, let base = tab.tableContext.viewWriteTarget?.baseTable else { return false } + return change.matches( + tableName: base.name, + databaseName: base.database ?? tab.tableContext.resolvedDatabaseName(browsing: browseDatabaseName), + schemaName: base.schema ?? tab.tableContext.schemaName + ) + }.map(\.id)) + } + + // A rows change refreshes structure too: a MongoDB collection's columns and a matview's indexes follow its rows. func applyObjectChange(_ change: DatabaseObjectChange) { guard change.connectionId == connectionId else { return } let showing = tabManager.tabs.filter { tab in @@ -143,15 +148,17 @@ extension MainContentCoordinator { } let showingIds = Set(showing.map(\.id)) let selected = tabManager.selectedTab.flatMap { showingIds.contains($0.id) ? $0 : nil } + // A view tab writing through the changed table shows its rows, so it reloads with it. + let writingIds = showingIds.union(viewTabIds(writingThrough: change)) switch change.kind { case .rows: let rows = TableFreshness.Change(extent: .rows, at: change.changedAt) - refreshTableTabs(for: rows, excluding: change.originTabId) { showingIds.contains($0.id) } + refreshTableTabs(for: rows, excluding: change.originTabId) { writingIds.contains($0.id) } case .structure: forgetSchemaColumns(of: change) let definition = TableFreshness.Change(extent: .definition, at: change.changedAt) - refreshTableTabs(for: definition, excluding: change.originTabId) { showingIds.contains($0.id) } + refreshTableTabs(for: definition, excluding: change.originTabId) { writingIds.contains($0.id) } case .comment: markTableMetadataStale(forTabs: showingIds) if let selected { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift index be655cf9ae..6cce31bb76 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift @@ -5,6 +5,7 @@ import Combine import Foundation +import TableProPluginKit import TableProUtilities // The Structure and Create Table owners answer which of their grids is on screen. @@ -200,14 +201,16 @@ extension MainContentCoordinator { .hideColumns, .showRowsAsJSON, .exportResults, .navigateForeignKey, .highlightValues, .displayAs, .freezeColumns, .valueFilter ] - if isEditable { - commands.formUnion([.addRow, .deleteRows, .undoDelete]) + let operations = isEditable ? (activeViewWriteTarget?.operations ?? .all) : [] + if operations.contains(.insert) { + commands.insert(.addRow) // A duplicate copies values the result set alone cannot vouch for until the schema arrives. if tabSessionRegistry.existingTableRows(for: tab.id)?.hasAuthoritativeSchema == true { commands.insert(.duplicateRows) } if tab.tabType == .table { commands.insert(.pasteRows) } } + if operations.contains(.delete) { commands.formUnion([.deleteRows, .undoDelete]) } if isSelected, canFilterRows { commands.insert(.filterWithColumn) } if tab.tableContext.tableName != nil { commands.insert(.copyAsSQL) } if tab.tabType == .table, !tab.tableContext.primaryKeyColumns.isEmpty { commands.insert(.loadUnloadedValues) } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift index 3062fb2c1a..309f70044e 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift @@ -4,18 +4,14 @@ // import Foundation +import TableProPluginKit extension MainContentCoordinator { - /// Why the rows on screen cannot be written back, or nil when they can. - /// - /// Only a query tab holds more than one result, so only a query tab can be showing rows that - /// its own table context no longer describes. A table tab has exactly one result and keeps the - /// gate it always had. - /// - /// This is fail-closed on purpose: a result that never captured where its rows came from - /// refuses the edit instead of borrowing whichever table the tab ran last (#2243). + // A query tab can show a result its table context no longer describes, so it asks the result's own + // origin, fail-closed (#2243). A table tab has one result and only a view there explains a refusal. var activeResultEditRefusal: ResultEditRefusal? { - guard let tab = tabManager.selectedTab, tab.tabType == .query else { return nil } + guard let tab = tabManager.selectedTab else { return nil } + guard tab.tabType == .query else { return tab.tableContext.viewEditRefusal } return ResultEditability.refusal( for: tab.display.activeResultSet?.origin, currentDatabaseName: scope(for: tab)?.database ?? browseDatabaseName @@ -36,4 +32,18 @@ extension MainContentCoordinator { && activeResultEditRefusal == nil && !tabExecution.isExecuting(tab.id) } + + // A query tab learns its result reads a view from the result's origin; a table tab from its context. + var activeViewWriteTarget: PluginViewWriteTarget? { + guard let tab = tabManager.selectedTab else { return nil } + guard tab.tabType == .query else { return tab.tableContext.viewWriteTarget } + guard let origin = tab.display.activeResultSet?.origin else { return nil } + return origin.viewRowEditing?.writeTarget(keyedOn: origin.primaryKeyColumns) + } + + // A view can allow updates while the server refuses inserts or deletes through it. + func canEditActiveResult(_ operation: PluginViewWriteOperations) -> Bool { + guard canEditActiveResult else { return false } + return (activeViewWriteTarget?.operations ?? .all).contains(operation) + } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index d5359844b6..6f34f12452 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -9,7 +9,7 @@ import TableProPluginKit extension MainContentCoordinator { // One definition for both the toolbar item and the data grid's empty-space Add Row item. var canAddRow: Bool { - guard canEditActiveResult, let tab = tabManager.selectedTab else { return false } + guard canEditActiveResult(.insert), let tab = tabManager.selectedTab else { return false } guard tab.tableContext.tableName != nil else { return false } // `GridSelectionOwner` answers `.none` in Chart and `.schemaGrid` in Structure, so elsewhere Add Row is inert or adds a column. guard tab.display.resultsViewMode == .data else { return false } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift index 3d10617af6..29c3628309 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift @@ -84,11 +84,7 @@ extension MainContentCoordinator { activeSheet = nil } - /// Everything one press of Save is about to do, in execution order. - /// - /// Save and Preview SQL both read this, so what the user is shown is what runs. Each step - /// carries the rows it should touch, which is what lets the executor hold the server to that - /// number, and the row operations carry the before and after images a later rewind needs. + // Save and Preview SQL both read this, so what the user is shown is what runs. func buildDataWritePlan( pendingTruncates: Set, pendingDeletes: Set, @@ -114,6 +110,7 @@ extension MainContentCoordinator { ) var rowOperations: [RowWriteOperation] = [] + let viewTarget = changeManager.hasChanges ? activeViewWriteTarget : nil if changeManager.hasChanges { /// The scope's schema, not the tab's: the scope has already fallen back to the session's /// browse schema, and a record stored under one spelling is never found under the other. @@ -121,7 +118,8 @@ extension MainContentCoordinator { database: scope.database, schema: scope.schema, containsTableOperation: hasPendingTableOps, - rowsWithUnloadedValues: rowsWithUnloadedValues() + rowsWithUnloadedValues: rowsWithUnloadedValues(), + writesThroughView: viewTarget != nil ) steps.append(contentsOf: rowWrite.steps) rowOperations = rowWrite.operations @@ -139,7 +137,7 @@ extension MainContentCoordinator { }) } - return DataWritePlan( + var plan = DataWritePlan( scope: scope, databaseType: dbType, steps: steps, @@ -147,6 +145,8 @@ extension MainContentCoordinator { prologue: prologue, epilogue: epilogue ) + plan.viewTarget = viewTarget + return plan } private func writeScope( diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift index cb5b336559..907ecde1d9 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift @@ -168,6 +168,17 @@ extension MainContentCoordinator { } } + // Beside a load's rows: the foreign key prefetch for any table, the schema read when the cache misses. + func startSchemaFetch( + _ tableName: String?, + uncached: Bool, + isView: Bool, + scope: DatabaseScope + ) -> Task? { + if let tableName, !tableName.isEmpty { prefetchForeignKeys(scope: scope) } + return QueryExecutor.schemaFetch(tableName: uncached ? tableName : nil, isView: isView, scope: scope) + } + /// The table a tab's Table Info describes, or nil on a tab with no table of its own, such as a /// query. The load's task key, the load and the read all ask this one question, so they agree on /// which tabs have Table Info and on when a retarget makes the cached numbers another table's. diff --git a/apple/TablePro/Views/Main/MainContentCommandActions.swift b/apple/TablePro/Views/Main/MainContentCommandActions.swift index 7622d46b33..9db904f8a0 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions.swift @@ -389,6 +389,16 @@ final class MainContentCommandActions: ObservableObject { } } + // A view can take updates while the server refuses inserts or deletes through it. + func currentTabAllows(_ operation: PluginViewWriteOperations) -> Bool { + guard let coordinator, coordinator.tabManager.selectedTab != nil else { return false } + switch selectionOwner { + case .schemaGrid: return isCurrentTabEditable + case .dataGrid: return coordinator.canEditActiveResult(operation) + case .none: return false + } + } + // Add and Duplicate Row gate on this; a structure grid adds only where its section allows it. var isCurrentTabSchemaResolved: Bool { guard let coordinator, let tabId = coordinator.tabManager.selectedTabId else { return false } @@ -442,7 +452,7 @@ final class MainContentCommandActions: ObservableObject { case .schemaGrid: return canPasteSchemaRows case .dataGrid: - return tab.tabType == .table && isCurrentTabEditable && isCurrentTabSchemaResolved + return tab.tabType == .table && currentTabAllows(.insert) && isCurrentTabSchemaResolved && ClipboardService.shared.hasText case .none: return false diff --git a/apple/TablePro/Views/Main/MainContentCoordinator.swift b/apple/TablePro/Views/Main/MainContentCoordinator.swift index 57992d1939..a4c91fec7e 100644 --- a/apple/TablePro/Views/Main/MainContentCoordinator.swift +++ b/apple/TablePro/Views/Main/MainContentCoordinator.swift @@ -1201,7 +1201,8 @@ final class MainContentCoordinator: ObservableObject { } return } - let (isTableTab, readsView) = (tab.tabType == .table, tab.tableContext.resolvedObjectKind() == .view) + let isTableTab = tab.tabType == .table + let readsView = tab.tableContext.resolvedObjectKind() == .view let (grammar, lengthColumns) = (lexicalGrammar, unloadedValueLengthColumns(for: tab)) let failureOutput = ServerOutputBox() @@ -1212,8 +1213,7 @@ final class MainContentCoordinator: ObservableObject { return } - if let tableName, !tableName.isEmpty { prefetchForeignKeys(scope: scope) } - let schemaTask = QueryExecutor.schemaFetch(tableName: needsMetadataFetch ? tableName : nil, isView: readsView, scope: scope) + let schemaTask = startSchemaFetch(tableName, uncached: needsMetadataFetch, isView: readsView, scope: scope) let fetchBeganAt = ContinuousClock.now do { diff --git a/apple/TableProTests/Core/DataWrite/CascadeDeletePlanTests.swift b/apple/TableProTests/Core/DataWrite/CascadeDeletePlanTests.swift index 71c6a792a9..8974dea87d 100644 --- a/apple/TableProTests/Core/DataWrite/CascadeDeletePlanTests.swift +++ b/apple/TableProTests/Core/DataWrite/CascadeDeletePlanTests.swift @@ -317,6 +317,47 @@ struct CascadeDeletePlanTests { #expect(root(editing: ["name"]).editsKeyColumns(of: keys, tableNames: names) == false) #expect(root(editing: []).editsKeyColumns(of: keys, tableNames: names) == false) } + + @Test("Rows deleted through a view cascade from its table, under the table's column names") + func viewDeleteStartsAtTheBaseTable() { + let viewRoot = CascadeDeleteRoot( + table: "v_customers", + columns: ["customer_id", "label", "total"], + primaryKeyColumns: ["customer_id"], + rows: [[.text("1"), .text("a"), .text("3")]], + editedColumns: ["label"] + ) + let target = TestFixtures.viewTarget( + base: PluginViewBaseTable(schema: "public", name: "customers"), + keys: ["customer_id"], + readOnly: ["total"], + baseColumnNames: ["customer_id": "id", "label": "name"] + ) + + let root = viewRoot.onBaseTable(of: target) + + #expect(root.table == "customers") + #expect(root.columns == ["id", "name"]) + #expect(root.rows == [[.text("1"), .text("a")]]) + #expect(root.primaryKeyColumns == ["id"]) + #expect(root.editedColumns == ["name"]) + #expect(root.primaryKeyRows == .keyed(table: "customers", columns: ["id"], values: [["1"]])) + } + + @Test("A view's table in another database cannot be previewed, one in another schema can") + func viewCascadeScope() { + let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: "reporting") + func plan(_ base: PluginViewBaseTable?) -> DataWritePlan { + var plan = DataWritePlan(scope: scope, databaseType: .postgresql, steps: []) + plan.viewTarget = base.map { TestFixtures.viewTarget(base: $0, keys: ["id"]) } + return plan + } + + #expect(plan(nil).cascadeScope == scope) + #expect(plan(PluginViewBaseTable(schema: "public", name: "t")).cascadeScope?.schema == "public") + #expect(plan(PluginViewBaseTable(database: "shop", name: "t")).cascadeScope == scope) + #expect(plan(PluginViewBaseTable(database: "archive", name: "t")).cascadeScope == nil) + } } struct ReferencingKeyTests { diff --git a/apple/TableProTests/Core/DataWrite/RewindPlannerTests.swift b/apple/TableProTests/Core/DataWrite/RewindPlannerTests.swift index 812c316353..bbd4243a41 100644 --- a/apple/TableProTests/Core/DataWrite/RewindPlannerTests.swift +++ b/apple/TableProTests/Core/DataWrite/RewindPlannerTests.swift @@ -38,7 +38,7 @@ struct RewindPlannerTests { ) } - private func planner(operations: [RowWriteOperation]) -> RewindPlanner { + private func planner(operations: [RowWriteOperation], writtenThroughView: Bool? = nil) -> RewindPlanner { let record = RewindRecord( id: UUID(), historyId: nil, @@ -48,7 +48,8 @@ struct RewindPlannerTests { capturedAt: Date(timeIntervalSince1970: 0), generatedColumns: [], identityColumns: [], - operations: operations + operations: operations, + writtenThroughView: writtenThroughView ) return RewindPlanner( record: record, @@ -64,6 +65,52 @@ struct RewindPlannerTests { ) } + @Test("A row deleted through a view is not restored, while an edit through it still is") + func viewDeleteIsRefused() throws { + let delete = operation(kind: .delete, preImage: ["7", "Ada"], postImage: nil, writtenColumns: columns) + let update = operation( + kind: .update, preImage: ["8", "Ada"], postImage: ["8", "Grace"], writtenColumns: ["name"] + ) + let plan = try planner(operations: [delete, update], writtenThroughView: true) + .plan(currentRows: [RewindCurrentRow(values: ["8", "Grace"])]) + + #expect(plan.rows.map { $0.outcome } == [.notReversible(.deletedThroughView), .willRestore]) + #expect(plan.record.reversibleOperations.map { $0.kind } == [.update]) + } + + @Test("A record written through a view stores no refusal an older build cannot read") + func viewRecordStaysReadable() throws { + let delete = operation( + kind: .delete, + preImage: ["7", "Ada"], + postImage: nil, + writtenColumns: columns, + refusal: .identityNotPreservable + ) + let record = planner(operations: [delete], writtenThroughView: true).record + let encoded = try JSONEncoder().encode(record) + + let text = try #require(String(bytes: encoded, encoding: .utf8)) + #expect(!text.contains("deletedThroughView")) + #expect(!text.contains("rowLeftView")) + let decoded = try JSONDecoder().decode(RewindRecord.self, from: encoded) + #expect(decoded.refusal(of: delete) == .deletedThroughView) + #expect(decoded.blanketRefusal == .deletedThroughView) + } + + @Test("A row a view no longer shows is refused, not reported as restored or gone") + func rowThatLeftTheViewIsRefused() throws { + let insert = operation(kind: .insert, preImage: nil, postImage: ["9", "Lin"], writtenColumns: columns) + let update = operation( + kind: .update, preImage: ["8", "Ada"], postImage: ["8", "Grace"], writtenColumns: ["name"] + ) + let throughView = try planner(operations: [insert, update], writtenThroughView: true).plan(currentRows: []) + #expect(throughView.rows.map { $0.outcome } == [.notReversible(.rowLeftView), .notReversible(.rowLeftView)]) + + let throughTable = try planner(operations: [insert, update]).plan(currentRows: []) + #expect(throughTable.rows.map { $0.outcome } == [.alreadyRestored, .rowMissing]) + } + @Test("A row still holding what the save wrote is restored") func unchangedRowRestores() throws { let plan = try planner(operations: [ diff --git a/apple/TableProTests/Core/DataWrite/RowWriteOperationBuilderTests.swift b/apple/TableProTests/Core/DataWrite/RowWriteOperationBuilderTests.swift index 17f6ddb827..d4c40a9899 100644 --- a/apple/TableProTests/Core/DataWrite/RowWriteOperationBuilderTests.swift +++ b/apple/TableProTests/Core/DataWrite/RowWriteOperationBuilderTests.swift @@ -19,7 +19,8 @@ struct RowWriteOperationBuilderTests { insertedRowIDs: Set = [], primaryKeyColumns: [String] = ["id"], generatedColumns: Set = [], - containsTableOperation: Bool = false + containsTableOperation: Bool = false, + writesThroughView: Bool = false ) -> [RowWriteOperation] { RowWriteOperationBuilder.operations( from: changes, @@ -30,7 +31,8 @@ struct RowWriteOperationBuilderTests { columns: columns, primaryKeyColumns: primaryKeyColumns, generatedColumns: generatedColumns, - containsTableOperation: containsTableOperation + containsTableOperation: containsTableOperation, + writesThroughView: writesThroughView ) } @@ -111,6 +113,14 @@ struct RowWriteOperationBuilderTests { #expect(result.first?.refusal == .destructiveTableOperation) } + @Test("A delete through a view stores a refusal every shipped build can read") + func deleteThroughViewIsRefused() { + let change = RowChange(rowID: .existing(0), type: .delete, originalRow: ["7", "Ada", "2026-01-01"]) + let result = operations(changes: [change], deletedRowIDs: [.existing(0)], writesThroughView: true) + + #expect(result.first?.refusal == .identityNotPreservable) + } + @Test("A delete keeps the whole row") func deleteCapturesTheRow() { let change = RowChange(rowID: .existing(0), type: .delete, originalRow: ["7", "Ada", "2026-01-01"]) diff --git a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 2a01ca20ff..17c5d3e4d3 100644 --- a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -603,7 +603,8 @@ struct MainMenuValidationTests { func readOnlyBlocksMutations() { var context = MenuValidationContext() context.isConnected = true - context.isCurrentTabEditable = true + context.canInsertRows = true + context.canDeleteRows = true context.isCurrentTabSchemaResolved = true context.hasTableSelection = true context.canTruncateSelectedTables = true @@ -759,7 +760,8 @@ struct MainMenuValidationTests { context.supportsContainerSwitching = true context.supportsServerDashboard = true context.supportsUserManagement = true - context.isCurrentTabEditable = true + context.canInsertRows = true + context.canDeleteRows = true context.isCurrentTabSchemaResolved = true context.hasTableSelection = true context.hasRowSelection = true diff --git a/apple/TableProTests/Core/Services/Infrastructure/MenuContentModeParityTests.swift b/apple/TableProTests/Core/Services/Infrastructure/MenuContentModeParityTests.swift index b982c55944..95af34205e 100644 --- a/apple/TableProTests/Core/Services/Infrastructure/MenuContentModeParityTests.swift +++ b/apple/TableProTests/Core/Services/Infrastructure/MenuContentModeParityTests.swift @@ -119,7 +119,8 @@ struct MenuContentModeParityTests { context.isQueryTab = true context.hasPendingChanges = true context.hasDataPendingChanges = true - context.isCurrentTabEditable = true + context.canInsertRows = true + context.canDeleteRows = true context.isCurrentTabSchemaResolved = true context.canRestorePreviousValues = true context.canNavigateBack = true diff --git a/apple/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift b/apple/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift index 9388ca8043..e18a7ea450 100644 --- a/apple/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift +++ b/apple/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift @@ -33,10 +33,11 @@ struct ParseSchemaMetadataTests { foreignKeys: nil, approximateRowCount: nil ) - schema.viewRowEditing = .editable(keyColumns: ["key_id"]) + let answer = PluginViewRowEditing.editable(TestFixtures.viewTarget(keys: ["key_id"])) + schema.viewRowEditing = answer let parsed = QueryExecutor.parseSchemaMetadata(schema) #expect(parsed.primaryKeyColumns == ["key_id"]) - #expect(parsed.viewRowEditing == .editable(keyColumns: ["key_id"])) + #expect(parsed.viewRowEditing == answer) } @Test("A read-only view keeps no key and carries its reason") @@ -48,6 +49,40 @@ struct ParseSchemaMetadataTests { #expect(parsed.viewRowEditing == .readOnly(.readsSeveralTables)) } + @Test("A view's computed and server-owned columns are never written, and its base table's defaults apply") + func editableViewTakesItsBaseColumns() { + var schema = FetchedTableSchema( + columns: ["id", "name", "q2", "ident", "note"].map { column($0, comment: nil) }, + foreignKeys: nil, + approximateRowCount: nil + ) + let serial = PluginViewBaseColumn( + name: "id", isNullable: false, defaultValue: "nextval('t_id_seq')", assignsValue: false, acceptsWrites: true + ) + let name = PluginViewBaseColumn( + name: "name", isNullable: false, defaultValue: nil, assignsValue: false, acceptsWrites: true + ) + let ident = PluginViewBaseColumn( + name: "ident", isNullable: false, defaultValue: nil, assignsValue: true, acceptsWrites: false + ) + let note = PluginViewBaseColumn( + name: "note", isNullable: true, defaultValue: "'n/a'", assignsValue: false, acceptsWrites: true + ) + schema.viewRowEditing = .editable(TestFixtures.viewTarget( + keys: ["id"], + readOnly: ["q2", "ident"], + baseColumnNames: ["id": "id", "name": "name", "ident": "ident", "note": "note"], + baseColumns: ["id": serial, "name": name, "ident": ident, "note": note] + )) + let parsed = QueryExecutor.parseSchemaMetadata(schema) + #expect(parsed.generatedColumns == ["q2", "ident"]) + #expect((parsed.columnDefaults["id"] ?? nil) == "nextval('t_id_seq')") + #expect((parsed.columnDefaults["note"] ?? nil) == "'n/a'") + #expect(parsed.columnNullable["name"] == false) + #expect(parsed.columnIdentity["ident"] == .always) + #expect(parsed.columnIdentity["name"] == nil) + } + @Test("Excludes nil and empty comments") func excludesNilAndEmpty() { let schema = FetchedTableSchema( diff --git a/apple/TableProTests/Helpers/TestFixtures.swift b/apple/TableProTests/Helpers/TestFixtures.swift index c982ab1466..84f1d17c15 100644 --- a/apple/TableProTests/Helpers/TestFixtures.swift +++ b/apple/TableProTests/Helpers/TestFixtures.swift @@ -11,6 +11,24 @@ import Testing @testable import TablePro enum TestFixtures { + static func viewTarget( + base: PluginViewBaseTable = PluginViewBaseTable(name: "t"), + keys: [String], + readOnly: Set = [], + operations: PluginViewWriteOperations = .all, + baseColumnNames: [String: String] = [:], + baseColumns: [String: PluginViewBaseColumn] = [:] + ) -> PluginViewWriteTarget { + PluginViewWriteTarget( + baseTable: base, + keyColumns: keys, + readOnlyColumns: readOnly, + operations: operations, + baseColumnNames: baseColumnNames, + baseColumns: baseColumns + ) + } + // MARK: - Database Types static let allDatabaseTypes: [DatabaseType] = [ diff --git a/apple/TableProTests/Models/Query/ResultEditabilityTests.swift b/apple/TableProTests/Models/Query/ResultEditabilityTests.swift index 0bad09bd0f..63a9ca1d81 100644 --- a/apple/TableProTests/Models/Query/ResultEditabilityTests.swift +++ b/apple/TableProTests/Models/Query/ResultEditabilityTests.swift @@ -10,6 +10,10 @@ import Testing @testable import TablePro struct ResultEditabilityTests { + private func editable(keys: [String]) -> PluginViewRowEditing { + .editable(TestFixtures.viewTarget(keys: keys)) + } + private func origin( tableName: String? = "users", databaseName: String = "app", @@ -58,14 +62,14 @@ struct ResultEditabilityTests { func editableViewIsEditable() { var view = origin(isView: true) view.objectType = .view - view.viewRowEditing = .editable(keyColumns: ["id"]) + view.viewRowEditing = editable(keys: ["id"]) #expect(ResultEditability.refusal(for: view, currentDatabaseName: "app") == nil) } @Test("A view refuses when the keys the edit would use are not the driver's") func viewWithStaleKeysRefuses() { var view = origin(isView: true) - view.viewRowEditing = .editable(keyColumns: ["id"]) + view.viewRowEditing = editable(keys: ["id"]) view.primaryKeyColumns = [] #expect(ResultEditability.refusal(for: view, currentDatabaseName: "app") == .view(nil)) view.primaryKeyColumns = ["code"] @@ -83,7 +87,7 @@ struct ResultEditabilityTests { func materializedViewStaysReadOnly() { var matview = origin(isView: true) matview.objectType = .materializedView - matview.viewRowEditing = .editable(keyColumns: ["id"]) + matview.viewRowEditing = editable(keys: ["id"]) #expect(ResultEditability.refusal(for: matview, currentDatabaseName: "app") == .view(nil)) } @@ -118,9 +122,11 @@ struct ResultEditabilityTests { @Test("Every refusal carries a sentence for the user") func everyRefusalExplainsItself() { - let viewRefusals: [ResultEditRefusal] = [ - .view(nil), .view(.notUpdatable), .view(.readsSeveralTables), .view(.baseTableHasNoKey), .view(.keyNotExposed), + let reasons: [PluginViewReadOnlyReason?] = [ + nil, .notUpdatable, .writesRunTriggers, .readsSeveralTables, + .baseTableHidden, .baseTableHasNoKey, .keyNotExposed, ] + let viewRefusals = reasons.map { ResultEditRefusal.view($0) } for refusal in [ResultEditRefusal.unknownTable, .databaseMoved, .keysUnresolved] + viewRefusals { #expect(!refusal.message.isEmpty) } diff --git a/apple/TableProTests/Models/Query/TabObjectKindTests.swift b/apple/TableProTests/Models/Query/TabObjectKindTests.swift index 2859e316f6..d5e0d50576 100644 --- a/apple/TableProTests/Models/Query/TabObjectKindTests.swift +++ b/apple/TableProTests/Models/Query/TabObjectKindTests.swift @@ -53,20 +53,36 @@ struct TabObjectKindTests { @Test("A view allows row edits only once its driver keyed it on the base table") func viewFollowsItsEditingAnswer() { + let target = TestFixtures.viewTarget(keys: ["id"], operations: [.update, .delete]) + let answer = PluginViewRowEditing.editable(target) var view = TabTableContext(isView: true, objectType: .view) #expect(!view.allowsRowEditing) + #expect(view.viewEditRefusal == .view(nil)) view.viewRowEditing = .readOnly(.notUpdatable) #expect(!view.allowsRowEditing) - view.viewRowEditing = .editable(keyColumns: ["id"]) + #expect(view.viewEditRefusal == .view(.notUpdatable)) + #expect(view.viewWriteTarget == nil) + view.viewRowEditing = answer #expect(!view.allowsRowEditing, "the tab does not key its edits on the driver's columns yet") view.primaryKeyColumns = ["id"] #expect(view.allowsRowEditing) + #expect(view.viewEditRefusal == nil) + #expect(view.viewWriteTarget?.operations == [.update, .delete]) view.isTemporary = true #expect(!view.allowsRowEditing) var matview = TabTableContext(isView: true, objectType: .materializedView) - matview.viewRowEditing = .editable(keyColumns: ["id"]) + matview.viewRowEditing = answer + matview.primaryKeyColumns = ["id"] #expect(!matview.allowsRowEditing) + #expect(matview.viewEditRefusal == nil) + } + + @Test("A table never explains a view refusal") + func tableOffersEveryOperation() { + let table = TabTableContext(isView: false, objectType: .table) + #expect(table.viewEditRefusal == nil) + #expect(table.viewWriteTarget == nil) } @Test("A writable kind, or no kind at all, keeps the rows writable") diff --git a/apple/TableProTests/Views/Main/UpdatableViewRowWriteTests.swift b/apple/TableProTests/Views/Main/UpdatableViewRowWriteTests.swift new file mode 100644 index 0000000000..fd2e3f5568 --- /dev/null +++ b/apple/TableProTests/Views/Main/UpdatableViewRowWriteTests.swift @@ -0,0 +1,127 @@ +import AppKit +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct UpdatableViewRowWriteTests { + private func openedView(answer: PluginViewRowEditing?, keys: [String]) throws -> MainContentCoordinator { + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(database: "shop", type: .postgresql), + tabManager: QueryTabManager(), + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + coordinator.openTableTab(TableInfo(name: "v_orders", type: .view, rowCount: nil, schema: "public")) + let index = try #require(coordinator.tabManager.selectedTabIndex) + coordinator.tabManager.tabs[index].tableContext.isEditable = true + coordinator.tabManager.tabs[index].tableContext.primaryKeyColumns = keys + coordinator.tabManager.tabs[index].tableContext.viewRowEditing = answer + coordinator.tabManager.tabs[index].display.resultsViewMode = .data + coordinator.setActiveTableRows( + TableRows.from( + queryRows: [[.text("1"), .text("Alice")]], + columns: ["order_id", "name"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)], + hasAuthoritativeSchema: true + ), + for: coordinator.tabManager.tabs[index].id + ) + return coordinator + } + + private func editable(_ operations: PluginViewWriteOperations) -> PluginViewRowEditing { + .editable(TestFixtures.viewTarget( + base: PluginViewBaseTable(schema: "public", name: "orders"), + keys: ["order_id"], + operations: operations, + baseColumnNames: ["order_id": "id", "name": "name"] + )) + } + + private func configuration(_ coordinator: MainContentCoordinator) throws -> DataGridConfiguration { + coordinator.gridConfiguration(for: try #require(coordinator.tabManager.selectedTab)) + } + + @Test("A view the driver has not answered for refuses edits and says so in the grid") + func unansweredViewExplainsItself() throws { + let coordinator = try openedView(answer: nil, keys: []) + defer { coordinator.teardown() } + + #expect(!coordinator.canEditActiveResult) + #expect(coordinator.activeResultEditRefusal == .view(nil)) + #expect(try configuration(coordinator).editRefusalMessage == ResultEditRefusal.view(nil).message) + } + + @Test("A read-only view's reason reaches the grid of the table tab") + func readOnlyViewReasonReachesTheGrid() throws { + let coordinator = try openedView(answer: .readOnly(.keyNotExposed), keys: []) + defer { coordinator.teardown() } + + #expect(!coordinator.canEditActiveResult) + #expect(try configuration(coordinator).editRefusalMessage == ResultEditRefusal.view(.keyNotExposed).message) + } + + @Test("A view that refuses inserts edits and deletes rows but offers no Add Row") + func viewWithoutInsert() throws { + let coordinator = try openedView(answer: editable([.update, .delete]), keys: ["order_id"]) + defer { coordinator.teardown() } + + #expect(coordinator.canEditActiveResult) + #expect(!coordinator.canAddRow) + let configuration = try configuration(coordinator) + #expect(configuration.editRefusalMessage == nil) + #expect(!configuration.commands.contains(.addRow)) + #expect(!configuration.commands.contains(.pasteRows)) + #expect(configuration.commands.contains(.deleteRows)) + } + + @Test("A view that refuses deletes offers Add Row but no Delete") + func viewWithoutDelete() throws { + let coordinator = try openedView(answer: editable([.update, .insert]), keys: ["order_id"]) + defer { coordinator.teardown() } + + #expect(coordinator.canAddRow) + let configuration = try configuration(coordinator) + #expect(configuration.commands.contains(.addRow)) + #expect(!configuration.commands.contains(.deleteRows)) + #expect(!configuration.commands.contains(.undoDelete)) + } + + @Test("An answer keyed on other columns than the tab holds refuses, as a stale one would") + func staleKeysRefuse() throws { + let coordinator = try openedView(answer: editable(.all), keys: ["id"]) + defer { coordinator.teardown() } + + #expect(!coordinator.canEditActiveResult) + #expect(coordinator.activeResultEditRefusal == .view(nil)) + } + + @Test("A save through a view carries the view's table into the plan") + func savePlanCarriesTheBaseTable() throws { + let coordinator = try openedView(answer: editable(.all), keys: ["order_id"]) + defer { coordinator.teardown() } + coordinator.changeManager.configureForTable( + tableName: "v_orders", + schemaName: "public", + columns: ["order_id", "name"], + primaryKeyColumns: ["order_id"], + databaseType: .postgresql, + generatedColumns: [] + ) + coordinator.changeManager.recordCellChange( + rowID: .existing(0), columnIndex: 1, columnName: "name", + oldValue: .text("Alice"), newValue: .text("Ada"), + originalRow: [.text("1"), .text("Alice")] + ) + + let plan = try coordinator.buildDataWritePlan( + pendingTruncates: [], pendingDeletes: [], tableOperationOptions: [:] + ) + #expect(plan.viewTarget?.baseTable.name == "orders") + } + } +} From fe2b43b353b686f406c3ab1311204e751a9d9900 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 1 Nov 2026 06:47:27 +0700 Subject: [PATCH 4/8] fix(data-grid): keep a view's answer with cached metadata across a pinned result switch --- .../QueryExecutionCoordinator+Parameters.swift | 2 +- .../Core/Services/Query/QueryExecutor.swift | 7 +++++++ .../Views/Main/MainContentCoordinator.swift | 2 +- .../Services/Query/ParseSchemaMetadataTests.swift | 13 +++++++++++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index 057151b078..af34c85c4e 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -155,7 +155,7 @@ extension QueryExecutionCoordinator { /// Captured now, while the result this decision was made against is still the active one. let cachedMetadata: ParsedSchemaMetadata? = needsMetadataFetch ? nil : ParsedSchemaMetadata.cached( rows: parent.tabSessionRegistry.tableRows(for: tabId), - primaryKeyColumns: tab.tableContext.primaryKeyColumns + context: tab.tableContext ) let readsView = tab.tableContext.resolvedObjectKind() == .view diff --git a/apple/TablePro/Core/Services/Query/QueryExecutor.swift b/apple/TablePro/Core/Services/Query/QueryExecutor.swift index 814efa74d4..30fec34a91 100644 --- a/apple/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/apple/TablePro/Core/Services/Query/QueryExecutor.swift @@ -65,6 +65,13 @@ struct ParsedSchemaMetadata { /// Reading it again when the result finally lands reads whichever result is active *then*, and /// selecting a pinned result in between made a cached rerun adopt that other result's identity /// and non-writable sets, with no schema fetch behind it to repair the mistake. + // The view answer travels with the keys: a pinned result selected before the rerun lands repoints the context. + static func cached(rows: TableRows, context: TabTableContext) -> ParsedSchemaMetadata { + var metadata = cached(rows: rows, primaryKeyColumns: context.primaryKeyColumns) + metadata.viewRowEditing = context.viewRowEditing + return metadata + } + static func cached(rows: TableRows, primaryKeyColumns: [String]) -> ParsedSchemaMetadata { ParsedSchemaMetadata( columnDefaults: rows.columnDefaults, diff --git a/apple/TablePro/Views/Main/MainContentCoordinator.swift b/apple/TablePro/Views/Main/MainContentCoordinator.swift index a4c91fec7e..a014b1856c 100644 --- a/apple/TablePro/Views/Main/MainContentCoordinator.swift +++ b/apple/TablePro/Views/Main/MainContentCoordinator.swift @@ -1186,7 +1186,7 @@ final class MainContentCoordinator: ObservableObject { // Captured now, while the result this decision was made against is still the active one. let cachedMetadata: ParsedSchemaMetadata? = needsMetadataFetch ? nil : ParsedSchemaMetadata.cached( rows: tabSessionRegistry.tableRows(for: tabId), - primaryKeyColumns: tabManager.tabs[index].tableContext.primaryKeyColumns + context: tabManager.tabs[index].tableContext ) if let tableName { Self.logger.info( diff --git a/apple/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift b/apple/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift index e18a7ea450..1331426d04 100644 --- a/apple/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift +++ b/apple/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift @@ -113,6 +113,19 @@ struct ParseSchemaMetadataTests { #expect(ParsedSchemaMetadata.cached(rows: rows, primaryKeyColumns: ["id"]).columnDeclaredTypes == parsed.columnDeclaredTypes) } + @Test("Cached metadata keeps the view answer captured with the keys") + func cachedMetadataKeepsTheViewAnswer() { + var context = TabTableContext(isView: true, objectType: .view) + context.primaryKeyColumns = ["id"] + context.viewRowEditing = .editable(TestFixtures.viewTarget(keys: ["id"])) + let rows = TableRows.from(queryRows: [], columns: ["id"], columnTypes: [.text(rawType: nil)]) + + let cached = ParsedSchemaMetadata.cached(rows: rows, context: context) + + #expect(cached.primaryKeyColumns == ["id"]) + #expect(cached.viewRowEditing == context.viewRowEditing) + } + @Test("Returns an empty dictionary when no column has a comment") func emptyWhenNoComments() { let schema = FetchedTableSchema( From d5c92483f0f9589507363bb9a12fff0ca70702ce Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 1 Nov 2026 07:04:03 +0700 Subject: [PATCH 5/8] fix(data-grid): close the key and identity gaps reviews found in view row editing --- .../TableProMSSQLCore/MSSQLViewSources.swift | 6 + .../MSSQLViewSourcesTests.swift | 15 + .../MSSQLPluginDriver+Schema.swift | 11 + .../MySQLPluginDriver+Views.swift | 14 +- .../MySQLViewRowEditing.swift | 20 +- .../MySQLViewRowEditingTests.swift | 30 +- .../PostgreSQLPluginDriver+Views.swift | 23 +- .../PluginViewRowEditing.swift | 2 + .../ChangeTracking/DataChangeManager.swift | 3 +- ...QueryExecutionCoordinator+Parameters.swift | 2 +- .../Core/DataWrite/DataWritePlan.swift | 2 +- .../DataWrite/RowChangeStatementFactory.swift | 15 +- .../Core/Services/Query/QueryExecutor.swift | 14 +- .../TablePro/Models/Query/QueryTabState.swift | 5 + .../TablePro/Models/Query/ResultOrigin.swift | 2 + .../TablePro/Resources/Localizable.xcstrings | 27 + .../Views/Main/MainContentCoordinator.swift | 2 +- ...wChangeStatementFactoryCoverageTests.swift | 21 + .../Models/Query/ResultEditabilityTests.swift | 13 +- .../Models/Query/TabObjectKindTests.swift | 26 + .../scripts/probes/check-view-row-editing.sh | 868 +++++++++++++----- 21 files changed, 865 insertions(+), 256 deletions(-) diff --git a/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift b/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift index 671cbce3ae..8ea375bf99 100644 --- a/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift +++ b/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift @@ -238,6 +238,12 @@ public enum MSSQLViewSources { """ } + // Browse mode names a table once however often the view joins it, so a self-join shows only as 4405 on DELETE. + public static func refusal(probeRows: [[String?]]) -> Refusal? { + let joins = probeRows.contains { $0.cell(0) == Operation.delete.rawValue && $0.cell(1) == "4405" } + return joins ? .readsSeveralTables : nil + } + public static func allowedOperations(writableColumns: [String], probeRows: [[String?]]) -> Set { var allowed: Set = writableColumns.isEmpty ? [.delete] : Set(Operation.allCases) for row in probeRows { diff --git a/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift b/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift index 85d6e36f77..b009881742 100644 --- a/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift @@ -236,6 +236,21 @@ struct MSSQLViewSourcesTests { #expect(operations.isEmpty) } + @Test("A self-join reads as one table in browse mode, and DELETE's 4405 says it is two") + func selfJoinIsSeveralTables() { + let browse = MSSQLViewSources.BrowseFacts(rows: [ + ["id", "0", nil, "rsprobe", "dbo", "emp", "id", "1"], + ["manager", "0", nil, "rsprobe", "dbo", "emp", "name", "1"], + ["name", "0", nil, "rsprobe", "dbo", "emp", "name", "1"], + ["email", "0", nil, "rsprobe", "dbo", "emp", "email", "1"], + ["id", "1", nil, "rsprobe", "dbo", "emp", "id", "1"], + ]) + #expect(browse.source == .table(table("rsprobe", "emp"))) + let probes: [[String?]] = [["delete", "4405"], ["delete", "11501"]] + #expect(MSSQLViewSources.refusal(probeRows: probes) == .readsSeveralTables) + #expect(MSSQLViewSources.refusal(probeRows: [["update", "4406"]]) == nil) + } + @Test("No probe error allows every operation") func probesClean() { let operations = MSSQLViewSources.allowedOperations(writableColumns: ["id"], probeRows: []) diff --git a/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift b/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift index fbddcc066d..356901c7d7 100644 --- a/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -63,7 +63,15 @@ extension MSSQLPluginDriver { let factRows = try await viewCatalogRows(MSSQLViewSources.viewFactsQuery(schema: schema, view: view)) guard let facts = MSSQLViewSources.ViewFacts(rows: factRows) else { return nil } if let refusal = facts.refusal { return .readOnly(PluginViewReadOnlyReason(refusal)) } + // Once the relation proved to be a view, a failed read must not let it pass for a table. + do { + return try await rowEditing(throughView: view, schema: schema) + } catch { + return .readOnly(.couldNotCheck) + } + } + private func rowEditing(throughView view: String, schema: String) async throws -> PluginViewRowEditing { let browse = MSSQLViewSources.BrowseFacts( rows: try await viewCatalogRows(MSSQLViewSources.browseQuery(schema: schema, view: view)) ) @@ -80,6 +88,9 @@ extension MSSQLPluginDriver { view: view, writableColumns: browse.writableColumns )) + if let refusal = MSSQLViewSources.refusal(probeRows: probeRows) { + return .readOnly(PluginViewReadOnlyReason(refusal)) + } let operations = MSSQLViewSources.allowedOperations( writableColumns: browse.writableColumns, probeRows: probeRows diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift index 2f877fb88b..beec9f910f 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift @@ -16,7 +16,18 @@ extension MySQLPluginDriver { let escape = escapeStringLiteral let topRows = try await catalogRows(MySQLViewRowEditing.viewQuery(database: schema, name: view, escape: escape)) guard let top = MySQLViewRowEditing.view(rows: topRows) else { return nil } + // Once the relation proved to be a view, a failed read must not let it pass for a table. + do { + return try await rowEditing(through: top, escape: escape) + } catch { + return .readOnly(.couldNotCheck) + } + } + private func rowEditing( + through top: MySQLViewRowEditing.View, + escape: (String) -> String + ) async throws -> PluginViewRowEditing { var levels: [MySQLViewRowEditing.Level] = [] var next: MySQLViewRowEditing.View? = top while let current = next { @@ -41,7 +52,8 @@ extension MySQLPluginDriver { levels: levels, keyRows: keyRows, columnRows: baseColumnRows, - quotesDefaults: catalogQuotesDefaults + quotesDefaults: catalogQuotesDefaults, + insertNeedsPlainColumns: flavor == .mysql ) } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift b/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift index cb9207fb42..0ae2735cb8 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift @@ -104,11 +104,12 @@ enum MySQLViewRowEditing { """ } + // STATISTICS, not KEY_COLUMN_USAGE: MySQL drops key columns the user holds no column grant on from the latter. static func primaryKeyQuery(_ relation: Relation, escape: (String) -> String) -> String { """ - SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE \ + SELECT COLUMN_NAME FROM information_schema.STATISTICS \ WHERE TABLE_SCHEMA = '\(escape(relation.database))' AND TABLE_NAME = '\(escape(relation.name))' \ - AND CONSTRAINT_NAME = 'PRIMARY' ORDER BY ORDINAL_POSITION + AND INDEX_NAME = 'PRIMARY' ORDER BY SEQ_IN_INDEX """ } @@ -163,11 +164,15 @@ enum MySQLViewRowEditing { levels: [Level], keyRows: [[String?]], columnRows: [[String?]], - quotesDefaults: Bool + quotesDefaults: Bool, + insertNeedsPlainColumns: Bool ) -> PluginViewRowEditing { guard let top = levels.first, let base = levels.last?.base else { return .readOnly(.notUpdatable) } let baseColumns = columnRows.compactMap { baseColumn(row: $0, quotesDefaults: quotesDefaults) } + let keyColumns = keyRows.compactMap { cell($0, 0) } let isVisible = !baseColumns.isEmpty + // Column grants hide the columns they leave out, so a key column missing here means the list is partial. + let isComplete = keyColumns.allSatisfy { key in baseColumns.contains { sameName($0.name, key) } } var sources = top.sources for level in levels.dropFirst() { @@ -180,9 +185,10 @@ enum MySQLViewRowEditing { } var operations: PluginViewWriteOperations = [.update, .delete] - // MySQL refuses an INSERT (1471) once a view column is not a base column or shows one twice. + // MySQL refuses an INSERT (1471) once a view column is not a base column or shows one twice; + // MariaDB 11.8 takes one that leaves those columns out, as the grid does. let shown = sources.compactMap { $0?.lowercased() } - if shown.count == sources.count, Set(shown).count == shown.count { + if !insertNeedsPlainColumns || (shown.count == sources.count && Set(shown).count == shown.count) { operations.insert(.insert) } @@ -192,8 +198,8 @@ enum MySQLViewRowEditing { columns: zip(top.columns, sources).map { PluginViewColumn(name: $0, baseColumn: $1, acceptsWrites: $1 != nil) }, - baseKeyColumns: isVisible ? keyRows.compactMap { cell($0, 0) } : nil, - baseColumns: isVisible ? baseColumns : nil + baseKeyColumns: isVisible || !keyColumns.isEmpty ? keyColumns : nil, + baseColumns: isVisible && isComplete ? baseColumns : nil ) } diff --git a/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift b/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift index 1727f6e6a4..0cd858ee87 100644 --- a/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift +++ b/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift @@ -110,9 +110,16 @@ struct MySQLViewRowEditingTests { _ levels: [MySQLViewRowEditing.Level], keys: [[String?]] = Fixture.keyRows, tableRows: [[String?]] = Fixture.mysqlTableRows, - quotesDefaults: Bool = false + quotesDefaults: Bool = false, + insertNeedsPlainColumns: Bool = true ) -> PluginViewRowEditing { - MySQLViewRowEditing.answer(levels: levels, keyRows: keys, columnRows: tableRows, quotesDefaults: quotesDefaults) + MySQLViewRowEditing.answer( + levels: levels, + keyRows: keys, + columnRows: tableRows, + quotesDefaults: quotesDefaults, + insertNeedsPlainColumns: insertNeedsPlainColumns + ) } @Test("A backquote followed by a combining mark still opens a quoted name") @@ -298,6 +305,23 @@ struct MySQLViewRowEditingTests { #expect(target.operations == [.update, .delete]) } + @Test("A key column a column grant hides keeps the view read-only") + func columnGrantHidesKeyColumn() throws { + let definition = "select `impb`.`t`.`id` AS `id`,`impb`.`t`.`name` AS `name` from `impb`.`t`" + let result = answer( + [try level(definition, columns: ["id", "name"])], + keys: [["tenant_id"], ["id"]], + tableRows: [["id", "NO", nil, "", "int"], ["name", "YES", nil, "", "varchar(20)"]] + ) + #expect(result == .readOnly(.keyNotExposed)) + } + + @Test("MariaDB inserts through a view with a computed column when the INSERT leaves it out") + func mariaDBInsertsPastComputedColumns() throws { + let result = answer([try level(Fixture.mysqlV, columns: Fixture.vColumns)], insertNeedsPlainColumns: false) + #expect(result.writeTarget?.operations == [.update, .insert, .delete]) + } + @Test("Columns map by position, never by the alias MySQL stores beside a column list") func columnList() throws { let mysql = answer([try level(Fixture.mysqlColumnList, columns: ["name", "id", "qty"])]) @@ -457,7 +481,7 @@ struct MySQLViewRowEditingTests { #expect(view.contains("TABLE_NAME = 'o''v'")) let relation = MySQLViewRowEditing.Relation(database: "sh'op", name: "t") #expect(MySQLViewRowEditing.viewQuery(database: "sh'op", name: "t", escape: escape).contains("'sh''op'")) - #expect(MySQLViewRowEditing.primaryKeyQuery(relation, escape: escape).contains("CONSTRAINT_NAME = 'PRIMARY'")) + #expect(MySQLViewRowEditing.primaryKeyQuery(relation, escape: escape).contains("INDEX_NAME = 'PRIMARY'")) #expect(MySQLViewRowEditing.columnsQuery(relation, escape: escape).hasSuffix("ORDER BY ORDINAL_POSITION")) } } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Views.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Views.swift index 507e146d7c..eb7340d2d0 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Views.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Views.swift @@ -27,7 +27,14 @@ extension PostgreSQLPluginDriver { var chain: [PostgreSQLViewRowEditing.Relation] = [] var query = PostgreSQLViewRowEditing.relationQuery(name: view, schema: schema ?? core.currentSchema) while true { - let row = try await catalogRead(query).rows.first?.map(\.asText) + let row: [String?]? + do { + row = try await catalogRead(query).rows.first?.map(\.asText) + } catch { + // Once the relation proved to be a view, a failed read must not let it pass for a table. + if chain.isEmpty { throw error } + return .readOnly(.couldNotCheck) + } switch PostgreSQLViewRowEditing.step(&chain, row: row) { case .notAView: return nil @@ -36,12 +43,14 @@ extension PostgreSQLPluginDriver { case .readRelation(let oid): query = PostgreSQLViewRowEditing.relationQuery(oid: oid) case .readColumns(let viewOID, let baseOID, let baseSchema): - let viewColumns = try await catalogRead(PostgreSQLViewRowEditing.viewColumnsQuery(viewOID: viewOID)) - // Relative, so a default reads as it does in the base table's own tab. - let baseColumns = try await catalogRead( - PostgreSQLViewRowEditing.baseColumnsQuery(baseOID: baseOID, capabilities: capabilities), - namesRelativeTo: baseSchema - ) + let viewColumnsQuery = PostgreSQLViewRowEditing.viewColumnsQuery(viewOID: viewOID) + guard let viewColumns = try? await catalogRead(viewColumnsQuery), + // Relative, so a default reads as it does in the base table's own tab. + let baseColumns = try? await catalogRead( + PostgreSQLViewRowEditing.baseColumnsQuery(baseOID: baseOID, capabilities: capabilities), + namesRelativeTo: baseSchema + ) + else { return .readOnly(.couldNotCheck) } return PostgreSQLViewRowEditing.resolve( chain: chain, viewColumns: viewColumns.rows.map { $0.map(\.asText) }, diff --git a/apple/Plugins/TableProPluginKit/PluginViewRowEditing.swift b/apple/Plugins/TableProPluginKit/PluginViewRowEditing.swift index 5e50db0137..44f132e7e0 100644 --- a/apple/Plugins/TableProPluginKit/PluginViewRowEditing.swift +++ b/apple/Plugins/TableProPluginKit/PluginViewRowEditing.swift @@ -81,6 +81,8 @@ public enum PluginViewReadOnlyReason: String, Sendable, Equatable { case baseTableHidden case baseTableHasNoKey case keyNotExposed + // A read failed after the relation proved to be a view. + case couldNotCheck } public struct PluginViewWriteOperations: OptionSet, Sendable, Hashable { diff --git a/apple/TablePro/Core/ChangeTracking/DataChangeManager.swift b/apple/TablePro/Core/ChangeTracking/DataChangeManager.swift index a58bee4a96..80248e92f5 100644 --- a/apple/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/apple/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -736,7 +736,8 @@ final class DataChangeManager: ObservableObject, ChangeManaging { rowsWithUnloadedValues: Set = [], writesThroughView: Bool = false ) throws -> RowWriteBuild { - let factory = try statementFactory() + var factory = try statementFactory() + factory.holdsKeyedWritesToOneRow = writesThroughView let operations = RowWriteOperationBuilder.operations( from: pending.changes, insertedRowData: pending.insertedRowData, diff --git a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index af34c85c4e..9918fc6451 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -158,7 +158,7 @@ extension QueryExecutionCoordinator { context: tab.tableContext ) - let readsView = tab.tableContext.resolvedObjectKind() == .view + let readsView = tab.tableContext.knowsView(named: tableName) let boundValues = BoundParameterValues(values: parameters) let failureOutput = ServerOutputBox() let grammar = parent.lexicalGrammar diff --git a/apple/TablePro/Core/DataWrite/DataWritePlan.swift b/apple/TablePro/Core/DataWrite/DataWritePlan.swift index 46fb456f25..9651dc55e3 100644 --- a/apple/TablePro/Core/DataWrite/DataWritePlan.swift +++ b/apple/TablePro/Core/DataWrite/DataWritePlan.swift @@ -179,7 +179,7 @@ extension DataWriteStep { DataWriteStep( kind: .rowWrite, statement: $0.statement, - expectedRowCount: $0.keylessRowCount, + expectedRowCount: $0.keylessRowCount ?? $0.keyedRowCount, tableName: tableName, matchesRowsWithoutKey: $0.keylessRowCount != nil, rowKind: $0.kind, diff --git a/apple/TablePro/Core/DataWrite/RowChangeStatementFactory.swift b/apple/TablePro/Core/DataWrite/RowChangeStatementFactory.swift index a3e3f43fd6..09336745f5 100644 --- a/apple/TablePro/Core/DataWrite/RowChangeStatementFactory.swift +++ b/apple/TablePro/Core/DataWrite/RowChangeStatementFactory.swift @@ -51,6 +51,8 @@ struct DriverWrittenStatement { let keylessRowCount: Int? var kind: RowWriteKind? var lookup: RowLookup? + // A keyed write through a view, held to its one row: the key is the view's answer, not the table's own. + var keyedRowCount: Int? } @MainActor @@ -68,6 +70,7 @@ struct RowChangeStatementFactory { /// The `GENERATED ALWAYS` and SQL Server `IDENTITY` columns among `generatedColumns`, which a restore writes back /// rather than leaving to the server. Nil when the source never said, as a record saved before it was kept. let identityColumns: Set? + var holdsKeyedWritesToOneRow = false init( tableName: String, @@ -520,7 +523,10 @@ struct RowChangeStatementFactory { statement: ParameterizedStatement(sql: write.statement, parameters: write.parameters.map(\.asAny)), keylessRowCount: write.lookup == nil ? keylessRowCount(of: write, keyed: keyed) : nil, kind: write.rowIndices.first.flatMap(keyed.writeKind(ofRowIndex:)), - lookup: write.lookup.map(RowLookup.init) + lookup: write.lookup.map(RowLookup.init), + keyedRowCount: write.lookup == nil && holdsKeyedWritesToOneRow + ? oneRowCount(of: write, keyed: keyed) + : nil ) } } @@ -545,8 +551,11 @@ struct RowChangeStatementFactory { /// A keyless match can find nothing and still succeed, so it is held to its row where the engine's count is real. /// Only a statement naming one row: the default `generateRowWrites` puts every row on its first statement. private func keylessRowCount(of write: PluginRowWrite, keyed: PluginKeyedChanges) -> Int? { - guard primaryKeyColumns.isEmpty, - DataWriteRowCounts.areMeaningful(for: databaseType), + primaryKeyColumns.isEmpty ? oneRowCount(of: write, keyed: keyed) : nil + } + + private func oneRowCount(of write: PluginRowWrite, keyed: PluginKeyedChanges) -> Int? { + guard DataWriteRowCounts.areMeaningful(for: databaseType), write.rowIndices.count == 1, let row = write.rowIndices.first else { return nil } diff --git a/apple/TablePro/Core/Services/Query/QueryExecutor.swift b/apple/TablePro/Core/Services/Query/QueryExecutor.swift index 30fec34a91..4cf6c90eba 100644 --- a/apple/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/apple/TablePro/Core/Services/Query/QueryExecutor.swift @@ -60,12 +60,7 @@ struct ParsedSchemaMetadata { var columnDeclaredTypes: [String: String] = [:] var viewRowEditing: PluginViewRowEditing? - /// The metadata a tab already holds, captured at the moment the cache decision is made. - /// - /// Reading it again when the result finally lands reads whichever result is active *then*, and - /// selecting a pinned result in between made a cached rerun adopt that other result's identity - /// and non-writable sets, with no schema fetch behind it to repair the mistake. - // The view answer travels with the keys: a pinned result selected before the rerun lands repoints the context. + // Captured when the cache decision is made: a pinned result selected before the rerun lands repoints the tab. static func cached(rows: TableRows, context: TabTableContext) -> ParsedSchemaMetadata { var metadata = cached(rows: rows, primaryKeyColumns: context.primaryKeyColumns) metadata.viewRowEditing = context.viewRowEditing @@ -274,7 +269,7 @@ final class QueryExecutor { // A view has no key of its own; a tab that does not know its kind asks whenever none shows. let asksView = isView || !columns.contains { $0.isPrimaryKey } let viewRowEditing = asksView - ? await fetchViewRowEditing(viewName: tableName, scope: scope, on: driver) + ? await fetchViewRowEditing(viewName: tableName, isKnownView: isView, scope: scope, on: driver) : nil return (columns, approximateRowCount, viewRowEditing) } @@ -290,9 +285,10 @@ final class QueryExecutor { ) } - // A failed check leaves the view read-only, as every view was before the check existed. + // A failed check keeps a known view read-only; a relation not known as one stays what it was. nonisolated private static func fetchViewRowEditing( viewName: String, + isKnownView: Bool, scope: DatabaseScope, on driver: DatabaseDriver ) async -> PluginViewRowEditing? { @@ -302,7 +298,7 @@ final class QueryExecutor { queryExecutorLog.error( "View row editing check failed for \(viewName, privacy: .private(mask: .hash)): \(error.publicLogShape, privacy: .public)" ) - return nil + return isKnownView ? .readOnly(.couldNotCheck) : nil } } diff --git a/apple/TablePro/Models/Query/QueryTabState.swift b/apple/TablePro/Models/Query/QueryTabState.swift index bde7902c20..61d71ff475 100644 --- a/apple/TablePro/Models/Query/QueryTabState.swift +++ b/apple/TablePro/Models/Query/QueryTabState.swift @@ -556,6 +556,11 @@ struct TabTableContext: Equatable { return viewRowEditing?.writeTarget(keyedOn: primaryKeyColumns) } + // A query tab knows a view only from an earlier answer for the same relation; that keeps a failed check read-only. + func knowsView(named name: String?) -> Bool { + resolvedObjectKind() == .view || (viewRowEditing != nil && name == tableName) + } + // A driver answers only for a view, so a table tab opened without a kind (a deep link, a script) learns it here. mutating func adoptViewAnswer(_ answer: PluginViewRowEditing?, tabType: TabType) { viewRowEditing = answer diff --git a/apple/TablePro/Models/Query/ResultOrigin.swift b/apple/TablePro/Models/Query/ResultOrigin.swift index 827fd18d70..d7891faccd 100644 --- a/apple/TablePro/Models/Query/ResultOrigin.swift +++ b/apple/TablePro/Models/Query/ResultOrigin.swift @@ -91,6 +91,8 @@ enum ResultEditRefusal: Equatable { return String(localized: "These rows come from a view whose table has no primary key, so an edit could change more than one row.") case .keyNotExposed: return String(localized: "These rows come from a view that does not show every primary key column of its table, so an edit could change more than one row.") + case .couldNotCheck: + return String(localized: "These rows come from a view TablePro could not check, so it cannot tell which row an edit would change.") case nil: return String(localized: "These rows come from a view, which cannot be edited.") @unknown default: diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 4b999ea5d9..b8692619ce 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -189437,6 +189437,12 @@ "The row has no loaded values to be found by." : { "extractionState" : "manual", "generatesSymbol" : false + }, + "The row no longer shows through the view, so it cannot be checked before writing." : { + + }, + "The row was deleted through a view, which may not show every column of its table." : { + }, "The row's loaded values are missing, so it cannot be found again." : { "extractionState" : "manual", @@ -193779,6 +193785,27 @@ } } } + }, + "These rows come from a view TablePro could not check, so it cannot tell which row an edit would change." : { + + }, + "These rows come from a view that does not show every primary key column of its table, so an edit could change more than one row." : { + + }, + "These rows come from a view that reads more than one table, so an edit cannot target one row." : { + + }, + "These rows come from a view the server cannot write through." : { + + }, + "These rows come from a view whose table has no primary key, so an edit could change more than one row." : { + + }, + "These rows come from a view whose table or key is hidden from this connection, so an edit cannot target one row." : { + + }, + "These rows come from a view whose writes run rules or triggers, so an edit may not change the row shown." : { + }, "These rows come from a view, which cannot be edited." : { "localizations" : { diff --git a/apple/TablePro/Views/Main/MainContentCoordinator.swift b/apple/TablePro/Views/Main/MainContentCoordinator.swift index a014b1856c..ed69bb516c 100644 --- a/apple/TablePro/Views/Main/MainContentCoordinator.swift +++ b/apple/TablePro/Views/Main/MainContentCoordinator.swift @@ -1202,7 +1202,7 @@ final class MainContentCoordinator: ObservableObject { return } let isTableTab = tab.tabType == .table - let readsView = tab.tableContext.resolvedObjectKind() == .view + let readsView = tab.tableContext.knowsView(named: tableName) let (grammar, lengthColumns) = (lexicalGrammar, unloadedValueLengthColumns(for: tab)) let failureOutput = ServerOutputBox() diff --git a/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryCoverageTests.swift b/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryCoverageTests.swift index e337c1d533..e688dff4ff 100644 --- a/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryCoverageTests.swift +++ b/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryCoverageTests.swift @@ -322,6 +322,27 @@ struct RowChangeStatementFactoryCoverageTests { #expect(build.steps.allSatisfy { $0.expectedRowCount == nil }) } + @Test("A driver's keyed statement through a view is held to its one row") + func buildRowWritesHoldsAKeyedDriverStatementThroughAView() throws { + let manager = DataChangeManager() + manager.configureForTable( + tableName: "v_items", columns: columns, primaryKeyColumns: ["_id"], + databaseType: .mssql, generatedColumns: [] + ) + manager.pluginDriver = writesOnlyUpdates() + manager.recordCellChange( + rowID: .existing(0), columnIndex: 1, columnName: "name", + oldValue: "a", newValue: "z", originalRow: ["0", "a"] + ) + + let build = try manager.buildRowWrites( + database: "d", schema: nil, containsTableOperation: false, writesThroughView: true + ) + + #expect(build.steps.map(\.expectedRowCount) == [1]) + #expect(build.steps.allSatisfy { !$0.matchesRowsWithoutKey }) + } + @Test("A keyless driver statement is not held where the engine reports no real count") func keylessDriverStatementOnAnEngineWithoutCountsIsNotHeld() throws { let written = try factory( diff --git a/apple/TableProTests/Models/Query/ResultEditabilityTests.swift b/apple/TableProTests/Models/Query/ResultEditabilityTests.swift index 63a9ca1d81..ff496e37f7 100644 --- a/apple/TableProTests/Models/Query/ResultEditabilityTests.swift +++ b/apple/TableProTests/Models/Query/ResultEditabilityTests.swift @@ -32,6 +32,17 @@ struct ResultEditabilityTests { ) } + @Test("A query result the driver answered for as a view takes the view's gate") + func queryResultOverAViewTakesTheViewGate() { + var result = origin() + result.viewRowEditing = editable(keys: ["id"]) + #expect(ResultEditability.refusal(for: result, currentDatabaseName: "app") == nil) + result.primaryKeyColumns = ["name"] + #expect(ResultEditability.refusal(for: result, currentDatabaseName: "app") == .view(nil)) + result.viewRowEditing = .readOnly(.couldNotCheck) + #expect(ResultEditability.refusal(for: result, currentDatabaseName: "app") == .view(.couldNotCheck)) + } + @Test("A result that captured its table is editable") func capturedTableIsEditable() { #expect(ResultEditability.refusal(for: origin(), currentDatabaseName: "app") == nil) @@ -124,7 +135,7 @@ struct ResultEditabilityTests { func everyRefusalExplainsItself() { let reasons: [PluginViewReadOnlyReason?] = [ nil, .notUpdatable, .writesRunTriggers, .readsSeveralTables, - .baseTableHidden, .baseTableHasNoKey, .keyNotExposed, + .baseTableHidden, .baseTableHasNoKey, .keyNotExposed, .couldNotCheck, ] let viewRefusals = reasons.map { ResultEditRefusal.view($0) } for refusal in [ResultEditRefusal.unknownTable, .databaseMoved, .keysUnresolved] + viewRefusals { diff --git a/apple/TableProTests/Models/Query/TabObjectKindTests.swift b/apple/TableProTests/Models/Query/TabObjectKindTests.swift index d5e0d50576..e7be827eed 100644 --- a/apple/TableProTests/Models/Query/TabObjectKindTests.swift +++ b/apple/TableProTests/Models/Query/TabObjectKindTests.swift @@ -78,6 +78,32 @@ struct TabObjectKindTests { #expect(matview.viewEditRefusal == nil) } + @Test("A query tab knows a view only from an answer for the same relation") + func queryTabKnowsViewFromItsAnswer() { + var query = TabTableContext() + query.tableName = "v_orders" + #expect(!query.knowsView(named: "v_orders")) + query.viewRowEditing = .readOnly(.keyNotExposed) + #expect(query.knowsView(named: "v_orders")) + #expect(!query.knowsView(named: "orders")) + #expect(TabTableContext(isView: true, objectType: .view).knowsView(named: "anything")) + } + + @Test("A table tab opened without a kind becomes a view once the driver answers for it") + func tableTabAdoptsTheViewKind() { + var context = TabTableContext() + context.adoptViewAnswer(nil, tabType: .table) + #expect(context.resolvedObjectKind() == .table) + context.adoptViewAnswer(.readOnly(.notUpdatable), tabType: .table) + #expect(context.resolvedObjectKind() == .view) + #expect(context.isView) + + var query = TabTableContext() + query.adoptViewAnswer(.readOnly(.notUpdatable), tabType: .query) + #expect(query.resolvedObjectKind() == .table) + #expect(query.viewRowEditing == .readOnly(.notUpdatable)) + } + @Test("A table never explains a view refusal") func tableOffersEveryOperation() { let table = TabTableContext(isView: false, objectType: .table) diff --git a/apple/scripts/probes/check-view-row-editing.sh b/apple/scripts/probes/check-view-row-editing.sh index bce31e74ef..d48b4b2f6a 100755 --- a/apple/scripts/probes/check-view-row-editing.sh +++ b/apple/scripts/probes/check-view-row-editing.sh @@ -1,28 +1,26 @@ #!/usr/bin/env bash # -# Check which views the data grid lets a user edit, and that an edit keyed the way the grid keys it -# changes exactly the row it names. +# Check which views the data grid lets a user edit, and that a write keyed the way the grid keys it +# changes exactly the base row it names. # -# The drivers decide from the server's catalog: PostgreSQL's pg_relation_is_updatable and the -# view's stored query, MySQL and MariaDB's information_schema.VIEWS, SQL Server's -# sys.dm_exec_describe_first_result_set. This builds one table and a view of every shape that -# matters (all columns, a renamed key, swapped names, no key, a join, a join showing one table's -# columns, DISTINCT, a keyless table, and on SQL Server a table in another database whose name the -# current one reuses with another key), -# runs the plugin's real queries and parsers from its own source files, compares each answer with -# the expected one, then writes through every view the answer calls editable: an UPDATE and a -# DELETE keyed on the columns it named, and an INSERT, each checked against the base table. +# Builds a base table and a view of every shape that matters, compiles the driver's own view code +# (its pure enum and PluginViewRowEditing.swift) into a harness, runs the catalog queries the driver +# runs through the engine's command-line client, and compares each answer (reason, base table, key, +# read-only columns, operations) with the expected one. Then it writes through every view the answer +# calls editable: an UPDATE keyed on the answer's key, an INSERT and a DELETE where the answer allows +# them, each checked against the base table. A write the answer refuses (an INSERT, an UPDATE of a +# read-only column) must fail at the server too. # # Usage: # scripts/probes/check-view-row-editing.sh [host] [port] [user] [password] # -# Needs psql, mysql or mariadb, or FreeTDS's tsql, and a server the user may create a database on. -# Throwaway servers that match the defaults: +# Needs swiftc, a server the user may create a database on, and psql, mysql or mariadb, or FreeTDS's +# tsql for SQL Server (sqlcmd is not used). Throwaway servers that match the defaults: # docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=probe postgres:17 # docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=probe mysql:8.4 # docker run -d -p 3306:3306 -e MARIADB_ROOT_PASSWORD=probe mariadb:11 # docker run -d -p 1433:1433 -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD=Probe_pw1234 mcr.microsoft.com/mssql/server:2022-latest -# Exits non-zero on a wrong answer or a wrong write, 3 when a prerequisite is missing. +# Exits 1 on a wrong answer, a wrong write or a harness that does not build, 3 when a prerequisite is missing. set -uo pipefail @@ -42,28 +40,50 @@ USER_NAME="${4:-$DEFAULT_USER}" PASSWORD="${5:-$DEFAULT_PASSWORD}" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" DATABASE="tablepro_views_$$_$RANDOM" +OTHER="${DATABASE}_elsewhere" WORK="$(mktemp -d)" CREATED=0 +CHECKS=0 FAILURES=0 +NEXT=100 -# One statement per call; prints rows with fields joined by a unit separator and NULL as empty. +# Runs one statement and prints its rows, fields split by \x1f. With catalog as $3 it prints what the +# harness reads: PostgreSQL records split by \x1e with NULL as \x1d, MySQL's XML, tsql's plain rows. +# A server error goes to stderr with a non-zero status. Not \x01 for NULL: declare -f mangles it. sql() { - local database="$1" statement="$2" + local database="$1" statement="$2" mode="${3:-rows}" status case "$ENGINE" in postgresql) + local format=(-F $'\x1f') + [ "$mode" = catalog ] && format+=(-R $'\x1e' -P null=$'\x1d') PGPASSWORD="$PASSWORD" psql -h "$HOST" -p "$PORT" -U "$USER_NAME" -d "$database" \ - -X -q -At -F $'\x1f' -v ON_ERROR_STOP=1 -c "$statement" 2>&1 + -X -q -A -t -v ON_ERROR_STOP=1 "${format[@]}" -c "$statement" < /dev/null ;; mysql | mariadb) - MYSQL_PWD="$PASSWORD" "$MYSQL_CLIENT" -h "$HOST" -P "$PORT" -u "$USER_NAME" --protocol=TCP \ - -N -B --raw ${database:+-D "$database"} -e "$statement" 2>&1 \ - | grep -v '^WARNING: option --ssl-verify-server-cert' \ - | sed -e $'s/\t/\x1f/g' -e $'s/\x1fNULL\x1f/\x1f\x1f/g' -e $'s/\x1fNULL$/\x1f/' -e $'s/^NULL\x1f/\x1f/' + local client=(env MYSQL_PWD="$PASSWORD" "$MYSQL_CLIENT" -h "$HOST" -P "$PORT" -u "$USER_NAME" + --protocol=TCP --default-character-set=utf8mb4) + [ -n "$database" ] && client+=(-D "$database") + if [ "$mode" = catalog ]; then + "${client[@]}" --xml -e "$statement" < /dev/null 2> "$WORK/stderr" + status=$? + else + "${client[@]}" -N -B -e "$statement" < /dev/null 2> "$WORK/stderr" | tr '\t' $'\x1f' + status=${PIPESTATUS[0]} + fi + grep -v '^WARNING: option --ssl-verify-server-cert' "$WORK/stderr" >&2 + return "$status" ;; mssql) printf 'SET NOCOUNT ON\ngo\n%s\ngo\n' "$statement" \ - | tsql -H "$HOST" -p "$PORT" -U "$USER_NAME" -P "$PASSWORD" -D "$database" -o fhq -t $'\x1f' 2>&1 \ - | sed -e $'s/\x1fNULL\x1f/\x1f\x1f/g' -e $'s/\x1fNULL$/\x1f/' -e $'s/^NULL\x1f/\x1f/' + | tsql -H "$HOST" -p "$PORT" -U "$USER_NAME" -P "$PASSWORD" -D "$database" -o fhq -t $'\x1f' \ + 2> "$WORK/stderr" + status=${PIPESTATUS[1]} + # tsql exits 0 after a server error; severity 11 and up is one. + if grep -Eq '^Msg [0-9]+ \(severity (1[1-9]|2[0-9])' "$WORK/stderr"; then + cat "$WORK/stderr" >&2 + return 1 + fi + return "$status" ;; esac } @@ -79,13 +99,17 @@ admin_database() { cleanup() { if [ "$CREATED" -eq 1 ]; then case "$ENGINE" in + postgresql) sql postgres "DROP DATABASE $DATABASE" > /dev/null 2>&1 ;; + mysql | mariadb) + sql "" "DROP DATABASE IF EXISTS $DATABASE" > /dev/null 2>&1 + sql "" "DROP DATABASE IF EXISTS $OTHER" > /dev/null 2>&1 + ;; mssql) local database - for database in "$DATABASE" "${DATABASE}_other"; do - sql master "IF DB_ID('$database') IS NOT NULL BEGIN ALTER DATABASE [$database] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [$database] END" > /dev/null + for database in "$DATABASE" "$OTHER"; do + sql master "IF DB_ID('$database') IS NOT NULL BEGIN ALTER DATABASE [$database] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [$database] END" > /dev/null 2>&1 done ;; - *) sql "$(admin_database)" "DROP DATABASE $DATABASE" > /dev/null ;; esac fi rm -rf "$WORK" @@ -95,279 +119,681 @@ trap cleanup EXIT case "$ENGINE" in postgresql) command -v psql > /dev/null || { echo "psql not found (brew install libpq)" >&2; exit 3; } ;; mysql | mariadb) - MYSQL_CLIENT="$(command -v mysql || command -v mariadb)" || { echo "mysql not found" >&2; exit 3; } + MYSQL_CLIENT="$(command -v mysql || command -v mariadb)" || { echo "mysql or mariadb not found" >&2; exit 3; } ;; mssql) command -v tsql > /dev/null || { echo "tsql not found (brew install freetds)" >&2; exit 3; } ;; esac -if [ -z "${DEVELOPER_DIR:-}" ]; then - DEVELOPER_DIR="$(xcode-select -p)" - case "$DEVELOPER_DIR" in - *CommandLineTools*) DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer ;; - esac -fi -export DEVELOPER_DIR +command -v swiftc > /dev/null || { echo "swiftc not found (xcode-select --install)" >&2; exit 3; } -if ! sql "$(admin_database)" "SELECT 1" | grep -qx '1'; then +if [ "$(sql "$(admin_database)" "SELECT 1" 2> /dev/null)" != 1 ]; then echo "no $ENGINE server at $HOST:$PORT as $USER_NAME" >&2 exit 3 fi -# The resolver reads the plugin's own files, so a change to a parser or a query is what this runs. -mkdir -p "$WORK/Sources/ViewEditing" -cat > "$WORK/Package.swift" << EOF -// swift-tools-version: 6.0 -import PackageDescription - -let package = Package( - name: "ViewEditing", - platforms: [.macOS(.v14)], - dependencies: [.package(path: "$ROOT/Packages/TableProCore")], - targets: [ - .executableTarget( - name: "ViewEditing", - dependencies: [ - .product(name: "TableProPluginKit", package: "TableProCore"), - .product(name: "TableProMSSQLCore", package: "TableProCore"), - ] - ) - ] -) -EOF -ln -s "$ROOT/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift" "$WORK/Sources/ViewEditing/" -ln -s "$ROOT/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift" "$WORK/Sources/ViewEditing/" -cat > "$WORK/Sources/ViewEditing/main.swift" << 'SWIFT' +# The harness runs the driver's own files, so a change to a query or a parser is what this checks. +PLUGINS="$ROOT/Plugins" +CORE="$ROOT/Packages/TableProCore/Sources" +mkdir -p "$WORK/modules" "$WORK/harness" +KIT_FILES=("$PLUGINS/TableProPluginKit/PluginViewRowEditing.swift" "$PLUGINS/TableProPluginKit/ArrayExtension.swift") +HARNESS_FILES=("$WORK/harness/common.swift" "$WORK/harness/main.swift") + +cat > "$WORK/harness/common.swift" << 'SWIFT' import Foundation -import TableProMSSQLCore import TableProPluginKit -// The driver's literal quoting, minus the backslash and NUL cases no fixture name has. -enum PostgreSQLObjectQueries { - static func quoteLiteral(_ value: String) -> String { "'\(value.replacingOccurrences(of: "'", with: "''"))'" } +struct CatalogError: Error, CustomStringConvertible { + let description: String } -let separator: Character = "\u{1F}" +let engine = ProcessInfo.processInfo.environment["PROBE_ENGINE"] ?? "" +let catalogClient = ProcessInfo.processInfo.environment["PROBE_CATALOG"] ?? "" -func rows(_ text: String) -> [[String?]] { - text.split(separator: "\n", omittingEmptySubsequences: true).map { line in - line.split(separator: separator, omittingEmptySubsequences: false).map { $0.isEmpty ? nil : String($0) } +// One client run per statement, sent whole as the driver sends it. +func catalogRows(_ statement: String) throws -> [[String?]] { + let process = Process() + process.executableURL = URL(fileURLWithPath: catalogClient) + let input = Pipe() + let output = Pipe() + let errors = Pipe() + process.standardInput = input + process.standardOutput = output + process.standardError = errors + try process.run() + input.fileHandleForWriting.write(Data(statement.utf8)) + try input.fileHandleForWriting.close() + let data = output.fileHandleForReading.readDataToEndOfFile() + let message = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw CatalogError(description: message.isEmpty ? "the client exited \(process.terminationStatus)" : message) + } + switch engine { + case "mysql", "mariadb": + guard let rows = XMLRows.parse(data) else { throw CatalogError(description: "unreadable XML") } + return rows + case "postgresql": + return separatedRows(data, recordSeparator: 0x1E, null: "\u{1D}") + default: + return separatedRows(data, recordSeparator: 0x0A, null: "NULL") } } -// Runs one statement through the probe's own client, which reads it from the environment. -func query(_ statement: String) -> [[String?]] { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/bash") - process.arguments = ["-c", "\"$PROBE_SQL\" \"$PROBE_DATABASE\" \"$1\"", "probe", statement] - let pipe = Pipe() - process.standardOutput = pipe - try? process.run() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - return rows(String(decoding: data, as: UTF8.self)) +// By byte: a combining mark after a separator would fuse the two into one Character. +func separatedRows(_ data: Data, recordSeparator: UInt8, null: String) -> [[String?]] { + var bytes = [UInt8](data) + if bytes.last == 0x0A { bytes.removeLast() } + guard !bytes.isEmpty else { return [] } + return bytes.split(separator: recordSeparator, omittingEmptySubsequences: false).map { record in + record.split(separator: 0x1F, omittingEmptySubsequences: false).map { field in + let value = String(decoding: field, as: UTF8.self) + return value == null ? nil : value + } + } } -func describe(_ editing: PluginViewRowEditing) -> String { - switch editing { - case .editable(let keys): return "editable:" + keys.joined(separator: ",") - case .readOnly(let reason): return "readonly:" + reason.rawValue - @unknown default: return "unknown" +// The mysql client's --xml is the only output that tells NULL from the text NULL. +final class XMLRows: NSObject, XMLParserDelegate { + private var rows: [[String?]] = [] + private var row: [String?] = [] + private var field: String? + private var inField = false + + static func parse(_ data: Data) -> [[String?]]? { + let reader = XMLRows() + let parser = XMLParser(data: data) + parser.delegate = reader + return parser.parse() ? reader.rows : nil + } + + func parser( + _ parser: XMLParser, + didStartElement element: String, + namespaceURI: String?, + qualifiedName: String?, + attributes: [String: String] + ) { + if element == "row" { row = [] } + if element == "field" { + inField = true + field = attributes["xsi:nil"] == "true" ? nil : "" + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + if inField, let text = field { field = text + string } + } + + func parser(_ parser: XMLParser, didEndElement element: String, namespaceURI: String?, qualifiedName: String?) { + if element == "field" { + row.append(field) + inField = false + } + if element == "row" { rows.append(row) } } } -func postgreSQL(view: String, schema: String) -> PluginViewRowEditing? { - guard let row = query(PostgreSQLViewRowEditing.viewQuery(name: view, schema: schema)).first, - let viewOID = row[0] else { return nil } - let isUpdatable = row[1] == "t" - let origins = PostgreSQLViewRowEditing.targetOrigins(nodeTree: row[2] ?? "") - var names: [Int: String] = [:] - var keys: [Int] = [] - if isUpdatable, let base = origins.first?.tableOID { - for detail in query(PostgreSQLViewRowEditing.columnsAndKeyQuery(viewOID: viewOID, baseTableOID: base)) { - guard let number = detail[1].flatMap(Int.init) else { continue } - if detail[0] == "v" { names[number] = detail[2] } else { keys.append(number) } +// view|answer|base|keys|writable view:base pairs|read-only columns|operations +func report(_ view: String, _ answer: () throws -> PluginViewRowEditing?) { + var fields: [String] + do { + switch try answer() { + case nil: + fields = ["not a view"] + case .readOnly(let reason): + fields = ["readOnly \(reason.rawValue)"] + case .editable(let target): + let base = [target.baseTable.database, target.baseTable.schema, target.baseTable.name] + .compactMap { $0 } + .joined(separator: ".") + let keys = target.keyColumns.joined(separator: ",") + let readOnly = target.readOnlyColumns.sorted().joined(separator: ",") + let operations = [(PluginViewWriteOperations.update, "update"), (.insert, "insert"), (.delete, "delete")] + .filter { target.operations.contains($0.0) } + .map(\.1) + .joined(separator: ",") + let writable = target.baseColumnNames + .filter { !target.readOnlyColumns.contains($0.key) } + .sorted { $0.key < $1.key } + .map { "\($0.key):\($0.value)" } + .joined(separator: ",") + fields = [ + "editable base=\(base) keys=\(keys) readonly=\(readOnly) ops=\(operations)", + base, keys, writable, readOnly, operations, + ] } + } catch { + fields = ["error: \(error)".split(whereSeparator: \.isNewline).joined(separator: " ")] } - return PostgreSQLViewRowEditing.resolve( - isUpdatable: isUpdatable, origins: origins, viewColumnNames: names, baseKeyColumnNumbers: keys - ) + print(([view] + fields).joined(separator: "|")) +} +SWIFT + +case "$ENGINE" in + postgresql) + CORE_MODULE=TableProPostgreSQLCore + CORE_FILES=( + "$CORE/TableProPostgreSQLCore/PostgreSQLCatalogBoolean.swift" + "$CORE/TableProPostgreSQLCore/PostgreSQLCapabilities.swift" + "$CORE/TableProPostgreSQLCore/PostgreSQLCatalogSearchPath.swift" + ) + HARNESS_FILES+=("$PLUGINS/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift" "$WORK/harness/QuoteLiteral.swift") + cat > "$WORK/harness/QuoteLiteral.swift" << 'SWIFT' +// PostgreSQLObjectQueries.quoteLiteral for names with no backslash or NUL; its own file needs most of PluginKit. +enum PostgreSQLObjectQueries { + static func quoteLiteral(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "''"))'" + } +} +SWIFT + cat > "$WORK/harness/main.swift" << 'SWIFT' +import Foundation +import TableProPluginKit +import TableProPostgreSQLCore + +let schema = CommandLine.arguments[1] +let capabilities = PostgreSQLCapabilities(serverVersion: Int32(CommandLine.arguments[2]) ?? 0) + +// PostgreSQLPluginDriver.fetchViewRowEditing, with catalogRead's search path. +func rowEditing(view: String) throws -> PluginViewRowEditing? { + guard capabilities.serverVersion >= PostgreSQLViewRowEditing.minimumServerVersion else { return nil } + var chain: [PostgreSQLViewRowEditing.Relation] = [] + var query = PostgreSQLViewRowEditing.relationQuery(name: view, schema: schema) + while true { + let row = try catalogRows(PostgreSQLCatalogSearchPath.pinned + query).first + switch PostgreSQLViewRowEditing.step(&chain, row: row) { + case .notAView: + return nil + case .readOnly(let reason): + return .readOnly(reason) + case .readRelation(let oid): + query = PostgreSQLViewRowEditing.relationQuery(oid: oid) + case .readColumns(let viewOID, let baseOID, let baseSchema): + let viewColumns = try catalogRows( + PostgreSQLCatalogSearchPath.pinned + PostgreSQLViewRowEditing.viewColumnsQuery(viewOID: viewOID) + ) + let baseColumns = try catalogRows( + PostgreSQLCatalogSearchPath.relative(to: baseSchema) + + PostgreSQLViewRowEditing.baseColumnsQuery(baseOID: baseOID, capabilities: capabilities) + ) + return PostgreSQLViewRowEditing.resolve(chain: chain, viewColumns: viewColumns, baseColumns: baseColumns) + } + } +} + +for view in CommandLine.arguments.dropFirst(3) { + report(view) { try rowEditing(view: view) } } +SWIFT + ;; + mysql | mariadb) + KIT_FILES+=("$PLUGINS/TableProPluginKit/PluginColumnInfo.swift") + CORE_MODULE=TableProMySQLCore + CORE_FILES=("$CORE/TableProMySQLCore/MySQLLiteralSQL.swift") + HARNESS_FILES+=( + "$PLUGINS/MySQLDriverPlugin/MySQLViewRowEditing.swift" + "$PLUGINS/MySQLDriverPlugin/MySQLCatalogDefault.swift" + "$PLUGINS/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift" + ) + cat > "$WORK/harness/main.swift" << 'SWIFT' +import Foundation +import TableProMySQLCore +import TableProPluginKit -func mySQL(view: String, database: String) -> PluginViewRowEditing? { - let escape: (String) -> String = { $0.replacingOccurrences(of: "'", with: "''") } - guard let row = query(MySQLViewRowEditing.viewQuery(name: view, database: database, escape: escape)).first - else { return nil } - let isUpdatable = row[0]?.uppercased() == "YES" - let definition = row[1].flatMap(MySQLViewRowEditing.parse(definition:)) - var keys: [String] = [] - if isUpdatable, let base = definition?.baseTable { - keys = query(MySQLViewRowEditing.primaryKeyQuery(table: base, viewDatabase: database, escape: escape)) - .compactMap { $0[0] } +let database = CommandLine.arguments[1] +let flavor = CommandLine.arguments[2] +// MySQLServerVersion.quotesColumnDefault: MariaDB from 10.2.7, and this probe needs no older one. +let quotesDefaults = flavor == "mariadb" + +// MySQLPluginDriver.fetchViewRowEditing. +func rowEditing(view: String) throws -> PluginViewRowEditing? { + let escape = mysqlEscapeStringLiteral + let topRows = try catalogRows(MySQLViewRowEditing.viewQuery(database: database, name: view, escape: escape)) + guard let top = MySQLViewRowEditing.view(rows: topRows) else { return nil } + + var levels: [MySQLViewRowEditing.Level] = [] + var next: MySQLViewRowEditing.View? = top + while let current = next { + guard levels.count < MySQLViewRowEditing.maximumDepth else { return .readOnly(.notUpdatable) } + let columnRows = try catalogRows(MySQLViewRowEditing.columnsQuery(current.relation, escape: escape)) + switch try MySQLViewRowEditing.step(view: current, columnRows: columnRows) { + case .readOnly(let reason): + return .readOnly(reason) + case .reads(let level): + levels.append(level) + let innerRows = try catalogRows( + MySQLViewRowEditing.viewQuery(database: level.base.database, name: level.base.name, escape: escape) + ) + next = MySQLViewRowEditing.view(rows: innerRows) + } } - return MySQLViewRowEditing.resolve(isUpdatable: isUpdatable, definition: definition, baseKeyColumns: keys) + + guard let base = levels.last?.base else { return .readOnly(.notUpdatable) } + let keyRows = try catalogRows(MySQLViewRowEditing.primaryKeyQuery(base, escape: escape)) + let baseColumnRows = try catalogRows(MySQLViewRowEditing.columnsQuery(base, escape: escape)) + return MySQLViewRowEditing.answer( + levels: levels, + keyRows: keyRows, + columnRows: baseColumnRows, + quotesDefaults: quotesDefaults, + insertNeedsPlainColumns: flavor == "mysql" + ) +} + +for view in CommandLine.arguments.dropFirst(3) { + report(view) { try rowEditing(view: view) } } +SWIFT + ;; + mssql) + CORE_MODULE=TableProMSSQLCore + CORE_FILES=( + "$CORE/TableProMSSQLCore/MSSQLViewSources.swift" + "$CORE/TableProMSSQLCore/MSSQLSchemaQueries.swift" + "$CORE/TableProMSSQLCore/MSSQLStringLiteral.swift" + ) + cat > "$WORK/harness/main.swift" << 'SWIFT' +import Foundation +import TableProMSSQLCore +import TableProPluginKit -func msSQL(view: String, schema: String) -> PluginViewRowEditing { - let errors = query(MSSQLViewSources.compileErrorsQuery(schema: schema, view: view)).compactMap { $0[0].flatMap(Int.init) } - let summary = MSSQLViewSources.summarize(rows: query(MSSQLViewSources.columnSourcesQuery(schema: schema, view: view))) - var keys: [String] = [] - if summary.baseTables.count == 1, let base = summary.baseTables.first { - keys = query(MSSQLViewSources.primaryKeyQuery(base)).compactMap { $0[0] } +let schema = CommandLine.arguments[1] + +// MSSQLPluginDriver.fetchViewRowEditing. +func rowEditing(view: String) throws -> PluginViewRowEditing? { + let factRows = try catalogRows(MSSQLViewSources.viewFactsQuery(schema: schema, view: view)) + guard let facts = MSSQLViewSources.ViewFacts(rows: factRows) else { return nil } + if let refusal = facts.refusal { return .readOnly(reason(refusal)) } + + let browse = MSSQLViewSources.BrowseFacts( + rows: try catalogRows(MSSQLViewSources.browseQuery(schema: schema, view: view)) + ) + let baseTable: MSSQLViewSources.BaseTable + switch browse.source { + case .refused(let refusal): + return .readOnly(reason(refusal)) + case .table(let table): + baseTable = table } + + let probeRows = try catalogRows(MSSQLViewSources.probesQuery( + schema: schema, + view: view, + writableColumns: browse.writableColumns + )) + if let refusal = MSSQLViewSources.refusal(probeRows: probeRows) { return .readOnly(reason(refusal)) } + let operations = MSSQLViewSources.allowedOperations(writableColumns: browse.writableColumns, probeRows: probeRows) + let base = MSSQLViewSources.BaseFacts(rows: try catalogRows(MSSQLViewSources.baseColumnsQuery(baseTable))) + return PluginViewRowEditing.resolve( - isUpdatable: MSSQLViewSources.isUpdatable(compileErrors: errors), - baseTableCount: MSSQLViewSources.baseTableCount(summary, compileErrors: errors), - baseKeyColumns: keys, - origins: summary.origins.map { PluginViewColumnOrigin(viewColumn: $0.viewColumn, baseColumn: $0.baseColumn) } + operations: writeOperations(operations), + baseTable: PluginViewBaseTable(database: baseTable.database, schema: baseTable.schema, name: baseTable.name), + columns: browse.columns.map { + PluginViewColumn(name: $0.name, baseColumn: $0.baseColumn, acceptsWrites: $0.acceptsWrites) + }, + baseKeyColumns: base?.keyColumns, + baseColumns: base?.columns.map { + PluginViewBaseColumn( + name: $0.name, + isNullable: $0.isNullable, + defaultValue: $0.defaultValue, + assignsValue: $0.assignsValue, + acceptsWrites: $0.acceptsWrites + ) + } ) } -let arguments = CommandLine.arguments -let engine = arguments[1], container = arguments[2], view = arguments[3] -let editing: PluginViewRowEditing? -switch engine { -case "postgresql": editing = postgreSQL(view: view, schema: container) -case "mssql": editing = msSQL(view: view, schema: container) -default: editing = mySQL(view: view, database: container) +// The driver's two private conversions. +func reason(_ refusal: MSSQLViewSources.Refusal) -> PluginViewReadOnlyReason { + switch refusal { + case .notUpdatable: return .notUpdatable + case .writesRunTriggers: return .writesRunTriggers + case .readsSeveralTables: return .readsSeveralTables + case .baseTableHidden: return .baseTableHidden + } +} + +func writeOperations(_ operations: Set) -> PluginViewWriteOperations { + var result: PluginViewWriteOperations = [] + if operations.contains(.update) { result.insert(.update) } + if operations.contains(.insert) { result.insert(.insert) } + if operations.contains(.delete) { result.insert(.delete) } + return result +} + +for view in CommandLine.arguments.dropFirst(3) { + report(view) { try rowEditing(view: view) } } -print(editing.map(describe) ?? "nil") SWIFT -swift build --package-path "$WORK" > "$WORK/build.log" 2>&1 || { - echo "the resolver failed to build:" >&2 - grep -E 'error:' "$WORK/build.log" | sort -u | head -20 >&2 - exit 3 + ;; +esac + +build_module() { + local name="$1" + shift + swiftc -parse-as-library -emit-library -static -emit-module -module-name "$name" -I "$WORK/modules" \ + -emit-module-path "$WORK/modules/$name.swiftmodule" -o "$WORK/lib$name.a" "$@" } -RESOLVER="$(swift build --package-path "$WORK" --show-bin-path)/ViewEditing" +if ! { + build_module TableProPluginKit "${KIT_FILES[@]}" \ + && build_module "$CORE_MODULE" "${CORE_FILES[@]}" \ + && swiftc -I "$WORK/modules" -L "$WORK" -lTableProPluginKit -l"$CORE_MODULE" \ + -o "$WORK/harness/harness" "${HARNESS_FILES[@]}" +} > "$WORK/build.log" 2>&1; then + echo "the harness failed to build:" >&2 + grep -E 'error:' "$WORK/build.log" | sort -u | head -20 >&2 + exit 1 +fi case "$ENGINE" in - mssql) out="$(sql master "CREATE DATABASE [$DATABASE]")" ;; - *) out="$(sql "$(admin_database)" "CREATE DATABASE $DATABASE")" ;; + postgresql) out="$(sql postgres "CREATE DATABASE $DATABASE" 2>&1)" ;; + mysql | mariadb) out="$(sql "" "CREATE DATABASE $DATABASE" 2>&1 && sql "" "CREATE DATABASE $OTHER" 2>&1)" ;; + mssql) out="$(sql master "CREATE DATABASE [$DATABASE]" 2>&1 && sql master "CREATE DATABASE [$OTHER]" 2>&1)" ;; esac -if [ -n "$out" ]; then +status=$? +CREATED=1 +if [ "$status" -ne 0 ]; then echo "could not create $DATABASE: $out" >&2 exit 3 fi -CREATED=1 +# EXPRESSION_OPS: what a view showing qty + 1 or the constant 1 beside plain columns allows. MySQL +# refuses any INSERT into it (1471); the others take one that names only the plain columns. case "$ENGINE" in - postgresql) CONTAINER=public Q_OPEN='"' Q_CLOSE='"' ;; - mysql | mariadb) CONTAINER="$DATABASE" Q_OPEN='`' Q_CLOSE='`' ;; - mssql) CONTAINER=dbo Q_OPEN='[' Q_CLOSE=']' ;; + postgresql) + CONTAINER=public Q_OPEN='"' Q_CLOSE='"' + ELSEWHERE=elsewhere BASE=public.t OTHER_BASE=elsewhere.t2 + GENERATED="gen int GENERATED ALWAYS AS (qty * 2) STORED" + EXPRESSION_OPS=update,insert,delete + HARNESS_OPTION="$(sql postgres "SHOW server_version_num")" + ;; + mysql | mariadb) + CONTAINER="$DATABASE" Q_OPEN='`' Q_CLOSE='`' + ELSEWHERE="$OTHER" BASE="$DATABASE.t" OTHER_BASE="$OTHER.t2" + GENERATED="gen int AS (qty * 2) STORED" + # The flavor the driver reads from the banner, whichever engine name was given. + case "$(sql "" "SELECT VERSION()")" in + *MariaDB*) HARNESS_OPTION=mariadb EXPRESSION_OPS=update,insert,delete ;; + *) HARNESS_OPTION=mysql EXPRESSION_OPS=update,delete ;; + esac + ;; + mssql) + CONTAINER=dbo Q_OPEN='[' Q_CLOSE=']' + ELSEWHERE="$OTHER.dbo" BASE="$DATABASE.dbo.t" OTHER_BASE="$OTHER.dbo.t2" + GENERATED="gen AS (qty * 2)" + EXPRESSION_OPS=update,insert,delete + HARNESS_OPTION=- + ;; esac q() { printf '%s%s%s' "$Q_OPEN" "$1" "$Q_CLOSE"; } -# One statement per line: SQL Server needs CREATE VIEW to open its own batch. -FIXTURE="CREATE TABLE t (id int PRIMARY KEY, name varchar(20), qty int) +# One statement per line: SQL Server needs CREATE VIEW and CREATE TRIGGER to open their own batch. +# The current database reuses the name t2 with another key, so a key read from the wrong catalog shows. +FIXTURE="CREATE TABLE t (id int PRIMARY KEY, name varchar(20), qty int NOT NULL DEFAULT 0, req varchar(20) NOT NULL, $GENERATED) CREATE TABLE u (tid int) CREATE TABLE k (name varchar(20), qty int) -INSERT INTO t (id, name, qty) VALUES (1, 'a', 1) -INSERT INTO t (id, name, qty) VALUES (2, 'b', 2) +CREATE TABLE t2 (id int, code int PRIMARY KEY) +CREATE TABLE $ELSEWHERE.t2 (id int PRIMARY KEY, code int NOT NULL) +INSERT INTO t (id, name, qty, req) VALUES (1, 'a', 1, 'r1') +INSERT INTO t (id, name, qty, req) VALUES (2, 'b', 2, 'r2') INSERT INTO u (tid) VALUES (1) -CREATE VIEW v_simple AS SELECT id, name, qty FROM t -CREATE VIEW v_renamed AS SELECT id AS key_id, name, qty + 1 AS q2 FROM t -CREATE VIEW v_swap AS SELECT name AS id, id AS name FROM t -CREATE VIEW v_nokey AS SELECT name, qty FROM t +INSERT INTO $ELSEWHERE.t2 (id, code) VALUES (1, 7) +INSERT INTO $ELSEWHERE.t2 (id, code) VALUES (2, 7) +CREATE VIEW v_all AS SELECT id, name, qty, req, gen FROM t +CREATE VIEW v_renamed AS SELECT id AS key_id, name AS label, qty AS amount, req FROM t +CREATE VIEW v_computed AS SELECT id, name, req, qty + 1 AS q2 FROM t +CREATE VIEW v_constant AS SELECT id, name, req, 1 AS c FROM t +CREATE VIEW v_hidden_key AS SELECT name, qty, req FROM t CREATE VIEW v_join AS SELECT t.id, t.name, u.tid FROM t JOIN u ON u.tid = t.id -CREATE VIEW v_join_one AS SELECT t.id, t.name FROM t JOIN u ON u.tid = t.id +CREATE VIEW v_self_join AS SELECT m.id, m.name AS manager, e.name FROM t e JOIN t m ON m.id = e.qty +CREATE VIEW v_aggregate AS SELECT name, count(*) AS n FROM t GROUP BY name CREATE VIEW v_distinct AS SELECT DISTINCT id, name FROM t -CREATE VIEW v_keyless AS SELECT name, qty FROM k" +CREATE VIEW v_keyless AS SELECT name, qty FROM k +CREATE VIEW v_hidden_required AS SELECT id, name, qty FROM t +CREATE VIEW v_nested AS SELECT key_id, label AS title FROM v_renamed +CREATE VIEW v_elsewhere AS SELECT code, id FROM $ELSEWHERE.t2" + +# PostgreSQL's DISTINCT and aggregate views read one table, so they fail its update check, not the table count. +EXPECTED="t|not a view +v_all|editable base=$BASE keys=id readonly=gen ops=update,insert,delete +v_renamed|editable base=$BASE keys=key_id readonly= ops=update,insert,delete +v_computed|editable base=$BASE keys=id readonly=q2 ops=$EXPRESSION_OPS +v_constant|editable base=$BASE keys=id readonly=c ops=$EXPRESSION_OPS +v_hidden_key|readOnly keyNotExposed +v_join|readOnly readsSeveralTables +v_aggregate|readOnly notUpdatable +v_distinct|readOnly notUpdatable +v_keyless|readOnly baseTableHasNoKey +v_hidden_required|editable base=$BASE keys=id readonly= ops=update,delete +v_nested|editable base=$BASE keys=key_id readonly= ops=update,delete +v_elsewhere|editable base=$OTHER_BASE keys=id readonly= ops=update,insert,delete" + +case "$ENGINE" in + postgresql) + FIXTURE="CREATE SCHEMA elsewhere +$FIXTURE +CREATE VIEW v_rule AS SELECT id, name FROM t +CREATE RULE r_rule AS ON UPDATE TO v_rule DO INSTEAD UPDATE t SET name = upper(NEW.name) WHERE id = OLD.id +CREATE FUNCTION f_trigger() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END' +CREATE VIEW v_trigger AS SELECT id, name FROM t +CREATE TRIGGER tr_trigger INSTEAD OF UPDATE ON v_trigger FOR EACH ROW EXECUTE FUNCTION f_trigger()" + EXPECTED="$EXPECTED +v_self_join|readOnly notUpdatable +v_rule|readOnly writesRunTriggers +v_trigger|readOnly writesRunTriggers" + ;; + mysql | mariadb) + # A column list renames by position: v_columns.name shows t.id. + FIXTURE="$FIXTURE +CREATE VIEW v_columns (name, id, qty, req) AS SELECT id, name, qty, req FROM t" + EXPECTED="$EXPECTED +v_self_join|readOnly readsSeveralTables +v_columns|editable base=$BASE keys=name readonly= ops=update,insert,delete" + ;; + mssql) + FIXTURE="$FIXTURE +CREATE VIEW v_trigger AS SELECT id, name FROM t +CREATE TRIGGER tr_trigger ON v_trigger INSTEAD OF UPDATE AS BEGIN SET NOCOUNT ON; END +CREATE VIEW v_metadata WITH VIEW_METADATA AS SELECT id, name FROM t" + EXPECTED="$EXPECTED +v_self_join|readOnly readsSeveralTables +v_trigger|readOnly writesRunTriggers +v_metadata|readOnly baseTableHidden" + ;; +esac + while IFS= read -r line; do - out="$(sql "$DATABASE" "$line")" - if [ -n "$out" ]; then + if ! out="$(sql "$DATABASE" "$line" 2>&1)"; then echo "fixture failed: $line: $out" >&2 exit 3 fi done <<< "$FIXTURE" -# The other database's t2 is keyed on id; the current one's t2 is keyed on code, which repeats over there. -if [ "$ENGINE" = mssql ]; then - OTHER_FIXTURE="CREATE DATABASE [${DATABASE}_other] -CREATE TABLE [${DATABASE}_other].dbo.t2 (code int NOT NULL, id int PRIMARY KEY) -INSERT INTO [${DATABASE}_other].dbo.t2 (code, id) VALUES (7, 1), (7, 2) -CREATE TABLE dbo.t2 (code int PRIMARY KEY, id int) -CREATE VIEW v_otherdb AS SELECT code, id FROM [${DATABASE}_other].dbo.t2" - while IFS= read -r line; do - out="$(sql "$DATABASE" "$line")" - if [ -n "$out" ]; then - echo "fixture failed: $line: $out" >&2 - exit 3 - fi - done <<< "$OTHER_FIXTURE" -fi - -case "$ENGINE" in - postgresql) JOIN_ANSWER=readonly:notUpdatable ;; - *) JOIN_ANSWER=readonly:readsSeveralTables ;; -esac -EXPECTED="v_simple editable:id -v_renamed editable:key_id -v_swap editable:name -v_nokey readonly:keyNotExposed -v_join $JOIN_ANSWER -v_join_one $JOIN_ANSWER -v_distinct readonly:notUpdatable -v_keyless readonly:baseTableHasNoKey" -[ "$ENGINE" = mssql ] && EXPECTED="$EXPECTED -v_otherdb editable:id" - -export PROBE_SQL="$WORK/sql.sh" PROBE_DATABASE="$DATABASE" { echo '#!/usr/bin/env bash' - declare -p ENGINE HOST PORT USER_NAME PASSWORD + declare -p ENGINE HOST PORT USER_NAME PASSWORD DATABASE WORK [ -n "${MYSQL_CLIENT:-}" ] && declare -p MYSQL_CLIENT declare -f sql - echo 'sql "$1" "$2"' -} > "$PROBE_SQL" -chmod +x "$PROBE_SQL" + echo 'sql "$DATABASE" "$(cat)" catalog' +} > "$WORK/catalog.sh" +chmod +x "$WORK/catalog.sh" + +pass() { + CHECKS=$((CHECKS + 1)) + echo " ok $1" +} +fail() { + CHECKS=$((CHECKS + 1)) + FAILURES=$((FAILURES + 1)) + echo " FAIL $1" +} +# The mariadb client repeats a failed statement between dashed lines before its error. +one_line() { + local text + text="$(printf '%s' "$1" | tr '\n\t' ' ' | tr -s ' ')" + printf '%s' "${text##*-------------- }" | cut -c 1-140 +} echo "Checking view row editing on $ENGINE at $HOST:$PORT" -while read -r view expected; do - actual="$("$RESOLVER" "$ENGINE" "$CONTAINER" "$view")" - if [ "$actual" = "$expected" ]; then - echo " ok $view: $actual" +echo "Answers" +ANSWERS="$(PROBE_ENGINE="$ENGINE" PROBE_CATALOG="$WORK/catalog.sh" "$WORK/harness/harness" \ + "$CONTAINER" "$HARNESS_OPTION" $(printf '%s\n' "$EXPECTED" | cut -d '|' -f 1) 2> "$WORK/harness.log")" +[ -s "$WORK/harness.log" ] && sed 's/^/ harness: /' "$WORK/harness.log" +EDITABLE="" +while IFS='|' read -r view expected; do + line="$(printf '%s\n' "$ANSWERS" | awk -F '|' -v view="$view" '$1 == view')" + if [ -z "$line" ]; then + fail "$view: no answer from the harness" + continue + fi + IFS='|' read -r _ answer _ <<< "$line" + if [ "$answer" = "$expected" ]; then + pass "$view: $answer" else - echo " FAIL $view: expected $expected, got $actual" - FAILURES=$((FAILURES + 1)) + fail "$view: expected $expected, got $answer" fi + case "$answer" in editable*) EDITABLE="$EDITABLE$line"$'\n' ;; esac done <<< "$EXPECTED" -# A write keyed the way the grid keys it. Each check reads the base table, never the view. +snapshot() { sql "$DATABASE" "SELECT * FROM $1 ORDER BY id"; } + +# Lines of $2 that $1 lacks. +lines_only_in() { + LC_ALL=C comm -13 <(printf '%s\n' "$1" | LC_ALL=C sort) <(printf '%s\n' "$2" | LC_ALL=C sort) | grep -v '^$' +} + +count_lines() { printf '%s' "$1" | grep -c '^'; } + +key_clause() { + local clause="" key + for key in ${1//,/ }; do + clause="${clause:+$clause AND }$(q "$key") = $2" + done + printf '%s' "$clause" +} + +# Every key in the fixture is t.id or t2.id, so row n is keyed by n. +value_for() { + case "$1" in + id | qty | code) printf '%s' "$2" ;; + *) printf "'w%s'" "$2" ;; + esac +} + +# Runs a write, then checks the base table $2: $4 rows gone, $5 rows new, every one of them row $6. +expect_change() { + local label="$1" base="$2" statement="$3" removed="$4" added="$5" id="$6" + local before after error gone new rows + before="$(snapshot "$base")" + if ! error="$(sql "$DATABASE" "$statement" 2>&1 > /dev/null)"; then + fail "$label: $(one_line "$error")" + return 1 + fi + after="$(snapshot "$base")" + gone="$(lines_only_in "$after" "$before")" + new="$(lines_only_in "$before" "$after")" + rows="$(printf '%s\n%s\n' "$gone" "$new" | grep -v '^$' | cut -d $'\x1f' -f 1 | sort -u | tr '\n' ' ')" + if [ "$(count_lines "$gone")" = "$removed" ] && [ "$(count_lines "$new")" = "$added" ] && [ "$rows" = "$id " ]; then + pass "$label" + return 0 + fi + fail "$label: $(count_lines "$gone") rows gone, $(count_lines "$new") new, ids ${rows:-none}" + return 1 +} + expect_value() { - local label="$1" statement="$2" expected="$3" - local actual - actual="$(sql "$DATABASE" "$statement")" + local label="$1" statement="$2" expected="$3" actual + actual="$(sql "$DATABASE" "$statement" 2>&1)" if [ "$actual" = "$expected" ]; then - echo " ok $label" + pass "$label" else - echo " FAIL $label: expected '$expected', got '$actual'" - FAILURES=$((FAILURES + 1)) + fail "$label: expected '$expected', got '$actual'" fi } -write() { - local label="$1" statement="$2" - local out - out="$(sql "$DATABASE" "$statement")" - if [ -n "$out" ]; then - echo " FAIL $label: $out" - FAILURES=$((FAILURES + 1)) + +expect_refusal() { + local label="$1" statement="$2" error + if error="$(sql "$DATABASE" "$statement" 2>&1 > /dev/null)"; then + fail "$label: the server accepted it" + else + pass "$label: $(one_line "$error")" fi } -echo "Writing through the editable views" -write "update v_renamed" "UPDATE $(q v_renamed) SET $(q name) = 'renamed' WHERE $(q key_id) = 2" -expect_value "update v_renamed changed row 2 only" "SELECT name FROM t ORDER BY id" $'a\nrenamed' -write "update v_swap" "UPDATE $(q v_swap) SET $(q id) = 'swapped' WHERE $(q name) = 1" -expect_value "update v_swap changed row 1 only" "SELECT name FROM t ORDER BY id" $'swapped\nrenamed' -write "insert v_simple" "INSERT INTO $(q v_simple) ($(q id), $(q name), $(q qty)) VALUES (3, 'new', 3)" -expect_value "insert v_simple added row 3" "SELECT COUNT(*) FROM t WHERE id = 3 AND name = 'new'" "1" -write "delete v_simple" "DELETE FROM $(q v_simple) WHERE $(q id) = 3" -expect_value "delete v_simple removed row 3 only" "SELECT COUNT(*) FROM t" "2" -if [ "$ENGINE" = mssql ]; then - write "update v_otherdb" "UPDATE [v_otherdb] SET [code] = 8 WHERE [id] = 2" - expect_value "update v_otherdb changed row 2 only" "SELECT code FROM [${DATABASE}_other].dbo.t2 ORDER BY id" $'7\n8' -fi +# Puts row $2 straight into the base table, for a DELETE through a view that cannot INSERT. +seed() { + case "${1##*.}" in + t2) sql "$DATABASE" "INSERT INTO $1 (id, code) VALUES ($2, $2)" ;; + *) sql "$DATABASE" "INSERT INTO $1 (id, name, qty, req) VALUES ($2, 'w$2', $2, 'w$2')" ;; + esac +} + +# The statements the grid builds: SET and VALUES over the columns it may write, WHERE over the key. +check_writes() { + local view="$1" base="$2" keys="$3" writable="$4" readonly="$5" ops="$6" + local pair column="" base_column="" value columns="" values="" id target="" error + + for pair in ${writable//,/ }; do + case ",$keys," in *",${pair%%:*},"*) continue ;; esac + column="${pair%%:*}" base_column="${pair#*:}" + break + done + if [ -z "$column" ]; then + fail "$view: no writable column outside the key to UPDATE" + else + NEXT=$((NEXT + 1)) + value="$(value_for "$base_column" "$NEXT")" + if expect_change "$view: UPDATE of $column keyed on $keys changes base row 2 only" "$base" \ + "UPDATE $(q "$view") SET $(q "$column") = $value WHERE $(key_clause "$keys" 2)" 1 1 2; then + expect_value "$view: base row 2 holds $value in $base_column" \ + "SELECT $base_column FROM $base WHERE id = 2" "${value//\'/}" + fi + fi + + NEXT=$((NEXT + 1)) + id=$NEXT + for pair in ${writable//,/ }; do + columns="${columns:+$columns, }$(q "${pair%%:*}")" + values="${values:+$values, }$(value_for "${pair#*:}" "$id")" + done + case ",$ops," in + *,insert,*) + expect_change "$view: INSERT adds base row $id only" "$base" \ + "INSERT INTO $(q "$view") ($columns) VALUES ($values)" 0 1 "$id" && target="$id" + ;; + *) expect_refusal "$view: the server refuses the INSERT too" "INSERT INTO $(q "$view") ($columns) VALUES ($values)" ;; + esac + + case ",$ops," in + *,delete,*) + if [ -z "$target" ]; then + NEXT=$((NEXT + 1)) + target=$NEXT + if ! error="$(seed "$base" "$target" 2>&1 > /dev/null)"; then + fail "$view: could not seed row $target for the DELETE: $(one_line "$error")" + target="" + fi + fi + [ -n "$target" ] && expect_change "$view: DELETE keyed on $keys removes base row $target only" "$base" \ + "DELETE FROM $(q "$view") WHERE $(key_clause "$keys" "$target")" 1 0 "$target" + ;; + *) expect_refusal "$view: the server refuses the DELETE too" "DELETE FROM $(q "$view") WHERE $(key_clause "$keys" 2)" ;; + esac + + # Assigning a column its own value still has to be refused. + for column in ${readonly//,/ }; do + expect_refusal "$view: the server refuses an UPDATE of read-only $column" \ + "UPDATE $(q "$view") SET $(q "$column") = $(q "$column") WHERE $(key_clause "$keys" 2)" + done +} + +echo "Writes through the editable views" +while IFS='|' read -r view _ base keys writable readonly ops; do + [ -n "$view" ] && check_writes "$view" "$base" "$keys" "$writable" "$readonly" "$ops" +done <<< "$EDITABLE" if [ "$FAILURES" -gt 0 ]; then - echo "$FAILURES check(s) failed" + echo "$FAILURES of $CHECKS checks failed" exit 1 fi -echo "All checks passed" +echo "All $CHECKS checks passed" From 75ec07abffcffc563126816f3cc010b4677acc44 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 1 Nov 2026 07:11:07 +0700 Subject: [PATCH 6/8] fix(mssql): keep a view read-only when its chain runs deeper than the trigger walk --- .../TableProMSSQLCore/MSSQLViewSources.swift | 21 ++++++++++++++++--- .../MSSQLViewSourcesTests.swift | 7 +++++++ .../MSSQLPluginDriver+Schema.swift | 1 + .../scripts/probes/check-view-row-editing.sh | 1 + 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift b/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift index 8ea375bf99..35ca9a4a2d 100644 --- a/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift +++ b/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift @@ -7,6 +7,7 @@ public enum MSSQLViewSources { case writesRunTriggers case readsSeveralTables case baseTableHidden + case couldNotCheck } public enum Operation: String, CaseIterable, Sendable { @@ -127,7 +128,13 @@ public enum MSSQLViewSources { FROM chain c JOIN sys.triggers tr ON tr.parent_id = c.object_id WHERE tr.is_instead_of_trigger = 1 AND tr.is_disabled = 0 - ) THEN 1 ELSE 0 END AS runs_instead_of_trigger + ) THEN 1 ELSE 0 END AS runs_instead_of_trigger, + CASE WHEN EXISTS ( + SELECT 1 + FROM chain c + JOIN @edges e ON e.referencing_id = c.object_id + WHERE c.depth = \(maxViewDepth) + ) THEN 1 ELSE 0 END AS chain_continues FROM sys.views v WHERE v.object_id = @view """ @@ -136,22 +143,30 @@ public enum MSSQLViewSources { public struct ViewFacts: Equatable, Sendable { public let hasOpaqueMetadata: Bool public let runsInsteadOfTrigger: Bool + // Views nest deeper than the walk followed, so a trigger further down went unseen. + public let chainContinues: Bool - public init(hasOpaqueMetadata: Bool, runsInsteadOfTrigger: Bool) { + public init(hasOpaqueMetadata: Bool, runsInsteadOfTrigger: Bool, chainContinues: Bool = false) { self.hasOpaqueMetadata = hasOpaqueMetadata self.runsInsteadOfTrigger = runsInsteadOfTrigger + self.chainContinues = chainContinues } // Nil when no view of that name is visible to the user. public init?(rows: [[String?]]) { guard let row = rows.first else { return nil } - self.init(hasOpaqueMetadata: row.cell(0) == "1", runsInsteadOfTrigger: row.cell(1) == "1") + self.init( + hasOpaqueMetadata: row.cell(0) == "1", + runsInsteadOfTrigger: row.cell(1) == "1", + chainContinues: row.cell(2) == "1" + ) } public var refusal: Refusal? { // WITH VIEW_METADATA makes browse mode name the view in place of its base table. if hasOpaqueMetadata { return .baseTableHidden } if runsInsteadOfTrigger { return .writesRunTriggers } + if chainContinues { return .couldNotCheck } return nil } } diff --git a/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift b/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift index b009881742..89c0533ad8 100644 --- a/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift @@ -151,6 +151,13 @@ struct MSSQLViewSourcesTests { #expect(MSSQLViewSources.ViewFacts(rows: []) == nil) } + @Test("A view chain deeper than the walk follows is not called checked") + func viewChainPastTheWalk() throws { + let facts = try #require(MSSQLViewSources.ViewFacts(rows: [["0", "0", "1"]])) + #expect(facts.refusal == .couldNotCheck) + #expect(MSSQLViewSources.ViewFacts(rows: [["0", "1", "1"]])?.refusal == .writesRunTriggers) + } + @Test("Browse mode maps shown columns to base columns and leaves computed ones without one") func browseOneTable() { let browse = MSSQLViewSources.BrowseFacts(rows: Rows.browseV) diff --git a/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift b/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift index 356901c7d7..62d065c772 100644 --- a/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -529,6 +529,7 @@ private extension PluginViewReadOnlyReason { case .writesRunTriggers: self = .writesRunTriggers case .readsSeveralTables: self = .readsSeveralTables case .baseTableHidden: self = .baseTableHidden + case .couldNotCheck: self = .couldNotCheck } } } diff --git a/apple/scripts/probes/check-view-row-editing.sh b/apple/scripts/probes/check-view-row-editing.sh index d48b4b2f6a..6dd0096c72 100755 --- a/apple/scripts/probes/check-view-row-editing.sh +++ b/apple/scripts/probes/check-view-row-editing.sh @@ -450,6 +450,7 @@ func reason(_ refusal: MSSQLViewSources.Refusal) -> PluginViewReadOnlyReason { case .writesRunTriggers: return .writesRunTriggers case .readsSeveralTables: return .readsSeveralTables case .baseTableHidden: return .baseTableHidden + case .couldNotCheck: return .couldNotCheck } } From 841c5541011561999436757bcac4c60eaac0793e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 1 Nov 2026 07:21:04 +0700 Subject: [PATCH 7/8] fix(data-grid): refuse inverse rewinds through a view and read only key columns as the key --- .../MySQLPluginDriver+Views.swift | 4 +- .../MySQLViewRowEditing.swift | 19 +++++++++- .../MySQLViewRowEditingTests.swift | 15 +++++++- .../PostgreSQLViewRowEditing.swift | 4 +- .../PostgreSQLViewRowEditingTests.swift | 2 + .../DataWrite/Rewind/RewindExecutor.swift | 37 +++++++++++-------- .../Core/DataWrite/RewindPlannerTests.swift | 12 ++++++ .../scripts/probes/check-view-row-editing.sh | 4 +- 8 files changed, 75 insertions(+), 22 deletions(-) diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift index beec9f910f..e47fd4c132 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift @@ -48,12 +48,14 @@ extension MySQLPluginDriver { guard let base = levels.last?.base else { return .readOnly(.notUpdatable) } let keyRows = try await catalogRows(MySQLViewRowEditing.primaryKeyQuery(base, escape: escape)) let baseColumnRows = try await catalogRows(MySQLViewRowEditing.columnsQuery(base, escape: escape)) + let grantRows = try await catalogRows(MySQLViewRowEditing.columnGrantsQuery(base, escape: escape)) return MySQLViewRowEditing.answer( levels: levels, keyRows: keyRows, columnRows: baseColumnRows, quotesDefaults: catalogQuotesDefaults, - insertNeedsPlainColumns: flavor == .mysql + insertNeedsPlainColumns: flavor == .mysql, + hasColumnGrants: MySQLViewRowEditing.hasColumnGrants(rows: grantRows) ) } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift b/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift index 0ae2735cb8..e0c803fa02 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift @@ -113,6 +113,20 @@ enum MySQLViewRowEditing { """ } + // The user's own column grants on the base table: COLUMNS then lists only the columns they cover. + static func columnGrantsQuery(_ relation: Relation, escape: (String) -> String) -> String { + """ + SELECT COUNT(*) FROM information_schema.COLUMN_PRIVILEGES \ + WHERE TABLE_SCHEMA = '\(escape(relation.database))' AND TABLE_NAME = '\(escape(relation.name))' \ + AND GRANTEE = CONCAT('''', SUBSTRING_INDEX(CURRENT_USER(), '@', 1), '''@''', \ + SUBSTRING_INDEX(CURRENT_USER(), '@', -1), '''') + """ + } + + static func hasColumnGrants(rows: [[String?]]) -> Bool { + (rows.first.flatMap { cell($0, 0) }.flatMap { Int($0) } ?? 0) > 0 + } + static func view(rows: [[String?]]) -> View? { guard let row = rows.first, let database = cell(row, 0), let name = cell(row, 1) else { return nil } return View( @@ -165,7 +179,8 @@ enum MySQLViewRowEditing { keyRows: [[String?]], columnRows: [[String?]], quotesDefaults: Bool, - insertNeedsPlainColumns: Bool + insertNeedsPlainColumns: Bool, + hasColumnGrants: Bool ) -> PluginViewRowEditing { guard let top = levels.first, let base = levels.last?.base else { return .readOnly(.notUpdatable) } let baseColumns = columnRows.compactMap { baseColumn(row: $0, quotesDefaults: quotesDefaults) } @@ -199,7 +214,7 @@ enum MySQLViewRowEditing { PluginViewColumn(name: $0, baseColumn: $1, acceptsWrites: $1 != nil) }, baseKeyColumns: isVisible || !keyColumns.isEmpty ? keyColumns : nil, - baseColumns: isVisible && isComplete ? baseColumns : nil + baseColumns: isVisible && isComplete && !hasColumnGrants ? baseColumns : nil ) } diff --git a/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift b/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift index 0cd858ee87..5524637db9 100644 --- a/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift +++ b/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift @@ -111,14 +111,16 @@ struct MySQLViewRowEditingTests { keys: [[String?]] = Fixture.keyRows, tableRows: [[String?]] = Fixture.mysqlTableRows, quotesDefaults: Bool = false, - insertNeedsPlainColumns: Bool = true + insertNeedsPlainColumns: Bool = true, + hasColumnGrants: Bool = false ) -> PluginViewRowEditing { MySQLViewRowEditing.answer( levels: levels, keyRows: keys, columnRows: tableRows, quotesDefaults: quotesDefaults, - insertNeedsPlainColumns: insertNeedsPlainColumns + insertNeedsPlainColumns: insertNeedsPlainColumns, + hasColumnGrants: hasColumnGrants ) } @@ -316,6 +318,15 @@ struct MySQLViewRowEditingTests { #expect(result == .readOnly(.keyNotExposed)) } + @Test("Column grants on the base table leave its column list partial, so Add Row stays off") + func columnGrantsDropInsert() throws { + let level = try level(Fixture.mysqlV, columns: Fixture.vColumns) + let target = try #require(answer([level], insertNeedsPlainColumns: false, hasColumnGrants: true).writeTarget) + #expect(target.operations == [.update, .delete]) + #expect(MySQLViewRowEditing.hasColumnGrants(rows: [["2"]])) + #expect(!MySQLViewRowEditing.hasColumnGrants(rows: [["0"]])) + } + @Test("MariaDB inserts through a view with a computed column when the INSERT leaves it out") func mariaDBInsertsPastComputedColumns() throws { let result = answer([try level(Fixture.mysqlV, columns: Fixture.vColumns)], insertNeedsPlainColumns: false) diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift index bcb6e441ca..baccd18fd2 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift @@ -100,6 +100,8 @@ enum PostgreSQLViewRowEditing { let oid = "\(PostgreSQLObjectQueries.quoteLiteral(baseOID))::oid" let identity = capabilities.hasIdentityColumns ? "a.attidentity::text" : "''" let generated = capabilities.hasGeneratedColumns ? "a.attgenerated::text" : "''" + // indkey lists INCLUDE columns after the key from 11 on; indnkeyatts counts the key ones. + let keyCount = capabilities.hasCoveringIndexes ? "i.indnkeyatts" : "i.indnatts" return """ SELECT a.attnum::text, @@ -115,7 +117,7 @@ enum PostgreSQLViewRowEditing { SELECT key.attnum, key.ord FROM pg_catalog.pg_index i CROSS JOIN LATERAL pg_catalog.unnest(i.indkey) WITH ORDINALITY AS key(attnum, ord) - WHERE i.indrelid = \(oid) AND i.indisprimary + WHERE i.indrelid = \(oid) AND i.indisprimary AND key.ord <= \(keyCount) ) k ON k.attnum = a.attnum WHERE a.attrelid = \(oid) AND a.attnum > 0 diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLViewRowEditingTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLViewRowEditingTests.swift index ce115845f6..9b887d7b97 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLViewRowEditingTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLViewRowEditingTests.swift @@ -252,6 +252,7 @@ struct PostgreSQLViewRowEditingTests { ) #expect(modern.contains("i.indrelid = '16733'::oid")) #expect(modern.contains("a.attidentity") && modern.contains("a.attgenerated")) + #expect(modern.contains("key.ord <= i.indnkeyatts")) } @Test("Servers without identity or generated columns read neither") @@ -262,6 +263,7 @@ struct PostgreSQLViewRowEditingTests { ) #expect(!query.contains("attidentity")) #expect(!query.contains("attgenerated")) + #expect(query.contains("key.ord <= i.indnatts")) } } diff --git a/apple/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift b/apple/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift index d0bda522c2..870dc78510 100644 --- a/apple/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift +++ b/apple/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift @@ -113,21 +113,7 @@ struct RewindExecutor { /// rather than a special case. private func captureInverseRecord(for plan: RewindPlan) async { guard AppSettingsManager.shared.history.keepRewindHistory else { return } - let operations = plan.rows.filter { $0.outcome.restores }.map { row -> RowWriteOperation in - let source = row.operation - return RowWriteOperation( - kind: source.kind.inverted, - target: source.target, - columns: source.columns, - primaryKeyColumns: source.primaryKeyColumns, - preImage: source.postImage, - postImage: source.preImage, - writtenColumns: source.writtenColumns, - refusal: nil, - preImageAbsentColumns: source.postImageAbsentColumns, - postImageAbsentColumns: source.preImageAbsentColumns - ) - } + let operations = Self.inverseOperations(of: plan) guard !operations.isEmpty else { return } await QueryHistoryManager.shared.recordRewindSnapshot( @@ -147,6 +133,27 @@ struct RewindExecutor { ) } + static func inverseOperations(of plan: RewindPlan) -> [RowWriteOperation] { + plan.rows.filter { $0.outcome.restores }.map { row -> RowWriteOperation in + let source = row.operation + let kind = source.kind.inverted + // The same stored refusal a save through a view writes, so a build without the view flag refuses too. + let throughView = plan.record.writtenThroughView == true && kind == .delete + return RowWriteOperation( + kind: kind, + target: source.target, + columns: source.columns, + primaryKeyColumns: source.primaryKeyColumns, + preImage: source.postImage, + postImage: source.preImage, + writtenColumns: source.writtenColumns, + refusal: throughView ? .identityNotPreservable : nil, + preImageAbsentColumns: source.postImageAbsentColumns, + postImageAbsentColumns: source.preImageAbsentColumns + ) + } + } + func apply(_ plan: RewindPlan) async throws -> RewindApplyResult { let displaySQL = plan.displayStatements.joined(separator: "\n") diff --git a/apple/TableProTests/Core/DataWrite/RewindPlannerTests.swift b/apple/TableProTests/Core/DataWrite/RewindPlannerTests.swift index bbd4243a41..fb7a319753 100644 --- a/apple/TableProTests/Core/DataWrite/RewindPlannerTests.swift +++ b/apple/TableProTests/Core/DataWrite/RewindPlannerTests.swift @@ -98,6 +98,18 @@ struct RewindPlannerTests { #expect(decoded.blanketRefusal == .deletedThroughView) } + @Test("Undoing an insert through a view records a delete older builds refuse to restore") + func inverseOfAViewInsertRefusesItsDelete() throws { + let insert = operation(kind: .insert, preImage: nil, postImage: ["9", "Lin"], writtenColumns: columns) + let plan = try planner(operations: [insert], writtenThroughView: true) + .plan(currentRows: [RewindCurrentRow(values: ["9", "Lin"])]) + + let inverse = RewindExecutor.inverseOperations(of: plan) + + #expect(inverse.map { $0.kind } == [.delete]) + #expect(inverse.first?.refusal == .identityNotPreservable) + } + @Test("A row a view no longer shows is refused, not reported as restored or gone") func rowThatLeftTheViewIsRefused() throws { let insert = operation(kind: .insert, preImage: nil, postImage: ["9", "Lin"], writtenColumns: columns) diff --git a/apple/scripts/probes/check-view-row-editing.sh b/apple/scripts/probes/check-view-row-editing.sh index 6dd0096c72..fda40e9029 100755 --- a/apple/scripts/probes/check-view-row-editing.sh +++ b/apple/scripts/probes/check-view-row-editing.sh @@ -370,12 +370,14 @@ func rowEditing(view: String) throws -> PluginViewRowEditing? { guard let base = levels.last?.base else { return .readOnly(.notUpdatable) } let keyRows = try catalogRows(MySQLViewRowEditing.primaryKeyQuery(base, escape: escape)) let baseColumnRows = try catalogRows(MySQLViewRowEditing.columnsQuery(base, escape: escape)) + let grantRows = try catalogRows(MySQLViewRowEditing.columnGrantsQuery(base, escape: escape)) return MySQLViewRowEditing.answer( levels: levels, keyRows: keyRows, columnRows: baseColumnRows, quotesDefaults: quotesDefaults, - insertNeedsPlainColumns: flavor == "mysql" + insertNeedsPlainColumns: flavor == "mysql", + hasColumnGrants: MySQLViewRowEditing.hasColumnGrants(rows: grantRows) ) } From d45f57ff2e30270dfea4bd1eab8fc76580ae7b21 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 1 Nov 2026 08:15:43 +0700 Subject: [PATCH 8/8] fix(probes): pass the view list to the harness as quoted words --- apple/scripts/probes/check-view-row-editing.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apple/scripts/probes/check-view-row-editing.sh b/apple/scripts/probes/check-view-row-editing.sh index fda40e9029..f01878adf3 100755 --- a/apple/scripts/probes/check-view-row-editing.sh +++ b/apple/scripts/probes/check-view-row-editing.sh @@ -639,8 +639,10 @@ one_line() { echo "Checking view row editing on $ENGINE at $HOST:$PORT" echo "Answers" +VIEWS=() +while IFS='|' read -r view _; do VIEWS+=("$view"); done <<< "$EXPECTED" ANSWERS="$(PROBE_ENGINE="$ENGINE" PROBE_CATALOG="$WORK/catalog.sh" "$WORK/harness/harness" \ - "$CONTAINER" "$HARNESS_OPTION" $(printf '%s\n' "$EXPECTED" | cut -d '|' -f 1) 2> "$WORK/harness.log")" + "$CONTAINER" "$HARNESS_OPTION" "${VIEWS[@]}" 2> "$WORK/harness.log")" [ -s "$WORK/harness.log" ] && sed 's/^/ harness: /' "$WORK/harness.log" EDITABLE="" while IFS='|' read -r view expected; do