diff --git a/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift b/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift new file mode 100644 index 0000000000..35ca9a4a2d --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLViewSources.swift @@ -0,0 +1,364 @@ +import Foundation + +// INFORMATION_SCHEMA says NO for every view; sys.dm_exec_describe_first_result_set compiles without running. +public enum MSSQLViewSources { + public enum Refusal: Equatable, Sendable { + case notUpdatable + case writesRunTriggers + case readsSeveralTables + case baseTableHidden + case couldNotCheck + } + + public enum Operation: String, CaseIterable, Sendable { + case update + case insert + case delete + } + + 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 + + public init(server: String? = nil, database: String?, schema: String, name: String) { + self.server = server + self.database = database + self.schema = schema + self.name = name + } + } + + public struct ViewColumn: Equatable, Sendable { + public let name: String + 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 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( + 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, + 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 + """ + } + + 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, 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", + 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 + } + } + + // 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 BrowseFacts: Equatable, Sendable { + public let columns: [ViewColumn] + public let baseTables: [BaseTable] + // 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) + } + } + + // 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 + """ + } + + // 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 { + guard let operation = row.cell(0).flatMap(Operation.init(rawValue:)), + let number = row.cell(1).flatMap({ Int($0) }), + structuralErrors.contains(number) + else { continue } + allowed.remove(operation) + } + 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 new file mode 100644 index 0000000000..89c0533ad8 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLViewSourcesTests.swift @@ -0,0 +1,436 @@ +import Foundation +@testable import TableProMSSQLCore +import Testing + +// 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 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("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("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) + #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(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("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: []) + #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 new file mode 100644 index 0000000000..b377bc544e --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginViewRowEditingTests.swift @@ -0,0 +1,138 @@ +import Foundation +import TableProPluginKit +import Testing + +struct PluginViewRowEditingTests { + 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 + ) + } + + @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")] + ) + 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 answer = resolve(columns: [column("id", "name"), column("name", "code")]) + #expect(answer == .readOnly(.keyNotExposed)) + } + + @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 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("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"), + ] + ) + 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 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("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(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 81c4b74fed..62d065c772 100644 --- a/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -58,6 +58,74 @@ extension MSSQLPluginDriver { } } + func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? { + 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)) } + // 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)) + ) + 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 + )) + if let refusal = MSSQLViewSources.refusal(probeRows: probeRows) { + return .readOnly(PluginViewReadOnlyReason(refusal)) + } + let operations = MSSQLViewSources.allowedOperations( + writableColumns: browse.writableColumns, + probeRows: probeRows + ) + let base = MSSQLViewSources.BaseFacts( + rows: try await viewCatalogRows(MSSQLViewSources.baseColumnsQuery(baseTable)) + ) + + return PluginViewRowEditing.resolve( + 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. @@ -453,3 +521,28 @@ 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 + case .couldNotCheck: self = .couldNotCheck + } + } +} + +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 new file mode 100644 index 0000000000..e47fd4c132 --- /dev/null +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Views.swift @@ -0,0 +1,65 @@ +// +// MySQLPluginDriver+Views.swift +// MySQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension MySQLPluginDriver { + // 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 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 { + 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) + } + } + + 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, + hasColumnGrants: MySQLViewRowEditing.hasColumnGrants(rows: grantRows) + ) + } + + 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 new file mode 100644 index 0000000000..e0c803fa02 --- /dev/null +++ b/apple/Plugins/MySQLDriverPlugin/MySQLViewRowEditing.swift @@ -0,0 +1,455 @@ +// +// MySQLViewRowEditing.swift +// MySQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +enum MySQLViewRowEditing { + static let maximumDepth = 8 + + struct Relation: Hashable { + let database: String + let name: String + } + + struct View: Equatable { + let relation: Relation + let isUpdatable: Bool + let definition: String + } + + 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 columnsQuery(_ relation: Relation, escape: (String) -> String) -> String { + """ + 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 + """ + } + + // 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.STATISTICS \ + WHERE TABLE_SCHEMA = '\(escape(relation.database))' AND TABLE_NAME = '\(escape(relation.name))' \ + AND INDEX_NAME = 'PRIMARY' ORDER BY SEQ_IN_INDEX + """ + } + + // 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( + relation: Relation(database: database, name: name), + isUpdatable: cell(row, 2)?.uppercased() == "YES", + definition: cell(row, 3) ?? "" + ) + } + + 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)) + } + } + + 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) + ) + } + + static func answer( + levels: [Level], + keyRows: [[String?]], + columnRows: [[String?]], + quotesDefaults: 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) } + 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() { + sources = sources.map { $0.flatMap(level.source(of:)) } + } + if isVisible { + sources = sources.map { source in + source.flatMap { name in baseColumns.first { sameName($0.name, name) }?.name } + } + } + + var operations: PluginViewWriteOperations = [.update, .delete] + // 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 !insertNeedsPlainColumns || (shown.count == sources.count && Set(shown).count == shown.count) { + operations.insert(.insert) + } + + 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 || !keyColumns.isEmpty ? keyColumns : nil, + baseColumns: isVisible && isComplete && !hasColumnGrants ? baseColumns : nil + ) + } + + 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...]) + } + guard tokens.first?.isWord("select") == true else { return .noTable } + + 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 + } + + 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 + } + + 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) + } + + // 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 + var index = 0 + + while index < scalars.count { + let scalar = scalars[index] + switch scalar { + case "`": + var name = String.UnicodeScalarView() + index += 1 + while index < scalars.count { + if scalars[index] == "`" { + guard index + 1 < scalars.count, scalars[index + 1] == "`" else { break } + index += 1 + } + name.append(scalars[index]) + index += 1 + } + index += 1 + tokens.append(Token(kind: .quoted, text: String(name), depth: depth)) + case "'", "\"": + let start = index + index += 1 + while index < scalars.count { + if scalars[index] == "\\" { + index += 2 + continue + } + if scalars[index] == scalar { + guard index + 1 < scalars.count, scalars[index + 1] == scalar else { break } + index += 1 + } + 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 new file mode 100644 index 0000000000..5524637db9 --- /dev/null +++ b/apple/Plugins/MySQLDriverTests/MySQLViewRowEditingTests.swift @@ -0,0 +1,498 @@ +// +// MySQLViewRowEditingTests.swift +// 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 rows(_ names: [String]) -> [[String?]] { + names.map { [$0] } + } + + 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, + insertNeedsPlainColumns: Bool = true, + hasColumnGrants: Bool = false + ) -> PluginViewRowEditing { + MySQLViewRowEditing.answer( + levels: levels, + keyRows: keys, + columnRows: tableRows, + quotesDefaults: quotesDefaults, + insertNeedsPlainColumns: insertNeedsPlainColumns, + hasColumnGrants: hasColumnGrants + ) + } + + @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(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("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("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) + #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"])]) + 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(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("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("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("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(database: nil, name: "o'v", escape: escape) + #expect(view.contains("TABLE_SCHEMA = DATABASE()")) + #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("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 9d29295ac9..eb7340d2d0 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) @@ -22,6 +21,45 @@ extension PostgreSQLPluginDriver { return PostgreSQLViewDefinition.statement(name: view, schema: resolvedSchema, row: catalogRow) } + func fetchViewRowEditing(view: String, schema: String?) async throws -> PluginViewRowEditing? { + 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: [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 + 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 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) }, + baseColumns: baseColumns.rows.map { $0.map(\.asText) } + ) + } + } + } + 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..baccd18fd2 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLViewRowEditing.swift @@ -0,0 +1,315 @@ +import Foundation +import TableProPluginKit +import TableProPostgreSQLCore + +enum PostgreSQLViewRowEditing { + 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, + 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 rt ON rt.ev_class = c.oid AND rt.rulename = '_RETURN' + WHERE \(condition) + """ + } + + static func viewColumnsQuery(viewOID: String) -> String { + """ + 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 + 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" : "''" + // 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, + 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 AND key.ord <= \(keyCount) + ) 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( + chain: [Relation], + viewColumns viewRows: [[String?]], + baseColumns baseRows: [[String?]] + ) -> PluginViewRowEditing { + 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( + operations: operations(events: outer.events), + baseTable: PluginViewBaseTable(schema: base.schema, name: base.name), + columns: columns, + baseKeyColumns: keyColumns, + baseColumns: attributes.map(\.column) + ) + } + + // 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 [:] } + 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: [Int: ColumnOrigin] = [:] + 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 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] + if value.depth == entryDepth, !value.isBracket { + fields[token.text] = value.text + } + } + index += 1 + } + return origins + } + + private static func origin(from fields: [String: String]) -> (number: Int, origin: ColumnOrigin)? { + guard fields[":resjunk"] != "true", + 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 (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 +} + +// 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 + + var isBracket: Bool { ["{", "}", "(", ")"].contains(text) } + + static func tokenize(_ text: String) -> [NodeTreeToken] { + var tokens: [NodeTreeToken] = [] + var depth = 0 + var word = String.UnicodeScalarView() + var escaped = false + + func flush() { + if !word.isEmpty { tokens.append(NodeTreeToken(text: String(word), depth: depth)) } + word = String.UnicodeScalarView() + } + + // 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 + 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..9b887d7b97 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLViewRowEditingTests.swift @@ -0,0 +1,556 @@ +import Foundation +import TableProPluginKit +import TableProPostgreSQLCore +import Testing + +struct PostgreSQLViewRowEditingTests { + @Test("Only the outermost target list counts, not a WHERE sub-select's") + func readsOuterTargetList() { + #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() { + #expect(PostgreSQLViewRowEditing.targetOrigins(nodeTree: Tree.innerCalc) == [ + 1: .init(relationOID: OID.t, attributeNumber: 1), + 3: .init(relationOID: OID.t, attributeNumber: 2), + ]) + } + + @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), + ]) + } + + @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("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( + chain: walked.chain, + viewColumns: Rows.vColumns, + baseColumns: Rows.tColumns + ) + 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("Base defaults, identity and generated columns reach the target as the catalog says") + func baseColumnFacts() throws { + let editing = PostgreSQLViewRowEditing.resolve( + 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("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")) + #expect(modern.contains("key.ord <= i.indnkeyatts")) + } + + @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")) + #expect(query.contains("key.ord <= i.indnatts")) + } +} + +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 ca8846ef83..c934c00981 100644 --- a/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -651,6 +651,9 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { schema: String? ) async throws -> PluginConcurrentRefreshAvailability? + // 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? @@ -1369,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( @@ -1727,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 new file mode 100644 index 0000000000..44f132e7e0 --- /dev/null +++ b/apple/Plugins/TableProPluginKit/PluginViewRowEditing.swift @@ -0,0 +1,177 @@ +import Foundation + +public enum PluginViewRowEditing: Sendable, Equatable { + case editable(PluginViewWriteTarget) + case readOnly(PluginViewReadOnlyReason) + + public var writeTarget: PluginViewWriteTarget? { + if case .editable(let target) = self { return target } + return nil + } + + // Engines return early for what only they can see; the rest of the answer is the same everywhere. + public static func resolve( + operations: PluginViewWriteOperations, + baseTable: PluginViewBaseTable, + columns: [PluginViewColumn], + baseKeyColumns: [String]?, + baseColumns: [PluginViewBaseColumn]? + ) -> PluginViewRowEditing { + 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 baseKey in baseKeyColumns { + guard let shown = columns.first(where: { $0.baseColumn == baseKey }) else { + return .readOnly(.keyNotExposed) + } + 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) + } + } + + 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 + // A read failed after the relation proved to be a view. + case couldNotCheck +} + +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(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 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..80248e92f5 100644 --- a/apple/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/apple/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -733,9 +733,11 @@ final class DataChangeManager: ObservableObject, ChangeManaging { database: String, schema: String?, containsTableOperation: Bool, - rowsWithUnloadedValues: Set = [] + 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, @@ -746,7 +748,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 7ed1294821..b984726674 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 + // 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, 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,8 @@ 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. + resultSet.origin?.viewRowEditing = parsed.viewRowEditing } private func applyEnumValues( @@ -615,6 +623,10 @@ extension QueryExecutionCoordinator { tab.display.activeResultSet?.origin?.primaryKeyColumns = parsed.primaryKeyColumns tab.display.activeResultSet?.origin?.keysResolved = true } + 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 dc863a5f69..9918fc6451 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -155,17 +155,19 @@ 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.knowsView(named: tableName) let boundValues = BoundParameterValues(values: parameters) let failureOutput = ServerOutputBox() let grammar = parent.lexicalGrammar 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, scope: scope) + let schemaTask = parent.startSchemaFetch( + tableName, uncached: needsMetadataFetch, isView: readsView, scope: scope + ) do { let (fetchResult, tableEdits) = try await DatabaseManager.shared.withScopedDriver( 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..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, @@ -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..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( @@ -141,11 +127,33 @@ struct RewindExecutor { generatedColumns: plan.record.generatedColumns, identityColumns: plan.record.identityColumns, columnTypeNames: plan.record.columnTypeNames, - operations: operations + operations: operations, + writtenThroughView: plan.record.writtenThroughView ) ) } + 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/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/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/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/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/Infrastructure/MainSplitViewController+MenuValidation.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 3b7c6f65d7..a6ee87fe59 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 @@ -231,7 +232,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 @@ -599,7 +600,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 e8a042f2c1..4cf6c90eba 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,12 +58,15 @@ struct ParsedSchemaMetadata { let isAuthoritative: Bool var rowMatchPolicy: RowMatchPolicy = .none var columnDeclaredTypes: [String: String] = [:] + var viewRowEditing: PluginViewRowEditing? + + // 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 + return metadata + } - /// 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. static func cached(rows: TableRows, primaryKeyColumns: [String]) -> ParsedSchemaMetadata { ParsedSchemaMetadata( columnDefaults: rows.columnDefaults, @@ -244,27 +248,58 @@ 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 + ) 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, isKnownView: isView, scope: scope, on: driver) + : nil + return (columns, approximateRowCount, viewRowEditing) } let foreignKeys = await fetchForeignKeys(scope: scope, tableName: tableName) 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 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? { + do { + 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)" + ) + return isKnownView ? .readOnly(.couldNotCheck) : nil + } } private static func fetchForeignKeys(scope: DatabaseScope, tableName: String) async -> [ForeignKeyInfo]? { @@ -303,6 +338,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 { @@ -319,12 +358,12 @@ final class QueryExecutor { columnDefaults: defaults, columnForeignKeys: fks, columnNullable: nullable, - primaryKeyColumns: 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, @@ -334,10 +373,25 @@ final class QueryExecutor { excludedColumns: columns(in: schema.columns, typedAnyOf: rowMatchExcludedTypePrefixes), textColumns: columns(in: schema.columns, typedAnyOf: rowMatchTextTypePrefixes) ), - columnDeclaredTypes: declaredTypes + columnDeclaredTypes: declaredTypes, + viewRowEditing: schema.viewRowEditing ) } + // 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 db3caf8fba..fa48f0b88c 100644 --- a/apple/TablePro/Models/Query/QueryTabState.swift +++ b/apple/TablePro/Models/Query/QueryTabState.swift @@ -534,12 +534,40 @@ 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 answers, and on 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 { 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 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 + guard answer != nil, tabType == .table, resolvedObjectKind() == .table else { return } + objectType = .view + isView = true } var primaryKeyColumn: String? { primaryKeyColumns.first } diff --git a/apple/TablePro/Models/Query/ResultOrigin.swift b/apple/TablePro/Models/Query/ResultOrigin.swift index cdf986a821..d7891faccd 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 + // Nil for a table, before the driver answers, and on an engine that never does. + 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,24 +68,65 @@ 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 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: + 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: + 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 { return .view } + // 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 } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 5a19ed0de4..407edf2f89 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -189603,6 +189603,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", @@ -193968,6 +193974,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/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/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/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 f00eefa556..ed69bb516c 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( @@ -1202,6 +1202,7 @@ final class MainContentCoordinator: ObservableObject { return } let isTableTab = tab.tabType == .table + let readsView = tab.tableContext.knowsView(named: tableName) 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, 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..fb7a319753 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,64 @@ 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("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) + 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/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/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 3b9cae5f9d..a05e9c6725 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 @@ -782,6 +783,8 @@ struct MainMenuValidationTests { context.supportsContainerSwitching = true context.supportsServerDashboard = true context.supportsUserManagement = true + context.canInsertRows = true + context.canDeleteRows = true context.supportsTableHealth = true context.isCurrentTabEditable = true context.isCurrentTabSchemaResolved = 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 821e8b1b42..1331426d04 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,63 @@ 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 + ) + 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 == answer) + } + + @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("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( @@ -55,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( 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 e4674a15c6..ff496e37f7 100644 --- a/apple/TableProTests/Models/Query/ResultEditabilityTests.swift +++ b/apple/TableProTests/Models/Query/ResultEditabilityTests.swift @@ -4,11 +4,16 @@ // import Foundation +import TableProPluginKit 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", @@ -27,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) @@ -50,7 +66,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(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(keys: ["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(keys: ["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 +133,12 @@ struct ResultEditabilityTests { @Test("Every refusal carries a sentence for the user") func everyRefusalExplainsItself() { - for refusal in [ResultEditRefusal.unknownTable, .view, .databaseMoved, .keysUnresolved] { + let reasons: [PluginViewReadOnlyReason?] = [ + nil, .notUpdatable, .writesRunTriggers, .readsSeveralTables, + .baseTableHidden, .baseTableHasNoKey, .keyNotExposed, .couldNotCheck, + ] + 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 ab288637c7..e7be827eed 100644 --- a/apple/TableProTests/Models/Query/TabObjectKindTests.swift +++ b/apple/TableProTests/Models/Query/TabObjectKindTests.swift @@ -51,6 +51,66 @@ 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() { + 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) + #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 = answer + matview.primaryKeyColumns = ["id"] + #expect(!matview.allowsRowEditing) + #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) + #expect(table.viewEditRefusal == nil) + #expect(table.viewWriteTarget == nil) + } + @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/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") + } + } +} 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. 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..f01878adf3 --- /dev/null +++ b/apple/scripts/probes/check-view-row-editing.sh @@ -0,0 +1,804 @@ +#!/usr/bin/env bash +# +# 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. +# +# 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 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 1 on a wrong answer, a wrong write or a harness that does not build, 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" +OTHER="${DATABASE}_elsewhere" +WORK="$(mktemp -d)" +CREATED=0 +CHECKS=0 +FAILURES=0 +NEXT=100 + +# 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" 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 -A -t -v ON_ERROR_STOP=1 "${format[@]}" -c "$statement" < /dev/null + ;; + mysql | mariadb) + 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> "$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 +} + +admin_database() { + case "$ENGINE" in + postgresql) echo postgres ;; + mysql | mariadb) echo "" ;; + mssql) echo master ;; + esac +} + +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" "$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 + ;; + 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 or mariadb not found" >&2; exit 3; } + ;; + mssql) command -v tsql > /dev/null || { echo "tsql not found (brew install freetds)" >&2; exit 3; } ;; +esac +command -v swiftc > /dev/null || { echo "swiftc not found (xcode-select --install)" >&2; exit 3; } + +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 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 TableProPluginKit + +struct CatalogError: Error, CustomStringConvertible { + let description: String +} + +let engine = ProcessInfo.processInfo.environment["PROBE_ENGINE"] ?? "" +let catalogClient = ProcessInfo.processInfo.environment["PROBE_CATALOG"] ?? "" + +// 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") + } +} + +// 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 + } + } +} + +// 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) } + } +} + +// 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: " ")] + } + 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 + +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) + } + } + + 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", + hasColumnGrants: MySQLViewRowEditing.hasColumnGrants(rows: grantRows) + ) +} + +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 + +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( + 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 + ) + } + ) +} + +// 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 + case .couldNotCheck: return .couldNotCheck + } +} + +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) } +} +SWIFT + ;; +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" "$@" +} +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 + 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 +status=$? +CREATED=1 +if [ "$status" -ne 0 ]; then + echo "could not create $DATABASE: $out" >&2 + exit 3 +fi + +# 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='"' + 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 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) +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) +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_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_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 + if ! out="$(sql "$DATABASE" "$line" 2>&1)"; then + echo "fixture failed: $line: $out" >&2 + exit 3 + fi +done <<< "$FIXTURE" + +{ + echo '#!/usr/bin/env bash' + declare -p ENGINE HOST PORT USER_NAME PASSWORD DATABASE WORK + [ -n "${MYSQL_CLIENT:-}" ] && declare -p MYSQL_CLIENT + declare -f 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" +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" "${VIEWS[@]}" 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 + fail "$view: expected $expected, got $answer" + fi + case "$answer" in editable*) EDITABLE="$EDITABLE$line"$'\n' ;; esac +done <<< "$EXPECTED" + +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" actual + actual="$(sql "$DATABASE" "$statement" 2>&1)" + if [ "$actual" = "$expected" ]; then + pass "$label" + else + fail "$label: expected '$expected', got '$actual'" + fi +} + +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 +} + +# 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 of $CHECKS checks failed" + exit 1 +fi +echo "All $CHECKS checks passed"