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