From b648ea6df791637588cfa2fdbbbdcdbdf08342cc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 23 Jun 2026 12:21:40 +0700 Subject: [PATCH 1/2] fix(plugins): qualify browse and filter queries with the table schema Claude-Session: https://claude.ai/code/session_0198faM6VCrViRU4XwRoS1DC --- CHANGELOG.md | 1 + .../MSSQLSchemaQueries.swift | 35 ++++++++++++ .../MSSQLSchemaQueriesTests.swift | 53 +++++++++++++++++++ Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 48 +++++++++++++---- Plugins/OracleDriverPlugin/OraclePlugin.swift | 43 +++++++++++++-- 5 files changed, 165 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 193784a44..1270489cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Raw filters in the data grid now apply on document and key-value databases; the typed text was being dropped before it reached the driver. (#1529) - Connecting to Oracle no longer crashes the app while reading certain server values during the handshake; a bad packet now fails the connection with an error instead. (#1746) +- Opening a SQL Server or Oracle table or view outside the default schema no longer fails with "Invalid object name"; the data query now qualifies the table with its schema. (#1754) ## [0.52.1] - 2026-06-22 diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift index a0a56b020..83ee4c9e5 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift @@ -13,6 +13,41 @@ public enum MSSQLSchemaQueries { "[\(escapeBracket(schema))].[\(escapeBracket(table))]" } + public static func qualifiedName(schema: String?, table: String) -> String { + guard let schema, !schema.isEmpty else { + return "[\(escapeBracket(table))]" + } + return bracketed(schema: schema, table: table) + } + + public static func browse( + schema: String?, + table: String, + orderByClause: String, + offset: Int, + limit: Int + ) -> String { + let target = qualifiedName(schema: schema, table: table) + return "SELECT * FROM \(target) \(orderByClause) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + } + + public static func filtered( + schema: String?, + table: String, + whereClause: String, + orderByClause: String, + offset: Int, + limit: Int + ) -> String { + let target = qualifiedName(schema: schema, table: table) + var query = "SELECT * FROM \(target)" + if !whereClause.isEmpty { + query += " WHERE \(whereClause)" + } + query += " \(orderByClause) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } + public static let currentSchema = "SELECT SCHEMA_NAME()" public static let serverVersion = "SELECT @@VERSION" public static let beginTransaction = "BEGIN TRANSACTION" diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift index 2b488d5d5..8ae5149e6 100644 --- a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift @@ -110,4 +110,57 @@ final class MSSQLSchemaQueriesTests: XCTestCase { XCTAssertEqual(parsed?.referencedTable, "users") XCTAssertEqual(parsed?.referencedColumn, "id") } + + func testQualifiedNamePrefixesSchema() { + XCTAssertEqual(MSSQLSchemaQueries.qualifiedName(schema: "sales", table: "routeCache"), "[sales].[routeCache]") + } + + func testQualifiedNameOmitsSchemaWhenNilOrEmpty() { + XCTAssertEqual(MSSQLSchemaQueries.qualifiedName(schema: nil, table: "routeCache"), "[routeCache]") + XCTAssertEqual(MSSQLSchemaQueries.qualifiedName(schema: "", table: "routeCache"), "[routeCache]") + } + + func testBrowseQualifiesNonDefaultSchema() { + let sql = MSSQLSchemaQueries.browse( + schema: "sales", table: "routeCache", + orderByClause: "ORDER BY (SELECT NULL)", offset: 0, limit: 200 + ) + XCTAssertEqual( + sql, + "SELECT * FROM [sales].[routeCache] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY" + ) + } + + func testBrowseWithoutSchemaStaysUnqualified() { + let sql = MSSQLSchemaQueries.browse( + schema: nil, table: "routeCache", + orderByClause: "ORDER BY (SELECT NULL)", offset: 10, limit: 50 + ) + XCTAssertEqual( + sql, + "SELECT * FROM [routeCache] ORDER BY (SELECT NULL) OFFSET 10 ROWS FETCH NEXT 50 ROWS ONLY" + ) + } + + func testFilteredQualifiesSchemaAndAppendsWhere() { + let sql = MSSQLSchemaQueries.filtered( + schema: "sales", table: "routeCache", whereClause: "[id] = 1", + orderByClause: "ORDER BY (SELECT NULL)", offset: 0, limit: 200 + ) + XCTAssertEqual( + sql, + "SELECT * FROM [sales].[routeCache] WHERE [id] = 1 ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY" + ) + } + + func testFilteredOmitsWhenWhereClauseEmpty() { + let sql = MSSQLSchemaQueries.filtered( + schema: "sales", table: "routeCache", whereClause: "", + orderByClause: "ORDER BY (SELECT NULL)", offset: 0, limit: 200 + ) + XCTAssertEqual( + sql, + "SELECT * FROM [sales].[routeCache] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY" + ) + } } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 6918520ba..477b80c09 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -562,13 +562,26 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { limit: Int, offset: Int ) -> String? { - let quotedTable = mssqlQuoteIdentifier(table) - var query = "SELECT * FROM \(quotedTable)" + buildBrowseQuery( + table: table, schema: nil, sortColumns: sortColumns, + columns: columns, limit: limit, offset: offset + ) + } + + func buildBrowseQuery( + table: String, + schema: String?, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { let orderBy = PluginSQLFilter.buildOrderByClause( sortColumns: sortColumns, columns: columns, quoteIdentifier: mssqlQuoteIdentifier ) ?? "ORDER BY (SELECT NULL)" - query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" - return query + return MSSQLSchemaQueries.browse( + schema: schema, table: table, orderByClause: orderBy, offset: offset, limit: limit + ) } func buildFilteredQuery( @@ -580,8 +593,22 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { limit: Int, offset: Int ) -> String? { - let quotedTable = mssqlQuoteIdentifier(table) - var query = "SELECT * FROM \(quotedTable)" + buildFilteredQuery( + table: table, schema: nil, filters: filters, logicMode: logicMode, + sortColumns: sortColumns, columns: columns, limit: limit, offset: offset + ) + } + + func buildFilteredQuery( + table: String, + schema: String?, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { let whereClause = PluginSQLFilter.buildWhereClause( filters: filters, logicMode: logicMode, @@ -591,14 +618,13 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { "\(quoted) LIKE '%\(value.replacingOccurrences(of: "'", with: "''"))%'" } ) - if !whereClause.isEmpty { - query += " WHERE \(whereClause)" - } let orderBy = PluginSQLFilter.buildOrderByClause( sortColumns: sortColumns, columns: columns, quoteIdentifier: mssqlQuoteIdentifier ) ?? "ORDER BY (SELECT NULL)" - query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" - return query + return MSSQLSchemaQueries.filtered( + schema: schema, table: table, whereClause: whereClause, + orderByClause: orderBy, offset: offset, limit: limit + ) } // MARK: - Query Building Helpers diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index c761298ab..72688184f 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -1109,8 +1109,21 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { limit: Int, offset: Int ) -> String? { - let quotedTable = oracleQuoteIdentifier(table) - var query = "SELECT * FROM \(quotedTable)" + buildBrowseQuery( + table: table, schema: nil, sortColumns: sortColumns, + columns: columns, limit: limit, offset: offset + ) + } + + func buildBrowseQuery( + table: String, + schema: String?, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + var query = "SELECT * FROM \(oracleQualifiedName(schema: schema, table: table))" let orderBy = PluginSQLFilter.buildOrderByClause( sortColumns: sortColumns, columns: columns, quoteIdentifier: oracleQuoteIdentifier ) ?? "ORDER BY 1" @@ -1127,8 +1140,23 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { limit: Int, offset: Int ) -> String? { - let quotedTable = oracleQuoteIdentifier(table) - var query = "SELECT * FROM \(quotedTable)" + buildFilteredQuery( + table: table, schema: nil, filters: filters, logicMode: logicMode, + sortColumns: sortColumns, columns: columns, limit: limit, offset: offset + ) + } + + func buildFilteredQuery( + table: String, + schema: String?, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + var query = "SELECT * FROM \(oracleQualifiedName(schema: schema, table: table))" let whereClause = PluginSQLFilter.buildWhereClause( filters: filters, logicMode: logicMode, @@ -1150,6 +1178,13 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Query Building Helpers + private func oracleQualifiedName(schema: String?, table: String) -> String { + guard let schema, !schema.isEmpty else { + return oracleQuoteIdentifier(table) + } + return "\(oracleQuoteIdentifier(schema)).\(oracleQuoteIdentifier(table))" + } + private func oracleQuoteIdentifier(_ identifier: String) -> String { "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\"" } From 5bda130dbde2be97bc401a2af27437c5432b3cd7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 23 Jun 2026 13:03:29 +0700 Subject: [PATCH 2/2] fix(plugins): qualify write statements and FK navigation with the table schema Claude-Session: https://claude.ai/code/session_0198faM6VCrViRU4XwRoS1DC --- CHANGELOG.md | 2 +- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 40 ++++++++++++++----- Plugins/OracleDriverPlugin/OraclePlugin.swift | 38 +++++++++++++----- .../PluginDatabaseDriver.swift | 7 ++++ .../ChangeTracking/DataChangeManager.swift | 7 +++- .../QueryExecutionCoordinator+Helpers.swift | 1 + .../MainContentCoordinator+FKNavigation.swift | 2 +- .../MainContentCoordinator+TabSwitch.swift | 8 +++- .../MainContentView+EventHandlers.swift | 1 + 9 files changed, 80 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1270489cf..c4ffe70f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Raw filters in the data grid now apply on document and key-value databases; the typed text was being dropped before it reached the driver. (#1529) - Connecting to Oracle no longer crashes the app while reading certain server values during the handshake; a bad packet now fails the connection with an error instead. (#1746) -- Opening a SQL Server or Oracle table or view outside the default schema no longer fails with "Invalid object name"; the data query now qualifies the table with its schema. (#1754) +- Browsing and editing a SQL Server or Oracle table or view outside the default schema no longer fails with "Invalid object name" or writes to the wrong table; data, filter, and save queries now qualify the table with its schema. (#1754) ## [0.52.1] - 2026-06-22 diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 477b80c09..1fcdfdc5a 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -313,6 +313,24 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { deletedRowIndices: Set, insertedRowIndices: Set ) -> [(statement: String, parameters: [PluginCellValue])]? { + generateStatements( + table: table, schema: nil, columns: columns, primaryKeyColumns: primaryKeyColumns, + changes: changes, insertedRowData: insertedRowData, + deletedRowIndices: deletedRowIndices, insertedRowIndices: insertedRowIndices + ) + } + + func generateStatements( + table: String, + schema: String?, + columns: [String], + primaryKeyColumns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [PluginCellValue])]? { + let qualifiedTable = MSSQLSchemaQueries.qualifiedName(schema: schema, table: table) var statements: [(statement: String, parameters: [PluginCellValue])] = [] var deleteChanges: [PluginRowChange] = [] @@ -322,13 +340,15 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { case .insert: guard insertedRowIndices.contains(change.rowIndex) else { continue } if let values = insertedRowData[change.rowIndex] { - if let stmt = generateMssqlInsert(table: table, columns: columns, values: values) { + if let stmt = generateMssqlInsert( + table: table, qualifiedTable: qualifiedTable, columns: columns, values: values + ) { statements.append(stmt) } } case .update: if let stmt = generateMssqlUpdate( - table: table, columns: columns, + qualifiedTable: qualifiedTable, columns: columns, primaryKeyColumns: primaryKeyColumns, change: change ) { statements.append(stmt) @@ -342,7 +362,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { if !deleteChanges.isEmpty { for change in deleteChanges { if let stmt = generateMssqlDelete( - table: table, columns: columns, + qualifiedTable: qualifiedTable, columns: columns, primaryKeyColumns: primaryKeyColumns, change: change ) { statements.append(stmt) @@ -355,6 +375,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private func generateMssqlInsert( table: String, + qualifiedTable: String, columns: [String], values: [PluginCellValue] ) -> (statement: String, parameters: [PluginCellValue])? { @@ -378,13 +399,12 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let columnList = nonDefaultColumns.joined(separator: ", ") let placeholders = parameters.map { _ in "?" }.joined(separator: ", ") - let escapedTable = "[\(table.replacingOccurrences(of: "]", with: "]]"))]" - let sql = "INSERT INTO \(escapedTable) (\(columnList)) VALUES (\(placeholders))" + let sql = "INSERT INTO \(qualifiedTable) (\(columnList)) VALUES (\(placeholders))" return (statement: sql, parameters: parameters) } private func generateMssqlUpdate( - table: String, + qualifiedTable: String, columns: [String], primaryKeyColumns: [String], change: PluginRowChange @@ -392,7 +412,6 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { guard !change.cellChanges.isEmpty else { return nil } guard let originalRow = change.originalRow else { return nil } - let escapedTable = "[\(table.replacingOccurrences(of: "]", with: "]]"))]" var parameters: [PluginCellValue] = [] let setClauses = change.cellChanges.map { cellChange -> String in @@ -422,19 +441,18 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let whereClause = conditions.joined(separator: " AND ") let topClause = primaryKeyColumns.isEmpty ? "TOP (1) " : "" - let sql = "UPDATE \(topClause)\(escapedTable) SET \(setClauses) WHERE \(whereClause)" + let sql = "UPDATE \(topClause)\(qualifiedTable) SET \(setClauses) WHERE \(whereClause)" return (statement: sql, parameters: parameters) } private func generateMssqlDelete( - table: String, + qualifiedTable: String, columns: [String], primaryKeyColumns: [String], change: PluginRowChange ) -> (statement: String, parameters: [PluginCellValue])? { guard let originalRow = change.originalRow else { return nil } - let escapedTable = "[\(table.replacingOccurrences(of: "]", with: "]]"))]" var parameters: [PluginCellValue] = [] var conditions: [String] = [] @@ -458,7 +476,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let whereClause = conditions.joined(separator: " AND ") let topClause = primaryKeyColumns.isEmpty ? "TOP (1) " : "" - let sql = "DELETE \(topClause)FROM \(escapedTable) WHERE \(whereClause)" + let sql = "DELETE \(topClause)FROM \(qualifiedTable) WHERE \(whereClause)" return (statement: sql, parameters: parameters) } diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 72688184f..2685be212 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -778,6 +778,24 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { deletedRowIndices: Set, insertedRowIndices: Set ) -> [(statement: String, parameters: [PluginCellValue])]? { + generateStatements( + table: table, schema: nil, columns: columns, primaryKeyColumns: primaryKeyColumns, + changes: changes, insertedRowData: insertedRowData, + deletedRowIndices: deletedRowIndices, insertedRowIndices: insertedRowIndices + ) + } + + func generateStatements( + table: String, + schema: String?, + columns: [String], + primaryKeyColumns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [PluginCellValue])]? { + let qualifiedTable = oracleQualifiedName(schema: schema, table: table) var statements: [(statement: String, parameters: [PluginCellValue])] = [] for change in changes { @@ -785,17 +803,17 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { case .insert: guard insertedRowIndices.contains(change.rowIndex) else { continue } if let values = insertedRowData[change.rowIndex] { - if let stmt = generateOracleInsert(table: table, columns: columns, values: values) { + if let stmt = generateOracleInsert(qualifiedTable: qualifiedTable, columns: columns, values: values) { statements.append(stmt) } } case .update: - if let stmt = generateOracleUpdate(table: table, columns: columns, change: change) { + if let stmt = generateOracleUpdate(qualifiedTable: qualifiedTable, columns: columns, change: change) { statements.append(stmt) } case .delete: guard deletedRowIndices.contains(change.rowIndex) else { continue } - if let stmt = generateOracleDelete(table: table, columns: columns, change: change) { + if let stmt = generateOracleDelete(qualifiedTable: qualifiedTable, columns: columns, change: change) { statements.append(stmt) } } @@ -809,7 +827,7 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } private func generateOracleInsert( - table: String, + qualifiedTable: String, columns: [String], values: [PluginCellValue] ) -> (statement: String, parameters: [PluginCellValue])? { @@ -832,18 +850,17 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let columnList = insertColumns.joined(separator: ", ") let valueList = valuesSQL.joined(separator: ", ") - let sql = "INSERT INTO \(escapeOracleIdentifier(table)) (\(columnList)) VALUES (\(valueList))" + let sql = "INSERT INTO \(qualifiedTable) (\(columnList)) VALUES (\(valueList))" return (statement: sql, parameters: parameters) } private func generateOracleUpdate( - table: String, + qualifiedTable: String, columns: [String], change: PluginRowChange ) -> (statement: String, parameters: [PluginCellValue])? { guard !change.cellChanges.isEmpty, let originalRow = change.originalRow else { return nil } - let escapedTable = escapeOracleIdentifier(table) var parameters: [PluginCellValue] = [] let setClauses = change.cellChanges.map { cellChange -> String in @@ -868,18 +885,17 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { guard !conditions.isEmpty else { return nil } let whereClause = conditions.joined(separator: " AND ") - let sql = "UPDATE \(escapedTable) SET \(setClauses) WHERE \(whereClause) AND ROWNUM = 1" + let sql = "UPDATE \(qualifiedTable) SET \(setClauses) WHERE \(whereClause) AND ROWNUM = 1" return (statement: sql, parameters: parameters) } private func generateOracleDelete( - table: String, + qualifiedTable: String, columns: [String], change: PluginRowChange ) -> (statement: String, parameters: [PluginCellValue])? { guard let originalRow = change.originalRow else { return nil } - let escapedTable = escapeOracleIdentifier(table) var parameters: [PluginCellValue] = [] var conditions: [String] = [] @@ -898,7 +914,7 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { guard !conditions.isEmpty else { return nil } let whereClause = conditions.joined(separator: " AND ") - let sql = "DELETE FROM \(escapedTable) WHERE \(whereClause) AND ROWNUM = 1" + let sql = "DELETE FROM \(qualifiedTable) WHERE \(whereClause) AND ROWNUM = 1" return (statement: sql, parameters: parameters) } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 10cb23b91..7ab9f248c 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -133,6 +133,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func fetchFilteredRowCount(table: String, filters: [(column: String, op: String, value: String)], logicMode: String) async throws -> Int? // Statement generation (optional, for NoSQL plugins) func generateStatements(table: String, columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [PluginCellValue])]? + func generateStatements(table: String, schema: String?, columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [PluginCellValue])]? // Database switching (SQL Server USE, ClickHouse database switch, etc.) func switchDatabase(to database: String) async throws @@ -314,6 +315,12 @@ public extension PluginDatabaseDriver { } func fetchFilteredRowCount(table: String, filters: [(column: String, op: String, value: String)], logicMode: String) async throws -> Int? { nil } func generateStatements(table: String, columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [PluginCellValue])]? { nil } + func generateStatements(table: String, schema: String?, columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [PluginCellValue])]? { + generateStatements( + table: table, columns: columns, primaryKeyColumns: primaryKeyColumns, changes: changes, + insertedRowData: insertedRowData, deletedRowIndices: deletedRowIndices, insertedRowIndices: insertedRowIndices + ) + } func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { nil } func generateModifyColumnSQL(table: String, oldColumn: PluginColumnDefinition, newColumn: PluginColumnDefinition) -> String? { nil } diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 1ddfe8eca..ac9b6af79 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -50,6 +50,7 @@ final class DataChangeManager: ChangeManaging { var insertedRowIndices: Set { pending.insertedRowIndices } var tableName: String = "" + var schemaName: String? var primaryKeyColumns: [String] = [] /// First PK column, for contexts that need a single column (paste, filters) var primaryKeyColumn: String? { primaryKeyColumns.first } @@ -92,12 +93,14 @@ final class DataChangeManager: ChangeManaging { func configureForTable( tableName: String, + schemaName: String? = nil, columns: [String], primaryKeyColumns: [String], databaseType: DatabaseType, triggerReload: Bool = true ) { self.tableName = tableName + self.schemaName = schemaName self.columns = columns self.primaryKeyColumns = primaryKeyColumns self.databaseType = databaseType @@ -400,6 +403,7 @@ final class DataChangeManager: ChangeManaging { let pluginInsertedRowData: [Int: [PluginCellValue]] = insertedRowData if let statements = pluginDriver.generateStatements( table: tableName, + schema: schemaName, columns: columns, primaryKeyColumns: primaryKeyColumns, changes: pluginChanges, @@ -489,8 +493,9 @@ final class DataChangeManager: ChangeManaging { pending.snapshot(primaryKeyColumns: primaryKeyColumns, columns: columns) } - func restoreState(from state: TabChangeSnapshot, tableName: String, databaseType: DatabaseType) { + func restoreState(from state: TabChangeSnapshot, tableName: String, schemaName: String? = nil, databaseType: DatabaseType) { self.tableName = tableName + self.schemaName = schemaName self.columns = state.columns self.primaryKeyColumns = state.primaryKeyColumns self.databaseType = databaseType diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 6c8d7c250..e8c8db7ed 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -179,6 +179,7 @@ extension QueryExecutionCoordinator { if parent.tabManager.selectedTabId == tabId { parent.changeManager.configureForTable( tableName: tableName ?? "", + schemaName: parent.tabManager.tabs[idx].tableContext.schemaName, columns: columns, primaryKeyColumns: resolvedPKs, databaseType: conn.type diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 367ae6d25..d490c94d4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -84,7 +84,7 @@ extension MainContentCoordinator { let tableRows = tabSessionRegistry.tableRows(for: tab.id) let filteredQuery = queryBuilder.buildFilteredQuery( tableName: referencedTable, - schemaName: fkInfo.referencedSchema, + schemaName: targetSchema, filters: [filter], columns: tableRows.columns, limit: tab.pagination.pageSize, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 18ee6850e..0c8612f94 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -69,10 +69,16 @@ extension MainContentCoordinator { let pendingState = newTab.pendingChanges if pendingState.hasChanges { - changeManager.restoreState(from: pendingState, tableName: newTab.tableContext.tableName ?? "", databaseType: connection.type) + changeManager.restoreState( + from: pendingState, + tableName: newTab.tableContext.tableName ?? "", + schemaName: newTab.tableContext.schemaName, + databaseType: connection.type + ) } else { changeManager.configureForTable( tableName: newTab.tableContext.tableName ?? "", + schemaName: newTab.tableContext.schemaName, columns: newRows.columns, primaryKeyColumns: newTab.tableContext.primaryKeyColumns.isEmpty ? newRows.columns.prefix(1).map { $0 } diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 76d49e5bc..cb833267e 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -86,6 +86,7 @@ extension MainContentView { changeManager.configureForTable( tableName: tab.tableContext.tableName ?? "", + schemaName: tab.tableContext.schemaName, columns: newColumns, primaryKeyColumns: tab.tableContext.primaryKeyColumns, databaseType: connection.type