diff --git a/CHANGELOG.md b/CHANGELOG.md index 2664ac6f52..981946fd20 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..4c6fc31eb1 --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Types.swift @@ -0,0 +1,192 @@ +// +// 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, + assemblyClass: match.attributes.first { $0.label == "Class" }?.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 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 + 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 + ) + } + 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, + 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, + 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, + checkConstraints: checks, + isMemoryOptimized: type.attributes.contains { $0.label == "Memory Optimized" && $0.value == "YES" }, + 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)) + } + 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, + 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..aac876d0be --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/MSSQLTypeDefinition.swift @@ -0,0 +1,198 @@ +// +// 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 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 keys: [IndexKey] + public let bucketCount: Int + + public init( + name: String?, + isPrimaryKey: Bool, + isUnique: Bool, + typeDescription: String? = nil, + keys: [IndexKey] = [], + bucketCount: Int = 0 + ) { + self.name = name + self.isPrimaryKey = isPrimaryKey + self.isUnique = isUnique + self.typeDescription = typeDescription + self.keys = keys + self.bucketCount = bucketCount + } + + var columnNames: [String] { keys.map(\.column) } + } + + 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 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);" + } + + /// `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], + checkConstraints: [String] = [], + isMemoryOptimized: Bool = false, + databaseCollation: String? + ) -> String { + 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, + primaryKey: inlinedKey.map { ($0.column, primaryKey?.typeDescription) }, + databaseCollation: databaseCollation + ) + } + 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") + let tail = isMemoryOptimized ? "\n)\nWITH (MEMORY_OPTIMIZED = ON);" : "\n);" + return "CREATE TYPE \(bracketed(schema)).\(bracketed(name)) AS TABLE (\n\(body)\(tail)" + } + + /// 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)") + 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 primaryKey, primaryKey.column == column.name { + parts.append("PRIMARY KEY\(clusteringClause(primaryKey.clustering))") + } + return parts.joined(separator: " ") + } + + 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 !index.keys.isEmpty else { return nil } + let columnList = keyList(index.keys) + if index.isPrimaryKey { + 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 unique = index.isUnique ? "UNIQUE " : "" + 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 new file mode 100644 index 0000000000..d465be5408 --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/MSSQLTypeQueries.swift @@ -0,0 +1,158 @@ +// +// 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, + 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 + """ + } + + /// 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.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 + ')' + 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 + """ + } + + /// 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 + 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, + 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, 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 + """ + } + + /// 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/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 new file mode 100644 index 0000000000..e26e19371b --- /dev/null +++ b/TableProTests/Plugins/MSSQLTypeQueryTests.swift @@ -0,0 +1,254 @@ +// +// 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. 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 i.index_id, 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", keys: [.init(column: "LineId", isDescending: false)]), + .init(name: "ix_sku", isPrimaryKey: false, isUnique: false, + 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 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])")) + #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, typeDescription: "NONCLUSTERED", + keys: [.init(column: "a", isDescending: false), .init(column: "b", isDescending: false)])], + databaseCollation: nil + ) + #expect(sql.contains("PRIMARY KEY NONCLUSTERED ([a], [b])")) + #expect(!sql.contains("[a] int NOT NULL PRIMARY KEY")) + #expect(!sql.contains("PK__x")) + } + + /// 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", assemblyClass: "Spatial.Point") + == "CREATE TYPE [dbo].[Geo] EXTERNAL NAME [SpatialLib].[Spatial.Point];") + } +} 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