From ec3670ca67623ff6dcae9b9242061a0fe6c9badf Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 15 Sep 2026 01:36:09 +0700 Subject: [PATCH 1/3] fix(plugin-mssql): establish the required session options and complete the routine list Claude-Session: https://claude.ai/code/session_016PSQC4EGqkpq32cWdPVMBJ --- CHANGELOG.md | 6 + .../TableProMSSQLCore/MSSQLServerBanner.swift | 36 +++ .../MSSQLSessionOptions.swift | 64 ++++++ .../MSSQLSessionOptionsTests.swift | 101 ++++++++ .../MSSQLDriverPlugin/FreeTDSConnection.swift | 19 +- .../MSSQLDriverPlugin/MSSQLCapabilities.swift | 13 +- .../MSSQLObjectQueries.swift | 64 +++++- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 2 +- .../MSSQLPluginDriver+Routines.swift | 18 +- .../MSSQLPluginDriver+Schema.swift | 2 +- .../Plugins/ObjectCatalogQueryTests.swift | 75 +++++- docs/databases/mssql.mdx | 14 ++ scripts/check-mssql-session-options.sh | 217 ++++++++++++++++++ 13 files changed, 600 insertions(+), 31 deletions(-) create mode 100644 Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLServerBanner.swift create mode 100644 Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSessionOptions.swift create mode 100644 Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSessionOptionsTests.swift create mode 100755 scripts/check-mssql-session-options.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index e1cf55947f..ff38d1dd21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,9 +33,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Welcome window list moved with the arrow keys instead of `Ctrl+J`, `Ctrl+K`, `Ctrl+H` and `Ctrl+L`. - Connection switcher lists Favorites, Recent and groups at every depth. - Connection rows without colored dots, on the Mac and on iOS. +- SQL Server sessions open with the ANSI SET profile the server requires, matching every other client. ### Fixed +- Empty Procedures and Functions lists on every SQL Server connection. +- SQL Server rows that could not be saved on a table with a filtered index or an index on a computed column. +- SQL Server CLR and extended procedures and functions missing from the Procedures and Functions lists. +- SQL Server routines labelled encrypted when the account simply cannot read their source. +- SQL Server version detection on a patched server, which left `CREATE OR ALTER` unused since 2016. - SQL editor jumping back while scrolling sideways near the start of a long line. (#2841) - SSH settings dropped from a Mac connection after it synced from the iPhone app, turning off its tunnel or remote database file. - Remote database file path and access mode dropped when a connection was exported, shared as a link, or imported. diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLServerBanner.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLServerBanner.swift new file mode 100644 index 0000000000..ad666e50c4 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLServerBanner.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Reads the two things TablePro needs out of `@@VERSION`. +/// +/// `@@VERSION` is a multi-line banner: a first line naming the product and its build, then the +/// build date, the copyright and the host OS, each on its own tab-indented line. Only the first +/// line carries the `major.minor.build.revision` the feature gates read, and only the first line is +/// worth showing a user. +/// +/// Truncating the banner to a fixed prefix does not work, and shipped as a silent regression: 50 +/// characters of a patched SQL Server 2022 is +/// `Microsoft SQL Server 2022 (RTM-CU26-GDR) (KB512276`, which cuts the build off mid-KB-number and +/// leaves no `major.minor.build` to match. The major version then read as unknown on every patched +/// server, so `CREATE OR ALTER` was never used even on servers that have had it since 2016. The +/// cumulative-update suffix grows with every patch, so the prefix that fitted when it was written +/// stops fitting later. +public enum MSSQLServerBanner { + /// The product line, without the build date, copyright and host OS lines under it. + public static func displayText(from banner: String) -> String { + let firstLine = banner.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false)[0] + return firstLine.trimmingCharacters(in: .whitespaces) + } + + /// Matches against the whole banner rather than the first line alone, so a future layout that + /// moves the build number cannot silently return nil the way the fixed prefix did. + public static func majorVersion(from banner: String?) -> Int? { + guard let banner else { return nil } + guard let regex = try? NSRegularExpression(pattern: #"(\d+)\.\d+\.\d+"#), + let match = regex.firstMatch(in: banner, range: NSRange(banner.startIndex..., in: banner)), + let range = Range(match.range(at: 1), in: banner) + else { + return nil + } + return Int(banner[range]) + } +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSessionOptions.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSessionOptions.swift new file mode 100644 index 0000000000..7181c61ed9 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSessionOptions.swift @@ -0,0 +1,64 @@ +import Foundation + +/// The session state a SQL Server connection has to be put into before anything else runs. +/// +/// FreeTDS db-lib inherits Sybase's 1990s defaults and hands back a session with every one of +/// these off. Measured on SQL Server 2022 by reading `SESSIONPROPERTY` for all seven straight +/// after `dbopen`: `0 0 0 0 0 0 0`. Every other client reaches the server with the six ON options +/// already on, because ODBC, OLE DB, JDBC and .NET all set them in the login packet, and +/// `sqlcmd -I` does the same; db-lib is the outlier. +/// +/// Six on and `NUMERIC_ROUNDABORT` off is the SET profile SQL Server requires for an indexed view, +/// an index on a computed column, a filtered index, an XML data type method and a spatial index +/// operation. Miss any one and the server answers `Msg 1934`, naming the options rather than the +/// real problem. Two of them reached users. The stored procedure and function lists were empty on +/// every connection, because the catalog query aggregates a parameter list through `FOR XML PATH` +/// and `.value()`. And no INSERT, UPDATE or DELETE could touch a table carrying a filtered index or +/// an index on a computed column, so such a row edit could never be saved. Both were measured +/// failing and then succeeding on one connection, either side of this establishment. +/// +/// `NUMERIC_ROUNDABORT` already arrives off, so setting it changes nothing on a default server. It +/// is set anyway because a database or a login can carry it on, and a session that inherits it +/// fails those same writes while all six ON options read correctly. Measured: with the six on and +/// `NUMERIC_ROUNDABORT` on, the INSERT fails with `Msg 1934` naming `NUMERIC_ROUNDABORT` alone. +/// +/// `ANSI_NULLS` and `CONCAT_NULL_YIELDS_NULL` also decide how the user's own SQL reads: `col = NULL` +/// stops matching, and concatenating a NULL yields NULL. That is the standard behaviour, the +/// behaviour every other client gets, and the behaviour Microsoft documents these options as always +/// having in a future version. A session that keeps db-lib's defaults is the surprising one. +/// +/// Statements are sent one at a time rather than as a batch, so a server that refuses one still +/// receives the rest. +public enum MSSQLSessionOptions { + /// Required on. `ARITHABORT` is implied by `ANSI_WARNINGS` at database compatibility level 90 + /// and above, which is every server TDS 7.4 can reach, so it is belt and braces rather than + /// load-bearing; it is set because Microsoft documents it as part of the required profile. + public static let optionsRequiredOn = [ + "ANSI_NULLS", + "ANSI_PADDING", + "ANSI_WARNINGS", + "ARITHABORT", + "CONCAT_NULL_YIELDS_NULL", + "QUOTED_IDENTIFIER" + ] + + /// Required off. The one member of the profile that is not an "ON". + public static let optionsRequiredOff = ["NUMERIC_ROUNDABORT"] + + /// Every option in the profile, paired with the value the server requires. + public static var requiredValues: [(name: String, isOn: Bool)] { + optionsRequiredOn.map { ($0, true) } + optionsRequiredOff.map { ($0, false) } + } + + /// `TEXTSIZE` defaults to 4096 bytes on db-lib, which truncates a large text or image column + /// mid-value and reads as corrupted data rather than as a limit. + public static let maxTextSize = "SET TEXTSIZE \(Int32.max)" + + public static let ansiDefaults = requiredValues + .map { "SET \($0.name) \($0.isOn ? "ON" : "OFF")" } + .joined(separator: " ") + + /// Run in this order: the profile first, because a server that drops the connection while + /// applying `TEXTSIZE` should not leave the session in db-lib's defaults. + public static let establishment = [ansiDefaults, maxTextSize] +} diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSessionOptionsTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSessionOptionsTests.swift new file mode 100644 index 0000000000..edd5b9e5f6 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSessionOptionsTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Testing + +@testable import TableProMSSQLCore + +@Suite("MSSQL Session Options") +struct MSSQLSessionOptionsTests { + /// Dropping any one leaves the server refusing every XML data type method and every write to a + /// table with a filtered or computed-column index, which is what emptied the procedure and + /// function lists. + @Test("Every option SQL Server requires on is turned on") + func requiredOnOptionsAreSet() { + let sql = MSSQLSessionOptions.ansiDefaults + for option in [ + "ANSI_NULLS", "ANSI_PADDING", "ANSI_WARNINGS", + "ARITHABORT", "CONCAT_NULL_YIELDS_NULL", "QUOTED_IDENTIFIER" + ] { + #expect(sql.contains("SET \(option) ON")) + } + #expect(MSSQLSessionOptions.optionsRequiredOn.count == 6) + } + + /// Measured on SQL Server 2022: with all six on and this one also on, the write still fails + /// with Msg 1934 naming NUMERIC_ROUNDABORT alone. It arrives off, so this only matters when a + /// database or a login carries it on, which is exactly the case nothing else would catch. + @Test("NUMERIC_ROUNDABORT is turned off, not on") + func roundabortIsTurnedOff() { + #expect(MSSQLSessionOptions.ansiDefaults.contains("SET NUMERIC_ROUNDABORT OFF")) + #expect(!MSSQLSessionOptions.ansiDefaults.contains("SET NUMERIC_ROUNDABORT ON")) + #expect(MSSQLSessionOptions.optionsRequiredOff == ["NUMERIC_ROUNDABORT"]) + } + + @Test("The profile is seven options and no option appears in both lists") + func profileIsSevenDistinctOptions() { + let values = MSSQLSessionOptions.requiredValues + #expect(values.count == 7) + #expect(Set(values.map(\.name)).count == 7) + #expect(values.filter { !$0.isOn }.map(\.name) == ["NUMERIC_ROUNDABORT"]) + } + + /// db-lib defaults TEXTSIZE to 4096 bytes, which truncates a large value mid-character and + /// reads as damaged data rather than as a limit. + @Test("Text size is raised to the protocol maximum") + func textSizeIsRaised() { + #expect(MSSQLSessionOptions.maxTextSize == "SET TEXTSIZE \(Int32.max)") + } + + /// One statement per element, so a server that refuses one still receives the rest. + @Test("Establishment sends the ANSI options before the text size") + func establishmentOrder() { + #expect(MSSQLSessionOptions.establishment == [ + MSSQLSessionOptions.ansiDefaults, + MSSQLSessionOptions.maxTextSize + ]) + } +} + +@Suite("MSSQL Server Banner") +struct MSSQLServerBannerTests { + /// Measured from a live SQL Server 2022 CU26. The 50-character prefix this replaced cut the + /// build off mid-KB-number, so every version gate read the server as unknown. + static let sqlServer2022 = """ + Microsoft SQL Server 2022 (RTM-CU26-GDR) (KB5122768) - 16.0.4275.2 (X64) \n\tAug 20 2026 00:33:45 \n\tCopyright (C) 2022 Microsoft Corporation\n\tDeveloper Edition (64-bit) on Linux (Ubuntu 22.04.5 LTS) + """ + + @Test("A patched server's major version survives the banner") + func patchedServerParses() { + #expect(MSSQLServerBanner.majorVersion(from: Self.sqlServer2022) == 16) + } + + @Test("A fixed 50-character prefix would have lost it") + func fixedPrefixLosesTheBuild() { + #expect(MSSQLServerBanner.majorVersion(from: String(Self.sqlServer2022.prefix(50))) == nil) + } + + @Test("Display text is the product line alone") + func displayTextIsOneLine() { + let text = MSSQLServerBanner.displayText(from: Self.sqlServer2022) + #expect(text == "Microsoft SQL Server 2022 (RTM-CU26-GDR) (KB5122768) - 16.0.4275.2 (X64)") + #expect(!text.contains("\n")) + #expect(!text.contains("Copyright")) + } + + @Test("Azure SQL Database reports its version on the first line too") + func azureParses() { + let banner = "Microsoft SQL Azure (RTM) - 12.0.2000.8 \n\tOct 18 2025 12:00:00 \n\tCopyright (C) 2025 Microsoft Corporation" + #expect(MSSQLServerBanner.majorVersion(from: banner) == 12) + #expect(MSSQLServerBanner.displayText(from: banner) == "Microsoft SQL Azure (RTM) - 12.0.2000.8") + } + + @Test("A banner with no build number reports no version rather than a wrong one") + func unparsableBanner() { + #expect(MSSQLServerBanner.majorVersion(from: "Microsoft SQL Server") == nil) + #expect(MSSQLServerBanner.majorVersion(from: nil) == nil) + } + + @Test("A single-line banner is returned whole") + func singleLineBanner() { + #expect(MSSQLServerBanner.displayText(from: "Microsoft SQL Server 2019 - 15.0.1.1") == "Microsoft SQL Server 2019 - 15.0.1.1") + } +} diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift index 39b18315d5..b0c5faed3e 100644 --- a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -305,7 +305,7 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { dbproc = proc _isConnected = true lock.unlock() - applyMaxTextSize(proc) + establishSession(proc) } private func teardown(_ proc: UnsafeMutablePointer) { @@ -313,11 +313,20 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { _ = dbclose(proc) } - private func applyMaxTextSize(_ proc: UnsafeMutablePointer) { - guard dbcmd(proc, "SET TEXTSIZE \(Int32.max)") != FAIL, dbsqlexec(proc) != FAIL else { - freetdsLogger.error("Failed to raise TEXTSIZE; large text columns may be truncated to the 2048-byte default") - return + /// A server that refuses one of these still gets a working connection: db-lib's own defaults + /// are wrong rather than fatal, and failing the connect over them would take the database away + /// from a user who could otherwise work in it. + private func establishSession(_ proc: UnsafeMutablePointer) { + for statement in MSSQLSessionOptions.establishment { + guard dbcmd(proc, statement) != FAIL, dbsqlexec(proc) != FAIL else { + freetdsLogger.error("Session option statement refused: \(statement, privacy: .public)") + continue + } + drainResults(proc) } + } + + private func drainResults(_ proc: UnsafeMutablePointer) { while true { let resCode = dbresults(proc) if resCode == FAIL || resCode == Int32(NO_MORE_RESULTS) { diff --git a/Plugins/MSSQLDriverPlugin/MSSQLCapabilities.swift b/Plugins/MSSQLDriverPlugin/MSSQLCapabilities.swift index 269b5e5ec9..644fa1636a 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLCapabilities.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLCapabilities.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProMSSQLCore struct MSSQLCapabilities: Sendable, Equatable { let major: Int @@ -13,17 +14,7 @@ struct MSSQLCapabilities: Sendable, Equatable { var hasCreateOrAlterView: Bool { major >= 13 } static func parse(_ versionString: String?) -> MSSQLCapabilities { - guard let versionString else { return .unknown } - let pattern = #"(\d+)\.\d+\.\d+"# - guard let regex = try? NSRegularExpression(pattern: pattern), - let match = regex.firstMatch( - in: versionString, - range: NSRange(versionString.startIndex..., in: versionString) - ), - let range = Range(match.range(at: 1), in: versionString), - let major = Int(versionString[range]) else { - return .unknown - } + guard let major = MSSQLServerBanner.majorVersion(from: versionString) else { return .unknown } return MSSQLCapabilities(major: major) } } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift b/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift index b40fc13c61..b8087acabe 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift @@ -13,18 +13,54 @@ public enum MSSQLObjectQueries { MSSQLStringLiteral.escaped(value) } + /// `sys.objects.type` for everything that is a routine. The three CLR codes and the extended + /// procedure were missing, so a database that has them listed fewer routines than it holds with + /// nothing saying so. `AF` is a CLR aggregate, which is called like a function and belongs with + /// them. + public static let routineObjectTypes = ["P", "PC", "X", "FN", "IF", "TF", "FS", "FT", "AF"] + + private static let procedureObjectTypes: Set = ["P", "PC", "X"] + + /// A CLR routine's body lives in sys.assembly_modules and an extended procedure's lives in a + /// DLL, so neither has a sys.sql_modules row. Without this the reader is told the source was + /// withheld by permissions, which is a different thing and sends them to the wrong fix. + private static let nonSQLObjectTypes: Set = ["PC", "FS", "FT", "AF", "X"] + + public static func routineHasSQLSource(forObjectType type: String) -> Bool { + !nonSQLObjectTypes.contains(normalizedObjectType(type)) + } + + public static func routineLanguage(forObjectType type: String) -> String { + let code = normalizedObjectType(type) + if code == "X" { return "Extended" } + return nonSQLObjectTypes.contains(code) ? "CLR" : "T-SQL" + } + + private static func normalizedObjectType(_ type: String) -> String { + type.trimmingCharacters(in: .whitespaces).uppercased() + } + /// Reads sys.sql_modules, never INFORMATION_SCHEMA.ROUTINES.ROUTINE_DEFINITION. That column is /// nvarchar(4000) and silently returns the first 4000 characters of a longer body, which looks /// like a routine that ends mid-statement. + /// + /// 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. + /// + /// 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 + /// comes back empty. public static func routineList(schema: String) -> String { let schemaLiteral = MSSQLStringLiteral.quoted(schema) + let typeList = routineObjectTypes.map { "'\($0)'" }.joined(separator: ", ") return """ SELECT o.name, s.name AS schema_name, o.type, - m.definition, - CASE WHEN m.definition IS NULL THEN 1 ELSE 0 END AS is_encrypted, + OBJECTPROPERTY(o.object_id, 'IsEncrypted') AS is_encrypted, + CASE WHEN m.definition IS NULL THEN 1 ELSE 0 END AS definition_withheld, ( SELECT STUFF(( SELECT ', ' + p.name + ' ' + TYPE_NAME(p.user_type_id) @@ -43,18 +79,30 @@ public enum MSSQLObjectQueries { JOIN sys.schemas s ON s.schema_id = o.schema_id LEFT JOIN sys.sql_modules m ON m.object_id = o.object_id WHERE s.name = \(schemaLiteral) - AND o.type IN ('P', 'FN', 'IF', 'TF') + AND o.type IN (\(typeList)) AND o.is_ms_shipped = 0 ORDER BY o.type, o.name """ } + /// `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 + /// cannot tell them apart. `OBJECTPROPERTY(..., 'IsEncrypted')` can, and answers for a + /// low-privilege caller too, so it comes back beside the definition and decides which of the + /// two the reader is told. + /// + /// Driven from sys.objects with sys.sql_modules joined on the outside, because a CLR routine + /// and an extended procedure have no row there at all. Measured: the inner join this replaced + /// 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 { """ - SELECT m.definition - FROM sys.sql_modules m - JOIN sys.objects o ON o.object_id = m.object_id + SELECT m.definition, OBJECTPROPERTY(o.object_id, 'IsEncrypted') AS is_encrypted, o.type + FROM sys.objects o JOIN sys.schemas s ON s.schema_id = o.schema_id + LEFT JOIN sys.sql_modules m ON m.object_id = o.object_id WHERE s.name = \(MSSQLStringLiteral.quoted(schema)) AND o.name = \(MSSQLStringLiteral.quoted(name)) """ } @@ -85,7 +133,9 @@ public enum MSSQLObjectQueries { """ } + /// `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 { - type.trimmingCharacters(in: .whitespaces).uppercased() == "P" ? "PROCEDURE" : "FUNCTION" + procedureObjectTypes.contains(normalizedObjectType(type)) ? "PROCEDURE" : "FUNCTION" } } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index d665270492..35dba7762f 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -341,7 +341,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { if let result = try? await executeInternal("SELECT @@VERSION"), let versionStr = result.rows.first?.first?.asText { - _serverVersion = String(versionStr.prefix(50)) + _serverVersion = MSSQLServerBanner.displayText(from: versionStr) } } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Routines.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Routines.swift index f67799cd6c..7bfd25f42c 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Routines.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Routines.swift @@ -16,8 +16,14 @@ extension MSSQLPluginDriver { let isProcedure = MSSQLObjectQueries.routineKind(forObjectType: objectType) == "PROCEDURE" var attributes: [PluginObjectAttribute] = [] attributes.append(PluginObjectAttribute(label: "Object Type", value: objectType.trimmingCharacters(in: .whitespaces))) - if row[safe: 4]?.asText == "1" { + let hasSQLSource = MSSQLObjectQueries.routineHasSQLSource(forObjectType: objectType) + if row[safe: 3]?.asText == "1" { attributes.append(PluginObjectAttribute(label: "Encrypted", value: "YES")) + } else if hasSQLSource, row[safe: 4]?.asText == "1" { + attributes.append(PluginObjectAttribute( + label: String(localized: "Source"), + value: String(localized: "Not readable with your permissions") + )) } let parameters = row[safe: 5]?.asText ?? "" return PluginRoutineInfo( @@ -25,7 +31,7 @@ extension MSSQLPluginDriver { kind: isProcedure ? .procedure : .function, schema: row[safe: 1]?.asText ?? resolvedSchema, returnType: isProcedure ? nil : row[safe: 6]?.asText, - language: "T-SQL", + language: MSSQLObjectQueries.routineLanguage(forObjectType: objectType), argumentSignature: "(\(parameters))", identity: nil, attributes: attributes @@ -40,10 +46,12 @@ extension MSSQLPluginDriver { guard let row = result.rows.first else { throw PluginObjectSourceError.notFound(routine.name) } - /// sys.sql_modules.definition is NULL for WITH ENCRYPTION, and for a caller without - /// VIEW DEFINITION. Neither means the routine is gone. guard let definition = row[safe: 0]?.asText, !definition.isEmpty else { - throw PluginObjectSourceError.insufficientPrivilege(routine.name) + let isEncrypted = row[safe: 1]?.asText == "1" + let hasSQLSource = MSSQLObjectQueries.routineHasSQLSource(forObjectType: row[safe: 2]?.asText ?? "") + throw isEncrypted || !hasSQLSource + ? PluginObjectSourceError.unsupported(routine.name) + : PluginObjectSourceError.insufficientPrivilege(routine.name) } return definition } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift index f2f3688c2d..ba3b427ba9 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -607,7 +607,7 @@ extension MSSQLPluginDriver { t.name as name, CASE WHEN v.object_id IS NOT NULL THEN 'VIEW' ELSE 'TABLE' END as kind, p.rows as estimated_rows, - CAST(ROUND(SUM(a.total_pages) * 8 / 1024.0, 2) AS VARCHAR) + ' MB' as total_size + CAST(ROUND(ISNULL(SUM(a.total_pages), 0) * 8 / 1024.0, 2) AS VARCHAR) + ' MB' as total_size FROM sys.tables t INNER JOIN sys.schemas s ON t.schema_id = s.schema_id INNER JOIN sys.indexes i ON t.object_id = i.object_id AND i.index_id IN (0, 1) diff --git a/TableProTests/Plugins/ObjectCatalogQueryTests.swift b/TableProTests/Plugins/ObjectCatalogQueryTests.swift index 34a0634a11..3555b2796b 100644 --- a/TableProTests/Plugins/ObjectCatalogQueryTests.swift +++ b/TableProTests/Plugins/ObjectCatalogQueryTests.swift @@ -269,7 +269,80 @@ struct MSSQLObjectQueryTests { @Test("Fixed catalog type codes stay plain literals") func catalogTypeCodesStayPlain() { - #expect(MSSQLObjectQueries.routineList(schema: "dbo").contains("o.type IN ('P', 'FN', 'IF', 'TF')")) + #expect(MSSQLObjectQueries.routineList(schema: "dbo") + .contains("o.type IN ('P', 'PC', 'X', 'FN', 'IF', 'TF', 'FS', 'FT', 'AF')")) + } + + /// A database with CLR routines listed fewer than it held, with nothing saying so. + @Test("CLR and extended routines are listed alongside the T-SQL ones") + func clrRoutinesAreListed() { + for code in ["PC", "FS", "FT", "AF", "X"] { + #expect(MSSQLObjectQueries.routineObjectTypes.contains(code)) + } + } + + @Test("Every procedure code reads as a procedure and every function code as a function") + func objectTypeMappingCoversClr() { + for code in ["P ", "PC", "X "] { + #expect(MSSQLObjectQueries.routineKind(forObjectType: code) == "PROCEDURE") + } + for code in ["FN", "IF", "TF", "FS", "FT", "AF"] { + #expect(MSSQLObjectQueries.routineKind(forObjectType: code) == "FUNCTION") + } + } + + /// Measured on SQL Server 2022: a caller with only SELECT and EXECUTE still sees the + /// sys.sql_modules row with a NULL definition, exactly as WITH ENCRYPTION does. Only + /// OBJECTPROPERTY tells the two apart, and it answers for that caller too. + @Test("Encryption is read from OBJECTPROPERTY, not from a missing definition") + func encryptionIsNotInferredFromNullDefinition() { + 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") + .contains("OBJECTPROPERTY(o.object_id, 'IsEncrypted')")) + } + + /// Selecting every body to draw a list of names pulled a whole schema's source over the wire + /// and dropped it; the reader's own open re-queries the one they asked for. + @Test("The listing carries no routine bodies") + func listingOmitsBodies() { + let list = MSSQLObjectQueries.routineList(schema: "dbo") + #expect(!list.contains("m.definition,")) + #expect(MSSQLObjectQueries.routineDefinition(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 + /// zero rows and the caller reported a routine sitting in the list as no longer existing. + /// Measured against SQL Server 2022 with an object that has no module row: inner join 0 rows, + /// 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") + #expect(sql.contains("FROM sys.objects o")) + #expect(sql.contains("LEFT JOIN sys.sql_modules m")) + #expect(!sql.contains("FROM sys.sql_modules")) + #expect(sql.contains("o.type")) + } + + @Test("Only T-SQL routines are expected to have a SQL source") + func sqlSourceIsPerObjectType() { + for code in ["P ", "FN", "IF", "TF"] { + #expect(MSSQLObjectQueries.routineHasSQLSource(forObjectType: code)) + } + for code in ["PC", "FS", "FT", "AF", "X "] { + #expect(!MSSQLObjectQueries.routineHasSQLSource(forObjectType: code)) + } + } + + /// Reporting a CLR routine's language as T-SQL is a claim about a body that is not there. + @Test("Language names the runtime the routine actually runs on") + func languageFollowsObjectType() { + #expect(MSSQLObjectQueries.routineLanguage(forObjectType: "P ") == "T-SQL") + #expect(MSSQLObjectQueries.routineLanguage(forObjectType: "TF") == "T-SQL") + #expect(MSSQLObjectQueries.routineLanguage(forObjectType: "PC") == "CLR") + #expect(MSSQLObjectQueries.routineLanguage(forObjectType: "AF") == "CLR") + #expect(MSSQLObjectQueries.routineLanguage(forObjectType: "X ") == "Extended") } } diff --git a/docs/databases/mssql.mdx b/docs/databases/mssql.mdx index 2bf6fdcf41..8de67bfd82 100644 --- a/docs/databases/mssql.mdx +++ b/docs/databases/mssql.mdx @@ -90,6 +90,20 @@ The sidebar nests tables under their schema, hides the built-in role schemas (`d INSERTs from the data grid leave IDENTITY columns out, so the server assigns them, and `TEXTSIZE` is raised at connect, so `nvarchar(max)` and `text` values arrive whole rather than cut to FreeTDS's 2048-byte default. +## Session options + +Every connection opens with the SET profile SQL Server requires for filtered indexes, indexes on computed columns, indexed views and XML data type methods: + +| Option | Value | +|---|---| +| `ANSI_NULLS`, `ANSI_PADDING`, `ANSI_WARNINGS` | ON | +| `ARITHABORT`, `CONCAT_NULL_YIELDS_NULL`, `QUOTED_IDENTIFIER` | ON | +| `NUMERIC_ROUNDABORT` | OFF | + +FreeTDS leaves all of them at Sybase's defaults, and a session that misses any one of them refuses every write to a table carrying a filtered index or an index on a computed column, with an error naming the SET options rather than the table. + +Two things change in the SQL editor as a result. `col = NULL` matches nothing, so compare with `col IS NULL`. And `'total: ' + @value` is NULL when `@value` is, so wrap it in `ISNULL(@value, '')` to keep the rest of the string. + ## SSL/TLS New connections start on **Preferred**. diff --git a/scripts/check-mssql-session-options.sh b/scripts/check-mssql-session-options.sh new file mode 100755 index 0000000000..6bb06af427 --- /dev/null +++ b/scripts/check-mssql-session-options.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# +# Check the session FreeTDS db-lib actually hands TablePro, against a real SQL Server. +# +# db-lib inherits Sybase's defaults and connects with every option of SQL Server's required SET +# profile off. While the profile is unmet the server answers Msg 1934 to any query using an XML +# data type method, and to any INSERT, UPDATE or DELETE against a table carrying a filtered index +# or an index on a computed column. That emptied the stored procedure and function lists and made +# such a table unwritable, and neither failure names the real cause. +# +# The profile is six options on and NUMERIC_ROUNDABORT off. Checking only the on ones is not +# enough: measured on SQL Server 2022, a session with all six on and NUMERIC_ROUNDABORT on still +# fails the write, with Msg 1934 naming NUMERIC_ROUNDABORT alone. +# +# No unit test can catch this: the statement text is correct and the defect is in the session it +# runs against. So this probe links the shipped Libs/libsybdb.a, connects the way the driver does, +# reads every option back before and after establishment, and asserts each one ends on the value +# the server requires. A FreeTDS bump re-checks it. The names and their required values are read +# out of MSSQLSessionOptions.swift rather than repeated here, so the probe cannot drift from what +# ships. +# +# Usage: +# scripts/check-mssql-session-options.sh [host] [port] [user] [password] +# +# Needs a reachable SQL Server. Exits non-zero when an option does not end on its required value. + +set -uo pipefail + +HOST="${1:-127.0.0.1}" +PORT="${2:-1433}" +USER_NAME="${3:-sa}" +PASSWORD="${4:-${MSSQL_SA_PASSWORD:-}}" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE="$ROOT/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSessionOptions.swift" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +[ -n "$PASSWORD" ] || { + echo "no password: pass one as the 4th argument or set MSSQL_SA_PASSWORD" >&2 + exit 3 +} +[ -f "$SOURCE" ] || { + echo "not found: $SOURCE" >&2 + exit 3 +} +[ -f "$ROOT/Libs/libsybdb.a" ] || { + echo "not found: Libs/libsybdb.a (run scripts/download-libs.sh)" >&2 + exit 3 +} + +# Every option carries the value the server requires: the optionsRequiredOn list ends ON, the +# optionsRequiredOff list ends OFF. Both are read out of the Swift so the probe cannot drift. +# Stops at the closing bracket even when it sits on the declaration's own line, which a sed range +# cannot do: a range needs its end match on a later line, so a single-line array ran to end of file +# and swept up every capitalised word after it. +read_option_list() { + awk -v name="$1" ' + index($0, name " = [") { collecting = 1 } + collecting { + line = $0 + while (match(line, /"[A-Z_]+"/)) { + print substr(line, RSTART + 1, RLENGTH - 2) + line = substr(line, RSTART + RLENGTH) + } + if (index($0, "]")) exit + } + ' "$SOURCE" +} + +EXPECTED="" +while IFS= read -r name; do + [ -n "$name" ] && EXPECTED="$EXPECTED $name:1" +done << EOF +$(read_option_list optionsRequiredOn) +EOF +while IFS= read -r name; do + [ -n "$name" ] && EXPECTED="$EXPECTED $name:0" +done << EOF +$(read_option_list optionsRequiredOff) +EOF + +COUNT=0 +SELECT_LIST="" +ESTABLISH="" +for entry in $EXPECTED; do + option="${entry%%:*}" + want="${entry##*:}" + COUNT=$((COUNT + 1)) + [ -n "$SELECT_LIST" ] && SELECT_LIST="$SELECT_LIST, " + SELECT_LIST="${SELECT_LIST}CAST(SESSIONPROPERTY('$option') AS varchar(4))" + if [ "$want" = "1" ]; then + ESTABLISH="${ESTABLISH}SET $option ON " + else + ESTABLISH="${ESTABLISH}SET $option OFF " + fi +done + +[ "$COUNT" -eq 7 ] || { + echo "expected 7 options across MSSQLSessionOptions.optionsRequiredOn and optionsRequiredOff, parsed $COUNT" >&2 + exit 3 +} + +cat > "$WORK/probe.c" << PROBE +#include +#include +#include +#include + +static int on_error(DBPROCESS *p, int s, int e, int o, const char *m, const char *sv) { + (void)p; (void)s; (void)o; (void)sv; + if (e != 20053) fprintf(stderr, "dberr %d: %s\n", e, m ? m : ""); + return INT_CANCEL; +} + +static int on_message(DBPROCESS *p, DBINT n, int st, int sev, char *t, char *sv, char *pr, int l) { + (void)p; (void)st; (void)sv; (void)pr; (void)l; + if (sev > 0) fprintf(stderr, "msg %d: %s\n", (int)n, t ? t : ""); + return 0; +} + +static void emit(DBPROCESS *dbp, const char *label, const char *sql) { + printf("%s\t", label); + if (dbcmd(dbp, (char *)sql) == FAIL || dbsqlexec(dbp) == FAIL) { + printf("\n"); + return; + } + RETCODE r; + while ((r = dbresults(dbp)) != NO_MORE_RESULTS) { + if (r == FAIL) continue; + int ncols = dbnumcols(dbp); + while (dbnextrow(dbp) != NO_MORE_ROWS) { + for (int i = 1; i <= ncols; i++) { + BYTE *data = dbdata(dbp, i); + DBINT len = dbdatlen(dbp, i); + char out[512]; + DBINT n = (!data || len <= 0) ? 0 : dbconvert(dbp, dbcoltype(dbp, i), data, len, SYBCHAR, (BYTE *)out, sizeof(out) - 1); + if (n < 0) n = 0; + out[n] = 0; + printf("%s%s", out, i == ncols ? "\n" : "\t"); + } + } + } +} + +int main(void) { + /* Credentials arrive through the environment at run time. Pasting a password into a C string + literal breaks compilation on a quote, rewrites the bytes on a backslash escape, and can + echo the secret in a compiler diagnostic. */ + const char *user = getenv("TP_PROBE_USER"); + const char *password = getenv("TP_PROBE_PASSWORD"); + const char *server = getenv("TP_PROBE_SERVER"); + if (!user || !password || !server) { + fprintf(stderr, "TP_PROBE_USER, TP_PROBE_PASSWORD and TP_PROBE_SERVER must be set\n"); + return 3; + } + if (dbinit() == FAIL) return 3; + dberrhandle(on_error); + dbmsghandle(on_message); + LOGINREC *login = dblogin(); + if (!login) return 3; + DBSETLUSER(login, user); + DBSETLPWD(login, password); + DBSETLAPP(login, "TableProSessionProbe"); + DBPROCESS *dbp = dbopen(login, server); + if (!dbp) { + fprintf(stderr, "dbopen failed\n"); + return 3; + } + emit(dbp, "defaults", "SELECT $SELECT_LIST"); + emit(dbp, "established", "$ESTABLISH SELECT $SELECT_LIST"); + dbclose(dbp); + dbexit(); + return 0; +} +PROBE + +DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode-beta.app/Contents/Developer}" \ + xcrun clang "$WORK/probe.c" \ + -I"$ROOT/Plugins/MSSQLDriverPlugin/CFreeTDS/include" \ + -L"$ROOT/Libs" -L"$ROOT/Libs/dylibs" \ + -lsybdb -lssl.3 -lcrypto.3 -liconv -lz \ + -framework GSS -framework Kerberos \ + -Wl,-rpath,"$ROOT/Libs/dylibs" \ + -o "$WORK/probe" || { + echo "probe failed to build" >&2 + exit 3 +} + +OUTPUT="$(TP_PROBE_USER="$USER_NAME" TP_PROBE_PASSWORD="$PASSWORD" TP_PROBE_SERVER="$HOST:$PORT" "$WORK/probe" 2> "$WORK/stderr")" +DEFAULTS="$(printf '%s\n' "$OUTPUT" | sed -n 's/^defaults //p')" +ESTABLISHED="$(printf '%s\n' "$OUTPUT" | sed -n 's/^established //p')" + +if [ -z "$ESTABLISHED" ]; then + echo "probe produced nothing; no SQL Server at $HOST:$PORT as $USER_NAME" >&2 + cat "$WORK/stderr" >&2 + exit 3 +fi + +echo "db-lib defaults: $(printf '%s' "$DEFAULTS" | tr '\t' ' ')" +echo "after establish: $(printf '%s' "$ESTABLISHED" | tr '\t' ' ')" + +status=0 +index=0 +for entry in $EXPECTED; do + option="${entry%%:*}" + want="${entry##*:}" + index=$((index + 1)) + value="$(printf '%s' "$ESTABLISHED" | cut -f "$index")" + if [ "$value" != "$want" ]; then + echo "FAIL: $option reads '$value' after establishment, expected $want" >&2 + status=1 + fi +done + +[ "$status" -eq 0 ] && echo "OK: all $COUNT options match the required profile after establishment" +exit "$status" From 2c2e4982dbf731dd5cb234279739ec4bbc010b2c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 15 Sep 2026 01:45:04 +0700 Subject: [PATCH 2/3] feat(plugin-mssql): browse SQL Server alias, table and CLR types Claude-Session: https://claude.ai/code/session_016PSQC4EGqkpq32cWdPVMBJ --- CHANGELOG.md | 1 + Plugins/BeancountDriverPlugin/Info.plist | 2 +- Plugins/BigQueryDriverPlugin/Info.plist | 2 +- Plugins/CSVExportPlugin/Info.plist | 2 +- Plugins/CSVImportPlugin/Info.plist | 2 +- Plugins/CassandraDriverPlugin/Info.plist | 2 +- Plugins/ClickHouseDriverPlugin/Info.plist | 2 +- Plugins/CloudflareD1DriverPlugin/Info.plist | 2 +- .../CloudflareR2SQLDriverPlugin/Info.plist | 2 +- Plugins/DamengDriverPlugin/Info.plist | 2 +- Plugins/DuckDBDriverPlugin/Info.plist | 2 +- Plugins/DynamoDBDriverPlugin/Info.plist | 2 +- Plugins/ElasticsearchDriverPlugin/Info.plist | 2 +- Plugins/EtcdDriverPlugin/Info.plist | 2 +- Plugins/HTMLExportPlugin/Info.plist | 2 +- Plugins/JSONExportPlugin/Info.plist | 2 +- Plugins/JSONImportPlugin/Info.plist | 2 +- Plugins/KafkaDriverPlugin/Info.plist | 2 +- Plugins/LibSQLDriverPlugin/Info.plist | 2 +- Plugins/MQLExportPlugin/Info.plist | 2 +- Plugins/MSSQLDriverPlugin/Info.plist | 2 +- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 1 + .../MSSQLPluginDriver+Types.swift | 161 +++++++++++++++++ .../MSSQLTypeDefinition.swift | 140 +++++++++++++++ .../MSSQLDriverPlugin/MSSQLTypeQueries.swift | 136 ++++++++++++++ Plugins/MarkdownExportPlugin/Info.plist | 2 +- Plugins/MongoDBDriverPlugin/Info.plist | 2 +- Plugins/MySQLDriverPlugin/Info.plist | 2 +- Plugins/OracleDriverPlugin/Info.plist | 2 +- Plugins/ParquetExportPlugin/Info.plist | 2 +- Plugins/PostgreSQLDriverPlugin/Info.plist | 2 +- Plugins/RedisDriverPlugin/Info.plist | 2 +- Plugins/SQLExportPlugin/Info.plist | 2 +- Plugins/SQLImportPlugin/Info.plist | 2 +- Plugins/SQLiteDriverPlugin/Info.plist | 2 +- Plugins/SnowflakeDriverPlugin/Info.plist | 2 +- Plugins/SpannerDriverPlugin/Info.plist | 2 +- Plugins/SurrealDBDriverPlugin/Info.plist | 2 +- .../PluginUserDefinedTypeInfo.swift | 41 ++++- Plugins/TeradataDriverPlugin/Info.plist | 2 +- Plugins/TrinoDriverPlugin/Info.plist | 2 +- Plugins/TypesenseDriverPlugin/Info.plist | 2 +- Plugins/WeaviateDriverPlugin/Info.plist | 2 +- Plugins/XLSXExportPlugin/Info.plist | 2 +- Plugins/XLSXImportPlugin/Info.plist | 2 +- Plugins/XMLExportPlugin/Info.plist | 2 +- .../Protocol/Tools/SchemaObjectTools.swift | 4 +- .../Core/Plugins/PluginDriverAdapter.swift | 5 +- TablePro/Core/Plugins/PluginManager.swift | 7 +- .../Core/Plugins/PluginObjectMapping.swift | 6 + .../Query/UserDefinedTypeSuggestions.swift | 4 + .../Models/Query/UserDefinedTypeInfo.swift | 18 ++ TablePro/Views/Sidebar/UserTypeRowView.swift | 8 +- .../Plugins/MSSQLTypeQueryTests.swift | 169 ++++++++++++++++++ docs/development/plugin-development.mdx | 2 +- docs/development/plugin-registry.mdx | 4 +- docs/features/user-defined-types.mdx | 7 +- project.yml | 2 + 58 files changed, 743 insertions(+), 53 deletions(-) create mode 100644 Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Types.swift create mode 100644 Plugins/MSSQLDriverPlugin/MSSQLTypeDefinition.swift create mode 100644 Plugins/MSSQLDriverPlugin/MSSQLTypeQueries.swift create mode 100644 TableProTests/Plugins/MSSQLTypeQueryTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ff38d1dd21..5bc90596b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A line on the welcome window naming the version TablePro updated from, with a link to what changed. - What's New window, from Help > What's New. - Update install mode and check frequency in the anonymous usage heartbeat. +- SQL Server alias, table and CLR types in the sidebar's **Types** section, each with a rebuilt `CREATE TYPE` statement. - OceanBase MySQL-mode connection type on the MySQL driver. (#1748) - On the Server mode for a SQLite Remote Database File, editing a database on an SSH server in place with statements run on the server. (#2831) - **System Databases and Schemas** for the sidebar tree, in View Options and Settings > General. (#2832) diff --git a/Plugins/BeancountDriverPlugin/Info.plist b/Plugins/BeancountDriverPlugin/Info.plist index 9c7121e3e9..c4e395aa53 100644 --- a/Plugins/BeancountDriverPlugin/Info.plist +++ b/Plugins/BeancountDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesDatabaseTypeIds Beancount diff --git a/Plugins/BigQueryDriverPlugin/Info.plist b/Plugins/BigQueryDriverPlugin/Info.plist index db6fa23e5f..1b8a8d9a3f 100644 --- a/Plugins/BigQueryDriverPlugin/Info.plist +++ b/Plugins/BigQueryDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 30 + 31 diff --git a/Plugins/CSVExportPlugin/Info.plist b/Plugins/CSVExportPlugin/Info.plist index 3044a4cb08..0adbabd254 100644 --- a/Plugins/CSVExportPlugin/Info.plist +++ b/Plugins/CSVExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesExportFormatIds csv diff --git a/Plugins/CSVImportPlugin/Info.plist b/Plugins/CSVImportPlugin/Info.plist index 85553e251d..7be770720a 100644 --- a/Plugins/CSVImportPlugin/Info.plist +++ b/Plugins/CSVImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesImportFormatIds csv diff --git a/Plugins/CassandraDriverPlugin/Info.plist b/Plugins/CassandraDriverPlugin/Info.plist index e4cf4cd74d..87f847d4d7 100644 --- a/Plugins/CassandraDriverPlugin/Info.plist +++ b/Plugins/CassandraDriverPlugin/Info.plist @@ -21,6 +21,6 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).CassandraPlugin TableProPluginKitVersion - 30 + 31 diff --git a/Plugins/ClickHouseDriverPlugin/Info.plist b/Plugins/ClickHouseDriverPlugin/Info.plist index e62856a2b6..affe9eeef4 100644 --- a/Plugins/ClickHouseDriverPlugin/Info.plist +++ b/Plugins/ClickHouseDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesDatabaseTypeIds ClickHouse diff --git a/Plugins/CloudflareD1DriverPlugin/Info.plist b/Plugins/CloudflareD1DriverPlugin/Info.plist index db6fa23e5f..1b8a8d9a3f 100644 --- a/Plugins/CloudflareD1DriverPlugin/Info.plist +++ b/Plugins/CloudflareD1DriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 30 + 31 diff --git a/Plugins/CloudflareR2SQLDriverPlugin/Info.plist b/Plugins/CloudflareR2SQLDriverPlugin/Info.plist index 261c69fbc7..a271c47c54 100644 --- a/Plugins/CloudflareR2SQLDriverPlugin/Info.plist +++ b/Plugins/CloudflareR2SQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesDatabaseTypeIds Cloudflare R2 SQL diff --git a/Plugins/DamengDriverPlugin/Info.plist b/Plugins/DamengDriverPlugin/Info.plist index 61d87c74aa..f684f97c15 100644 --- a/Plugins/DamengDriverPlugin/Info.plist +++ b/Plugins/DamengDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesDatabaseTypeIds Dameng diff --git a/Plugins/DuckDBDriverPlugin/Info.plist b/Plugins/DuckDBDriverPlugin/Info.plist index e615b82bed..8d60bc2d35 100644 --- a/Plugins/DuckDBDriverPlugin/Info.plist +++ b/Plugins/DuckDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 30 + 31 diff --git a/Plugins/DynamoDBDriverPlugin/Info.plist b/Plugins/DynamoDBDriverPlugin/Info.plist index db6fa23e5f..1b8a8d9a3f 100644 --- a/Plugins/DynamoDBDriverPlugin/Info.plist +++ b/Plugins/DynamoDBDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 30 + 31 diff --git a/Plugins/ElasticsearchDriverPlugin/Info.plist b/Plugins/ElasticsearchDriverPlugin/Info.plist index ed7033f514..09eca4b396 100644 --- a/Plugins/ElasticsearchDriverPlugin/Info.plist +++ b/Plugins/ElasticsearchDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.53.0 TableProPluginKitVersion - 30 + 31 diff --git a/Plugins/EtcdDriverPlugin/Info.plist b/Plugins/EtcdDriverPlugin/Info.plist index db6fa23e5f..1b8a8d9a3f 100644 --- a/Plugins/EtcdDriverPlugin/Info.plist +++ b/Plugins/EtcdDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 30 + 31 diff --git a/Plugins/HTMLExportPlugin/Info.plist b/Plugins/HTMLExportPlugin/Info.plist index f63ead16d4..a5d32f06cc 100644 --- a/Plugins/HTMLExportPlugin/Info.plist +++ b/Plugins/HTMLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesExportFormatIds html diff --git a/Plugins/JSONExportPlugin/Info.plist b/Plugins/JSONExportPlugin/Info.plist index 3b4efd5302..b29468136d 100644 --- a/Plugins/JSONExportPlugin/Info.plist +++ b/Plugins/JSONExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesExportFormatIds json diff --git a/Plugins/JSONImportPlugin/Info.plist b/Plugins/JSONImportPlugin/Info.plist index e6c08b5801..8caf03ae76 100644 --- a/Plugins/JSONImportPlugin/Info.plist +++ b/Plugins/JSONImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesImportFormatIds json diff --git a/Plugins/KafkaDriverPlugin/Info.plist b/Plugins/KafkaDriverPlugin/Info.plist index a85662f58f..a4d44e3627 100644 --- a/Plugins/KafkaDriverPlugin/Info.plist +++ b/Plugins/KafkaDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesDatabaseTypeIds Kafka diff --git a/Plugins/LibSQLDriverPlugin/Info.plist b/Plugins/LibSQLDriverPlugin/Info.plist index db6fa23e5f..1b8a8d9a3f 100644 --- a/Plugins/LibSQLDriverPlugin/Info.plist +++ b/Plugins/LibSQLDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 30 + 31 diff --git a/Plugins/MQLExportPlugin/Info.plist b/Plugins/MQLExportPlugin/Info.plist index fe6729c476..84cd3846af 100644 --- a/Plugins/MQLExportPlugin/Info.plist +++ b/Plugins/MQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesExportFormatIds mql diff --git a/Plugins/MSSQLDriverPlugin/Info.plist b/Plugins/MSSQLDriverPlugin/Info.plist index e615b82bed..8d60bc2d35 100644 --- a/Plugins/MSSQLDriverPlugin/Info.plist +++ b/Plugins/MSSQLDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 30 + 31 diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 35dba7762f..cb33aa0b7d 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -195,6 +195,7 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsDropSchema = true static let supportsTriggers = true static let supportsRoutines = true + static let supportsUserDefinedTypeBrowse = true static let supportsDatabaseTriggerBrowse = true static let supportsTriggerEditing = true static let supportsCheckConstraints = true diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Types.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Types.swift new file mode 100644 index 0000000000..fcb4ee6988 --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Types.swift @@ -0,0 +1,161 @@ +// +// MSSQLPluginDriver+Types.swift +// MSSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension MSSQLPluginDriver { + /// The listing is one query and carries no definitions: a table type's statement needs two more + /// round trips per type, and a schema with a hundred types would pay for all of them to draw a + /// list of names. `fetchUserDefinedType` fills in the definition for the one the reader opens. + func fetchUserDefinedTypes(schema: String?) async throws -> [PluginUserDefinedTypeInfo] { + let resolvedSchema = effectiveSchema(schema) + let result = try await execute(query: MSSQLTypeQueries.userDefinedTypeList(schema: resolvedSchema)) + return result.rows.compactMap { row in + listedType(from: row, schema: resolvedSchema) + } + } + + /// Addressed by `user_type_id`, so a type renamed since the listing still resolves, and the kind + /// is never part of the lookup. + func fetchUserDefinedType(_ type: PluginUserDefinedTypeInfo) async throws -> PluginUserDefinedTypeInfo { + let resolvedSchema = effectiveSchema(type.schema) + let result = try await execute(query: MSSQLTypeQueries.userDefinedTypeList(schema: resolvedSchema)) + let listed = result.rows.compactMap { listedType(from: $0, schema: resolvedSchema) } + let match = type.identity.flatMap { identity in listed.first { $0.identity == identity } } + ?? listed.first { $0.name == type.name } + guard let match else { throw PluginObjectSourceError.notFound(type.name) } + + switch match.kind { + case .tableType: + return try await tableType(match, schema: resolvedSchema) + case .clrType: + return match.withDefinition(MSSQLTypeDefinition.clrStatement( + schema: resolvedSchema, + name: match.name, + assembly: match.attributes.first { $0.label == "Assembly" }?.value + )) + default: + return match.withDefinition(MSSQLTypeDefinition.aliasStatement( + schema: resolvedSchema, + name: match.name, + baseType: match.baseType, + isNullable: match.attributes.first { $0.label == "Nullable" }?.value != "NO" + )) + } + } + + private func tableType( + _ type: PluginUserDefinedTypeInfo, + schema: String + ) async throws -> PluginUserDefinedTypeInfo { + let columnRows = try await execute( + query: MSSQLTypeQueries.tableTypeColumns(schema: schema, name: type.name) + ).rows + let indexRows = try await execute( + query: MSSQLTypeQueries.tableTypeIndexes(schema: schema, name: type.name) + ).rows + let collation = try? await execute(query: MSSQLTypeQueries.databaseCollation).rows.first?[safe: 0]?.asText + + let columns = columnRows.compactMap { row -> MSSQLTypeDefinition.Column? in + guard let name = row[safe: 0]?.asText else { return nil } + return MSSQLTypeDefinition.Column( + name: name, + type: row[safe: 1]?.asText, + isNullable: row[safe: 2]?.asText == "1", + identitySpec: row[safe: 3]?.asText, + computedDefinition: row[safe: 4]?.asText, + defaultDefinition: row[safe: 5]?.asText, + collation: row[safe: 6]?.asText + ) + } + let indexes = indexRows.map { row in + MSSQLTypeDefinition.Index( + name: row[safe: 0]?.asText, + isPrimaryKey: row[safe: 1]?.asText == "1", + isUnique: row[safe: 2]?.asText == "1", + typeDescription: row[safe: 3]?.asText, + keyColumns: row[safe: 4]?.asText + ) + } + + return PluginUserDefinedTypeInfo( + name: type.name, + kind: .tableType, + schema: schema, + identity: type.identity, + fields: columns.map { + PluginUserDefinedTypeField(name: $0.name, type: $0.type ?? "", collation: $0.collation) + }, + columnTypeSpelling: type.columnTypeSpelling, + definition: MSSQLTypeDefinition.tableStatement( + schema: schema, + name: type.name, + columns: columns, + indexes: indexes, + databaseCollation: collation ?? nil + ), + attributes: type.attributes + ) + } + + private func listedType(from row: [PluginCellValue], schema: String) -> PluginUserDefinedTypeInfo? { + guard let name = row[safe: 0]?.asText else { return nil } + let kindCode = row[safe: 3]?.asText ?? "" + let kind: PluginUserDefinedTypeKind = switch MSSQLTypeQueries.Kind(rawValue: kindCode) { + case .table: .tableType + case .clr: .clrType + default: .aliasType + } + + var attributes: [PluginObjectAttribute] = [] + if kind == .aliasType { + attributes.append(PluginObjectAttribute( + label: "Nullable", + value: row[safe: 5]?.asText == "1" ? "YES" : "NO" + )) + } + if let collation = row[safe: 6]?.asText, !collation.isEmpty { + attributes.append(PluginObjectAttribute(label: "Collation", value: collation)) + } + if let assembly = row[safe: 7]?.asText, !assembly.isEmpty { + attributes.append(PluginObjectAttribute(label: "Assembly", value: assembly)) + } + + return PluginUserDefinedTypeInfo( + name: name, + kind: kind, + schema: row[safe: 1]?.asText ?? schema, + identity: row[safe: 2]?.asText, + baseType: row[safe: 4]?.asText, + columnTypeSpelling: columnTypeSpelling(kind: kind, schema: row[safe: 1]?.asText ?? schema, name: name), + attributes: attributes + ) + } + + /// A table type is never a column type, so it gets no spelling at all rather than one the + /// column picker would offer and the server would reject. + private func columnTypeSpelling(kind: PluginUserDefinedTypeKind, schema: String, name: String) -> String? { + guard kind != .tableType else { return nil } + return "\(MSSQLTypeDefinition.bracketed(schema)).\(MSSQLTypeDefinition.bracketed(name))" + } +} + +private extension PluginUserDefinedTypeInfo { + func withDefinition(_ definition: String) -> PluginUserDefinedTypeInfo { + PluginUserDefinedTypeInfo( + name: name, + kind: kind, + schema: schema, + identity: identity, + enumLabels: enumLabels, + fields: fields, + baseType: baseType, + columnTypeSpelling: columnTypeSpelling, + definition: definition, + attributes: attributes + ) + } +} diff --git a/Plugins/MSSQLDriverPlugin/MSSQLTypeDefinition.swift b/Plugins/MSSQLDriverPlugin/MSSQLTypeDefinition.swift new file mode 100644 index 0000000000..d20388abc1 --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/MSSQLTypeDefinition.swift @@ -0,0 +1,140 @@ +// +// MSSQLTypeDefinition.swift +// MSSQLDriverPlugin +// +// Rebuilds a CREATE TYPE statement from the catalog. Pure, so it is testable without a server. +// + +import Foundation +import TableProPluginKit + +/// A type has no `sys.sql_modules` row on any engine path, so unlike a procedure there is no stored +/// text to read back. Every statement here is synthesized from the catalog, and both shapes were +/// executed against SQL Server 2022 to prove they parse. +public enum MSSQLTypeDefinition { + public struct Column: Sendable, Equatable { + public let name: String + public let type: String? + public let isNullable: Bool + public let identitySpec: String? + public let computedDefinition: String? + public let defaultDefinition: String? + public let collation: String? + + public init( + name: String, + type: String?, + isNullable: Bool, + identitySpec: String? = nil, + computedDefinition: String? = nil, + defaultDefinition: String? = nil, + collation: String? = nil + ) { + self.name = name + self.type = type + self.isNullable = isNullable + self.identitySpec = identitySpec + self.computedDefinition = computedDefinition + self.defaultDefinition = defaultDefinition + self.collation = collation + } + } + + public struct Index: Sendable, Equatable { + public let name: String? + public let isPrimaryKey: Bool + public let isUnique: Bool + public let typeDescription: String? + public let keyColumns: String? + + public init( + name: String?, + isPrimaryKey: Bool, + isUnique: Bool, + typeDescription: String? = nil, + keyColumns: String? = nil + ) { + self.name = name + self.isPrimaryKey = isPrimaryKey + self.isUnique = isUnique + self.typeDescription = typeDescription + self.keyColumns = keyColumns + } + } + + public static func bracketed(_ identifier: String) -> String { + "[\(identifier.replacingOccurrences(of: "]", with: "]]"))]" + } + + /// `CREATE TYPE x FROM base NULL|NOT NULL`. Nullability is always spelled out, because the + /// default differs with `ANSI_NULL_DFLT_ON` and a reader cannot tell which applied. + public static func aliasStatement(schema: String, name: String, baseType: String?, isNullable: Bool) -> String { + let base = baseType.map { " FROM \($0)" } ?? "" + return "CREATE TYPE \(bracketed(schema)).\(bracketed(name))\(base) \(isNullable ? "NULL" : "NOT NULL");" + } + + /// A CLR type's body is compiled into a .NET assembly, so this names the assembly rather than + /// pretending to a definition. The class name is not in `sys.assembly_types` under a name this + /// driver reads, so the statement is left with the type's own name, which is what SQL Server + /// requires them to match in practice. + public static func clrStatement(schema: String, name: String, assembly: String?) -> String { + let external = assembly.map { "\(bracketed($0)).[\(name)]" } ?? ".[\(name)]" + return "CREATE TYPE \(bracketed(schema)).\(bracketed(name)) EXTERNAL NAME \(external);" + } + + /// `CREATE TYPE x AS TABLE (...)`. A primary key goes inline on its column when it covers one + /// column, because that is how it reads back and because the constraint's own name is + /// server-generated (`PK__TT_IdLis__3214EC07...`) and carrying it forward is noise. + public static func tableStatement( + schema: String, + name: String, + columns: [Column], + indexes: [Index], + databaseCollation: String? + ) -> String { + let singleColumnPrimaryKey = indexes.first { $0.isPrimaryKey && !($0.keyColumns?.contains(",") ?? true) }?.keyColumns + var lines = columns.map { column in + columnClause(column, primaryKeyColumn: singleColumnPrimaryKey, databaseCollation: databaseCollation) + } + lines.append(contentsOf: indexClauses(indexes, inlinedPrimaryKey: singleColumnPrimaryKey)) + let body = lines.map { " \($0)" }.joined(separator: ",\n") + return "CREATE TYPE \(bracketed(schema)).\(bracketed(name)) AS TABLE (\n\(body)\n);" + } + + private static func columnClause(_ column: Column, primaryKeyColumn: String?, databaseCollation: String?) -> String { + var parts = [bracketed(column.name)] + if let computed = column.computedDefinition, !computed.isEmpty { + parts.append("AS \(computed)") + return parts.joined(separator: " ") + } + if let type = column.type, !type.isEmpty { parts.append(type) } + if let collation = column.collation, !collation.isEmpty, collation != databaseCollation { + parts.append("COLLATE \(collation)") + } + if let identity = column.identitySpec, !identity.isEmpty { parts.append("IDENTITY(\(identity))") } + parts.append(column.isNullable ? "NULL" : "NOT NULL") + if let value = column.defaultDefinition, !value.isEmpty { parts.append("DEFAULT \(value)") } + if let key = primaryKeyColumn, key == column.name { parts.append("PRIMARY KEY") } + return parts.joined(separator: " ") + } + + private static func indexClauses(_ indexes: [Index], inlinedPrimaryKey: String?) -> [String] { + indexes.compactMap { index -> String? in + guard let keyColumns = index.keyColumns, !keyColumns.isEmpty else { return nil } + let columnList = keyColumns + .split(separator: ",") + .map { bracketed($0.trimmingCharacters(in: .whitespaces)) } + .joined(separator: ", ") + if index.isPrimaryKey { + guard inlinedPrimaryKey != keyColumns else { return nil } + return "PRIMARY KEY (\(columnList))" + } + guard let name = index.name, !name.isEmpty else { + return index.isUnique ? "UNIQUE (\(columnList))" : nil + } + let clustering = index.typeDescription.map { " \($0.replacingOccurrences(of: "_", with: " "))" } ?? "" + let unique = index.isUnique ? "UNIQUE " : "" + return "\(unique)INDEX \(bracketed(name))\(clustering) (\(columnList))" + } + } +} diff --git a/Plugins/MSSQLDriverPlugin/MSSQLTypeQueries.swift b/Plugins/MSSQLDriverPlugin/MSSQLTypeQueries.swift new file mode 100644 index 0000000000..483ef64e9c --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/MSSQLTypeQueries.swift @@ -0,0 +1,136 @@ +// +// MSSQLTypeQueries.swift +// MSSQLDriverPlugin +// +// Catalog SQL for user-defined types. Pure, so it is testable without a server. +// + +import Foundation +import TableProMSSQLCore + +public enum MSSQLTypeQueries { + /// SQL Server has three shapes of user-defined type and `sys.types` tells them apart with two + /// flags rather than a kind column. + public enum Kind: String { + case alias = "ALIAS" + case table = "TABLE" + case clr = "CLR" + } + + /// Rebuilds the base type the way the user wrote it. `max_length` is in BYTES, so an + /// `nvarchar(320)` reports 640 and has to be halved, and -1 means MAX. Measured against a + /// server holding `nvarchar(320)`, `nvarchar(max)`, `varchar(32)` and `decimal(18,4)`: every + /// spelling came back matching the original `CREATE TYPE`. + private static let baseTypeSpelling = """ + CASE WHEN t.is_table_type = 1 OR t.is_assembly_type = 1 THEN NULL + WHEN bt.name IN ('nvarchar', 'nchar') + THEN bt.name + '(' + CASE WHEN t.max_length = -1 THEN 'max' + ELSE CONVERT(varchar(11), t.max_length / 2) END + ')' + WHEN bt.name IN ('varchar', 'char', 'varbinary', 'binary') + THEN bt.name + '(' + CASE WHEN t.max_length = -1 THEN 'max' + ELSE CONVERT(varchar(11), t.max_length) END + ')' + WHEN bt.name IN ('decimal', 'numeric') + THEN bt.name + '(' + CONVERT(varchar(11), t.precision) + ',' + CONVERT(varchar(11), t.scale) + ')' + WHEN bt.name IN ('datetime2', 'time', 'datetimeoffset') + THEN bt.name + '(' + CONVERT(varchar(11), t.scale) + ')' + ELSE bt.name END + """ + + /// The identity is `user_type_id`, which is stable for the life of the type and survives a + /// rename, so a re-fetch never keys on the name or the kind. + public static func userDefinedTypeList(schema: String) -> String { + """ + SELECT + t.name, + s.name AS schema_name, + CONVERT(varchar(11), t.user_type_id) AS identity_id, + CASE WHEN t.is_table_type = 1 THEN 'TABLE' + WHEN t.is_assembly_type = 1 THEN 'CLR' + ELSE 'ALIAS' END AS kind, + \(baseTypeSpelling) AS base_type, + CONVERT(varchar(1), t.is_nullable) AS is_nullable, + t.collation_name, + a.name AS assembly_name + FROM sys.types t + JOIN sys.schemas s ON s.schema_id = t.schema_id + LEFT JOIN sys.types bt ON bt.user_type_id = t.system_type_id AND bt.is_user_defined = 0 + LEFT JOIN sys.assembly_types at ON at.user_type_id = t.user_type_id + LEFT JOIN sys.assemblies a ON a.assembly_id = at.assembly_id + WHERE t.is_user_defined = 1 AND s.name = \(MSSQLStringLiteral.quoted(schema)) + ORDER BY t.name + """ + } + + /// A table type's columns, in declaration order. `sys.table_types.type_table_object_id` is the + /// hidden table behind the type, which is what `sys.columns` is keyed on. + public static func tableTypeColumns(schema: String, name: String) -> String { + """ + SELECT + c.name, + CASE WHEN c.is_computed = 1 THEN NULL + WHEN bt.name IN ('nvarchar', 'nchar') + THEN bt.name + '(' + CASE WHEN c.max_length = -1 THEN 'max' + ELSE CONVERT(varchar(11), c.max_length / 2) END + ')' + WHEN bt.name IN ('varchar', 'char', 'varbinary', 'binary') + THEN bt.name + '(' + CASE WHEN c.max_length = -1 THEN 'max' + ELSE CONVERT(varchar(11), c.max_length) END + ')' + WHEN bt.name IN ('decimal', 'numeric') + THEN bt.name + '(' + CONVERT(varchar(11), c.precision) + ',' + CONVERT(varchar(11), c.scale) + ')' + WHEN bt.name IN ('datetime2', 'time', 'datetimeoffset') + THEN bt.name + '(' + CONVERT(varchar(11), c.scale) + ')' + ELSE bt.name END AS column_type, + CONVERT(varchar(1), c.is_nullable) AS is_nullable, + CASE WHEN c.is_identity = 1 + THEN CONVERT(varchar(40), ic.seed_value) + ',' + CONVERT(varchar(40), ic.increment_value) END AS identity_spec, + cc.definition AS computed_definition, + dc.definition AS default_definition, + c.collation_name + FROM sys.table_types tt + JOIN sys.schemas s ON s.schema_id = tt.schema_id + JOIN sys.columns c ON c.object_id = tt.type_table_object_id + LEFT JOIN sys.types bt ON bt.user_type_id = c.user_type_id + LEFT JOIN sys.identity_columns ic ON ic.object_id = c.object_id AND ic.column_id = c.column_id + LEFT JOIN sys.computed_columns cc ON cc.object_id = c.object_id AND cc.column_id = c.column_id + LEFT JOIN sys.default_constraints dc + ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id + WHERE tt.is_user_defined = 1 + AND s.name = \(MSSQLStringLiteral.quoted(schema)) + AND tt.name = \(MSSQLStringLiteral.quoted(name)) + ORDER BY c.column_id + """ + } + + /// A table type's primary key and its inline indexes. The key columns are aggregated through + /// `FOR XML PATH`, so this needs the session `MSSQLSessionOptions` establishes, exactly as the + /// routine list does. + public static func tableTypeIndexes(schema: String, name: String) -> String { + """ + SELECT + i.name, + CONVERT(varchar(1), i.is_primary_key) AS is_primary_key, + CONVERT(varchar(1), i.is_unique) AS is_unique, + i.type_desc, + STUFF(( + SELECT ', ' + c.name + FROM sys.index_columns ic + JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id + WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 + ORDER BY ic.key_ordinal + FOR XML PATH(''), TYPE + ).value('.', 'nvarchar(max)'), 1, 2, '') AS key_columns + FROM sys.table_types tt + JOIN sys.schemas s ON s.schema_id = tt.schema_id + JOIN sys.indexes i ON i.object_id = tt.type_table_object_id + WHERE tt.is_user_defined = 1 + AND i.type > 0 + AND s.name = \(MSSQLStringLiteral.quoted(schema)) + AND tt.name = \(MSSQLStringLiteral.quoted(name)) + ORDER BY i.index_id + """ + } + + /// The database's own collation. A column keeps its collation in `sys.columns` whether or not + /// it differs from the database's, so without this every column would carry a `COLLATE` clause + /// the user never wrote. + public static let databaseCollation = "SELECT CONVERT(varchar(128), DATABASEPROPERTYEX(DB_NAME(), 'Collation'))" +} diff --git a/Plugins/MarkdownExportPlugin/Info.plist b/Plugins/MarkdownExportPlugin/Info.plist index 38272c1634..7fe409f85b 100644 --- a/Plugins/MarkdownExportPlugin/Info.plist +++ b/Plugins/MarkdownExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesExportFormatIds md diff --git a/Plugins/MongoDBDriverPlugin/Info.plist b/Plugins/MongoDBDriverPlugin/Info.plist index e615b82bed..8d60bc2d35 100644 --- a/Plugins/MongoDBDriverPlugin/Info.plist +++ b/Plugins/MongoDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 30 + 31 diff --git a/Plugins/MySQLDriverPlugin/Info.plist b/Plugins/MySQLDriverPlugin/Info.plist index a7fc882a7d..168ad3907b 100644 --- a/Plugins/MySQLDriverPlugin/Info.plist +++ b/Plugins/MySQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesDatabaseTypeIds MySQL diff --git a/Plugins/OracleDriverPlugin/Info.plist b/Plugins/OracleDriverPlugin/Info.plist index e615b82bed..8d60bc2d35 100644 --- a/Plugins/OracleDriverPlugin/Info.plist +++ b/Plugins/OracleDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 30 + 31 diff --git a/Plugins/ParquetExportPlugin/Info.plist b/Plugins/ParquetExportPlugin/Info.plist index 093776b2b2..59e7e32f4b 100644 --- a/Plugins/ParquetExportPlugin/Info.plist +++ b/Plugins/ParquetExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesExportFormatIds parquet diff --git a/Plugins/PostgreSQLDriverPlugin/Info.plist b/Plugins/PostgreSQLDriverPlugin/Info.plist index 39d44a69e1..c42d1de32a 100644 --- a/Plugins/PostgreSQLDriverPlugin/Info.plist +++ b/Plugins/PostgreSQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesDatabaseTypeIds PostgreSQL diff --git a/Plugins/RedisDriverPlugin/Info.plist b/Plugins/RedisDriverPlugin/Info.plist index c5bde7a8d9..f3dc15b9a7 100644 --- a/Plugins/RedisDriverPlugin/Info.plist +++ b/Plugins/RedisDriverPlugin/Info.plist @@ -21,7 +21,7 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).RedisPlugin TableProPluginKitVersion - 30 + 31 TableProProvidesDatabaseTypeIds Redis diff --git a/Plugins/SQLExportPlugin/Info.plist b/Plugins/SQLExportPlugin/Info.plist index 756ca7a898..4a07d41afa 100644 --- a/Plugins/SQLExportPlugin/Info.plist +++ b/Plugins/SQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesExportFormatIds sql diff --git a/Plugins/SQLImportPlugin/Info.plist b/Plugins/SQLImportPlugin/Info.plist index a652d06269..b244acd4af 100644 --- a/Plugins/SQLImportPlugin/Info.plist +++ b/Plugins/SQLImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesImportFormatIds sql diff --git a/Plugins/SQLiteDriverPlugin/Info.plist b/Plugins/SQLiteDriverPlugin/Info.plist index 6d0a4f307d..efa0219a65 100644 --- a/Plugins/SQLiteDriverPlugin/Info.plist +++ b/Plugins/SQLiteDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesDatabaseTypeIds SQLite diff --git a/Plugins/SnowflakeDriverPlugin/Info.plist b/Plugins/SnowflakeDriverPlugin/Info.plist index b4740d825c..a09b72b23e 100644 --- a/Plugins/SnowflakeDriverPlugin/Info.plist +++ b/Plugins/SnowflakeDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.48.0 TableProPluginKitVersion - 30 + 31 diff --git a/Plugins/SpannerDriverPlugin/Info.plist b/Plugins/SpannerDriverPlugin/Info.plist index ecc245df11..eed4f67e06 100644 --- a/Plugins/SpannerDriverPlugin/Info.plist +++ b/Plugins/SpannerDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 30 + 31 TableProProvidesDatabaseTypeIds Spanner diff --git a/Plugins/SurrealDBDriverPlugin/Info.plist b/Plugins/SurrealDBDriverPlugin/Info.plist index 45d589ad0d..7806e7adec 100644 --- a/Plugins/SurrealDBDriverPlugin/Info.plist +++ b/Plugins/SurrealDBDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesDatabaseTypeIds SurrealDB diff --git a/Plugins/TableProPluginKit/PluginUserDefinedTypeInfo.swift b/Plugins/TableProPluginKit/PluginUserDefinedTypeInfo.swift index cbcef22985..6d80b56013 100644 --- a/Plugins/TableProPluginKit/PluginUserDefinedTypeInfo.swift +++ b/Plugins/TableProPluginKit/PluginUserDefinedTypeInfo.swift @@ -2,17 +2,35 @@ // PluginUserDefinedTypeInfo.swift // TableProPluginKit // -// Transfer type describing a named type the user created: an enum, a composite, a domain or a -// range. Engines without named types never produce one. +// Transfer type describing a named type the user created. Engines without named types never +// produce one. // import Foundation +/// Deliberately not `@frozen`, so it can take a case for each engine's own shape as drivers arrive. +/// Every app-side switch over it carries `@unknown default`, which is what makes a new case additive +/// rather than breaking, the same growth path `PluginCapability` uses. +/// +/// A kind names the shape, not the engine's word for it. SQL Server's alias type is a named type +/// over a base type, which is what `domain` already describes, but a reader on SQL Server has never +/// heard the word domain and calling it one would be wrong on screen rather than merely imprecise. public enum PluginUserDefinedTypeKind: String, Codable, Sendable { case enumeration = "enum" case composite case domain case range + + /// SQL Server `CREATE TYPE x FROM base`: a base type, a length and a nullability, nothing else. + case aliasType + + /// SQL Server `CREATE TYPE x AS TABLE (...)`: columns rather than fields, and only usable as a + /// table-valued parameter, never as a column type. + case tableType + + /// SQL Server `CREATE TYPE x EXTERNAL NAME assembly.class`. Its definition lives in a .NET + /// assembly, so no catalog read can produce its source. + case clrType } public struct PluginUserDefinedTypeField: Codable, Sendable, Hashable { @@ -92,4 +110,23 @@ public struct PluginUserDefinedTypeInfo: Codable, Sendable { self.definition = definition self.attributes = attributes } + + /// Fills in the schema the read was scoped to when the driver did not name one, so a type's + /// qualified name is never bare. A driver that did name one keeps it: a type moved to another + /// schema still reports where it actually lives. + public func adoptingSchema(_ fallback: String?) -> PluginUserDefinedTypeInfo { + guard schema?.isEmpty ?? true, let fallback, !fallback.isEmpty else { return self } + return PluginUserDefinedTypeInfo( + name: name, + kind: kind, + schema: fallback, + identity: identity, + enumLabels: enumLabels, + fields: fields, + baseType: baseType, + columnTypeSpelling: columnTypeSpelling, + definition: definition, + attributes: attributes + ) + } } diff --git a/Plugins/TeradataDriverPlugin/Info.plist b/Plugins/TeradataDriverPlugin/Info.plist index e615b82bed..8d60bc2d35 100644 --- a/Plugins/TeradataDriverPlugin/Info.plist +++ b/Plugins/TeradataDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 30 + 31 diff --git a/Plugins/TrinoDriverPlugin/Info.plist b/Plugins/TrinoDriverPlugin/Info.plist index e615b82bed..8d60bc2d35 100644 --- a/Plugins/TrinoDriverPlugin/Info.plist +++ b/Plugins/TrinoDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 30 + 31 diff --git a/Plugins/TypesenseDriverPlugin/Info.plist b/Plugins/TypesenseDriverPlugin/Info.plist index e6b2e321c8..fe7c4e896c 100644 --- a/Plugins/TypesenseDriverPlugin/Info.plist +++ b/Plugins/TypesenseDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.73.0 TableProPluginKitVersion - 30 + 31 TableProProvidesDatabaseTypeIds Typesense diff --git a/Plugins/WeaviateDriverPlugin/Info.plist b/Plugins/WeaviateDriverPlugin/Info.plist index 24905f8d1f..105fd79960 100644 --- a/Plugins/WeaviateDriverPlugin/Info.plist +++ b/Plugins/WeaviateDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.73.0 TableProPluginKitVersion - 30 + 31 TableProProvidesDatabaseTypeIds Weaviate diff --git a/Plugins/XLSXExportPlugin/Info.plist b/Plugins/XLSXExportPlugin/Info.plist index dc35aeca50..d9cd42e25a 100644 --- a/Plugins/XLSXExportPlugin/Info.plist +++ b/Plugins/XLSXExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesExportFormatIds xlsx diff --git a/Plugins/XLSXImportPlugin/Info.plist b/Plugins/XLSXImportPlugin/Info.plist index f7a5830fdd..1fc45c87b8 100644 --- a/Plugins/XLSXImportPlugin/Info.plist +++ b/Plugins/XLSXImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesImportFormatIds xlsx diff --git a/Plugins/XMLExportPlugin/Info.plist b/Plugins/XMLExportPlugin/Info.plist index e6a31abc73..eb8cae7b5d 100644 --- a/Plugins/XMLExportPlugin/Info.plist +++ b/Plugins/XMLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 30 + 31 TableProProvidesExportFormatIds xml diff --git a/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift b/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift index 3c0a31ec92..061f9d172c 100644 --- a/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift +++ b/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift @@ -315,7 +315,7 @@ public struct ListUserDefinedTypesTool: MCPToolImplementation { public static let name = "list_types" public static let title: String? = String(localized: "List Types") public static let description = String( - localized: "List the user-defined types in a schema: enums, composites, domains and ranges." + localized: "List the user-defined types in a schema, whichever shapes the engine has." ) public static let requiredScopes: Set = [.toolsRead] public static let annotations = MCPToolAnnotations( @@ -348,7 +348,7 @@ public struct ListUserDefinedTypesTool: MCPToolImplementation { of: MCPToolSchema.object( properties: [ "name": MCPToolSchema.string(String(localized: "Type name")), - "kind": MCPToolSchema.string(String(localized: "enum, composite, domain or range")), + "kind": MCPToolSchema.string(String(localized: "The type's shape, such as enum, composite, domain, range, aliasType, tableType or clrType")), "schema": MCPToolSchema.string(String(localized: "Schema the type lives in")), "qualified_name": MCPToolSchema.string(String(localized: "Schema-qualified name")), "labels": MCPToolSchema.array( diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 2d373e44b3..9eec7ae348 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -519,11 +519,14 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor try await pluginDriver.fetchRoutineDDL(routine.pluginRoutine) } + /// The resolved schema is stamped on any type that came back without one, the same backfill + /// `fetchRoutines` does above. A driver that leaves it nil produces types whose qualified name + /// is bare, which the sidebar then files under no schema at all. func fetchUserDefinedTypes(schema: String?) async throws -> [UserDefinedTypeInfo] { let resolvedSchema = schema ?? pluginDriver.currentSchema do { return try await pluginDriver.fetchUserDefinedTypes(schema: resolvedSchema) - .map(UserDefinedTypeInfo.init) + .map { UserDefinedTypeInfo($0.adoptingSchema(resolvedSchema)) } .sorted { $0.name < $1.name } } catch { Self.logger.warning("fetchUserDefinedTypes failed: \(error.localizedDescription, privacy: .public)") diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index ce71f96c48..f8ed57a5f2 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -61,7 +61,12 @@ final class PluginManager { /// rebuilt CassandraDriver for the v20 requirements it implements none of. Left at 20, such a /// plugin passes `validateBundleVersions` in a shipped v20 app and then fails /// `Bundle.loadAndReturnError`; at 21 that app refuses it and says to update. - nonisolated static let currentPluginKitVersion = 30 + /// + /// 31 adds `aliasType`, `tableType` and `clrType` to `PluginUserDefinedTypeKind` and + /// `adoptingSchema` to `PluginUserDefinedTypeInfo`. The enum is not `@frozen` and every app-side + /// switch over it already carries `@unknown default`, so an already-built plugin keeps loading; + /// the minimum stays where it is and no bulk re-release is needed. + nonisolated static let currentPluginKitVersion = 31 /// Still 19, so every plugin already published for the previous release keeps loading. nonisolated static let minimumCompatiblePluginKitVersion = 19 diff --git a/TablePro/Core/Plugins/PluginObjectMapping.swift b/TablePro/Core/Plugins/PluginObjectMapping.swift index 3a1675ebd4..b33744ba8f 100644 --- a/TablePro/Core/Plugins/PluginObjectMapping.swift +++ b/TablePro/Core/Plugins/PluginObjectMapping.swift @@ -111,6 +111,9 @@ extension UserDefinedTypeInfo.Kind { case .composite: self = .composite case .domain: self = .domain case .range: self = .range + case .aliasType: self = .aliasType + case .tableType: self = .tableType + case .clrType: self = .clrType @unknown default: self = .other } } @@ -121,6 +124,9 @@ extension UserDefinedTypeInfo.Kind { case .composite: return .composite case .domain: return .domain case .range: return .range + case .aliasType: return .aliasType + case .tableType: return .tableType + case .clrType: return .clrType case .other: return nil } } diff --git a/TablePro/Core/Services/Query/UserDefinedTypeSuggestions.swift b/TablePro/Core/Services/Query/UserDefinedTypeSuggestions.swift index 5f72970454..86faa20593 100644 --- a/TablePro/Core/Services/Query/UserDefinedTypeSuggestions.swift +++ b/TablePro/Core/Services/Query/UserDefinedTypeSuggestions.swift @@ -20,8 +20,12 @@ enum UserDefinedTypeSuggestions { /// table's schema holds a domain called `text`. The engine's own spelling is used where the /// driver supplied one, because only the engine knows which names it folds or reserves; the /// fallback quotes each part that is not a plain lower-case identifier. + /// + /// A kind that cannot stand as a column type is left out rather than offered and rejected by + /// the server: a SQL Server table type is a table-valued parameter and nothing else. static func entries(types: [UserDefinedTypeInfo], tableSchema: String?) -> [String] { types + .filter(\.kind.isUsableAsColumnType) .map { type -> String in if let spelling = type.columnTypeSpelling, !spelling.isEmpty { return spelling } guard let schema = type.schema, !schema.isEmpty else { return identifier(type.name) } diff --git a/TablePro/Models/Query/UserDefinedTypeInfo.swift b/TablePro/Models/Query/UserDefinedTypeInfo.swift index ee9fc66044..5f09855bcb 100644 --- a/TablePro/Models/Query/UserDefinedTypeInfo.swift +++ b/TablePro/Models/Query/UserDefinedTypeInfo.swift @@ -17,14 +17,23 @@ struct UserDefinedTypeInfo: Identifiable, Hashable, Sendable { case composite case domain case range + case aliasType + case tableType + case clrType case other + /// The engine's own word for the shape. A SQL Server reader has never met a domain or a + /// composite, so an alias type and a table type say so in SQL Server's vocabulary even + /// though their shapes are the same two. var displayName: String { switch self { case .enumeration: return String(localized: "Enum Type") case .composite: return String(localized: "Composite Type") case .domain: return String(localized: "Domain") case .range: return String(localized: "Range Type") + case .aliasType: return String(localized: "Alias Type") + case .tableType: return String(localized: "Table Type") + case .clrType: return String(localized: "CLR Type") case .other: return String(localized: "Type") } } @@ -35,9 +44,18 @@ struct UserDefinedTypeInfo: Identifiable, Hashable, Sendable { case .composite: return "rectangle.split.3x1" case .domain: return "checkmark.seal" case .range: return "arrow.left.and.right.square" + case .aliasType: return "character.textbox" + case .tableType: return "tablecells" + case .clrType: return "shippingbox" case .other: return SidebarObjectKind.type.iconName } } + + /// A table type is only ever a table-valued parameter, so it must never reach the column + /// type picker; SQL Server rejects a column declared as one. + var isUsableAsColumnType: Bool { + self != .tableType + } } struct Field: Hashable, Sendable { diff --git a/TablePro/Views/Sidebar/UserTypeRowView.swift b/TablePro/Views/Sidebar/UserTypeRowView.swift index 0fef2255d5..3d455b28fe 100644 --- a/TablePro/Views/Sidebar/UserTypeRowView.swift +++ b/TablePro/Views/Sidebar/UserTypeRowView.swift @@ -17,9 +17,11 @@ enum UserTypeRowLogic { switch type.kind { case .enumeration where !type.enumLabels.isEmpty: lines.append(type.enumLabels.joined(separator: ", ")) - case .composite where !type.fields.isEmpty: - lines.append(type.fields.map { "\($0.name) \($0.type)" }.joined(separator: ", ")) - case .domain, .range: + case .composite, .tableType: + if !type.fields.isEmpty { + lines.append(type.fields.map { "\($0.name) \($0.type)" }.joined(separator: ", ")) + } + case .domain, .range, .aliasType: if let baseType = type.baseType, !baseType.isEmpty { lines.append(baseType) } default: break diff --git a/TableProTests/Plugins/MSSQLTypeQueryTests.swift b/TableProTests/Plugins/MSSQLTypeQueryTests.swift new file mode 100644 index 0000000000..cbae9b6d89 --- /dev/null +++ b/TableProTests/Plugins/MSSQLTypeQueryTests.swift @@ -0,0 +1,169 @@ +// +// MSSQLTypeQueryTests.swift +// TableProTests +// +// The catalog SQL and the CREATE TYPE synthesis for SQL Server user-defined types. +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("MSSQL Type Catalog Queries") +struct MSSQLTypeQueryTests { + @Test("Only user-defined types are listed, and the three kinds are separated") + func listsOnlyUserDefinedTypes() { + let sql = MSSQLTypeQueries.userDefinedTypeList(schema: "dbo") + #expect(sql.contains("t.is_user_defined = 1")) + #expect(sql.contains("WHEN t.is_table_type = 1 THEN 'TABLE'")) + #expect(sql.contains("WHEN t.is_assembly_type = 1 THEN 'CLR'")) + #expect(sql.contains("ELSE 'ALIAS'")) + } + + /// `user_type_id` survives a rename and never changes, unlike the name the listing showed. + @Test("A type is addressed by user_type_id, not by name or kind") + func identityIsUserTypeId() { + #expect(MSSQLTypeQueries.userDefinedTypeList(schema: "dbo") + .contains("CONVERT(varchar(11), t.user_type_id) AS identity_id")) + } + + /// max_length is in bytes, so an n-type is halved, and -1 is MAX. Measured against SQL Server + /// 2022: nvarchar(320) reports 640 and nvarchar(max) reports -1. + @Test("The base type spelling handles the byte-length and MAX traps") + func baseTypeSpellingHandlesLengths() { + let sql = MSSQLTypeQueries.userDefinedTypeList(schema: "dbo") + #expect(sql.contains("bt.name IN ('nvarchar', 'nchar')")) + #expect(sql.contains("CONVERT(varchar(11), t.max_length / 2)")) + #expect(sql.contains("WHEN t.max_length = -1 THEN 'max'")) + #expect(sql.contains("bt.name IN ('decimal', 'numeric')")) + } + + @Test("A quote in a schema or type name is escaped as a national literal") + func literalsAreEscaped() { + #expect(MSSQLTypeQueries.userDefinedTypeList(schema: "it's").contains("N'it''s'")) + #expect(MSSQLTypeQueries.tableTypeColumns(schema: "販売", name: "o'brien") + .contains("N'o''brien'")) + #expect(MSSQLTypeQueries.tableTypeIndexes(schema: "dbo", name: "it's").contains("N'it''s'")) + } + + /// The hidden table behind a table type is what sys.columns is keyed on; sys.table_types alone + /// has no columns. + @Test("Table type columns come through type_table_object_id in declaration order") + func tableTypeColumnsReadTheHiddenTable() { + let sql = MSSQLTypeQueries.tableTypeColumns(schema: "dbo", name: "t") + #expect(sql.contains("c.object_id = tt.type_table_object_id")) + #expect(sql.contains("ORDER BY c.column_id")) + #expect(sql.contains("sys.identity_columns")) + #expect(sql.contains("sys.computed_columns")) + #expect(sql.contains("sys.default_constraints")) + } + + /// An INCLUDE column is not part of the key, and listing it as one is the same defect the + /// table index reader carries. + @Test("Table type index keys exclude INCLUDE columns and sort by key ordinal") + func indexKeysExcludeIncludedColumns() { + let sql = MSSQLTypeQueries.tableTypeIndexes(schema: "dbo", name: "t") + #expect(sql.contains("ic.is_included_column = 0")) + #expect(sql.contains("ORDER BY ic.key_ordinal")) + #expect(sql.contains("i.type > 0")) + } +} + +@Suite("MSSQL Type Definition Synthesis") +struct MSSQLTypeDefinitionTests { + /// Executed verbatim against SQL Server 2022 and accepted. + @Test("An alias type rebuilds its CREATE TYPE ... FROM statement") + func aliasStatement() { + #expect(MSSQLTypeDefinition.aliasStatement( + schema: "dbo", name: "EmailAddress", baseType: "nvarchar(320)", isNullable: false + ) == "CREATE TYPE [dbo].[EmailAddress] FROM nvarchar(320) NOT NULL;") + #expect(MSSQLTypeDefinition.aliasStatement( + schema: "sales", name: "BigText", baseType: "nvarchar(max)", isNullable: true + ) == "CREATE TYPE [sales].[BigText] FROM nvarchar(max) NULL;") + } + + @Test("A bracket in a name is doubled, not left to close the identifier early") + func bracketsAreEscaped() { + #expect(MSSQLTypeDefinition.bracketed("a]b") == "[a]]b]") + #expect(MSSQLTypeDefinition.aliasStatement( + schema: "dbo", name: "a]b", baseType: "int", isNullable: true + ).contains("[a]]b]")) + } + + /// The whole shape, measured: this statement was executed against SQL Server 2022 and created + /// a type matching the fixture it was rebuilt from. + @Test("A table type rebuilds identity, default, collation, computed column and inline index") + func tableStatement() { + let sql = MSSQLTypeDefinition.tableStatement( + schema: "dbo", + name: "OrderLineTable", + columns: [ + .init(name: "LineId", type: "int", isNullable: false, identitySpec: "1,1"), + .init(name: "Sku", type: "varchar(32)", isNullable: false, collation: "SQL_Latin1_General_CP1_CI_AS"), + .init(name: "Qty", type: "int", isNullable: false, defaultDefinition: "((1))"), + .init(name: "Price", type: "decimal(18,4)", isNullable: true), + .init(name: "Note", type: "nvarchar(200)", isNullable: true, collation: "Latin1_General_BIN2"), + .init(name: "Total", type: nil, isNullable: true, computedDefinition: "([Qty]*[Price])") + ], + indexes: [ + .init(name: "PK__TT_Order__2EAE", isPrimaryKey: true, isUnique: true, + typeDescription: "CLUSTERED", keyColumns: "LineId"), + .init(name: "ix_sku", isPrimaryKey: false, isUnique: false, + typeDescription: "NONCLUSTERED", keyColumns: "Sku") + ], + databaseCollation: "SQL_Latin1_General_CP1_CI_AS" + ) + #expect(sql.hasPrefix("CREATE TYPE [dbo].[OrderLineTable] AS TABLE (")) + #expect(sql.contains("[LineId] int IDENTITY(1,1) NOT NULL PRIMARY KEY")) + #expect(sql.contains("[Qty] int NOT NULL DEFAULT ((1))")) + #expect(sql.contains("[Total] AS ([Qty]*[Price])")) + #expect(sql.contains("INDEX [ix_sku] NONCLUSTERED ([Sku])")) + #expect(sql.hasSuffix(");")) + } + + /// A column keeps its collation in sys.columns whether or not it differs, so emitting it + /// unconditionally puts a COLLATE clause on every column the user never wrote. + @Test("Only a collation that differs from the database's is spelled out") + func collationIsOmittedWhenItMatchesTheDatabase() { + let sql = MSSQLTypeDefinition.tableStatement( + schema: "dbo", + name: "t", + columns: [ + .init(name: "Sku", type: "varchar(32)", isNullable: false, collation: "SQL_Latin1_General_CP1_CI_AS"), + .init(name: "Note", type: "nvarchar(200)", isNullable: true, collation: "Latin1_General_BIN2") + ], + indexes: [], + databaseCollation: "SQL_Latin1_General_CP1_CI_AS" + ) + #expect(!sql.contains("[Sku] varchar(32) COLLATE")) + #expect(sql.contains("[Note] nvarchar(200) COLLATE Latin1_General_BIN2 NULL")) + } + + /// The constraint name is server-generated, so carrying it forward is noise; a multi-column key + /// still needs its own clause because it cannot sit on one column. + @Test("A composite primary key becomes its own clause rather than an inline one") + func compositePrimaryKeyIsATableClause() { + let sql = MSSQLTypeDefinition.tableStatement( + schema: "dbo", + name: "t", + columns: [ + .init(name: "a", type: "int", isNullable: false), + .init(name: "b", type: "int", isNullable: false) + ], + indexes: [.init(name: "PK__x", isPrimaryKey: true, isUnique: true, keyColumns: "a, b")], + databaseCollation: nil + ) + #expect(sql.contains("PRIMARY KEY ([a], [b])")) + #expect(!sql.contains("[a] int NOT NULL PRIMARY KEY")) + #expect(!sql.contains("PK__x")) + } + + /// A CLR type's body is in a .NET assembly, so there is nothing to rebuild and the statement + /// says where it came from instead of inventing one. + @Test("A CLR type names its assembly") + func clrStatementNamesTheAssembly() { + #expect(MSSQLTypeDefinition.clrStatement(schema: "dbo", name: "Geo", assembly: "SpatialLib") + == "CREATE TYPE [dbo].[Geo] EXTERNAL NAME [SpatialLib].[Geo];") + } +} diff --git a/docs/development/plugin-development.mdx b/docs/development/plugin-development.mdx index fa1850ce58..2ef621f16d 100644 --- a/docs/development/plugin-development.mdx +++ b/docs/development/plugin-development.mdx @@ -13,7 +13,7 @@ A plugin is a macOS loadable bundle target with `WRAPPER_EXTENSION = tableplugin | Key | Type | Required | Purpose | |-----|------|----------|---------| -| `TableProPluginKitVersion` | integer | Yes | The PluginKit ABI the plugin was built against. Current value: 30 | +| `TableProPluginKitVersion` | integer | Yes | The PluginKit ABI the plugin was built against. Current value: 31 | | `TableProProvidesDatabaseTypeIds` | array of strings | Recommended | Database type IDs the plugin serves, which is what makes lazy loading possible | | `CFBundleShortVersionString` | string | Yes | Plugin version, read by registry update checks | | `TableProMinAppVersion` | string | No | The loader rejects the plugin on an older app | diff --git a/docs/development/plugin-registry.mdx b/docs/development/plugin-registry.mdx index 2c555a18ea..95f9dbc2dd 100644 --- a/docs/development/plugin-registry.mdx +++ b/docs/development/plugin-registry.mdx @@ -68,13 +68,13 @@ Themes carry no native code, so they match on architecture alone. "binaries": [ { "architecture": "arm64", - "pluginKitVersion": 30, + "pluginKitVersion": 31, "downloadURL": "https://github.com/TableProApp/TablePro/releases/download/plugin-oracle-v1.0.26/OracleDriver-arm64.zip", "sha256": "" }, { "architecture": "x86_64", - "pluginKitVersion": 30, + "pluginKitVersion": 31, "downloadURL": "https://github.com/TableProApp/TablePro/releases/download/plugin-oracle-v1.0.26/OracleDriver-x86_64.zip", "sha256": "" } diff --git a/docs/features/user-defined-types.mdx b/docs/features/user-defined-types.mdx index b31047828f..dc83532f76 100644 --- a/docs/features/user-defined-types.mdx +++ b/docs/features/user-defined-types.mdx @@ -15,9 +15,10 @@ Expand a schema and **Types** sits beside **Functions** and **Triggers**, one ro | Engine | Types listed | Enum labels editable | |---|---|---| | PostgreSQL, PGlite | Enums, composites, domains, ranges | Yes | +| SQL Server | Alias types, table types, CLR types | No | | Every other engine | None | No | -A table's own row type, the array type created beside every type, the multirange created beside every range, and any type an extension installed are left out. A type in another schema is listed under that schema. +A table's own row type, the array type created beside every type, the multirange created beside every range, and any type an extension installed are left out. A type in another schema is listed under that schema. On SQL Server the built-in types are left out too, so the section holds only what the database declares. ## Reading a definition @@ -25,6 +26,10 @@ The definition is rebuilt from the catalog rather than read back as typed: comme Hover a row in the sidebar for the same facts without opening it: an enum's labels, a composite's fields, a domain's base type. +On SQL Server nothing is stored to read back at all, because a type has no `sys.sql_modules` row. A table type's statement is rebuilt from its columns and indexes, keeping identity, defaults, computed columns, inline indexes and any collation that differs from the database's; an alias type's is rebuilt from its base type and nullability. A CLR type names the assembly it came from, since its body is compiled .NET rather than T-SQL. + +A SQL Server table type is a table-valued parameter and cannot declare a column, so it is left out of the column type picker. Alias types appear there. + ## Editing an enum's labels An enum's labels are listed above its definition in the order the server keeps them. Each edit runs as one `ALTER TYPE` statement the moment it is committed, on a connection of its own rather than inside a transaction open in a query tab; there is nothing to save afterwards, and the statement lands in the query history. diff --git a/project.yml b/project.yml index 34786f1c75..4e2c8e10d5 100644 --- a/project.yml +++ b/project.yml @@ -452,6 +452,8 @@ targets: - Plugins/JSONImportPlugin/JSONImportParsing.swift - Plugins/JSONImportPlugin/JSONImportPlugin.swift - Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift + - Plugins/MSSQLDriverPlugin/MSSQLTypeQueries.swift + - Plugins/MSSQLDriverPlugin/MSSQLTypeDefinition.swift - Plugins/MSSQLDriverPlugin/MSSQLLoginParameters.swift - Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift - Plugins/MQLExportPlugin/MQLExportHelpers.swift From e7234e25e3025db42ca7aa2d8ccdee32a086c1df Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 15 Sep 2026 02:11:43 +0700 Subject: [PATCH 3/3] fix(plugin-mssql): keep constraints, key direction and CLR class in a rebuilt type Claude-Session: https://claude.ai/code/session_016PSQC4EGqkpq32cWdPVMBJ --- .../MSSQLPluginDriver+Types.swift | 39 ++++++- .../MSSQLTypeDefinition.swift | 106 +++++++++++++---- .../MSSQLDriverPlugin/MSSQLTypeQueries.swift | 48 +++++--- .../UserDefinedTypeToolSchemaTests.swift | 3 +- .../Plugins/MSSQLTypeQueryTests.swift | 109 ++++++++++++++++-- 5 files changed, 251 insertions(+), 54 deletions(-) diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Types.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Types.swift index fcb4ee6988..4c6fc31eb1 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Types.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Types.swift @@ -35,7 +35,8 @@ extension MSSQLPluginDriver { return match.withDefinition(MSSQLTypeDefinition.clrStatement( schema: resolvedSchema, name: match.name, - assembly: match.attributes.first { $0.label == "Assembly" }?.value + assembly: match.attributes.first { $0.label == "Assembly" }?.value, + assemblyClass: match.attributes.first { $0.label == "Class" }?.value )) default: return match.withDefinition(MSSQLTypeDefinition.aliasStatement( @@ -57,6 +58,9 @@ extension MSSQLPluginDriver { let indexRows = try await execute( query: MSSQLTypeQueries.tableTypeIndexes(schema: schema, name: type.name) ).rows + let checkRows = try await execute( + query: MSSQLTypeQueries.tableTypeCheckConstraints(schema: schema, name: type.name) + ).rows let collation = try? await execute(query: MSSQLTypeQueries.databaseCollation).rows.first?[safe: 0]?.asText let columns = columnRows.compactMap { row -> MSSQLTypeDefinition.Column? in @@ -71,15 +75,34 @@ extension MSSQLPluginDriver { collation: row[safe: 6]?.asText ) } - let indexes = indexRows.map { row in - MSSQLTypeDefinition.Index( + var indexOrder: [String] = [] + var indexesById: [String: MSSQLTypeDefinition.Index] = [:] + for row in indexRows { + guard let indexId = row[safe: 6]?.asText, let column = row[safe: 4]?.asText else { continue } + let key = MSSQLTypeDefinition.IndexKey(column: column, isDescending: row[safe: 5]?.asText == "1") + if let existing = indexesById[indexId] { + indexesById[indexId] = MSSQLTypeDefinition.Index( + name: existing.name, + isPrimaryKey: existing.isPrimaryKey, + isUnique: existing.isUnique, + typeDescription: existing.typeDescription, + keys: existing.keys + [key], + bucketCount: existing.bucketCount + ) + continue + } + indexOrder.append(indexId) + indexesById[indexId] = MSSQLTypeDefinition.Index( name: row[safe: 0]?.asText, isPrimaryKey: row[safe: 1]?.asText == "1", isUnique: row[safe: 2]?.asText == "1", typeDescription: row[safe: 3]?.asText, - keyColumns: row[safe: 4]?.asText + keys: [key], + bucketCount: row[safe: 7]?.asText.flatMap(Int.init) ?? 0 ) } + let indexes = indexOrder.compactMap { indexesById[$0] } + let checks = checkRows.compactMap { $0[safe: 0]?.asText } return PluginUserDefinedTypeInfo( name: type.name, @@ -95,6 +118,8 @@ extension MSSQLPluginDriver { name: type.name, columns: columns, indexes: indexes, + checkConstraints: checks, + isMemoryOptimized: type.attributes.contains { $0.label == "Memory Optimized" && $0.value == "YES" }, databaseCollation: collation ?? nil ), attributes: type.attributes @@ -123,6 +148,12 @@ extension MSSQLPluginDriver { if let assembly = row[safe: 7]?.asText, !assembly.isEmpty { attributes.append(PluginObjectAttribute(label: "Assembly", value: assembly)) } + if let assemblyClass = row[safe: 8]?.asText, !assemblyClass.isEmpty { + attributes.append(PluginObjectAttribute(label: "Class", value: assemblyClass)) + } + if kind == .tableType, row[safe: 9]?.asText == "1" { + attributes.append(PluginObjectAttribute(label: "Memory Optimized", value: "YES")) + } return PluginUserDefinedTypeInfo( name: name, diff --git a/Plugins/MSSQLDriverPlugin/MSSQLTypeDefinition.swift b/Plugins/MSSQLDriverPlugin/MSSQLTypeDefinition.swift index d20388abc1..aac876d0be 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLTypeDefinition.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLTypeDefinition.swift @@ -40,26 +40,43 @@ public enum MSSQLTypeDefinition { } } + public struct IndexKey: Sendable, Equatable { + public let column: String + public let isDescending: Bool + + public init(column: String, isDescending: Bool) { + self.column = column + self.isDescending = isDescending + } + } + + /// Keys stay structured rather than comma-joined: a column name may legally contain a comma, + /// and only a per-key flag can carry `DESC`. public struct Index: Sendable, Equatable { public let name: String? public let isPrimaryKey: Bool public let isUnique: Bool public let typeDescription: String? - public let keyColumns: String? + public let keys: [IndexKey] + public let bucketCount: Int public init( name: String?, isPrimaryKey: Bool, isUnique: Bool, typeDescription: String? = nil, - keyColumns: String? = nil + keys: [IndexKey] = [], + bucketCount: Int = 0 ) { self.name = name self.isPrimaryKey = isPrimaryKey self.isUnique = isUnique self.typeDescription = typeDescription - self.keyColumns = keyColumns + self.keys = keys + self.bucketCount = bucketCount } + + var columnNames: [String] { keys.map(\.column) } } public static func bracketed(_ identifier: String) -> String { @@ -74,11 +91,18 @@ public enum MSSQLTypeDefinition { } /// A CLR type's body is compiled into a .NET assembly, so this names the assembly rather than - /// pretending to a definition. The class name is not in `sys.assembly_types` under a name this - /// driver reads, so the statement is left with the type's own name, which is what SQL Server - /// requires them to match in practice. - public static func clrStatement(schema: String, name: String, assembly: String?) -> String { - let external = assembly.map { "\(bracketed($0)).[\(name)]" } ?? ".[\(name)]" + /// pretending to a definition. The managed class is `sys.assembly_types.assembly_class` and is + /// free to differ from the SQL type's name; substituting the SQL name produced an + /// `EXTERNAL NAME` pointing at a class that does not exist. + public static func clrStatement( + schema: String, + name: String, + assembly: String?, + assemblyClass: String? + ) -> String { + let managedClass = (assemblyClass?.isEmpty == false ? assemblyClass : nil) ?? name + let external = assembly.map { "\(bracketed($0)).\(bracketed(managedClass))" } + ?? ".\(bracketed(managedClass))" return "CREATE TYPE \(bracketed(schema)).\(bracketed(name)) EXTERNAL NAME \(external);" } @@ -90,18 +114,45 @@ public enum MSSQLTypeDefinition { name: String, columns: [Column], indexes: [Index], + checkConstraints: [String] = [], + isMemoryOptimized: Bool = false, databaseCollation: String? ) -> String { - let singleColumnPrimaryKey = indexes.first { $0.isPrimaryKey && !($0.keyColumns?.contains(",") ?? true) }?.keyColumns + let primaryKey = indexes.first(where: \.isPrimaryKey) + let inlinedKey = primaryKey.flatMap { key -> IndexKey? in + guard key.keys.count == 1, let only = key.keys.first, !only.isDescending else { return nil } + return only + } var lines = columns.map { column in - columnClause(column, primaryKeyColumn: singleColumnPrimaryKey, databaseCollation: databaseCollation) + columnClause( + column, + primaryKey: inlinedKey.map { ($0.column, primaryKey?.typeDescription) }, + databaseCollation: databaseCollation + ) } - lines.append(contentsOf: indexClauses(indexes, inlinedPrimaryKey: singleColumnPrimaryKey)) + lines.append(contentsOf: indexClauses(indexes, inlinedPrimaryKeyColumn: inlinedKey?.column)) + lines.append(contentsOf: checkConstraints.filter { !$0.isEmpty }.map { "CHECK \($0)" }) let body = lines.map { " \($0)" }.joined(separator: ",\n") - return "CREATE TYPE \(bracketed(schema)).\(bracketed(name)) AS TABLE (\n\(body)\n);" + let tail = isMemoryOptimized ? "\n)\nWITH (MEMORY_OPTIMIZED = ON);" : "\n);" + return "CREATE TYPE \(bracketed(schema)).\(bracketed(name)) AS TABLE (\n\(body)\(tail)" } - private static func columnClause(_ column: Column, primaryKeyColumn: String?, databaseCollation: String?) -> String { + /// SQL Server defaults a primary key to CLUSTERED, so a NONCLUSTERED one replays with a + /// different layout, and a clustered secondary index then makes the replay fail outright. + private static func clusteringClause(_ typeDescription: String?) -> String { + guard let description = typeDescription?.uppercased(), + description == "CLUSTERED" || description == "NONCLUSTERED" + else { + return "" + } + return " \(description)" + } + + private static func columnClause( + _ column: Column, + primaryKey: (column: String, clustering: String?)?, + databaseCollation: String? + ) -> String { var parts = [bracketed(column.name)] if let computed = column.computedDefinition, !computed.isEmpty { parts.append("AS \(computed)") @@ -114,27 +165,34 @@ public enum MSSQLTypeDefinition { if let identity = column.identitySpec, !identity.isEmpty { parts.append("IDENTITY(\(identity))") } parts.append(column.isNullable ? "NULL" : "NOT NULL") if let value = column.defaultDefinition, !value.isEmpty { parts.append("DEFAULT \(value)") } - if let key = primaryKeyColumn, key == column.name { parts.append("PRIMARY KEY") } + if let primaryKey, primaryKey.column == column.name { + parts.append("PRIMARY KEY\(clusteringClause(primaryKey.clustering))") + } return parts.joined(separator: " ") } - private static func indexClauses(_ indexes: [Index], inlinedPrimaryKey: String?) -> [String] { + private static func keyList(_ keys: [IndexKey]) -> String { + keys + .map { "\(bracketed($0.column))\($0.isDescending ? " DESC" : "")" } + .joined(separator: ", ") + } + + private static func indexClauses(_ indexes: [Index], inlinedPrimaryKeyColumn: String?) -> [String] { indexes.compactMap { index -> String? in - guard let keyColumns = index.keyColumns, !keyColumns.isEmpty else { return nil } - let columnList = keyColumns - .split(separator: ",") - .map { bracketed($0.trimmingCharacters(in: .whitespaces)) } - .joined(separator: ", ") + guard !index.keys.isEmpty else { return nil } + let columnList = keyList(index.keys) if index.isPrimaryKey { - guard inlinedPrimaryKey != keyColumns else { return nil } - return "PRIMARY KEY (\(columnList))" + guard index.columnNames != [inlinedPrimaryKeyColumn].compactMap({ $0 }) else { return nil } + return "PRIMARY KEY\(clusteringClause(index.typeDescription)) (\(columnList))" } guard let name = index.name, !name.isEmpty else { return index.isUnique ? "UNIQUE (\(columnList))" : nil } - let clustering = index.typeDescription.map { " \($0.replacingOccurrences(of: "_", with: " "))" } ?? "" let unique = index.isUnique ? "UNIQUE " : "" - return "\(unique)INDEX \(bracketed(name))\(clustering) (\(columnList))" + if index.bucketCount > 0 { + return "\(unique)INDEX \(bracketed(name)) HASH (\(columnList)) WITH (BUCKET_COUNT = \(index.bucketCount))" + } + return "\(unique)INDEX \(bracketed(name))\(clusteringClause(index.typeDescription)) (\(columnList))" } } } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLTypeQueries.swift b/Plugins/MSSQLDriverPlugin/MSSQLTypeQueries.swift index 483ef64e9c..d465be5408 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLTypeQueries.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLTypeQueries.swift @@ -50,12 +50,15 @@ public enum MSSQLTypeQueries { \(baseTypeSpelling) AS base_type, CONVERT(varchar(1), t.is_nullable) AS is_nullable, t.collation_name, - a.name AS assembly_name + a.name AS assembly_name, + at.assembly_class, + CONVERT(varchar(1), ISNULL(tt.is_memory_optimized, 0)) AS is_memory_optimized FROM sys.types t JOIN sys.schemas s ON s.schema_id = t.schema_id LEFT JOIN sys.types bt ON bt.user_type_id = t.system_type_id AND bt.is_user_defined = 0 LEFT JOIN sys.assembly_types at ON at.user_type_id = t.user_type_id LEFT JOIN sys.assemblies a ON a.assembly_id = at.assembly_id + LEFT JOIN sys.table_types tt ON tt.user_type_id = t.user_type_id WHERE t.is_user_defined = 1 AND s.name = \(MSSQLStringLiteral.quoted(schema)) ORDER BY t.name """ @@ -68,6 +71,9 @@ public enum MSSQLTypeQueries { SELECT c.name, CASE WHEN c.is_computed = 1 THEN NULL + WHEN bt.is_user_defined = 1 + THEN '[' + REPLACE(SCHEMA_NAME(bt.schema_id), ']', ']]') + '].[' + + REPLACE(bt.name, ']', ']]') + ']' WHEN bt.name IN ('nvarchar', 'nchar') THEN bt.name + '(' + CASE WHEN c.max_length = -1 THEN 'max' ELSE CONVERT(varchar(11), c.max_length / 2) END + ')' @@ -100,9 +106,10 @@ public enum MSSQLTypeQueries { """ } - /// A table type's primary key and its inline indexes. The key columns are aggregated through - /// `FOR XML PATH`, so this needs the session `MSSQLSessionOptions` establishes, exactly as the - /// routine list does. + /// One row per index key column rather than a comma-joined aggregate. A column name may legally + /// contain a comma (`[a,b]` is a valid identifier), which splitting turns into two columns, and + /// an aggregate has nowhere to carry `is_descending_key`, so a `DESC` key silently replayed as + /// ascending. Structured rows lose neither. public static func tableTypeIndexes(schema: String, name: String) -> String { """ SELECT @@ -110,22 +117,37 @@ public enum MSSQLTypeQueries { CONVERT(varchar(1), i.is_primary_key) AS is_primary_key, CONVERT(varchar(1), i.is_unique) AS is_unique, i.type_desc, - STUFF(( - SELECT ', ' + c.name - FROM sys.index_columns ic - JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id - WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 - ORDER BY ic.key_ordinal - FOR XML PATH(''), TYPE - ).value('.', 'nvarchar(max)'), 1, 2, '') AS key_columns + c.name AS key_column, + CONVERT(varchar(1), ic.is_descending_key) AS is_descending, + CONVERT(varchar(11), i.index_id) AS index_id, + CONVERT(varchar(11), ISNULL(hi.bucket_count, 0)) AS bucket_count FROM sys.table_types tt JOIN sys.schemas s ON s.schema_id = tt.schema_id JOIN sys.indexes i ON i.object_id = tt.type_table_object_id + JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id + JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id + LEFT JOIN sys.hash_indexes hi ON hi.object_id = i.object_id AND hi.index_id = i.index_id WHERE tt.is_user_defined = 1 AND i.type > 0 + AND ic.is_included_column = 0 AND s.name = \(MSSQLStringLiteral.quoted(schema)) AND tt.name = \(MSSQLStringLiteral.quoted(name)) - ORDER BY i.index_id + ORDER BY i.index_id, ic.key_ordinal + """ + } + + /// A CHECK on a table type is part of what the type validates, so a rebuilt statement that + /// drops it recreates a type with weaker validation than the original. + public static func tableTypeCheckConstraints(schema: String, name: String) -> String { + """ + SELECT cc.definition + FROM sys.table_types tt + JOIN sys.schemas s ON s.schema_id = tt.schema_id + JOIN sys.check_constraints cc ON cc.parent_object_id = tt.type_table_object_id + WHERE tt.is_user_defined = 1 + AND s.name = \(MSSQLStringLiteral.quoted(schema)) + AND tt.name = \(MSSQLStringLiteral.quoted(name)) + ORDER BY cc.object_id """ } diff --git a/TableProTests/Core/MCP/Protocol/Tools/UserDefinedTypeToolSchemaTests.swift b/TableProTests/Core/MCP/Protocol/Tools/UserDefinedTypeToolSchemaTests.swift index a762284225..361dbe208e 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/UserDefinedTypeToolSchemaTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/UserDefinedTypeToolSchemaTests.swift @@ -26,7 +26,8 @@ struct UserDefinedTypeToolSchemaTests { let schema = ListUserDefinedTypesTool.inputSchema #expect(schema["required"]?.arrayValue?.compactMap(\.stringValue) == ["connection_id"]) let kinds = schema["properties"]?["kind"]?["enum"]?.arrayValue?.compactMap(\.stringValue) - #expect(kinds == ["enum", "composite", "domain", "range"]) + #expect(kinds == ["enum", "composite", "domain", "range", "aliasType", "tableType", "clrType"]) + #expect(kinds?.contains("other") == false) } @Test("list_types is registered as a read-only tool") diff --git a/TableProTests/Plugins/MSSQLTypeQueryTests.swift b/TableProTests/Plugins/MSSQLTypeQueryTests.swift index cbae9b6d89..e26e19371b 100644 --- a/TableProTests/Plugins/MSSQLTypeQueryTests.swift +++ b/TableProTests/Plugins/MSSQLTypeQueryTests.swift @@ -60,12 +60,13 @@ struct MSSQLTypeQueryTests { } /// An INCLUDE column is not part of the key, and listing it as one is the same defect the - /// table index reader carries. - @Test("Table type index keys exclude INCLUDE columns and sort by key ordinal") + /// table index reader carries. Rows come back one key at a time, so they group by index before + /// they sort by ordinal; the caller folds them back into one index each. + @Test("Table type index keys exclude INCLUDE columns and sort by index then key ordinal") func indexKeysExcludeIncludedColumns() { let sql = MSSQLTypeQueries.tableTypeIndexes(schema: "dbo", name: "t") #expect(sql.contains("ic.is_included_column = 0")) - #expect(sql.contains("ORDER BY ic.key_ordinal")) + #expect(sql.contains("ORDER BY i.index_id, ic.key_ordinal")) #expect(sql.contains("i.type > 0")) } } @@ -108,14 +109,14 @@ struct MSSQLTypeDefinitionTests { ], indexes: [ .init(name: "PK__TT_Order__2EAE", isPrimaryKey: true, isUnique: true, - typeDescription: "CLUSTERED", keyColumns: "LineId"), + typeDescription: "CLUSTERED", keys: [.init(column: "LineId", isDescending: false)]), .init(name: "ix_sku", isPrimaryKey: false, isUnique: false, - typeDescription: "NONCLUSTERED", keyColumns: "Sku") + typeDescription: "NONCLUSTERED", keys: [.init(column: "Sku", isDescending: false)]) ], databaseCollation: "SQL_Latin1_General_CP1_CI_AS" ) #expect(sql.hasPrefix("CREATE TYPE [dbo].[OrderLineTable] AS TABLE (")) - #expect(sql.contains("[LineId] int IDENTITY(1,1) NOT NULL PRIMARY KEY")) + #expect(sql.contains("[LineId] int IDENTITY(1,1) NOT NULL PRIMARY KEY CLUSTERED")) #expect(sql.contains("[Qty] int NOT NULL DEFAULT ((1))")) #expect(sql.contains("[Total] AS ([Qty]*[Price])")) #expect(sql.contains("INDEX [ix_sku] NONCLUSTERED ([Sku])")) @@ -151,19 +152,103 @@ struct MSSQLTypeDefinitionTests { .init(name: "a", type: "int", isNullable: false), .init(name: "b", type: "int", isNullable: false) ], - indexes: [.init(name: "PK__x", isPrimaryKey: true, isUnique: true, keyColumns: "a, b")], + indexes: [.init(name: "PK__x", isPrimaryKey: true, isUnique: true, typeDescription: "NONCLUSTERED", + keys: [.init(column: "a", isDescending: false), .init(column: "b", isDescending: false)])], databaseCollation: nil ) - #expect(sql.contains("PRIMARY KEY ([a], [b])")) + #expect(sql.contains("PRIMARY KEY NONCLUSTERED ([a], [b])")) #expect(!sql.contains("[a] int NOT NULL PRIMARY KEY")) #expect(!sql.contains("PK__x")) } - /// A CLR type's body is in a .NET assembly, so there is nothing to rebuild and the statement - /// says where it came from instead of inventing one. + /// SQL Server defaults a primary key to CLUSTERED, so a NONCLUSTERED one replayed with a + /// different layout and failed outright when the type also had a clustered secondary index. + @Test("A NONCLUSTERED primary key keeps its clustering on the inline form too") + func inlinePrimaryKeyKeepsClustering() { + let sql = MSSQLTypeDefinition.tableStatement( + schema: "dbo", + name: "t", + columns: [.init(name: "Id", type: "int", isNullable: false)], + indexes: [.init(name: "PK__x", isPrimaryKey: true, isUnique: true, + typeDescription: "NONCLUSTERED", keys: [.init(column: "Id", isDescending: false)])], + databaseCollation: nil + ) + #expect(sql.contains("[Id] int NOT NULL PRIMARY KEY NONCLUSTERED")) + } + + /// A comma is legal inside a bracketed identifier, so the comma-joined string this replaced + /// turned one column into two; and the string had nowhere to carry DESC at all. + @Test("A descending key keeps its direction and a comma in a name stays one column") + func descendingKeysAndCommasSurvive() { + let sql = MSSQLTypeDefinition.tableStatement( + schema: "dbo", + name: "t", + columns: [.init(name: "Ranked", type: "int", isNullable: false), + .init(name: "a,b", type: "int", isNullable: false)], + indexes: [.init(name: "ix", isPrimaryKey: false, isUnique: false, typeDescription: "NONCLUSTERED", + keys: [.init(column: "Ranked", isDescending: true), + .init(column: "a,b", isDescending: false)])], + databaseCollation: nil + ) + #expect(sql.contains("INDEX [ix] NONCLUSTERED ([Ranked] DESC, [a,b])")) + } + + /// Dropping a CHECK recreates the type with weaker validation than the original. + @Test("CHECK constraints are carried into the rebuilt statement") + func checkConstraintsSurvive() { + let sql = MSSQLTypeDefinition.tableStatement( + schema: "dbo", + name: "t", + columns: [.init(name: "Amount", type: "decimal(10,2)", isNullable: true)], + indexes: [], + checkConstraints: ["([Amount]>=(0))"], + databaseCollation: nil + ) + #expect(sql.contains("CHECK ([Amount]>=(0))")) + } + + /// Without the clause the replay makes a disk-backed type, and a hash index needs its bucket + /// count or it cannot be created at all. + @Test("A memory-optimized table type keeps its option and hash bucket count") + func memoryOptimizedTableType() { + let sql = MSSQLTypeDefinition.tableStatement( + schema: "dbo", + name: "t", + columns: [.init(name: "Id", type: "int", isNullable: false)], + indexes: [.init(name: "ix_hash", isPrimaryKey: false, isUnique: false, + typeDescription: "NONCLUSTERED HASH", + keys: [.init(column: "Id", isDescending: false)], bucketCount: 1_024)], + isMemoryOptimized: true, + databaseCollation: nil + ) + #expect(sql.contains("INDEX [ix_hash] HASH ([Id]) WITH (BUCKET_COUNT = 1024)")) + #expect(sql.hasSuffix("WITH (MEMORY_OPTIMIZED = ON);")) + } + + /// A table-type column can itself be an alias or CLR type, and a bare name binds to a different + /// type or fails outright when the UDT lives outside the default schema. + @Test("A user-defined column type is rendered schema-qualified by the catalog query") + func userDefinedColumnTypesAreQualified() { + let sql = MSSQLTypeQueries.tableTypeColumns(schema: "dbo", name: "t") + #expect(sql.contains("bt.is_user_defined = 1")) + #expect(sql.contains("SCHEMA_NAME(bt.schema_id)")) + } + + @Test("Index keys and check constraints are read as structured catalog rows") + func indexAndCheckQueriesAreStructured() { + let indexes = MSSQLTypeQueries.tableTypeIndexes(schema: "dbo", name: "t") + #expect(indexes.contains("ic.is_descending_key")) + #expect(indexes.contains("sys.hash_indexes")) + #expect(!indexes.contains("FOR XML PATH")) + #expect(MSSQLTypeQueries.tableTypeCheckConstraints(schema: "dbo", name: "t") + .contains("sys.check_constraints")) + } + + /// A CLR type's managed class is free to differ from the SQL type's name, and substituting the + /// SQL name produced an EXTERNAL NAME pointing at a class that does not exist. @Test("A CLR type names its assembly") func clrStatementNamesTheAssembly() { - #expect(MSSQLTypeDefinition.clrStatement(schema: "dbo", name: "Geo", assembly: "SpatialLib") - == "CREATE TYPE [dbo].[Geo] EXTERNAL NAME [SpatialLib].[Geo];") + #expect(MSSQLTypeDefinition.clrStatement(schema: "dbo", name: "Geo", assembly: "SpatialLib", assemblyClass: "Spatial.Point") + == "CREATE TYPE [dbo].[Geo] EXTERNAL NAME [SpatialLib].[Spatial.Point];") } }