From 105e1597992b4d9a23c660b5128e648388343806 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 18:52:10 +0700 Subject: [PATCH 1/3] fix(plugins): show a view's own definition in Show DDL and Copy DDL fetchTableDDL now answers a view or materialized view with its own CREATE statement on SQL Server, Oracle, Dameng, DuckDB, Redshift, SQLite, libSQL, D1, SAP HANA, Spanner, Trino and Teradata, instead of a CREATE TABLE rebuilt from its columns or an error. --- CHANGELOG.md | 5 + Packages/TableProCore/Package.swift | 2 +- .../TableProMSSQLCore/MSSQLModuleHeader.swift | 139 ++++++++++++++++++ .../SQLLexicalProfile.swift | 2 +- .../TeradataSchemaQueries.swift | 7 + .../TrinoIntrospectionSQL.swift | 17 +++ .../MSSQLModuleHeaderTests.swift | 101 +++++++++++++ .../TeradataSchemaQueriesTests.swift | 7 + .../TrinoIntrospectionSQLTests.swift | 21 +++ .../OracleSchemaQueries.swift | 15 +- .../OracleViewDefinitionTests.swift | 28 ++++ .../CloudflareD1PluginDriver.swift | 10 +- .../DamengPluginDriver+Schema.swift | 17 ++- Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift | 10 ++ .../HanaPluginDriver+Catalog.swift | 8 + .../HanaPluginDriverTests.swift | 14 ++ .../LibSQLPluginDriver.swift | 10 +- .../MSSQLObjectQueries.swift | 49 +++++- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 11 +- .../MSSQLPluginDriver+Routines.swift | 2 +- .../MSSQLPluginDriver+Schema.swift | 23 ++- Plugins/OracleDriverPlugin/OraclePlugin.swift | 34 ++++- .../RedshiftPluginDriver.swift | 26 ++-- Plugins/SQLiteDriverPlugin/SQLitePlugin.swift | 10 +- .../SpannerPluginDriver+Metadata.swift | 5 +- .../PluginDatabaseDriver.swift | 4 + .../TeradataPluginDriver+Schema.swift | 19 ++- .../TrinoPluginDriver+Schema.swift | 36 ++++- TablePro/Core/Database/DatabaseDriver.swift | 2 +- TablePro/Core/Database/TableDDLComposer.swift | 30 +++- .../Core/Utilities/SQL/SQLScriptText.swift | 2 +- TablePro/Resources/Localizable.xcstrings | 4 + ...ainContentCoordinator+SidebarActions.swift | 29 +++- .../Core/Database/TableDDLComposerTests.swift | 87 +++++++++++ .../Helpers/SourceDefinitionStubDriver.swift | 5 +- .../Plugins/LibSQLRelationDDLTests.swift | 73 +++++++++ .../Plugins/ObjectCatalogQueryTests.swift | 90 +++++++++++- 37 files changed, 883 insertions(+), 71 deletions(-) create mode 100644 Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLModuleHeader.swift create mode 100644 Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLModuleHeaderTests.swift create mode 100644 Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleViewDefinitionTests.swift create mode 100644 TableProTests/Plugins/LibSQLRelationDDLTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7817a15a1c..2b34f88912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Reopen Last Session forgetting its connections after an MCP client launched TablePro in the background. - GitHub Copilot starting at launch when not in use, and running on after AI was turned off or Copilot was removed. - Settings > AI reading sign-in tokens and running command line tools for providers that were never added. +- Show DDL, Copy DDL and Structure > DDL showing a made-up CREATE TABLE for a view on SQL Server, Oracle, Dameng, DuckDB and Redshift. (#3402) +- Show DDL and Copy DDL failing on every view on SQLite, libSQL, Cloudflare D1, SAP HANA, Spanner, Trino and Teradata. +- SQL Server view definitions naming the view's old name after a rename, and blank for an encrypted view or a name with a period. +- SQL Server Edit View Definition template creating the view in the default schema instead of the view's own. +- Trino materialized views left out of SQL exports. ### Security diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index 2ba5fa6f0e..3659fba6b4 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -114,7 +114,7 @@ let package = Package( ), .target( name: "TableProMSSQLCore", - dependencies: ["TableProCoreTypes"], + dependencies: ["TableProCoreTypes", "TableProSQLGrammar"], path: "Sources/TableProMSSQLCore" ), .target( diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLModuleHeader.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLModuleHeader.swift new file mode 100644 index 0000000000..143f9a6d05 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLModuleHeader.swift @@ -0,0 +1,139 @@ +import Foundation +import TableProSQLGrammar + +/// The object name a stored module's `CREATE` header declares. +/// +/// SQL Server keeps the batch that created a view, routine or trigger as it was sent, and `sp_rename` renames the +/// object without touching that text. Measured on Azure SQL Edge 15.0: after `sp_rename 'dbo.v_old', 'v_new'` the +/// definition still reads `CREATE VIEW dbo.v_old AS ...`, so the text read back creates the old name. SSMS rewrites +/// the name in the header when it scripts the object, and so does this. The header is found with the shared lexer, +/// because the stored batch keeps its leading comments, and a comment can read `CREATE VIEW` too. +public struct MSSQLModuleHeader: Sendable, Equatable { + /// Where the whole name sits in the definition, in UTF-16 units, from its first part to its last. + public let nameRange: NSRange + + /// The name's parts without their quotes, the schema first when the header names one. + public let nameParts: [String] + + /// The header of `definition`, which must open with `CREATE [OR ALTER] ` or + /// `ALTER `, or nil when it does not or names its object with more than two parts. + public static func parse(_ definition: String, objectKeyword: String) -> MSSQLModuleHeader? { + var cursor = Cursor(text: definition as NSString) + cursor.skipTrivia() + if cursor.keyword("CREATE") { + cursor.skipTrivia() + if cursor.keyword("OR") { + cursor.skipTrivia() + guard cursor.keyword("ALTER") else { return nil } + cursor.skipTrivia() + } + } else if cursor.keyword("ALTER") { + cursor.skipTrivia() + } else { + return nil + } + guard cursor.keyword(objectKeyword) else { return nil } + cursor.skipTrivia() + + let start = cursor.index + var parts: [String] = [] + var end = start + while true { + guard let part = cursor.identifier() else { return nil } + parts.append(part) + end = cursor.index + cursor.skipTrivia() + guard cursor.consumePeriod() else { break } + cursor.skipTrivia() + } + guard (1...2).contains(parts.count) else { return nil } + return MSSQLModuleHeader(nameRange: NSRange(location: start, length: end - start), nameParts: parts) + } + + /// `definition` with its header naming `[schema].[name]` when the name it declares is not the object's own. + /// A header that already names the object, with or without its schema, is left exactly as it was written. + public static func namingObject( + in definition: String, + objectKeyword: String, + schema: String, + name: String + ) -> String { + guard let header = parse(definition, objectKeyword: objectKeyword) else { return definition } + let declaredSchema = header.nameParts.count == 2 ? header.nameParts.first : nil + let isCurrent = header.nameParts.last == name && (declaredSchema == nil || declaredSchema == schema) + guard !isCurrent else { return definition } + let current = MSSQLSchemaQueries.bracketed(schema: schema, table: name) + return (definition as NSString).replacingCharacters(in: header.nameRange, with: current) + } +} + +private struct Cursor { + private static let period = UInt16(UnicodeScalar(".").value) + private static let openBracket = UInt16(UnicodeScalar("[").value) + private static let identifierSymbols: Set = Set("_@#$".utf16) + + let text: NSString + let length: Int + var index = 0 + + init(text: NSString) { + self.text = text + self.length = text.length + } + + mutating func skipTrivia() { + while index < length { + if SqlLexer.isWhitespace(text.character(at: index)) { + index += 1 + continue + } + guard let span = SQLNonCodeSpan.span(at: index, in: text, grammar: SQLLexicalProfile.sqlServer), + span.kind.isComment + else { return } + index = max(span.end, index + 1) + } + } + + mutating func keyword(_ expected: String) -> Bool { + let start = index + guard let word = word(), word.caseInsensitiveCompare(expected) == .orderedSame else { + index = start + return false + } + return true + } + + mutating func consumePeriod() -> Bool { + guard index < length, text.character(at: index) == Self.period else { return false } + index += 1 + return true + } + + /// One part of a name: `[a]]b]`, `"a""b"` or a regular identifier. + mutating func identifier() -> String? { + guard index < length else { return nil } + let opener = text.character(at: index) + guard opener == Self.openBracket || opener == SqlLexer.doubleQuote else { return word() } + guard let span = SQLNonCodeSpan.span(at: index, in: text, grammar: SQLLexicalProfile.sqlServer), + span.kind == .quoted, span.isTerminated + else { return nil } + let body = text.substring(with: NSRange(location: span.start + 1, length: span.contentEnd - span.start - 1)) + index = span.end + return opener == Self.openBracket + ? body.replacingOccurrences(of: "]]", with: "]", options: .literal) + : body.replacingOccurrences(of: "\"\"", with: "\"", options: .literal) + } + + private mutating func word() -> String? { + let start = index + while index < length, isIdentifierUnit(text.character(at: index)) { + index += 1 + } + guard index > start else { return nil } + return text.substring(with: NSRange(location: start, length: index - start)) + } + + private func isIdentifierUnit(_ unit: UInt16) -> Bool { + SQLNonCodeSpan.isWordUnit(unit) || Self.identifierSymbols.contains(unit) + } +} diff --git a/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift b/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift index 7a9ab63213..d60c9bd3f9 100644 --- a/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift +++ b/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift @@ -133,7 +133,7 @@ public struct SQLLexicalProfile: Sendable, Hashable { /// A `GO` line is sqlcmd's, not the server's: sent to it, Azure SQL Edge answers Msg 102 and runs nothing. A /// statement needs no `;`: `SELECT 1` followed by `DROP TABLE t` runs both. A `MERGE` is the exception, and one /// sent without its `;` fails the whole batch with Msg 10713. - static let sqlServer: SQLLexicalGrammar = [ + public static let sqlServer: SQLLexicalGrammar = [ .bracketQuotedIdentifiers, .doubledClosingBracketEscapes, .nestedBlockComments, .carriageReturnEndsLineComments, .batchSeparatorLines, .unterminatedStatements, .terminatedMergeStatements, ] diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift index 8a8fb6cb88..6df930bd12 100644 --- a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataSchemaQueries.swift @@ -50,6 +50,13 @@ public enum TeradataSchemaQueries { "SHOW TABLE \(qualifiedName(database: database, table: table))" } + public static func tableKind(database: String, table: String) -> String { + """ + SELECT TableKind FROM DBC.TablesV \ + WHERE DatabaseName = \(quoteLiteral(database)) AND TableName = \(quoteLiteral(table)) + """ + } + public static func viewDefinition(database: String, view: String) -> String { """ SELECT RequestText FROM DBC.TablesV \ diff --git a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift index 9c0a00ef78..c2729732a5 100644 --- a/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift +++ b/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift @@ -68,6 +68,19 @@ public enum TrinoIntrospectionSQL { """ } + /// One relation's `table_type`, which says `VIEW` for a view. A materialized view is not told + /// apart here: ``materializedView(catalog:schema:name:)`` answers for it. + public static func relationType(catalog: String, schema: String, table: String) -> String { + """ + SELECT table_type FROM \(quoteIdentifier(catalog)).information_schema.tables \ + WHERE table_schema = \(quoteLiteral(schema)) AND table_name = \(quoteLiteral(table)) + """ + } + + public static func materializedView(catalog: String, schema: String, name: String) -> String { + listMaterializedViews(catalog: catalog, schema: schema) + " AND name = \(quoteLiteral(name))" + } + public static func approximateRowCount(catalog: String?, schema: String?, table: String) -> String { "SHOW STATS FOR \(qualifiedName(catalog: catalog, schema: schema, table: table))" } @@ -79,4 +92,8 @@ public enum TrinoIntrospectionSQL { public static func showCreateView(catalog: String?, schema: String?, view: String) -> String { "SHOW CREATE VIEW \(qualifiedName(catalog: catalog, schema: schema, table: view))" } + + public static func showCreateMaterializedView(catalog: String?, schema: String?, view: String) -> String { + "SHOW CREATE MATERIALIZED VIEW \(qualifiedName(catalog: catalog, schema: schema, table: view))" + } } diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLModuleHeaderTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLModuleHeaderTests.swift new file mode 100644 index 0000000000..58a20f3815 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLModuleHeaderTests.swift @@ -0,0 +1,101 @@ +@testable import TableProMSSQLCore +import XCTest + +final class MSSQLModuleHeaderTests: XCTestCase { + func testReadsATwoPartName() throws { + let header = try XCTUnwrap(MSSQLModuleHeader.parse("CREATE VIEW dbo.v AS SELECT 1 AS a", objectKeyword: "VIEW")) + XCTAssertEqual(header.nameParts, ["dbo", "v"]) + XCTAssertEqual(header.nameRange, NSRange(location: 12, length: 5)) + } + + /// The stored batch keeps the comments before the header, and a comment can name another object. + func testSkipsLeadingCommentsThatReadLikeAHeader() throws { + let definition = "-- CREATE VIEW dbo.other AS\r\n/* outer /* nested */ CREATE VIEW x */\ncreate view [dbo].[v_dscp1]\r\nAS\r\nSELECT 1" + let header = try XCTUnwrap(MSSQLModuleHeader.parse(definition, objectKeyword: "VIEW")) + XCTAssertEqual(header.nameParts, ["dbo", "v_dscp1"]) + XCTAssertEqual((definition as NSString).substring(with: header.nameRange), "[dbo].[v_dscp1]") + } + + func testUnquotesBracketedAndDoubleQuotedParts() throws { + let header = try XCTUnwrap(MSSQLModuleHeader.parse("CREATE VIEW [we]]ird].\"a\"\"b\" AS SELECT 1", objectKeyword: "VIEW")) + XCTAssertEqual(header.nameParts, ["we]ird", "a\"b"]) + } + + /// A period inside brackets is part of the name, not a separator. + func testKeepsAPeriodInsideBrackets() throws { + let header = try XCTUnwrap(MSSQLModuleHeader.parse("CREATE VIEW sales.[v.1] AS SELECT 1", objectKeyword: "VIEW")) + XCTAssertEqual(header.nameParts, ["sales", "v.1"]) + } + + func testAllowsCommentsAndSpacesAroundTheParts() throws { + let definition = "/*X*/ CrEaTe /*Y*/ VIEW dbo . v(a) AS SELECT 1 AS a;" + let header = try XCTUnwrap(MSSQLModuleHeader.parse(definition, objectKeyword: "VIEW")) + XCTAssertEqual(header.nameParts, ["dbo", "v"]) + XCTAssertEqual((definition as NSString).substring(with: header.nameRange), "dbo . v") + } + + func testAcceptsCreateOrAlterAndAlter() { + XCTAssertEqual(MSSQLModuleHeader.parse("CREATE OR ALTER VIEW v AS SELECT 1", objectKeyword: "VIEW")?.nameParts, ["v"]) + XCTAssertEqual(MSSQLModuleHeader.parse("ALTER VIEW s.v AS SELECT 1", objectKeyword: "VIEW")?.nameParts, ["s", "v"]) + } + + func testRefusesTextThatIsNotThatHeader() { + XCTAssertNil(MSSQLModuleHeader.parse("SELECT 1", objectKeyword: "VIEW")) + XCTAssertNil(MSSQLModuleHeader.parse("CREATE TABLE t (a int)", objectKeyword: "VIEW")) + XCTAssertNil(MSSQLModuleHeader.parse("CREATE VIEWS v AS SELECT 1", objectKeyword: "VIEW")) + XCTAssertNil(MSSQLModuleHeader.parse("CREATE VIEW db.dbo.v AS SELECT 1", objectKeyword: "VIEW")) + XCTAssertNil(MSSQLModuleHeader.parse("CREATE VIEW [unterminated AS SELECT 1", objectKeyword: "VIEW")) + XCTAssertNil(MSSQLModuleHeader.parse("", objectKeyword: "VIEW")) + } + + /// Measured: after `sp_rename '[dbo].[v_old]', 'v_new'` the stored text still reads `CREATE VIEW dbo.v_old`. + func testRenamesAStaleName() { + let renamed = MSSQLModuleHeader.namingObject( + in: "CREATE VIEW dbo.v_old AS SELECT 2 AS y\n", + objectKeyword: "VIEW", + schema: "dbo", + name: "v_new" + ) + XCTAssertEqual(renamed, "CREATE VIEW [dbo].[v_new] AS SELECT 2 AS y\n") + } + + func testRenamesAStaleSchema() { + let moved = MSSQLModuleHeader.namingObject( + in: "CREATE VIEW dbo.v AS SELECT 1", objectKeyword: "VIEW", schema: "sales", name: "v" + ) + XCTAssertEqual(moved, "CREATE VIEW [sales].[v] AS SELECT 1") + } + + func testAnUnqualifiedStaleNameGainsItsSchema() { + let renamed = MSSQLModuleHeader.namingObject( + in: "CREATE VIEW v_old AS SELECT 1", objectKeyword: "VIEW", schema: "dbo", name: "v_new" + ) + XCTAssertEqual(renamed, "CREATE VIEW [dbo].[v_new] AS SELECT 1") + } + + func testLeavesACurrentHeaderAsWritten() { + let definitions = [ + "-- owner: finance\r\ncreate view [dbo].[v] AS SELECT 1 -- trailing note\r\n", + "CREATE VIEW v AS SELECT 1", + "CREATE VIEW dbo.v AS SELECT 1", + ] + for definition in definitions { + XCTAssertEqual( + MSSQLModuleHeader.namingObject(in: definition, objectKeyword: "VIEW", schema: "dbo", name: "v"), + definition + ) + } + } + + func testEscapesAClosingBracketInTheNewName() { + let renamed = MSSQLModuleHeader.namingObject( + in: "CREATE VIEW dbo.v AS SELECT 1", objectKeyword: "VIEW", schema: "dbo", name: "a]b" + ) + XCTAssertEqual(renamed, "CREATE VIEW [dbo].[a]]b] AS SELECT 1") + } + + func testLeavesUnreadableTextAlone() { + let text = "SELECT 1" + XCTAssertEqual(MSSQLModuleHeader.namingObject(in: text, objectKeyword: "VIEW", schema: "dbo", name: "v"), text) + } +} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift index 4acbbb0940..0ca8521ee2 100644 --- a/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift +++ b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift @@ -119,4 +119,11 @@ final class TeradataSchemaQueriesTests: XCTestCase { XCTAssertEqual(TeradataSchemaQueries.quoteLiteral("a'\u{0301}b"), "'a''\u{0301}b'") XCTAssertEqual(TeradataSchemaQueries.quoteIdentifier("a\"\u{0301}b"), "\"a\"\"\u{0301}b\"") } + + func testTableKindReadsOneObject() { + let sql = TeradataSchemaQueries.tableKind(database: "Retail", table: "o'v") + XCTAssertTrue(sql.hasPrefix("SELECT TableKind FROM DBC.TablesV")) + XCTAssertTrue(sql.contains("DatabaseName = 'Retail'")) + XCTAssertTrue(sql.contains("TableName = 'o''v'")) + } } diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift index 22d4866f6a..0eb7c9b6b7 100644 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift +++ b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift @@ -72,4 +72,25 @@ final class TrinoIntrospectionSQLTests: XCTestCase { "SHOW CREATE VIEW \"hive\".\"sales\".\"v\"" ) } + + /// Trino refuses `SHOW CREATE VIEW` on a materialized view, which has its own statement. + func testShowCreateMaterializedView() { + XCTAssertEqual( + TrinoIntrospectionSQL.showCreateMaterializedView(catalog: "hive", schema: "sales", view: "mv"), + "SHOW CREATE MATERIALIZED VIEW \"hive\".\"sales\".\"mv\"" + ) + } + + func testRelationTypeReadsOneRelation() { + let sql = TrinoIntrospectionSQL.relationType(catalog: "hive", schema: "sales", table: "o'v") + XCTAssertTrue(sql.hasPrefix("SELECT table_type FROM \"hive\".information_schema.tables")) + XCTAssertTrue(sql.contains("table_schema = 'sales'")) + XCTAssertTrue(sql.contains("table_name = 'o''v'")) + } + + func testMaterializedViewNarrowsTheListingToOneName() { + let sql = TrinoIntrospectionSQL.materializedView(catalog: "hive", schema: "sales", name: "mv") + XCTAssertTrue(sql.hasPrefix(TrinoIntrospectionSQL.listMaterializedViews(catalog: "hive", schema: "sales"))) + XCTAssertTrue(sql.hasSuffix(" AND name = 'mv'")) + } } diff --git a/Packages/TableProOracle/Sources/TableProOracleCore/OracleSchemaQueries.swift b/Packages/TableProOracle/Sources/TableProOracleCore/OracleSchemaQueries.swift index 910467d10d..e72f8ae493 100644 --- a/Packages/TableProOracle/Sources/TableProOracleCore/OracleSchemaQueries.swift +++ b/Packages/TableProOracle/Sources/TableProOracleCore/OracleSchemaQueries.swift @@ -436,14 +436,25 @@ public enum OracleSchemaQueries { return "SELECT NVL(SUM(BYTES), 0) FROM \(OracleDictionary.dbaSegments) WHERE \(scoped)OWNER = '\(owner)'" } - /// The view's `SELECT` text, without the LONG `TEXT` column OracleNIO cannot decode. `TEXT_VC` is `VARCHAR2(4000)`. + /// The view's `SELECT` text, without the LONG `TEXT` column OracleNIO cannot decode. `TEXT_VC` is `VARCHAR2(4000)` + /// and cuts a longer text, so its byte length comes back beside `TEXT_LENGTH` for + /// ``isViewTextTruncated(storedLength:returnedLength:)``. public static func viewDefinition(schema: String, view: String) -> String { """ - SELECT TEXT_VC FROM \(OracleDictionary.allViews) \ + SELECT TEXT_VC, TEXT_LENGTH, LENGTHB(TEXT_VC) FROM \(OracleDictionary.allViews) \ WHERE VIEW_NAME = '\(escapeLiteral(view))' AND OWNER = '\(escapeLiteral(schema))' """ } + /// Measured on Oracle 23 Free: `TEXT_LENGTH` equals `LENGTHB(TEXT_VC)` for a whole text, a 58-byte multibyte one + /// included, and a 160-column view read 6892 against 4000. + public static func isViewTextTruncated(storedLength: String?, returnedLength: String?) -> Bool { + guard let stored = storedLength.flatMap(integer), let returned = returnedLength.flatMap(integer) else { + return false + } + return stored > returned + } + /// The tables of one schema for the object tree's bulk metadata read. public static func allTablesMetadata(schema: String) -> String { """ diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleViewDefinitionTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleViewDefinitionTests.swift new file mode 100644 index 0000000000..94e7519648 --- /dev/null +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleViewDefinitionTests.swift @@ -0,0 +1,28 @@ +@testable import TableProOracleCore +import XCTest + +/// `ALL_VIEWS.TEXT_VC` is `VARCHAR2(4000)` and cuts a longer view text mid-identifier, which Show DDL and a dump then +/// handed out as the whole view. +final class OracleViewDefinitionTests: XCTestCase { + func testReadsTheStoredAndReturnedLengthsBesideTheText() { + let sql = OracleSchemaQueries.viewDefinition(schema: "HR", view: "V") + XCTAssertTrue(sql.hasPrefix("SELECT TEXT_VC, TEXT_LENGTH, LENGTHB(TEXT_VC) FROM")) + XCTAssertTrue(sql.contains("VIEW_NAME = 'V' AND OWNER = 'HR'")) + } + + /// Measured on Oracle 23 Free: a 160-column view read `TEXT_LENGTH` 6892 against `LENGTHB(TEXT_VC)` 4000. + func testALongerStoredTextIsTruncated() { + XCTAssertTrue(OracleSchemaQueries.isViewTextTruncated(storedLength: "6892", returnedLength: "4000")) + } + + /// Measured: a 58-byte Vietnamese text of 48 characters read 58 for both. + func testAWholeTextIsNotTruncated() { + XCTAssertFalse(OracleSchemaQueries.isViewTextTruncated(storedLength: "58", returnedLength: "58")) + XCTAssertFalse(OracleSchemaQueries.isViewTextTruncated(storedLength: " 29", returnedLength: "29 ")) + } + + func testAnUnreadableLengthIsNotReportedAsTruncation() { + XCTAssertFalse(OracleSchemaQueries.isViewTextTruncated(storedLength: nil, returnedLength: "4000")) + XCTAssertFalse(OracleSchemaQueries.isViewTextTruncated(storedLength: "6892", returnedLength: nil)) + } +} diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index 759f9b877a..fc2fc7ee5b 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -415,19 +415,23 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable "DROP TRIGGER IF EXISTS \(quoteIdentifier(name))" } + /// A view's `CREATE VIEW` sits in `sqlite_master` beside the tables, so one read answers both. + /// The view goes back exactly as it was written: SQLite keeps a trailing comment in it, which a + /// `;` appended here would land inside. func fetchTableDDL(table: String, schema: String?) async throws -> String { let safeTable = escapeStringLiteral(table) let query = """ - SELECT sql FROM sqlite_master - WHERE type = 'table' AND name = '\(safeTable)' + SELECT type, sql FROM sqlite_master + WHERE type IN ('table', 'view') AND name = '\(safeTable)' """ let result = try await execute(query: query) guard let firstRow = result.rows.first, - let ddl = firstRow[0].asText else { + let ddl = firstRow[safe: 1]?.asText else { throw CloudflareD1Error(message: "Failed to fetch DDL for table '\(table)'") } + guard firstRow[safe: 0]?.asText != "view" else { return ddl } let formatted = formatDDL(ddl) return formatted.hasSuffix(";") ? formatted : formatted + ";" } diff --git a/Plugins/DamengDriverPlugin/DamengPluginDriver+Schema.swift b/Plugins/DamengDriverPlugin/DamengPluginDriver+Schema.swift index 8a92da0cea..891f70e18b 100644 --- a/Plugins/DamengDriverPlugin/DamengPluginDriver+Schema.swift +++ b/Plugins/DamengDriverPlugin/DamengPluginDriver+Schema.swift @@ -302,13 +302,20 @@ extension DamengPluginDriver { } func fetchViewDefinition(view: String, schema: String?) async throws -> String { - let result = try await executeParameterized( + try viewDefinition(from: try await storedView(view, schema: effectiveSchema(schema))) + } + + private func storedView(_ view: String, schema: String) async throws -> PluginQueryResult { + try await executeParameterized( query: """ SELECT CAST(TEXT AS VARCHAR(8188)), LENGTHB(TEXT) FROM SYS.ALL_VIEWS WHERE OWNER = ? AND VIEW_NAME = ? """, - parameters: [.text(effectiveSchema(schema)), .text(view)] + parameters: [.text(schema), .text(view)] ) + } + + private func viewDefinition(from result: PluginQueryResult) throws -> String { guard let row = result.rows.first, let definition = row[safe: 0]?.asText else { throw DamengError(message: String(localized: "Dameng did not return the view definition.")) } @@ -322,8 +329,14 @@ extension DamengPluginDriver { return definition } + /// `ALL_TAB_COLUMNS` lists a view's columns as it lists a table's, so a view is looked up first: + /// without it Show DDL rebuilt a view as a `CREATE TABLE` of its columns. func fetchTableDDL(table: String, schema: String?) async throws -> String { let owner = effectiveSchema(schema) + let view = try await storedView(table, schema: owner) + if !view.rows.isEmpty { + return try viewDefinition(from: view) + } let columns = try await fetchColumns(table: table, schema: owner) guard !columns.isEmpty else { throw DamengError(message: String(localized: "Dameng did not return the table definition.")) diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index b173dbf3c3..9791796aed 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -869,6 +869,16 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return sql.hasSuffix(";") ? sql : sql + ";" } + /// `duckdb_tables()` has no row for a view while `duckdb_columns()` lists its columns, so + /// without this a view fell through to a `CREATE TABLE` rebuilt from them. + let viewResult = try await executeParameterized( + query: DuckDBSchemaQueries.viewDefinition, + parameters: [.text(try requireCatalog()), .text(schemaName), .text(table)] + ) + if let definition = viewResult.rows.first?[safe: 0]?.asText?.nilIfEmpty { + return DuckDBViewDefinition.makeReplaceable(definition) + } + // Fallback: synthesize DDL from schema metadata let columns = try await fetchColumns(table: table, schema: schemaName) let fks = try await fetchForeignKeys(table: table, schema: schemaName) diff --git a/Plugins/HanaDriverPlugin/HanaPluginDriver+Catalog.swift b/Plugins/HanaDriverPlugin/HanaPluginDriver+Catalog.swift index c19e2ce378..bd63157296 100644 --- a/Plugins/HanaDriverPlugin/HanaPluginDriver+Catalog.swift +++ b/Plugins/HanaDriverPlugin/HanaPluginDriver+Catalog.swift @@ -64,9 +64,17 @@ extension HanaPluginDriver { return HanaCatalogMapping.integer(result.rows.first?.first) } + /// `SYS.TABLES` has no row for a view, so a view answers with its own definition from `SYS.VIEWS` + /// instead of the error a missing table gets. func fetchTableDDL(table: String, schema: String?) async throws -> String { let owner = try effectiveSchema(schema) let store = try await execute(query: HanaCatalogQueries.tableStore(schema: owner, table: table)) + if store.rows.isEmpty, + let definition = HanaCatalogMapping.nonEmptyText( + try await execute(query: HanaCatalogQueries.viewDefinition(schema: owner, view: table)).rows.first?.first + ) { + return HanaCatalogMapping.viewDDL(schema: owner, view: table, definition: definition) + } let columns = try await catalogColumns(table: table, schema: owner) guard let storeRow = store.rows.first, !columns.isEmpty else { throw HanaError( diff --git a/Plugins/HanaDriverPluginTests/HanaPluginDriverTests.swift b/Plugins/HanaDriverPluginTests/HanaPluginDriverTests.swift index b513c14315..1b9d2bb3d7 100644 --- a/Plugins/HanaDriverPluginTests/HanaPluginDriverTests.swift +++ b/Plugins/HanaDriverPluginTests/HanaPluginDriverTests.swift @@ -260,6 +260,20 @@ final class HanaPluginDriverTests: XCTestCase { XCTAssertEqual(result.rows.count, 2) } + /// `SYS.TABLES` has no row for a view, so Show DDL reported every view as a missing table. + func testTableDDLAnswersAViewWithItsDefinition() async throws { + let session = HanaFakeSession() + session.respond(HanaEnvelopes.empty) + session.respond(HanaEnvelopes.rows(["DEFINITION"], [[.text("SELECT ID FROM ORDERS WHERE OPEN = 1")]])) + let driver = HanaPluginDriver(config: config(), session: session) + try await driver.connect() + + let ddl = try await driver.fetchTableDDL(table: "OPEN_ORDERS", schema: "APP") + + XCTAssertEqual(ddl, "CREATE VIEW \"APP\".\"OPEN_ORDERS\" AS\nSELECT ID FROM ORDERS WHERE OPEN = 1") + XCTAssertEqual(session.executed.count, 2) + } + func testExplainWithParametersIsExecutedAsTyped() async throws { let session = HanaFakeSession() let driver = HanaPluginDriver(config: config(), session: session) diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift index de0537c908..6b29d162ac 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift @@ -493,19 +493,23 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { "DROP TRIGGER IF EXISTS \(quoteIdentifier(name))" } + /// A view's `CREATE VIEW` sits in `sqlite_master` beside the tables, so one read answers both. + /// The view goes back exactly as it was written: SQLite keeps a trailing comment in it, which a + /// `;` appended here would land inside. func fetchTableDDL(table: String, schema: String?) async throws -> String { let safeTable = escapeStringLiteral(table) let query = """ - SELECT sql FROM sqlite_master - WHERE type = 'table' AND name = '\(safeTable)' + SELECT type, sql FROM sqlite_master + WHERE type IN ('table', 'view') AND name = '\(safeTable)' """ let result = try await execute(query: query) guard let firstRow = result.rows.first, - let ddl = firstRow[0].asText else { + let ddl = firstRow[safe: 1]?.asText else { throw LibSQLError(message: "Failed to fetch DDL for table '\(table)'") } + guard firstRow[safe: 0]?.asText != "view" else { return ddl } let formatted = formatDDL(ddl) return formatted.hasSuffix(";") ? formatted : formatted + ";" } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift b/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift index b8087acabe..4a129f65d5 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift @@ -2,11 +2,12 @@ // MSSQLObjectQueries.swift // MSSQLDriverPlugin // -// Catalog SQL for routines and triggers. Pure, so it is testable without a server. +// Catalog SQL for routines, triggers and views. Pure, so it is testable without a server. // import Foundation import TableProMSSQLCore +import TableProPluginKit public enum MSSQLObjectQueries { public static func escapeLiteral(_ value: String) -> String { @@ -46,7 +47,7 @@ public enum MSSQLObjectQueries { /// /// The body itself is deliberately not selected. A listing needs a name, a kind and a /// signature; fetching every body to show a row per routine pulls a whole schema's source over - /// the wire and drops it, and `routineDefinition` reads the one the reader opens anyway. + /// the wire and drops it, and `moduleDefinition` reads the one the reader opens anyway. /// /// This query uses an XML data type method, so it needs the session `MSSQLSessionOptions` /// establishes. Against db-lib's own defaults the server answers `Msg 1934` and the whole list @@ -85,6 +86,10 @@ public enum MSSQLObjectQueries { """ } + /// One object's stored source and kind, found by schema and name. Routines, triggers and views all + /// keep their text in `sys.sql_modules`, and a table has a `sys.objects` row with no module, so the + /// same row tells a view from a table and carries the view's text. + /// /// `definition` is NULL for two unrelated reasons, and the second one is the common one: /// `WITH ENCRYPTION`, and a caller without VIEW DEFINITION. Measured on SQL Server 2022, a user /// with only SELECT and EXECUTE still sees the `sys.sql_modules` row, so the row's presence @@ -97,7 +102,7 @@ public enum MSSQLObjectQueries { /// returned zero rows for such an object, which the caller read as "no longer exists" for a /// routine sitting in the list in front of the reader. The object type comes back so the caller /// can say the source is not T-SQL rather than guess at a cause. - public static func routineDefinition(schema: String, name: String) -> String { + public static func moduleDefinition(schema: String, name: String) -> String { """ SELECT m.definition, OBJECTPROPERTY(o.object_id, 'IsEncrypted') AS is_encrypted, o.type FROM sys.objects o @@ -133,6 +138,44 @@ public enum MSSQLObjectQueries { """ } + /// What Edit View opens when the definition cannot be read. Qualified, because a tab's schema + /// is not a search path on SQL Server: an unqualified name binds to the login's default schema, + /// so running the template for `sales.v` created `dbo.v` beside it. Before `CREATE OR ALTER` + /// (2016 SP1) the view is dropped first, and the `GO` line keeps `CREATE VIEW` first in its own + /// batch, which the server requires. + public static func editViewTemplate(schema: String, view: String, createOrAlter: Bool) -> String { + let qualified = MSSQLSchemaQueries.bracketed(schema: schema, table: view) + guard !createOrAlter else { + return "CREATE OR ALTER VIEW \(qualified) AS\nSELECT * FROM table_name;" + } + return """ + IF OBJECT_ID(\(MSSQLStringLiteral.quoted(qualified)), 'V') IS NOT NULL DROP VIEW \(qualified); + GO + CREATE VIEW \(qualified) AS + SELECT * FROM table_name; + """ + } + + public static func isView(objectType type: String) -> Bool { + normalizedObjectType(type) == "V" + } + + /// A view's source from its ``moduleDefinition(schema:name:)`` row, `nil` when no such view + /// exists. The stored text gets its header naming the view as it is named now, since `sp_rename` + /// leaves the old name in it. A missing definition is an encrypted view or one this login may not + /// read, and either is said rather than handed back as an empty definition. + public static func viewSource(row: [String?]?, schema: String, name: String) throws -> String { + guard let row, isView(objectType: row[safe: 2].flatMap { $0 } ?? "") else { + throw PluginObjectSourceError.notFound(name) + } + guard let definition = row[safe: 0].flatMap({ $0 }), !definition.isEmpty else { + throw row[safe: 1].flatMap({ $0 }) == "1" + ? PluginObjectSourceError.unsupported(name) + : PluginObjectSourceError.insufficientPrivilege(name) + } + return MSSQLModuleHeader.namingObject(in: definition, objectKeyword: "VIEW", schema: schema, name: name) + } + /// `sys.objects.type` is `char(2)`, so a one-letter code arrives padded. Anything unrecognised /// reads as a function, which is what a future routine code is far more likely to be. public static func routineKind(forObjectType type: String) -> String { diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index d989119c4c..dab24af008 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -320,12 +320,11 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func editViewFallbackTemplate(viewName: String) -> String? { - let quoted = quoteIdentifier(viewName) - if MSSQLCapabilities.parse(serverVersion).hasCreateOrAlterView { - return "CREATE OR ALTER VIEW \(quoted) AS\nSELECT * FROM table_name;" - } - let viewLiteral = MSSQLStringLiteral.quoted(viewName) - return "IF OBJECT_ID(\(viewLiteral), 'V') IS NOT NULL DROP VIEW \(quoted);\nCREATE VIEW \(quoted) AS\nSELECT * FROM table_name;" + MSSQLObjectQueries.editViewTemplate( + schema: effectiveSchema(nil), + view: viewName, + createOrAlter: MSSQLCapabilities.parse(serverVersion).hasCreateOrAlterView + ) } func castColumnToText(_ column: String) -> String { diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Routines.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Routines.swift index 7bfd25f42c..d55203b809 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Routines.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Routines.swift @@ -41,7 +41,7 @@ extension MSSQLPluginDriver { func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String { let resolvedSchema = effectiveSchema(routine.schema) - let query = MSSQLObjectQueries.routineDefinition(schema: resolvedSchema, name: routine.name) + let query = MSSQLObjectQueries.moduleDefinition(schema: resolvedSchema, name: routine.name) let result = try await execute(query: query) guard let row = result.rows.first else { throw PluginObjectSourceError.notFound(routine.name) diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift index 33c035f30e..e47377887a 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -286,8 +286,17 @@ extension MSSQLPluginDriver { } } + /// A view answers with its own definition. Its columns are listed like a table's, and a column + /// over an identity column reports `IsIdentity`, so a table rebuilt from them read + /// `CREATE TABLE ... IDENTITY(1,1)` for a view that declares nothing of the kind. func fetchTableDDL(table: String, schema: String?) async throws -> String { - let qualified = MSSQLSchemaQueries.bracketed(schema: effectiveSchema(schema), table: table) + let resolvedSchema = effectiveSchema(schema) + let module = try await storedModule(schema: resolvedSchema, name: table) + if MSSQLObjectQueries.isView(objectType: module?[safe: 2].flatMap { $0 } ?? "") { + return try MSSQLObjectQueries.viewSource(row: module, schema: resolvedSchema, name: table) + } + + let qualified = MSSQLSchemaQueries.bracketed(schema: resolvedSchema, table: table) let cols = try await fetchColumns(table: table, schema: schema) let indexes = try await fetchIndexes(table: table, schema: schema) let fks = try await fetchForeignKeys(table: table, schema: schema) @@ -320,10 +329,14 @@ extension MSSQLPluginDriver { } func fetchViewDefinition(view: String, schema: String?) async throws -> String { - let viewLiteral = MSSQLStringLiteral.quoted("\(effectiveSchema(schema)).\(view)") - let sql = "SELECT definition FROM sys.sql_modules WHERE object_id = OBJECT_ID(\(viewLiteral))" - let result = try await execute(query: sql) - return result.rows.first?.first?.asText ?? "" + let resolvedSchema = effectiveSchema(schema) + let module = try await storedModule(schema: resolvedSchema, name: view) + return try MSSQLObjectQueries.viewSource(row: module, schema: resolvedSchema, name: view) + } + + private func storedModule(schema: String, name: String) async throws -> [String?]? { + let result = try await execute(query: MSSQLObjectQueries.moduleDefinition(schema: schema, name: name)) + return result.rows.first.map { $0.map(\.asText) } } func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 3fa39f9ad4..04bbfc7dd9 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -630,9 +630,16 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return sizes } + /// `ALL_TAB_COLS` lists a view's columns as it lists a table's, so a view is looked up first: + /// without it Show DDL rebuilt a view as a `CREATE TABLE` of its columns. func fetchTableDDL(table: String, schema: String?) async throws -> String { - OracleTableDDL.createTable( - qualifiedTable: "\(quoteIdentifier(effectiveSchema(schema))).\(quoteIdentifier(table))", + let owner = effectiveSchema(schema) + let view = try await execute(query: OracleSchemaQueries.viewDefinition(schema: owner, view: table)) + if !view.rows.isEmpty { + return try viewStatement(from: view, schema: owner, view: table) + } + return OracleTableDDL.createTable( + qualifiedTable: "\(quoteIdentifier(owner)).\(quoteIdentifier(table))", columns: try await fetchColumns(table: table, schema: schema), quote: quoteIdentifier ) @@ -642,16 +649,27 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { // ALL_VIEWS.TEXT is LONG (crashes OracleNIO). TEXT_VC is VARCHAR2(4000), safe. // Do NOT use DBMS_METADATA.GET_DDL — wrong object type triggers ORA-31603 // which corrupts OracleNIO's connection state machine. - let sql = OracleSchemaQueries.viewDefinition(schema: effectiveSchema(schema), view: view) - let result = try await execute(query: sql) - guard let body = result.rows.first?.first?.asText, + let owner = effectiveSchema(schema) + let result = try await execute(query: OracleSchemaQueries.viewDefinition(schema: owner, view: view)) + return try viewStatement(from: result, schema: owner, view: view) + } + + /// `TEXT_VC` is the view's `SELECT` and nothing else, so it needs the header a dump replays. + /// Written bare it made the restore run a query and create no view. + private func viewStatement(from result: PluginQueryResult, schema: String, view: String) throws -> String { + guard let row = result.rows.first, let body = row.first?.asText, !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw OraclePluginError(core: .queryFailed( String(format: String(localized: "Oracle returned no definition for view '%@'."), view))) } - /// `TEXT_VC` is the view's `SELECT` and nothing else, so it needs the header a dump replays. - /// Written bare it made the restore run a query and create no view. - return "CREATE OR REPLACE VIEW \(quoteIdentifier(effectiveSchema(schema))).\(quoteIdentifier(view)) AS\n\(body)" + guard !OracleSchemaQueries.isViewTextTruncated( + storedLength: row[safe: 1]?.asText, returnedLength: row[safe: 2]?.asText + ) else { + throw OraclePluginError(core: .queryFailed(String( + localized: "Oracle returns only the first 4000 bytes of this view's definition, so the full text cannot be read." + ))) + } + return "CREATE OR REPLACE VIEW \(quoteIdentifier(schema)).\(quoteIdentifier(view)) AS\n\(body)" } func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift index e82eb9494b..0e6304275b 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift @@ -352,6 +352,13 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { Self.logger.debug("SHOW TABLE not available, falling back to manual reconstruction") } + /// `pg_attribute` lists a view's columns as it lists a table's, so a view is looked up before + /// the reconstruction below, which rebuilt a view as a `CREATE TABLE` of its columns. + let view = try await execute(query: Self.viewDefinitionQuery(view: table, schema: resolvedSchema)) + if let ddl = view.rows.first?[safe: 0]?.asText { + return ddl + } + let columnsQuery = """ SELECT quote_ident(a.attname) || ' ' || format_type(a.atttypid, a.atttypmod) || @@ -427,21 +434,22 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchViewDefinition(view: String, schema: String?) async throws -> String { - let viewLiteral = PostgreSQLObjectQueries.quoteLiteral(view) - let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema ?? core.currentSchema) - let query = """ - SELECT 'CREATE OR REPLACE VIEW ' || quote_ident(schemaname) || '.' || quote_ident(viewname) || ' AS ' || E'\\n' || definition AS ddl - FROM pg_views - WHERE viewname = \(viewLiteral) - AND schemaname = \(schemaLiteral) - """ - let result = try await execute(query: query) + let result = try await execute(query: Self.viewDefinitionQuery(view: view, schema: schema ?? core.currentSchema)) guard let firstRow = result.rows.first, let ddl = firstRow[0].asText else { throw LibPQPluginError(message: "Failed to fetch definition for view '\(view)'", sqlState: nil, detail: nil) } return ddl } + private static func viewDefinitionQuery(view: String, schema: String) -> String { + """ + SELECT 'CREATE OR REPLACE VIEW ' || quote_ident(schemaname) || '.' || quote_ident(viewname) || ' AS ' || E'\\n' || definition AS ddl + FROM pg_views + WHERE viewname = \(PostgreSQLObjectQueries.quoteLiteral(view)) + AND schemaname = \(PostgreSQLObjectQueries.quoteLiteral(schema)) + """ + } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { let resolvedSchema = schema ?? core.currentSchema guard !isExternalSchema(resolvedSchema) else { diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 7f1958c231..e0267f3bbc 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -573,19 +573,23 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { "DROP TRIGGER IF EXISTS \(quoteIdentifier(name))" } + /// A view's `CREATE VIEW` sits in `sqlite_master` beside the tables, so one read answers both. + /// The view goes back exactly as it was written: SQLite keeps a trailing comment in it, which a + /// `;` appended here would land inside. func fetchTableDDL(table: String, schema: String?) async throws -> String { let safeTable = escapeStringLiteral(table) let query = """ - SELECT sql FROM sqlite_master - WHERE type = 'table' AND name = '\(safeTable)' + SELECT type, sql FROM sqlite_master + WHERE type IN ('table', 'view') AND name = '\(safeTable)' """ let result = try await execute(query: query) guard let firstRow = result.rows.first, - let ddl = firstRow[0].asText else { + let ddl = firstRow[safe: 1]?.asText else { throw SQLitePluginError.queryFailed("Failed to fetch DDL for table '\(table)'") } + guard firstRow[safe: 0]?.asText != "view" else { return ddl } let formatted = formatDDL(ddl) return formatted.hasSuffix(";") ? formatted : formatted + ";" } diff --git a/Plugins/SpannerDriverPlugin/SpannerPluginDriver+Metadata.swift b/Plugins/SpannerDriverPlugin/SpannerPluginDriver+Metadata.swift index 82d89c5708..dd79598a57 100644 --- a/Plugins/SpannerDriverPlugin/SpannerPluginDriver+Metadata.swift +++ b/Plugins/SpannerDriverPlugin/SpannerPluginDriver+Metadata.swift @@ -68,10 +68,13 @@ extension SpannerPluginDriver { nil } + /// The DDL catalog keeps views apart from tables, so a view answers with its own `CREATE VIEW` + /// rather than being reported missing. func fetchTableDDL(table: String, schema: String?) async throws -> String { let sqlSchema = sqlSchema(schema) let catalog = try await ddlCatalog() - guard let ddl = catalog.tableDDL(schema: sqlSchema, name: table) else { + guard let ddl = catalog.tableDDL(schema: sqlSchema, name: table) ?? catalog.viewDDL(schema: sqlSchema, name: table) + else { throw SpannerDriverError.objectNotFound(table) } return ddl diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index a175514434..9791248ab1 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -195,6 +195,10 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { /// Reads one type again, definition included. The type must be one this driver listed, /// because its `identity` is the driver's own key for finding it. func fetchUserDefinedType(_ type: PluginUserDefinedTypeInfo) async throws -> PluginUserDefinedTypeInfo + /// The statement that creates the named relation, whatever kind it is: a view or a materialized + /// view answers with its own `CREATE VIEW`, never a `CREATE TABLE` rebuilt from its columns, which + /// most catalogs list for a view as they do for a table. A driver may answer a view here from + /// `fetchViewDefinition`, or the other way round, but not both. func fetchTableDDL(table: String, schema: String?) async throws -> String func fetchViewDefinition(view: String, schema: String?) async throws -> String func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata diff --git a/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Schema.swift b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Schema.swift index 16c1054662..f6c68eb7a3 100644 --- a/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Schema.swift +++ b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Schema.swift @@ -84,11 +84,24 @@ extension TeradataPluginDriver { [] } + /// Teradata refuses `SHOW TABLE` on a view, so a refusal is answered with the view's own text. A + /// table costs no extra lookup. func fetchTableDDL(table: String, schema: String?) async throws -> String { let database = effectiveDatabase(schema) - let result = try await execute( - query: TeradataSchemaQueries.showTableDDL(database: database, table: table)) - return result.rows.compactMap { text($0.first) }.joined() + do { + let result = try await execute( + query: TeradataSchemaQueries.showTableDDL(database: database, table: table)) + return result.rows.compactMap { text($0.first) }.joined() + } catch { + guard (try? await isView(table, database: database)) == true else { throw error } + return try await fetchViewDefinition(view: table, schema: schema) + } + } + + private func isView(_ name: String, database: String?) async throws -> Bool { + guard let database else { return false } + let result = try await execute(query: TeradataSchemaQueries.tableKind(database: database, table: name)) + return text(result.rows.first?.first)?.trimmingCharacters(in: .whitespaces) == "V" } func fetchViewDefinition(view: String, schema: String?) async throws -> String { diff --git a/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift b/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift index e16b7bff4d..e7f49225dd 100644 --- a/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift +++ b/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift @@ -105,16 +105,46 @@ extension TrinoPluginDriver { [] } + /// Trino refuses `SHOW CREATE TABLE` on a view ("is a view, not a table") and on a materialized + /// view, so a refusal is answered with the view's own statement. A table costs no extra lookup. func fetchTableDDL(table: String, schema: String?) async throws -> String { - let result = try await execute(query: "SHOW CREATE TABLE \(qualifiedName(table: table, schema: schema))") - return result.rows.compactMap { firstText($0) }.joined(separator: "\n") + do { + let result = try await execute(query: "SHOW CREATE TABLE \(qualifiedName(table: table, schema: schema))") + return result.rows.compactMap { firstText($0) }.joined(separator: "\n") + } catch { + guard (try? await isView(table, schema: schema)) == true else { throw error } + return try await fetchViewDefinition(view: table, schema: schema) + } } + /// `SHOW CREATE VIEW` refuses a materialized view ("is a materialized view, not a view"). func fetchViewDefinition(view: String, schema: String?) async throws -> String { - let result = try await execute(query: "SHOW CREATE VIEW \(qualifiedName(table: view, schema: schema))") + let statement = await isMaterializedView(view, schema: schema) + ? TrinoIntrospectionSQL.showCreateMaterializedView( + catalog: currentCatalog, schema: resolveSchema(schema), view: view) + : TrinoIntrospectionSQL.showCreateView(catalog: currentCatalog, schema: resolveSchema(schema), view: view) + let result = try await execute(query: statement) return result.rows.compactMap { firstText($0) }.joined(separator: "\n") } + private func isView(_ name: String, schema: String?) async throws -> Bool { + guard let catalog = currentCatalog, let targetSchema = resolveSchema(schema) else { return false } + if await isMaterializedView(name, schema: schema) { return true } + let result = try await execute( + query: TrinoIntrospectionSQL.relationType(catalog: catalog, schema: targetSchema, table: name) + ) + return Self.tableType(result.rows.first.flatMap { firstText($0) }) == "VIEW" + } + + /// A connector with no materialized views can refuse the metadata table, which the listing + /// reads the same forgiving way. + private func isMaterializedView(_ name: String, schema: String?) async -> Bool { + guard let catalog = currentCatalog, let targetSchema = resolveSchema(schema) else { return false } + let sql = TrinoIntrospectionSQL.materializedView(catalog: catalog, schema: targetSchema, name: name) + guard let result = try? await execute(query: sql) else { return false } + return !result.rows.isEmpty + } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { let rowCount = try? await fetchApproximateRowCount(table: table, schema: schema) let comment = await tableComment(table: table, schema: schema) diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 9a8021f2b9..c57eb076fb 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -224,7 +224,7 @@ protocol DatabaseDriver: AnyObject, Sendable { /// pattern and type, rather than by table filters. Nil when the driver has no such count. func fetchExactRowCount(table: String, browseFilters: [PluginQueryFilter]) async throws -> Int? - /// Fetch the DDL (CREATE TABLE statement) for a specific table + /// The statement that creates the named relation: a view answers with its own `CREATE VIEW`. func fetchTableDDL(table: String) async throws -> String /// The CREATE INDEX statements this table needs that `fetchTableDDL` does not already declare. diff --git a/TablePro/Core/Database/TableDDLComposer.swift b/TablePro/Core/Database/TableDDLComposer.swift index d048b0488e..527b6aff61 100644 --- a/TablePro/Core/Database/TableDDLComposer.swift +++ b/TablePro/Core/Database/TableDDLComposer.swift @@ -4,10 +4,11 @@ // import Foundation +import TableProSQLGrammar -/// Joins a table's `CREATE TABLE` to the statements that stand outside it. +/// Joins a relation's `CREATE` statement to the statements that stand outside it. /// -/// A driver answers `fetchTableDDL` with the table alone, `fetchCommentDDL` with its comments and +/// A driver answers `fetchTableDDL` with the relation alone, `fetchCommentDDL` with its comments and /// `fetchIndexDDL` with the indexes that statement does not declare, because a dump replays them in /// different phases: the table and its comments before its rows, the indexes after. Anything showing /// one table's whole definition at once, Copy DDL and the MCP schema tools among them, puts the @@ -15,18 +16,33 @@ import Foundation internal enum TableDDLComposer { /// The comments keep the dump's own placement, between the table and its indexes, so the text /// Show DDL and Copy DDL hand over is the text a restore runs. + /// + /// An engine whose client ends a batch at a `GO` line gets one after every statement once more + /// than one is written. SQL Server reads everything after a `CREATE VIEW` in its batch as part of + /// the view, so an indexed view's `CREATE INDEX` written after a `;` failed with Msg 156. internal static func compose( tableDDL: String, indexDDL: [String], commentDDL: [String] = [], - preamble: String = "" + preamble: String = "", + databaseType: DatabaseType? = nil ) -> String { let comments = terminated(commentDDL) let indexes = terminated(indexDDL) + if let databaseType, databaseType.lexicalGrammar.contains(.batchSeparatorLines), + !comments.isEmpty || !indexes.isEmpty { + let script = SQLScriptText(databaseType: databaseType) + let blocks = [[tableDDL], comments, indexes].filter { !$0.isEmpty }.map(script.script) + let composed = blocks.joined(separator: "\n\n") + return preamble.isEmpty ? composed : "\(preamble)\n\(composed)" + } + var composed = preamble.isEmpty ? tableDDL : "\(preamble)\n\(tableDDL)" guard !comments.isEmpty || !indexes.isEmpty else { return composed } - if !composed.hasSuffix(";") { + if let databaseType { + composed = SQLScriptText(databaseType: databaseType).endedBySemicolon(composed) + } else if !composed.hasSuffix(";") { composed += ";" } for block in [comments, indexes] where !block.isEmpty { @@ -44,7 +60,8 @@ internal enum TableDDLComposer { /// One object's whole definition, read on a driver already pinned to its scope. The Structure /// tab's DDL, Show DDL and Copy DDL all come through here, so the three can never disagree about - /// the same object. `includesDependencies` adds the sequences and enum types the table's columns + /// the same object. A view comes through here too: `fetchTableDDL` answers it with its own + /// definition. `includesDependencies` adds the sequences and enum types the table's columns /// use, which the Structure tab writes first so its text runs on its own. /// `schema` names the container the caller means, for the callers that know it. All three reads /// take it together: a DDL composed from one container's CREATE TABLE and another's indexes and @@ -63,7 +80,8 @@ internal enum TableDDLComposer { tableDDL: baseDDL, indexDDL: indexDDL, commentDDL: commentDDL, - preamble: preamble + preamble: preamble, + databaseType: driver.connection.type ) } diff --git a/TablePro/Core/Utilities/SQL/SQLScriptText.swift b/TablePro/Core/Utilities/SQL/SQLScriptText.swift index 586b77ccd3..560661b8e0 100644 --- a/TablePro/Core/Utilities/SQL/SQLScriptText.swift +++ b/TablePro/Core/Utilities/SQL/SQLScriptText.swift @@ -160,7 +160,7 @@ internal struct SQLScriptText { } /// A terminator written after a line comment would be part of the comment, so it goes on a line of its own. - private func endedBySemicolon(_ statement: String) -> String { + internal func endedBySemicolon(_ statement: String) -> String { guard !statement.hasSuffix(";") else { return statement } return codeShape(of: statement).endsInLineComment ? "\(statement)\n;" : "\(statement);" } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 514da3e4b8..c899409a26 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -126293,6 +126293,10 @@ } } }, + "Oracle returns only the first 4000 bytes of this view's definition, so the full text cannot be read." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "OracleNIO has no TLS fallback. Preferred connects in plain TCP. Use Required for TCPS to Oracle Autonomous Database." : { "localizations" : { "fr" : { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index fd90a4a7f0..9b7eb35523 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -135,14 +135,32 @@ extension MainContentCoordinator { /// tab there. It used to read through the browse scope with the name alone, so a view selected /// in another schema opened the definition of a same-named view in the browsed one, and running /// it replaced that other view. + /// + /// The fallback template comes from the driver pinned to the view's schema, because a driver + /// that qualifies the template, as SQL Server must, names the schema it is pinned to. func editViewDefinition(_ ref: DatabaseTreeTableRef) { guard let target = objectTarget(for: ref) else { return } let viewName = ref.table.name Task { + let lineComment = self.services.pluginManager.editorLanguage(for: self.connection.type).lineCommentMarker let query: String do { - query = try await DatabaseManager.shared.withMetadataDriver(scope: target.scope) { driver in - try await driver.fetchViewDefinition(view: viewName) + let read = try await DatabaseManager.shared.withMetadataDriver(scope: target.scope) { driver in + do { + return ViewDefinitionRead.definition(try await driver.fetchViewDefinition(view: viewName)) + } catch { + return ViewDefinitionRead.unreadable( + error, template: driver.editViewFallbackTemplate(viewName: viewName) + ) + } + } + switch read { + case .definition(let definition): + query = definition + case .unreadable(let error, let template): + query = Self.viewDefinitionFallback( + viewName: viewName, error: error, template: template, lineComment: lineComment + ) } } catch { query = Self.viewDefinitionFallback( @@ -150,7 +168,7 @@ extension MainContentCoordinator { error: error, template: DatabaseManager.shared.driver(for: self.connection.id)? .editViewFallbackTemplate(viewName: viewName), - lineComment: self.services.pluginManager.editorLanguage(for: self.connection.type).lineCommentMarker + lineComment: lineComment ) } WindowManager.shared.openTab(payload: EditorTabPayload( @@ -386,3 +404,8 @@ extension MainContentCoordinator { } } } + +private enum ViewDefinitionRead: Sendable { + case definition(String) + case unreadable(any Error, template: String?) +} diff --git a/TableProTests/Core/Database/TableDDLComposerTests.swift b/TableProTests/Core/Database/TableDDLComposerTests.swift index c99b5798b3..17dd93ed3d 100644 --- a/TableProTests/Core/Database/TableDDLComposerTests.swift +++ b/TableProTests/Core/Database/TableDDLComposerTests.swift @@ -75,4 +75,91 @@ struct TableDDLComposerTests { #expect(composed == tableDDL) } + + /// SQL Server reads everything after a `CREATE VIEW` in its batch as part of the view: measured on + /// Azure SQL Edge, a view and its index after a `;` failed with Msg 156. + @Test("A SQL Server view and its index each end in their own batch") + func sqlServerStatementsEndTheirBatch() { + let view = "-- owner: finance\ncreate view [dbo].[v] WITH SCHEMABINDING AS SELECT id FROM dbo.t -- note" + let composed = TableDDLComposer.compose( + tableDDL: view, + indexDDL: ["CREATE UNIQUE CLUSTERED INDEX [IX_v] ON [dbo].[v] ([id])"], + databaseType: .mssql + ) + + #expect(composed == """ + \(view) + ; + GO + + CREATE UNIQUE CLUSTERED INDEX [IX_v] ON [dbo].[v] ([id]); + GO + """) + } + + @Test("A SQL Server view with nothing after it is its stored text alone") + func sqlServerLoneViewIsUnchanged() { + let view = "CREATE VIEW dbo.v AS SELECT 1 AS a" + #expect(TableDDLComposer.compose(tableDDL: view, indexDDL: [], databaseType: .mssql) == view) + } + + /// SQL Server's own scripts end every statement with a `GO` line, tables included. + @Test("A SQL Server table and its indexes each end in their own batch") + func sqlServerTableStatementsEndTheirBatch() { + let composed = TableDDLComposer.compose( + tableDDL: "CREATE TABLE [dbo].[t] (\n [id] INT NOT NULL\n);", + indexDDL: ["CREATE UNIQUE INDEX [UQ_a] ON [dbo].[t] ([a])", "CREATE INDEX [IX_b] ON [dbo].[t] ([b]);"], + databaseType: .mssql + ) + + #expect(composed == """ + CREATE TABLE [dbo].[t] ( + [id] INT NOT NULL + ); + GO + + CREATE UNIQUE INDEX [UQ_a] ON [dbo].[t] ([a]); + GO + CREATE INDEX [IX_b] ON [dbo].[t] ([b]); + GO + """) + } + + /// A view's stored text can end in a line comment, which a `;` written after it would join. + @Test("A statement ending in a line comment is terminated on its own line") + func lineCommentKeepsItsTerminatorOutside() { + let view = "CREATE VIEW \"app\".\"v\" AS SELECT 1 -- note" + let composed = TableDDLComposer.compose( + tableDDL: view, indexDDL: [], commentDDL: [comment], databaseType: .postgresql) + + #expect(composed == "\(view)\n;\n\n\(comment);") + } + + @Test("A SQL Server view read through the driver keeps its index out of its batch") + @MainActor + func fetchedSQLServerViewSeparatesItsIndex() async throws { + let stub = SourceDefinitionStubDriver() + stub.tableDDL["v"] = "CREATE VIEW dbo.v WITH SCHEMABINDING AS SELECT id FROM dbo.t" + stub.indexDDL["v"] = ["CREATE UNIQUE CLUSTERED INDEX [IX_v] ON [dbo].[v] ([id])"] + let adapter = PluginDriverAdapter(connection: TestFixtures.makeConnection(type: .mssql), pluginDriver: stub) + + let ddl = try await TableDDLComposer.fetchDDL(for: "v", using: adapter, includesDependencies: true) + + #expect(ddl == """ + CREATE VIEW dbo.v WITH SCHEMABINDING AS SELECT id FROM dbo.t; + GO + + CREATE UNIQUE CLUSTERED INDEX [IX_v] ON [dbo].[v] ([id]); + GO + """) + } + + @Test("An engine without batch separator lines composes as before") + func otherEnginesKeepTheirComposition() { + let composed = TableDDLComposer.compose( + tableDDL: tableDDL, indexDDL: [index], commentDDL: [comment], databaseType: .postgresql) + + #expect(composed == TableDDLComposer.compose(tableDDL: tableDDL, indexDDL: [index], commentDDL: [comment])) + #expect(!composed.contains("GO")) + } } diff --git a/TableProTests/Helpers/SourceDefinitionStubDriver.swift b/TableProTests/Helpers/SourceDefinitionStubDriver.swift index 2bca0ef4c1..e0cd664bb6 100644 --- a/TableProTests/Helpers/SourceDefinitionStubDriver.swift +++ b/TableProTests/Helpers/SourceDefinitionStubDriver.swift @@ -23,6 +23,8 @@ internal final class SourceDefinitionStubDriver: PluginDatabaseDriver, @unchecke internal var wholeSchemaTriggers: Result<[PluginTriggerInfo], any Error>? internal var tableTriggers: [String: Result<[PluginTriggerInfo], any Error>] = [:] internal var triggerDDL: [String: Result] = [:] + internal var tableDDL: [String: String] = [:] + internal var indexDDL: [String: [String]] = [:] internal var recordedCalls: [String] { lock.withLock { calls } @@ -76,7 +78,8 @@ internal final class SourceDefinitionStubDriver: PluginDatabaseDriver, @unchecke internal func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } internal func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } internal func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - internal func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + internal func fetchTableDDL(table: String, schema: String?) async throws -> String { tableDDL[table] ?? "" } + internal func fetchIndexDDL(table: String, schema: String?) async throws -> [String] { indexDDL[table] ?? [] } internal func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { PluginTableMetadata(tableName: table) diff --git a/TableProTests/Plugins/LibSQLRelationDDLTests.swift b/TableProTests/Plugins/LibSQLRelationDDLTests.swift new file mode 100644 index 0000000000..ae5dc72fce --- /dev/null +++ b/TableProTests/Plugins/LibSQLRelationDDLTests.swift @@ -0,0 +1,73 @@ +// +// LibSQLRelationDDLTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +private struct LibSQLRelationDDLRuntime: LibSQLLocalDatabaseRuntime { + func prepareDatabase(_: OpaquePointer, loading _: [LoadableExtension]) throws {} + + func stepFirst(_ statement: OpaquePointer?) -> LibSQLLocalFirstStep { + let firstStep = SQLiteResultColumns.stepFirst(statement) + return LibSQLLocalFirstStep(result: firstStep.result, names: firstStep.names, typeNames: firstStep.typeNames) + } +} + +/// SQLite, libSQL and D1 read a relation's DDL from `sqlite_master` the same way, and libSQL's is the +/// one that runs against a real file here. A view used to fail with "Failed to fetch DDL for table", +/// which Show DDL and Copy DDL showed for every view. +struct LibSQLRelationDDLTests { + private func withDriver(_ body: (LibSQLPluginDriver) async throws -> Void) async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("libsql-relation-ddl-\(UUID().uuidString).sqlite") + defer { try? FileManager.default.removeItem(at: url) } + let driver = LibSQLPluginDriver( + config: DriverConnectionConfig( + host: "", + port: 0, + username: "", + password: "", + database: "", + additionalFields: ["libsqlMode": "local", "libsqlFilePath": url.path] + ), + localDatabaseRuntime: LibSQLRelationDDLRuntime() + ) + try await driver.connect() + defer { driver.disconnect() } + _ = try await driver.execute(query: "CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT)") + _ = try await driver.execute( + query: "CREATE VIEW named_item AS SELECT id, name FROM item WHERE name IS NOT NULL -- kept" + ) + try await body(driver) + } + + /// SQLite stores the trailing comment, so nothing is appended after it. + @Test("A view answers with its own CREATE VIEW, exactly as written") + func viewAnswersWithItsDefinition() async throws { + try await withDriver { driver in + let ddl = try await driver.fetchTableDDL(table: "named_item", schema: nil) + #expect(ddl == "CREATE VIEW named_item AS SELECT id, name FROM item WHERE name IS NOT NULL -- kept") + } + } + + @Test("A table still answers with its CREATE TABLE") + func tableStillAnswersWithItsTable() async throws { + try await withDriver { driver in + let ddl = try await driver.fetchTableDDL(table: "item", schema: nil) + #expect(ddl.hasPrefix("CREATE TABLE")) + #expect(!ddl.contains("CREATE VIEW")) + } + } + + @Test("A name that is neither still fails") + func missingRelationStillFails() async throws { + try await withDriver { driver in + await #expect(throws: LibSQLError.self) { + _ = try await driver.fetchTableDDL(table: "absent", schema: nil) + } + } + } +} diff --git a/TableProTests/Plugins/ObjectCatalogQueryTests.swift b/TableProTests/Plugins/ObjectCatalogQueryTests.swift index 42dd19f3ff..a4415b0405 100644 --- a/TableProTests/Plugins/ObjectCatalogQueryTests.swift +++ b/TableProTests/Plugins/ObjectCatalogQueryTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro @@ -347,13 +348,92 @@ struct MSSQLObjectQueryTests { @Test("Routine source comes from sys.sql_modules, never ROUTINE_DEFINITION") func routineSourceAvoidsInformationSchema() { let list = MSSQLObjectQueries.routineList(schema: "dbo") - let definition = MSSQLObjectQueries.routineDefinition(schema: "dbo", name: "p") + let definition = MSSQLObjectQueries.moduleDefinition(schema: "dbo", name: "p") #expect(list.contains("sys.sql_modules")) #expect(definition.contains("sys.sql_modules")) #expect(!list.contains("ROUTINE_DEFINITION")) #expect(!definition.contains("ROUTINE_DEFINITION")) } + /// `OBJECT_ID(N'sales.v.1')` parses `v.1` as two parts and finds nothing, so a view is matched by + /// its schema and name as literals rather than through a name the server re-parses. + @Test("A module is found by its schema and name, never through OBJECT_ID text") + func moduleIsMatchedByLiteralNames() { + let sql = MSSQLObjectQueries.moduleDefinition(schema: "sales", name: "v.1") + #expect(sql.contains("s.name = N'sales' AND o.name = N'v.1'")) + #expect(sql.contains("LEFT JOIN sys.sql_modules")) + #expect(!sql.contains("OBJECT_ID(")) + } + + @Test("Only the view type code reads as a view") + func viewTypeCode() { + #expect(MSSQLObjectQueries.isView(objectType: "V ")) + #expect(MSSQLObjectQueries.isView(objectType: "v")) + #expect(!MSSQLObjectQueries.isView(objectType: "U ")) + #expect(!MSSQLObjectQueries.isView(objectType: "P ")) + #expect(!MSSQLObjectQueries.isView(objectType: "")) + } + + /// Measured on Azure SQL Edge: the stored text keeps its comments, and an encrypted view's + /// definition is NULL with `IsEncrypted` 1. + @Test("A view's stored text is its source, its header naming the view as it is named now") + func viewSourceIsTheStoredText() throws { + let stored = "-- owner: finance\r\ncreate view [dbo].[v_dscp1]\r\nAS\r\nSELECT id AS idxr FROM dbo.t -- note\r\n" + #expect(try MSSQLObjectQueries.viewSource(row: [stored, "0", "V "], schema: "dbo", name: "v_dscp1") == stored) + + let renamed = try MSSQLObjectQueries.viewSource( + row: ["CREATE VIEW dbo.v_old AS SELECT 2 AS y", "0", "V "], schema: "dbo", name: "v_new" + ) + #expect(renamed == "CREATE VIEW [dbo].[v_new] AS SELECT 2 AS y") + } + + @Test("An encrypted view, a withheld definition and a missing view are three different errors") + func viewSourceFailures() { + func failure(_ row: [String?]?, _ name: String) -> PluginObjectSourceError? { + do { + _ = try MSSQLObjectQueries.viewSource(row: row, schema: "dbo", name: name) + return nil + } catch { + return error as? PluginObjectSourceError + } + } + guard case .unsupported("v")? = failure([nil, "1", "V "], "v") else { + Issue.record("An encrypted view must say its source cannot be shown") + return + } + guard case .insufficientPrivilege("v")? = failure([nil, "0", "V "], "v") else { + Issue.record("A withheld definition must say the account may not read it") + return + } + guard case .notFound("v")? = failure(nil, "v") else { + Issue.record("A missing view must say it no longer exists") + return + } + guard case .notFound("t")? = failure([nil, "0", "U "], "t") else { + Issue.record("A table is not a view") + return + } + } + + /// A tab's schema is not a search path on SQL Server, so an unqualified template for `sales.v` + /// created `dbo.v` when run. + @Test("The Edit View template names the view's schema") + func editViewTemplateIsQualified() { + let template = MSSQLObjectQueries.editViewTemplate(schema: "sales", view: "v", createOrAlter: true) + #expect(template == "CREATE OR ALTER VIEW [sales].[v] AS\nSELECT * FROM table_name;") + } + + @Test("Before CREATE OR ALTER the template drops in its own batch") + func legacyEditViewTemplateSeparatesBatches() { + let template = MSSQLObjectQueries.editViewTemplate(schema: "we]ird", view: "o'v", createOrAlter: false) + #expect(template == """ + IF OBJECT_ID(N'[we]]ird].[o''v]', 'V') IS NOT NULL DROP VIEW [we]]ird].[o'v]; + GO + CREATE VIEW [we]]ird].[o'v] AS + SELECT * FROM table_name; + """) + } + @Test("Both trigger scopes come from one builder") func triggerScopesShareOneQuery() { let all = MSSQLObjectQueries.triggerList(schema: "dbo", table: nil) @@ -380,7 +460,7 @@ struct MSSQLObjectQueryTests { @Test("A non-ASCII schema, routine or table name is an nvarchar literal") func catalogNamesAreNationalLiterals() { #expect(MSSQLObjectQueries.routineList(schema: "販売").contains("s.name = N'販売'")) - let definition = MSSQLObjectQueries.routineDefinition(schema: "販売", name: "集計") + let definition = MSSQLObjectQueries.moduleDefinition(schema: "販売", name: "集計") #expect(definition.contains("s.name = N'販売' AND o.name = N'集計'")) let triggers = MSSQLObjectQueries.triggerList(schema: "販売", table: "注文") #expect(triggers.contains("s.name = N'販売'")) @@ -419,7 +499,7 @@ struct MSSQLObjectQueryTests { let list = MSSQLObjectQueries.routineList(schema: "dbo") #expect(list.contains("OBJECTPROPERTY(o.object_id, 'IsEncrypted') AS is_encrypted")) #expect(list.contains("AS definition_withheld")) - #expect(MSSQLObjectQueries.routineDefinition(schema: "dbo", name: "p") + #expect(MSSQLObjectQueries.moduleDefinition(schema: "dbo", name: "p") .contains("OBJECTPROPERTY(o.object_id, 'IsEncrypted')")) } @@ -429,7 +509,7 @@ struct MSSQLObjectQueryTests { func listingOmitsBodies() { let list = MSSQLObjectQueries.routineList(schema: "dbo") #expect(!list.contains("m.definition,")) - #expect(MSSQLObjectQueries.routineDefinition(schema: "dbo", name: "p").contains("m.definition")) + #expect(MSSQLObjectQueries.moduleDefinition(schema: "dbo", name: "p").contains("m.definition")) } /// A CLR routine has no sys.sql_modules row at all, so the inner join this replaced returned @@ -438,7 +518,7 @@ struct MSSQLObjectQueryTests { /// left join 1 row. @Test("The definition query survives a routine with no SQL module row") func definitionQueryUsesLeftJoin() { - let sql = MSSQLObjectQueries.routineDefinition(schema: "dbo", name: "p") + let sql = MSSQLObjectQueries.moduleDefinition(schema: "dbo", name: "p") #expect(sql.contains("FROM sys.objects o")) #expect(sql.contains("LEFT JOIN sys.sql_modules m")) #expect(!sql.contains("FROM sys.sql_modules")) From fcf84a226a897ce5f13ce672b6b9aafa2a23c29a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 19:03:11 +0700 Subject: [PATCH 2/3] fix(plugins): keep view column names and escape renamed view headers Oracle view DDL names the view's columns, SQL Server escapes ] by code unit and rewrites only the stale part of a view header, and Cassandra answers a materialized view with its own statement. --- CHANGELOG.md | 3 +- .../TableProMSSQLCore/MSSQLModuleHeader.swift | 47 ++++++++++++++----- .../MSSQLSchemaQueries.swift | 5 +- .../MSSQLModuleHeaderTests.swift | 28 +++++++++-- .../MSSQLSchemaQueriesTests.swift | 9 ++++ .../CassandraPlugin.swift | 16 +++++++ Plugins/OracleDriverPlugin/OraclePlugin.swift | 13 +++-- .../OracleDriverPlugin/OracleTableDDL.swift | 10 +++- .../Plugins/ObjectCatalogQueryTests.swift | 2 +- .../Plugins/OracleTableDDLTests.swift | 16 +++++++ 10 files changed, 125 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b34f88912..255626478a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,10 +72,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Reopen Last Session forgetting its connections after an MCP client launched TablePro in the background. - GitHub Copilot starting at launch when not in use, and running on after AI was turned off or Copilot was removed. - Settings > AI reading sign-in tokens and running command line tools for providers that were never added. -- Show DDL, Copy DDL and Structure > DDL showing a made-up CREATE TABLE for a view on SQL Server, Oracle, Dameng, DuckDB and Redshift. (#3402) +- View DDL shown as a made-up CREATE TABLE on SQL Server, Oracle, Dameng, DuckDB, Redshift and Cassandra. (#3402) - Show DDL and Copy DDL failing on every view on SQLite, libSQL, Cloudflare D1, SAP HANA, Spanner, Trino and Teradata. - SQL Server view definitions naming the view's old name after a rename, and blank for an encrypted view or a name with a period. - SQL Server Edit View Definition template creating the view in the default schema instead of the view's own. +- Oracle view DDL losing the view's column names, and cut off after 4000 bytes without saying so. - Trino materialized views left out of SQL exports. ### Security diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLModuleHeader.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLModuleHeader.swift index 143f9a6d05..b5f09d27fb 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLModuleHeader.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLModuleHeader.swift @@ -10,11 +10,18 @@ import TableProSQLGrammar /// because the stored batch keeps its leading comments, and a comment can read `CREATE VIEW` too. public struct MSSQLModuleHeader: Sendable, Equatable { /// Where the whole name sits in the definition, in UTF-16 units, from its first part to its last. - public let nameRange: NSRange + public var nameRange: NSRange { + guard let first = partRanges.first, let last = partRanges.last else { return NSRange(location: 0, length: 0) } + return NSRange(location: first.location, length: NSMaxRange(last) - first.location) + } /// The name's parts without their quotes, the schema first when the header names one. public let nameParts: [String] + /// Where each part sits, quotes included, so a rename replaces the parts and keeps what lies + /// between them. + public let partRanges: [NSRange] + /// The header of `definition`, which must open with `CREATE [OR ALTER] ` or /// `ALTER `, or nil when it does not or names its object with more than two parts. public static func parse(_ definition: String, objectKeyword: String) -> MSSQLModuleHeader? { @@ -35,35 +42,49 @@ public struct MSSQLModuleHeader: Sendable, Equatable { guard cursor.keyword(objectKeyword) else { return nil } cursor.skipTrivia() - let start = cursor.index var parts: [String] = [] - var end = start + var ranges: [NSRange] = [] while true { + let start = cursor.index guard let part = cursor.identifier() else { return nil } parts.append(part) - end = cursor.index + ranges.append(NSRange(location: start, length: cursor.index - start)) cursor.skipTrivia() guard cursor.consumePeriod() else { break } cursor.skipTrivia() } guard (1...2).contains(parts.count) else { return nil } - return MSSQLModuleHeader(nameRange: NSRange(location: start, length: end - start), nameParts: parts) + return MSSQLModuleHeader(nameParts: parts, partRanges: ranges) } - /// `definition` with its header naming `[schema].[name]` when the name it declares is not the object's own. - /// A header that already names the object, with or without its schema, is left exactly as it was written. + /// `definition` with its header naming the object as it is named now. Only a stale part is + /// replaced, so a comment between the parts stays, and a stale unqualified name gains its schema. + /// A header that already names the object is left exactly as it was written. public static func namingObject( in definition: String, objectKeyword: String, schema: String, name: String ) -> String { - guard let header = parse(definition, objectKeyword: objectKeyword) else { return definition } - let declaredSchema = header.nameParts.count == 2 ? header.nameParts.first : nil - let isCurrent = header.nameParts.last == name && (declaredSchema == nil || declaredSchema == schema) - guard !isCurrent else { return definition } - let current = MSSQLSchemaQueries.bracketed(schema: schema, table: name) - return (definition as NSString).replacingCharacters(in: header.nameRange, with: current) + guard let header = parse(definition, objectKeyword: objectKeyword), + let nameRange = header.partRanges.last + else { return definition } + let text = definition as NSString + let bracketedName = "[\(MSSQLSchemaQueries.escapeBracket(name))]" + let bracketedSchema = "[\(MSSQLSchemaQueries.escapeBracket(schema))]" + + guard header.nameParts.count == 2, let schemaRange = header.partRanges.first else { + guard header.nameParts.last != name else { return definition } + return text.replacingCharacters(in: nameRange, with: "\(bracketedSchema).\(bracketedName)") + } + var renamed = definition + if header.nameParts[1] != name { + renamed = (renamed as NSString).replacingCharacters(in: nameRange, with: bracketedName) + } + if header.nameParts[0] != schema { + renamed = (renamed as NSString).replacingCharacters(in: schemaRange, with: bracketedSchema) + } + return renamed } } diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift index ec2c19c396..60b1e8528e 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift @@ -11,8 +11,11 @@ public enum MSSQLSchemaQueries { MSSQLStringLiteral.escaped(value) } + /// By code unit, as ``MSSQLStringLiteral`` escapes a quote: a default replacement matches whole + /// grapheme clusters, so a `]` followed by a combining mark was never doubled and closed the + /// identifier early. public static func escapeBracket(_ value: String) -> String { - value.replacingOccurrences(of: "]", with: "]]") + value.replacingOccurrences(of: "]", with: "]]", options: .literal) } public static func bracketed(schema: String, table: String) -> String { diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLModuleHeaderTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLModuleHeaderTests.swift index 58a20f3815..d6672b1f05 100644 --- a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLModuleHeaderTests.swift +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLModuleHeaderTests.swift @@ -56,14 +56,29 @@ final class MSSQLModuleHeaderTests: XCTestCase { schema: "dbo", name: "v_new" ) - XCTAssertEqual(renamed, "CREATE VIEW [dbo].[v_new] AS SELECT 2 AS y\n") + XCTAssertEqual(renamed, "CREATE VIEW dbo.[v_new] AS SELECT 2 AS y\n") } func testRenamesAStaleSchema() { let moved = MSSQLModuleHeader.namingObject( in: "CREATE VIEW dbo.v AS SELECT 1", objectKeyword: "VIEW", schema: "sales", name: "v" ) - XCTAssertEqual(moved, "CREATE VIEW [sales].[v] AS SELECT 1") + XCTAssertEqual(moved, "CREATE VIEW [sales].v AS SELECT 1") + } + + func testKeepsWhatLiesBetweenTheParts() { + let renamed = MSSQLModuleHeader.namingObject( + in: "CREATE VIEW dbo /* keep this */ . old_name AS SELECT 1 AS n", + objectKeyword: "VIEW", + schema: "dbo", + name: "new_name" + ) + XCTAssertEqual(renamed, "CREATE VIEW dbo /* keep this */ . [new_name] AS SELECT 1 AS n") + + let both = MSSQLModuleHeader.namingObject( + in: "CREATE VIEW dbo/*a*/./*b*/old AS SELECT 1", objectKeyword: "VIEW", schema: "sales", name: "new" + ) + XCTAssertEqual(both, "CREATE VIEW [sales]/*a*/./*b*/[new] AS SELECT 1") } func testAnUnqualifiedStaleNameGainsItsSchema() { @@ -91,7 +106,14 @@ final class MSSQLModuleHeaderTests: XCTestCase { let renamed = MSSQLModuleHeader.namingObject( in: "CREATE VIEW dbo.v AS SELECT 1", objectKeyword: "VIEW", schema: "dbo", name: "a]b" ) - XCTAssertEqual(renamed, "CREATE VIEW [dbo].[a]]b] AS SELECT 1") + XCTAssertEqual(renamed, "CREATE VIEW dbo.[a]]b] AS SELECT 1") + } + + func testANewNameCannotCloseItsBracketsEarly() { + let renamed = MSSQLModuleHeader.namingObject( + in: "CREATE VIEW dbo.a AS SELECT 1", objectKeyword: "VIEW", schema: "dbo", name: "v]\u{0301}\nGO\nDROP TABLE t" + ) + XCTAssertEqual(renamed, "CREATE VIEW dbo.[v]]\u{0301}\nGO\nDROP TABLE t] AS SELECT 1") } func testLeavesUnreadableTextAlone() { diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift index aa221397ae..a1c19ea5f1 100644 --- a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift @@ -11,6 +11,15 @@ final class MSSQLSchemaQueriesTests: XCTestCase { XCTAssertEqual(MSSQLSchemaQueries.escapeBracket("weird]name"), "weird]]name") } + /// A name `sp_rename` accepted verbatim must not close its brackets early and start a new batch. + func testEscapeBracketDoublesABracketBeforeACombiningMark() { + XCTAssertEqual(MSSQLSchemaQueries.escapeBracket("v]\u{0301}\nGO"), "v]]\u{0301}\nGO") + XCTAssertEqual( + MSSQLSchemaQueries.bracketed(schema: "dbo", table: "v]\u{0301}"), + "[dbo].[v]]\u{0301}]" + ) + } + func testBracketedComposesIdentifier() { XCTAssertEqual(MSSQLSchemaQueries.bracketed(schema: "dbo", table: "Users"), "[dbo].[Users]") XCTAssertEqual(MSSQLSchemaQueries.bracketed(schema: "weird]", table: "x"), "[weird]]].[x]") diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index e738d3234f..210f6a0a90 100644 --- a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -551,8 +551,13 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen quote: { "\"\(escapeIdentifier($0))\"" }) } + /// `system_schema.columns` lists a materialized view's columns as it lists a table's, so a view + /// is looked up first: without it a view read here came back as a `CREATE TABLE` of its columns. func fetchTableDDL(table: String, schema: String?) async throws -> String { let ks = resolveKeyspace(schema) + if await isMaterializedView(table, keyspace: ks) { + return try await fetchViewDefinition(view: table, schema: ks) + } let columns = try await fetchColumns(table: table, schema: ks) @@ -580,6 +585,17 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen return ddl } + /// A cluster without `system_schema.views` has no materialized views, so a failed read answers no. + private func isMaterializedView(_ name: String, keyspace: String) async -> Bool { + let query = """ + SELECT view_name FROM system_schema.views + WHERE keyspace_name = '\(escapeSingleQuote(keyspace))' + AND view_name = '\(escapeSingleQuote(name))' + """ + guard let result = try? await execute(query: query) else { return false } + return !result.rows.isEmpty + } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { let ks = resolveKeyspace(schema) let query = """ diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 04bbfc7dd9..a74cf19263 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -636,7 +636,7 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let owner = effectiveSchema(schema) let view = try await execute(query: OracleSchemaQueries.viewDefinition(schema: owner, view: table)) if !view.rows.isEmpty { - return try viewStatement(from: view, schema: owner, view: table) + return try await viewStatement(from: view, schema: owner, view: table) } return OracleTableDDL.createTable( qualifiedTable: "\(quoteIdentifier(owner)).\(quoteIdentifier(table))", @@ -651,12 +651,12 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { // which corrupts OracleNIO's connection state machine. let owner = effectiveSchema(schema) let result = try await execute(query: OracleSchemaQueries.viewDefinition(schema: owner, view: view)) - return try viewStatement(from: result, schema: owner, view: view) + return try await viewStatement(from: result, schema: owner, view: view) } /// `TEXT_VC` is the view's `SELECT` and nothing else, so it needs the header a dump replays. /// Written bare it made the restore run a query and create no view. - private func viewStatement(from result: PluginQueryResult, schema: String, view: String) throws -> String { + private func viewStatement(from result: PluginQueryResult, schema: String, view: String) async throws -> String { guard let row = result.rows.first, let body = row.first?.asText, !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw OraclePluginError(core: .queryFailed( @@ -669,7 +669,12 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { localized: "Oracle returns only the first 4000 bytes of this view's definition, so the full text cannot be read." ))) } - return "CREATE OR REPLACE VIEW \(quoteIdentifier(schema)).\(quoteIdentifier(view)) AS\n\(body)" + return OracleTableDDL.createView( + qualifiedView: "\(quoteIdentifier(schema)).\(quoteIdentifier(view))", + columns: try await fetchColumns(table: view, schema: schema).map(\.name), + query: body, + quote: quoteIdentifier + ) } func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { diff --git a/Plugins/OracleDriverPlugin/OracleTableDDL.swift b/Plugins/OracleDriverPlugin/OracleTableDDL.swift index 63dbf7b9ad..36f867eac7 100644 --- a/Plugins/OracleDriverPlugin/OracleTableDDL.swift +++ b/Plugins/OracleDriverPlugin/OracleTableDDL.swift @@ -6,7 +6,7 @@ import Foundation import TableProPluginKit -/// The `CREATE TABLE` the Structure DDL view shows and a SQL export replays, built from the catalog's columns. +/// The `CREATE TABLE` and `CREATE VIEW` the Structure DDL view shows and a SQL export replays, built from the catalog. /// /// `DBMS_METADATA.GET_DDL` is not used: on the wrong object type it raises ORA-31603, which leaves oracle-nio's /// connection state machine broken. @@ -24,6 +24,14 @@ internal enum OracleTableDDL { return "CREATE TABLE \(qualifiedTable) (\n" + definitions.joined(separator: ",\n") + "\n);" } + /// `TEXT_VC` is the view's query alone, so its column names go in the header: a view declared + /// `v (public_id) AS SELECT internal_id ...` replayed without them exposes `INTERNAL_ID`, and one + /// over an unaliased expression cannot be recreated at all. `DBMS_METADATA` writes the list too. + static func createView(qualifiedView: String, columns: [String], query: String, quote: (String) -> String) -> String { + let columnList = columns.isEmpty ? "" : " (" + columns.map(quote).joined(separator: ", ") + ")" + return "CREATE OR REPLACE VIEW \(qualifiedView)\(columnList) AS\n\(query)" + } + static func columnDefinition(_ column: PluginColumnInfo, quote: (String) -> String) -> String { var parts = [quote(column.name), column.ddlSpelling ?? column.dataType] if let clause = valueClause(for: column) { diff --git a/TableProTests/Plugins/ObjectCatalogQueryTests.swift b/TableProTests/Plugins/ObjectCatalogQueryTests.swift index a4415b0405..69c2f340bc 100644 --- a/TableProTests/Plugins/ObjectCatalogQueryTests.swift +++ b/TableProTests/Plugins/ObjectCatalogQueryTests.swift @@ -384,7 +384,7 @@ struct MSSQLObjectQueryTests { let renamed = try MSSQLObjectQueries.viewSource( row: ["CREATE VIEW dbo.v_old AS SELECT 2 AS y", "0", "V "], schema: "dbo", name: "v_new" ) - #expect(renamed == "CREATE VIEW [dbo].[v_new] AS SELECT 2 AS y") + #expect(renamed == "CREATE VIEW dbo.[v_new] AS SELECT 2 AS y") } @Test("An encrypted view, a withheld definition and a missing view are three different errors") diff --git a/TableProTests/Plugins/OracleTableDDLTests.swift b/TableProTests/Plugins/OracleTableDDLTests.swift index 19cebe56b3..88eef06919 100644 --- a/TableProTests/Plugins/OracleTableDDLTests.swift +++ b/TableProTests/Plugins/OracleTableDDLTests.swift @@ -101,4 +101,20 @@ struct OracleTableDDLTests { ); """) } + + /// Measured on Oracle 23 Free: `v (public_id) AS SELECT internal_id` replayed from `TEXT_VC` alone + /// exposed `INTERNAL_ID`. + @Test("A view names its columns in the header, since its stored text is the query alone") + func viewNamesItsColumns() { + let ddl = OracleTableDDL.createView( + qualifiedView: "\"HR\".\"V\"", columns: ["PUBLIC_ID", "n\"x"], query: "SELECT internal_id, 1 FROM t", quote: quote + ) + #expect(ddl == "CREATE OR REPLACE VIEW \"HR\".\"V\" (\"PUBLIC_ID\", \"n\"\"x\") AS\nSELECT internal_id, 1 FROM t") + } + + @Test("A view whose columns could not be listed keeps the bare header") + func viewWithoutColumnsKeepsTheBareHeader() { + let ddl = OracleTableDDL.createView(qualifiedView: "\"HR\".\"V\"", columns: [], query: "SELECT 1 FROM dual", quote: quote) + #expect(ddl == "CREATE OR REPLACE VIEW \"HR\".\"V\" AS\nSELECT 1 FROM dual") + } } From d7cb949d045ce6388e46591563a359f6319448a3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 19:05:51 +0700 Subject: [PATCH 3/3] docs(plugins): note how SQL Server stores an altered view --- Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift b/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift index 4a129f65d5..a2e94ba002 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift @@ -160,9 +160,10 @@ public enum MSSQLObjectQueries { normalizedObjectType(type) == "V" } - /// A view's source from its ``moduleDefinition(schema:name:)`` row, `nil` when no such view - /// exists. The stored text gets its header naming the view as it is named now, since `sp_rename` - /// leaves the old name in it. A missing definition is an encrypted view or one this login may not + /// A view's source from its ``moduleDefinition(schema:name:)`` row. The stored text gets its + /// header naming the view as it is named now, since `sp_rename` leaves the old name in it. Its + /// verb needs nothing: measured on Azure SQL Edge, `ALTER VIEW` and `CREATE OR ALTER VIEW` are + /// both stored as `CREATE`. A missing definition is an encrypted view or one this login may not /// read, and either is said rather than handed back as an empty definition. public static func viewSource(row: [String?]?, schema: String, name: String) throws -> String { guard let row, isView(objectType: row[safe: 2].flatMap { $0 } ?? "") else {