diff --git a/apple/Plugins/CassandraDriverPlugin/CassandraObjectQueries.swift b/apple/Plugins/CassandraDriverPlugin/CassandraObjectQueries.swift index dafe3e9de5..0589bb557a 100644 --- a/apple/Plugins/CassandraDriverPlugin/CassandraObjectQueries.swift +++ b/apple/Plugins/CassandraDriverPlugin/CassandraObjectQueries.swift @@ -58,8 +58,8 @@ public enum CassandraObjectQueries { } public static func signature(argumentNames: String?, argumentTypes: String?, quotingNames: Bool = false) -> String { - let names = elements(of: argumentNames) - let types = elements(of: argumentTypes) + let names = Self.names(of: argumentNames) + let types = Self.types(of: argumentTypes) guard !types.isEmpty else { return "()" } let parts = types.enumerated().map { index, type -> String in guard index < names.count, !names[index].isEmpty else { return type } @@ -68,34 +68,53 @@ public enum CassandraObjectQueries { return "(\(parts.joined(separator: ", ")))" } - /// The driver renders a CQL list as `[a, b]`, with no quotes, and an element can itself hold a comma, as the - /// type `frozen>` does. So the list splits only at commas outside angle brackets. - public static func elements(of value: String?) -> [String] { - guard let value else { return [] } - let trimmed = value.trimmingCharacters(in: .whitespaces) - let body = trimmed.hasPrefix("[") && trimmed.hasSuffix("]") ? String(trimmed.dropFirst().dropLast()) : trimmed + /// The driver renders a CQL list as `[a, b]`: the elements joined by ", ", nothing quoted or trimmed. A name is + /// raw text, so one holding ", " reads as two and the list comes back longer than its types. + public static func names(of value: String?) -> [String] { + guard let body = listBody(value) else { return [] } + return body.components(separatedBy: ", ") + } + + /// A type is CQL as the server spells it, `frozen>` or `frozen<"My, Type">`, so the list splits + /// only at a ", " outside angle brackets, parentheses and quoted names. + public static func types(of value: String?) -> [String] { + guard let body = listBody(value) else { return [] } var items: [String] = [] var current = "" var depth = 0 - for character in body { - switch character { - case "<", "(": - depth += 1 - case ">", ")": - depth -= 1 - case "," where depth == 0: - items.append(current) - current = "" - continue - default: - break + var isQuoted = false + var index = body.startIndex + while index < body.endIndex { + let character = body[index] + let next = body.index(after: index) + if character == "\"" { + isQuoted.toggle() + } else if !isQuoted { + switch character { + case "<", "(": + depth += 1 + case ">", ")": + depth -= 1 + case "," where depth == 0 && next < body.endIndex && body[next] == " ": + items.append(current) + current = "" + index = body.index(after: next) + continue + default: + break + } } current.append(character) + index = next } items.append(current) return items - .map { $0.trimmingCharacters(in: CharacterSet(charactersIn: " '\"")) } - .filter { !$0.isEmpty } + } + + private static func listBody(_ value: String?) -> String? { + guard let value else { return nil } + let body = value.hasPrefix("[") && value.hasSuffix("]") ? String(value.dropFirst().dropLast()) : value + return body.isEmpty ? nil : body } public static func functionDefinition( diff --git a/apple/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/apple/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index c6090a6a05..8d1edb7201 100644 --- a/apple/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/apple/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -35,6 +35,7 @@ internal final class CassandraPlugin: NSObject, TableProPlugin, DriverPlugin, Pl static let supportsForeignKeys = false static let supportsRoutines = true static let supportsDatabaseTriggerBrowse = true + static let supportsUserDefinedTypeBrowse = true static let brandColorHex = "#26A0D8" static let queryLanguageName = "CQL" static let supportsDatabaseSwitching = true diff --git a/apple/Plugins/CassandraDriverPlugin/CassandraPluginDriver+Types.swift b/apple/Plugins/CassandraDriverPlugin/CassandraPluginDriver+Types.swift new file mode 100644 index 0000000000..10ea71b798 --- /dev/null +++ b/apple/Plugins/CassandraDriverPlugin/CassandraPluginDriver+Types.swift @@ -0,0 +1,46 @@ +// +// CassandraPluginDriver+Types.swift +// CassandraDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension CassandraPluginDriver { + func fetchUserDefinedTypes(schema: String?) async throws -> [PluginUserDefinedTypeInfo] { + let keyspace = resolveKeyspace(schema) + let result = try await execute(query: CassandraUserTypes.listQuery(keyspace: keyspace)) + return result.rows.compactMap { userType(from: $0, keyspace: keyspace) } + } + + func fetchUserDefinedType(_ type: PluginUserDefinedTypeInfo) async throws -> PluginUserDefinedTypeInfo { + let keyspace = resolveKeyspace(type.schema) + let result = try await execute(query: CassandraUserTypes.typeQuery(keyspace: keyspace, name: type.name)) + let match = result.rows + .compactMap { userType(from: $0, keyspace: keyspace) } + .first { $0.name == type.name } + guard let match else { throw PluginObjectSourceError.notFound(type.name) } + return match + } + + func generateDropTypeSQL(type: PluginUserDefinedTypeInfo) -> String? { + CassandraUserTypes.dropStatement(keyspace: resolveKeyspace(type.schema), name: type.name) + } + + private func userType(from row: [PluginCellValue], keyspace: String) -> PluginUserDefinedTypeInfo? { + guard let name = row[safe: 0]?.asText else { return nil } + let fields = CassandraUserTypes.fields(names: row[safe: 1]?.asText, types: row[safe: 2]?.asText) + return PluginUserDefinedTypeInfo( + name: name, + kind: .udt, + schema: keyspace, + fields: (fields ?? []).map { PluginUserDefinedTypeField(name: $0.name, type: $0.type) }, + columnTypeSpelling: CassandraUserTypes.columnTypeSpelling( + name: name, + fields: fields, + releaseVersion: serverVersion + ), + definition: fields.map { CassandraUserTypes.definition(keyspace: keyspace, name: name, fields: $0) } + ) + } +} diff --git a/apple/Plugins/CassandraDriverPlugin/CassandraUserTypes.swift b/apple/Plugins/CassandraDriverPlugin/CassandraUserTypes.swift new file mode 100644 index 0000000000..0f714a5781 --- /dev/null +++ b/apple/Plugins/CassandraDriverPlugin/CassandraUserTypes.swift @@ -0,0 +1,98 @@ +// +// CassandraUserTypes.swift +// CassandraDriverPlugin +// + +import Foundation + +// Foundation only, so apple/scripts/probes/check-cassandra-user-types.sh can compile it against a live server. +public enum CassandraUserTypes { + public struct Field: Equatable, Sendable { + public let name: String + public let type: String + + public init(name: String, type: String) { + self.name = name + self.type = type + } + } + + // From reserved_keywords.txt in the apache-cassandra 5.0.9 jar. + static let reservedKeywords: Set = [ + "ADD", "ALLOW", "ALTER", "AND", "APPLY", "ASC", "AUTHORIZE", "BATCH", "BEGIN", "BY", "COLUMNFAMILY", + "CREATE", "DELETE", "DESC", "DESCRIBE", "DROP", "ENTRIES", "EXECUTE", "FROM", "FULL", "GRANT", "IF", + "IN", "INDEX", "INFINITY", "INSERT", "INTO", "IS", "KEYSPACE", "LIMIT", "MATERIALIZED", "MODIFY", "NAN", + "NORECURSIVE", "NOT", "NULL", "OF", "ON", "OR", "ORDER", "PRIMARY", "RENAME", "REVOKE", "SCHEMA", + "SELECT", "SET", "TABLE", "TO", "TOKEN", "TRUNCATE", "UNLOGGED", "UPDATE", "USE", "USING", "VIEW", + "WHERE", "WITH", + ] + + // CQL3Type.Native, the collection keywords and the parser's reserved type names, all from the 5.0.9 jar. + // Unquoted, `text` names the native type even where a keyspace holds a type called "text". + static let typeNames: Set = [ + "ASCII", "BIGINT", "BLOB", "BOOLEAN", "COUNTER", "DATE", "DECIMAL", "DOUBLE", "DURATION", "EMPTY", "FLOAT", + "INET", "INT", "SMALLINT", "TEXT", "TIME", "TIMESTAMP", "TIMEUUID", "TINYINT", "UUID", "VARCHAR", "VARINT", + "FROZEN", "LIST", "MAP", "SET", "TUPLE", "VECTOR", + "BITSTRING", "BYTE", "COMPLEX", "ENUM", "INTERVAL", "MACADDR", + ] + + public static func listQuery(keyspace: String) -> String { + """ + SELECT type_name, field_names, field_types FROM system_schema.types + WHERE keyspace_name = '\(CassandraObjectQueries.escapeLiteral(keyspace))' + """ + } + + public static func typeQuery(keyspace: String, name: String) -> String { + listQuery(keyspace: keyspace) + " AND type_name = '\(CassandraObjectQueries.escapeLiteral(name))'" + } + + // Nil for a field name holding ", ", which no reading of the driver's list text can place. + public static func fields(names: String?, types: String?) -> [Field]? { + let names = CassandraObjectQueries.names(of: names) + let types = CassandraObjectQueries.types(of: types) + guard !names.isEmpty, names.count == types.count else { return nil } + return zip(names, types).map { Field(name: $0, type: $1) } + } + + public static func definition(keyspace: String, name: String, fields: [Field]) -> String { + let body = fields + .map { " \(CassandraObjectQueries.quote($0.name)) \($0.type)" } + .joined(separator: ",\n") + return "CREATE TYPE \(CassandraObjectQueries.quote(keyspace)).\(CassandraObjectQueries.quote(name)) (\n\(body)\n);" + } + + public static func dropStatement(keyspace: String, name: String) -> String { + "DROP TYPE \(CassandraObjectQueries.quote(keyspace)).\(CassandraObjectQueries.quote(name))" + } + + // A type is usable only in its own keyspace, so the bare name is the one a column definition takes. + public static func columnTypeSpelling(name: String, fields: [Field]?, releaseVersion: String?) -> String { + let reference = identifier(name) + guard let fields, holdsNonFrozenUserTypes(releaseVersion: releaseVersion), + !fields.contains(where: { isNonFrozenCollection($0.type) }) + else { return "frozen<\(reference)>" } + return reference + } + + // CASSANDRA-7423 added non-frozen UDTs in 3.6. ScyllaDB reports 3.0.8 here, so it gets frozen, which it accepts. + public static func holdsNonFrozenUserTypes(releaseVersion: String?) -> Bool { + guard let releaseVersion else { return false } + let parts = releaseVersion.split(separator: ".").prefix(2).map { Int($0.prefix(while: \.isNumber)) } + guard parts.count == 2, let major = parts[0], let minor = parts[1] else { return false } + return (major, minor) >= (3, 6) + } + + static func isNonFrozenCollection(_ type: String) -> Bool { + let lowered = type.trimmingCharacters(in: .whitespaces).lowercased() + return ["list<", "set<", "map<"].contains { lowered.hasPrefix($0) } + } + + // Cassandra's ColumnIdentifier.maybeQuote, which leaves "text" bare, plus the type names. + public static func identifier(_ name: String) -> String { + let isPlain = name.range(of: "^[a-z][a-z0-9_]*$", options: .regularExpression) != nil + let word = name.uppercased() + if isPlain, !reservedKeywords.contains(word), !typeNames.contains(word) { return name } + return CassandraObjectQueries.quote(name) + } +} diff --git a/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Types.swift b/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Types.swift index 4c6fc31eb1..8fb6d3ea88 100644 --- a/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Types.swift +++ b/apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Types.swift @@ -48,6 +48,10 @@ extension MSSQLPluginDriver { } } + func generateDropTypeSQL(type: PluginUserDefinedTypeInfo) -> String? { + "DROP TYPE \(MSSQLTypeDefinition.bracketed(effectiveSchema(type.schema))).\(MSSQLTypeDefinition.bracketed(type.name))" + } + private func tableType( _ type: PluginUserDefinedTypeInfo, schema: String diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift index 6604f9496a..6387b57c35 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift @@ -312,6 +312,10 @@ nonisolated public enum PostgreSQLObjectQueries { "ALTER TYPE \(qualifiedName(schema: schema, name: name)) RENAME VALUE \(quoteLiteral(oldLabel)) TO \(quoteLiteral(newLabel))" } + public static func dropType(schema: String, name: String, kind: PluginUserDefinedTypeKind) -> String { + "\(kind == .domain ? "DROP DOMAIN" : "DROP TYPE") \(qualifiedName(schema: schema, name: name))" + } + public static func createTypeTemplate(schema: String) -> String { """ CREATE TYPE \(qualifiedName(schema: schema, name: "type_name")) AS ENUM ( diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Types.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Types.swift index 623d2429ec..669cdcd079 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Types.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Types.swift @@ -67,6 +67,14 @@ extension PostgreSQLPluginDriver { PostgreSQLObjectQueries.createTypeTemplate(schema: schema ?? currentSchema ?? "public") } + func generateDropTypeSQL(type: PluginUserDefinedTypeInfo) -> String? { + PostgreSQLObjectQueries.dropType( + schema: type.schema ?? currentSchema ?? "public", + name: type.name, + kind: type.kind + ) + } + func generateAddEnumLabelSQL( type: PluginUserDefinedTypeInfo, label: String, diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLTypeQueryTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLTypeQueryTests.swift index 96bf3ee2c6..22c8650a5d 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLTypeQueryTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLTypeQueryTests.swift @@ -260,4 +260,14 @@ struct PostgreSQLTypeQueryTests { #expect(template.hasPrefix("CREATE TYPE \"sales\".\"type_name\" AS ENUM (")) #expect(template.hasSuffix(");")) } + + @Test("A domain drops as a domain and every other kind as a type, without CASCADE") + func dropStatement() { + #expect(PostgreSQLObjectQueries.dropType(schema: "app", name: "email", kind: .domain) + == "DROP DOMAIN \"app\".\"email\"") + for kind in [PluginUserDefinedTypeKind.enumeration, .composite, .range] { + #expect(PostgreSQLObjectQueries.dropType(schema: "app", name: "Mood \"x\"", kind: kind) + == "DROP TYPE \"app\".\"Mood \"\"x\"\"\"") + } + } } diff --git a/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index f44c266c16..aa870998d1 100644 --- a/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -667,6 +667,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { placement: PluginEnumLabelPlacement? ) -> String? func generateRenameEnumLabelSQL(type: PluginUserDefinedTypeInfo, from oldLabel: String, to newLabel: String) -> String? + func generateDropTypeSQL(type: PluginUserDefinedTypeInfo) -> String? // All-tables metadata SQL (optional — returns nil for non-SQL databases) func allTablesMetadataSQL(schema: String?) -> String? @@ -784,6 +785,8 @@ public extension PluginDatabaseDriver { to newLabel: String ) -> String? { nil } + func generateDropTypeSQL(type: PluginUserDefinedTypeInfo) -> String? { nil } + /// Engines whose partitions are metadata on one table object, rather than /// separate relations, have nothing to nest and keep the empty default. func fetchPartitions(table: String, schema: String?) async throws -> [PluginTableInfo] { [] } diff --git a/apple/Plugins/TableProPluginKit/PluginUserDefinedTypeInfo.swift b/apple/Plugins/TableProPluginKit/PluginUserDefinedTypeInfo.swift index 6d80b56013..1ed1707b65 100644 --- a/apple/Plugins/TableProPluginKit/PluginUserDefinedTypeInfo.swift +++ b/apple/Plugins/TableProPluginKit/PluginUserDefinedTypeInfo.swift @@ -31,6 +31,9 @@ public enum PluginUserDefinedTypeKind: String, Codable, Sendable { /// 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 + + // Cassandra `CREATE TYPE x (field type, ...)`: a composite's shape, under the name CQL gives it. + case udt } public struct PluginUserDefinedTypeField: Codable, Sendable, Hashable { diff --git a/apple/TablePro/Core/Database/DatabaseDriver.swift b/apple/TablePro/Core/Database/DatabaseDriver.swift index 8134688c52..f449e66e1b 100644 --- a/apple/TablePro/Core/Database/DatabaseDriver.swift +++ b/apple/TablePro/Core/Database/DatabaseDriver.swift @@ -288,6 +288,8 @@ protocol DatabaseDriver: AnyObject, Sendable { /// listed, because its `identity` is the driver's own key for finding it again. func fetchUserDefinedType(_ type: UserDefinedTypeInfo) async throws -> UserDefinedTypeInfo + func generateDropTypeSQL(type: UserDefinedTypeInfo) -> String? + /// Fetch every trigger in the given schema, across all its tables. func fetchAllTriggers(schema: String?) async throws -> [TriggerInfo] @@ -841,6 +843,8 @@ extension DatabaseDriver { return type } + func generateDropTypeSQL(type: UserDefinedTypeInfo) -> String? { nil } + func fetchAllTriggers(schema: String?) async throws -> [TriggerInfo] { [] } func fetchTriggerDDL(_ trigger: TriggerInfo) async throws -> String { diff --git a/apple/TablePro/Core/Database/DatabaseObjectToolEligibility.swift b/apple/TablePro/Core/Database/DatabaseObjectToolEligibility.swift index b91b31bcdd..07fca94080 100644 --- a/apple/TablePro/Core/Database/DatabaseObjectToolEligibility.swift +++ b/apple/TablePro/Core/Database/DatabaseObjectToolEligibility.swift @@ -5,9 +5,9 @@ import Foundation -/// Which of the per-object commands (Show DDL, Copy DDL, Refresh Materialized View, Edit Comment) -/// apply to a row. The sidebar and the menu bar ask the same functions, so the two can never offer -/// a command to a different set of objects; the sidebar omits what the menu bar disables. +/// Which of the per-object commands (Show DDL, Copy DDL, Refresh Materialized View, Edit Comment, +/// Drop Type) apply to a row. The sidebar and the menu bar ask the same functions, so the two can +/// never offer a command to a different set of objects; the sidebar omits what the menu bar disables. enum DatabaseObjectToolEligibility { /// What the connection's driver can do, read once per menu build rather than per item. /// @@ -16,6 +16,7 @@ enum DatabaseObjectToolEligibility { struct Support: Equatable { var canRefreshMaterializedViews = false var commentableTypes: Set = [] + var droppableTypeKinds: Set = [] static let none = Support() @@ -25,11 +26,15 @@ enum DatabaseObjectToolEligibility { let commentable = TableInfo.TableType.allCases.filter { type in driver.objectCommentStatement(name: "t", objectType: type.rawValue, schema: "s", comment: nil) != nil } + let droppable = UserDefinedTypeInfo.Kind.allCases.filter { kind in + driver.generateDropTypeSQL(type: UserDefinedTypeInfo(name: "t", kind: kind, schema: "s")) != nil + } return Support( canRefreshMaterializedViews: driver.refreshMaterializedViewStatement( name: "t", schema: "s", concurrently: false ) != nil, - commentableTypes: Set(commentable) + commentableTypes: Set(commentable), + droppableTypeKinds: Set(droppable) ) } } @@ -50,4 +55,8 @@ enum DatabaseObjectToolEligibility { guard !isReadOnly, let type else { return false } return support.commentableTypes.contains(type) } + + static func canDropType(_ kind: UserDefinedTypeInfo.Kind, support: Support, isReadOnly: Bool) -> Bool { + !isReadOnly && support.droppableTypeKinds.contains(kind) + } } diff --git a/apple/TablePro/Core/Database/MaterializedViewRefreshing.swift b/apple/TablePro/Core/Database/MaterializedViewRefreshing.swift index 1677d8c3e4..5cdc4d9a8f 100644 --- a/apple/TablePro/Core/Database/MaterializedViewRefreshing.swift +++ b/apple/TablePro/Core/Database/MaterializedViewRefreshing.swift @@ -45,7 +45,7 @@ enum MaterializedViewRefreshing { } try await DatabaseObjectCommandRunner.run( sql, - on: target, + in: target.scope, connection: connection, kind: .maintenance, operationDescription: String(localized: "Refresh Materialized View"), diff --git a/apple/TablePro/Core/Database/ObjectCommentEditing.swift b/apple/TablePro/Core/Database/ObjectCommentEditing.swift index 383e253939..e021cce785 100644 --- a/apple/TablePro/Core/Database/ObjectCommentEditing.swift +++ b/apple/TablePro/Core/Database/ObjectCommentEditing.swift @@ -13,13 +13,11 @@ import TableProPluginKit enum DatabaseObjectCommandError: LocalizedError, Equatable { case notConnected case unsupported - case denied(String) var errorDescription: String? { switch self { case .notConnected: String(localized: "Not connected to database") case .unsupported: String(localized: "This database cannot run this command on this object") - case let .denied(reason): reason } } } @@ -62,7 +60,7 @@ enum ObjectCommentEditing { } try await DatabaseObjectCommandRunner.run( sql, - on: target, + in: target.scope, connection: connection, kind: .schemaMutation, operationDescription: String(localized: "Edit Comment"), @@ -80,7 +78,7 @@ enum ObjectCommentEditing { enum DatabaseObjectCommandRunner { static func run( _ sql: String, - on target: DatabaseObjectTarget, + in scope: DatabaseScope, connection: DatabaseConnection, kind: OperationKind, operationDescription: String, @@ -97,13 +95,8 @@ enum DatabaseObjectCommandRunner { operationDescription: operationDescription ) ) - guard case .authorized = decision else { - throw DatabaseObjectCommandError.denied( - decision.deniedReason ?? String(localized: "Operation not permitted") - ) - } + if let denial = decision.denialError { throw denial } - let scope = target.scope let startedAt = Date() try await DatabaseManager.shared.withScopedDriver( scope: scope, diff --git a/apple/TablePro/Core/Database/UserTypeDropping.swift b/apple/TablePro/Core/Database/UserTypeDropping.swift new file mode 100644 index 0000000000..010d377c7f --- /dev/null +++ b/apple/TablePro/Core/Database/UserTypeDropping.swift @@ -0,0 +1,33 @@ +// +// UserTypeDropping.swift +// TablePro +// + +import Foundation + +@MainActor +enum UserTypeDropping { + // The gate shows the statement and confirms it as destructive, so no prompt of its own is needed. + static func drop( + _ type: UserDefinedTypeInfo, + in scope: DatabaseScope, + connection: DatabaseConnection, + gate: any ExecutionGate = ExecutionGateProvider.shared + ) async throws { + let generated = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + driver.generateDropTypeSQL(type: type) + } + guard let sql = generated else { throw DatabaseObjectCommandError.unsupported } + try await DatabaseObjectCommandRunner.run( + sql, + in: scope, + connection: connection, + kind: .destructiveQuery, + operationDescription: String(localized: "Drop Type"), + gate: gate + ) + CatalogChangeService.shared.record( + .changed(CatalogChange(connectionId: connection.id, database: scope.database, kinds: .types)) + ) + } +} diff --git a/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift b/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift index e334959cb0..b4ac9300f6 100644 --- a/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -744,6 +744,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor UserDefinedTypeInfo(try await pluginDriver.fetchUserDefinedType(type.pluginType)) } + func generateDropTypeSQL(type: UserDefinedTypeInfo) -> String? { + pluginDriver.generateDropTypeSQL(type: type.pluginType) + } + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { let pluginMeta = try await pluginDriver.fetchDatabaseMetadata(database) return Self.databaseMetadata(pluginMeta, systemDatabaseNames: systemDatabaseNames) diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift index 97cd1617a5..bbec7becc0 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift @@ -452,6 +452,7 @@ extension PluginMetadataRegistry { supportsModifyPrimaryKey: false, supportsRoutines: true, supportsDatabaseTriggerBrowse: true, + supportsUserDefinedTypeBrowse: true, supportsOpportunisticTLS: false, supportsClientKeyPassphrase: true, pagination: .leadingRowsOnly(maximumRows: nil), diff --git a/apple/TablePro/Core/Plugins/PluginObjectMapping.swift b/apple/TablePro/Core/Plugins/PluginObjectMapping.swift index 46b0c7fc74..5500e6d41e 100644 --- a/apple/TablePro/Core/Plugins/PluginObjectMapping.swift +++ b/apple/TablePro/Core/Plugins/PluginObjectMapping.swift @@ -204,6 +204,7 @@ extension UserDefinedTypeInfo.Kind { case .aliasType: self = .aliasType case .tableType: self = .tableType case .clrType: self = .clrType + case .udt: self = .udt @unknown default: self = .other } } @@ -217,6 +218,7 @@ extension UserDefinedTypeInfo.Kind { case .aliasType: return .aliasType case .tableType: return .tableType case .clrType: return .clrType + case .udt: return .udt case .other: return nil } } diff --git a/apple/TablePro/Models/Query/UserDefinedTypeInfo.swift b/apple/TablePro/Models/Query/UserDefinedTypeInfo.swift index 7d71876d61..64330cf47e 100644 --- a/apple/TablePro/Models/Query/UserDefinedTypeInfo.swift +++ b/apple/TablePro/Models/Query/UserDefinedTypeInfo.swift @@ -14,6 +14,7 @@ struct UserDefinedTypeInfo: Identifiable, Hashable, Sendable { case aliasType case tableType case clrType + case udt case other /// The engine's own word for the shape. A SQL Server reader has never met a domain or a @@ -28,6 +29,7 @@ struct UserDefinedTypeInfo: Identifiable, Hashable, Sendable { case .aliasType: return String(localized: "Alias Type") case .tableType: return String(localized: "Table Type") case .clrType: return String(localized: "CLR Type") + case .udt: return String(localized: "User-Defined Type") case .other: return String(localized: "Type") } } @@ -41,6 +43,7 @@ struct UserDefinedTypeInfo: Identifiable, Hashable, Sendable { case .aliasType: return "character.textbox" case .tableType: return "tablecells" case .clrType: return "shippingbox" + case .udt: return "rectangle.split.3x1" case .other: return SidebarObjectKind.type.iconName } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseObjectTools.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseObjectTools.swift index 0ec3e8b810..16540b5878 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseObjectTools.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseObjectTools.swift @@ -2,7 +2,7 @@ // MainContentCoordinator+DatabaseObjectTools.swift // TablePro // -// Copy DDL, Refresh Materialized View and Edit Comment on one table-like object. +// Copy DDL, Refresh Materialized View, Edit Comment and Drop Type on one object. // import AppKit @@ -92,6 +92,7 @@ extension MainContentCoordinator { message: String(format: String(localized: "“%@” now holds the current result of its query."), target.qualifiedName), window: contentWindow ) + } catch ExecutionGateError.cancelledByUser { } catch { AlertHelper.showErrorSheet( title: String(localized: "Couldn't Refresh Materialized View"), @@ -102,6 +103,31 @@ extension MainContentCoordinator { } } + // MARK: - Drop Type + + func dropUserType(_ ref: DatabaseTreeUserTypeRef) { + guard !safeModeLevel.blocksAllWrites, + let scope = services.databaseManager.resolvedScope( + database: ref.database, + schema: ref.schema, + for: connectionId + ) + else { return } + Task { + do { + try await UserTypeDropping.drop(ref.type, in: scope, connection: connection) + } catch ExecutionGateError.cancelledByUser { + } catch { + Self.objectToolsLogger.error("Drop Type failed: \(error.publicLogShape, privacy: .public)") + AlertHelper.showErrorSheet( + title: String(localized: "Drop Failed"), + message: error.localizedDescription, + window: contentWindow + ) + } + } + } + // MARK: - Edit Comment func editComment(of ref: DatabaseTreeTableRef) { diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift index 116c87d3ad..ee1de99ca3 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift @@ -158,6 +158,8 @@ extension DatabaseTreeOutlineCoordinator { ClipboardService.shared.writeText(text) case .showObjectSource(let ref): mainCoordinator?.showObjectSource(ref) + case .dropUserType(let ref): + mainCoordinator?.dropUserType(ref) case .refreshRedisKeys: sidebarState?.redisKeyTreeViewModel?.reload() case .copyRedisNamespacePrefix(let prefix): diff --git a/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index d64056b749..59fa1cace4 100644 --- a/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -43,7 +43,7 @@ internal enum DatabaseTreeMenuSpec { case .trigger(let ref): return triggerSections(ref) case .userType(let ref): - return userTypeSections(ref) + return userTypeSections(ref, context: context) case .objectKindSection(let kind): return objectKindSections(kind, context: context) case .containerObjectKindSection(let group): @@ -282,15 +282,26 @@ internal enum DatabaseTreeMenuSpec { ] } - private static func userTypeSections(_ ref: DatabaseTreeUserTypeRef) -> [DatabaseTreeMenuSection] { + private static func userTypeSections( + _ ref: DatabaseTreeUserTypeRef, + context: DatabaseTreeMenuContext + ) -> [DatabaseTreeMenuSection] { var copies: [DatabaseTreeMenuItem] = [.command(String(localized: "Copy Name"), .copyText(ref.type.name))] if ref.type.qualifiedName != ref.type.name { copies.append(.command(String(localized: "Copy Qualified Name"), .copyText(ref.type.qualifiedName))) } - return [ + var sections = [ DatabaseTreeMenuSection(copies), DatabaseTreeMenuSection([.command(String(localized: "Show Definition"), .showObjectSource(ref.objectRef))]) ] + if DatabaseObjectToolEligibility.canDropType( + ref.type.kind, + support: context.objectToolSupport, + isReadOnly: context.isReadOnly + ) { + sections.append(DatabaseTreeMenuSection([.command(String(localized: "Drop Type…"), .dropUserType(ref))])) + } + return sections } private static func redisSections(_ node: RedisKeyNode) -> [DatabaseTreeMenuSection] { diff --git a/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift b/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift index 34978c52ba..a1d62e2525 100644 --- a/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift +++ b/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift @@ -72,6 +72,7 @@ internal enum SidebarMenuCommand: Equatable { case refreshHierarchicalSchema(String) case copyText(String) case showObjectSource(DatabaseObjectRef) + case dropUserType(DatabaseTreeUserTypeRef) case refreshRedisKeys case copyRedisNamespacePrefix(String) case copyRedisKey(String) diff --git a/apple/TablePro/Views/Sidebar/ObjectCommentSheet.swift b/apple/TablePro/Views/Sidebar/ObjectCommentSheet.swift index 411c9f4d76..7044a1a511 100644 --- a/apple/TablePro/Views/Sidebar/ObjectCommentSheet.swift +++ b/apple/TablePro/Views/Sidebar/ObjectCommentSheet.swift @@ -150,6 +150,8 @@ struct ObjectCommentSheet: View { do { try await ObjectCommentEditing.setComment(draft.commentToSave, on: target, connection: connection) dismiss() + } catch ExecutionGateError.cancelledByUser { + phase = .editing } catch { saveError = error.localizedDescription phase = .editing diff --git a/apple/TablePro/Views/Sidebar/UserTypeRowView.swift b/apple/TablePro/Views/Sidebar/UserTypeRowView.swift index 0aaa4855c6..9b3bf88ead 100644 --- a/apple/TablePro/Views/Sidebar/UserTypeRowView.swift +++ b/apple/TablePro/Views/Sidebar/UserTypeRowView.swift @@ -17,7 +17,7 @@ enum UserTypeRowLogic { switch type.kind { case .enumeration where !type.enumLabels.isEmpty: lines.append(type.enumLabels.joined(separator: ", ")) - case .composite, .tableType: + case .composite, .tableType, .udt: if !type.fields.isEmpty { lines.append(type.fields.map { "\($0.name) \($0.type)" }.joined(separator: ", ")) } diff --git a/apple/TableProTests/Core/Database/UserTypeDroppingTests.swift b/apple/TableProTests/Core/Database/UserTypeDroppingTests.swift new file mode 100644 index 0000000000..eaaaaae73a --- /dev/null +++ b/apple/TableProTests/Core/Database/UserTypeDroppingTests.swift @@ -0,0 +1,158 @@ +// +// UserTypeDroppingTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class TypeDropStubDriver: PluginDriverStub, @unchecked Sendable { + private let lock = NSLock() + private var queries: [String] = [] + + let droppableKinds: Set + + init(droppableKinds: Set = [.udt]) { + self.droppableKinds = droppableKinds + } + + var executedQueries: [String] { + lock.withLock { queries } + } + + func execute(query: String) async throws -> PluginQueryResult { + lock.withLock { queries.append(query) } + return .empty + } + + func generateDropTypeSQL(type: PluginUserDefinedTypeInfo) -> String? { + guard droppableKinds.contains(type.kind) else { return nil } + return "DROP TYPE \(type.schema ?? "?").\(type.name)" + } +} + +private struct CancellingGate: ExecutionGate { + func authorize(_ request: OperationRequest) async -> OperationDecision { + .denied(reason: "Operation cancelled by user", cause: .cancelledByUser) + } +} + +@MainActor +struct UserTypeDroppingTests { + private let type = UserDefinedTypeInfo(name: "address", kind: .udt, schema: "shop") + + private func install() async throws -> ( + connection: DatabaseConnection, + scope: DatabaseScope, + session: TypeDropStubDriver, + pooled: TypeDropStubDriver + ) { + let connection = TestFixtures.makeConnection(database: "shop", type: .cassandra) + let sessionStub = TypeDropStubDriver() + DatabaseManager.shared.injectSession( + ConnectionSession( + connection: connection, + driver: PluginDriverAdapter(connection: connection, pluginDriver: sessionStub) + ), + for: connection.id + ) + let scope = DatabaseScope(connectionId: connection.id, database: "shop", schema: nil) + let pooledStub = TypeDropStubDriver() + let pooledAdapter = PluginDriverAdapter(connection: connection, pluginDriver: pooledStub) + try await pooledAdapter.connect() + MetadataConnectionPool.shared.injectEntry(pooledAdapter, scope: scope) + return (connection, scope, sessionStub, pooledStub) + } + + private func uninstall(_ connection: DatabaseConnection) { + MetadataConnectionPool.shared.closeAll(connectionId: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } + + @Test("The drop is built and run on the scope's own connection, never the session's") + func dropRunsOnThePooledConnection() async throws { + let installed = try await install() + defer { uninstall(installed.connection) } + + try await UserTypeDropping.drop( + type, + in: installed.scope, + connection: installed.connection, + gate: AlwaysAllowGate() + ) + + #expect(installed.pooled.executedQueries == ["DROP TYPE shop.address"]) + #expect(installed.session.executedQueries.isEmpty) + } + + @Test("A refused drop runs nothing and says why") + func refusedDropRunsNothing() async throws { + let installed = try await install() + defer { uninstall(installed.connection) } + + await #expect(throws: ExecutionGateError.self) { + try await UserTypeDropping.drop( + type, + in: installed.scope, + connection: installed.connection, + gate: AlwaysDenyGate() + ) + } + #expect(installed.pooled.executedQueries.isEmpty) + } + + @Test("Cancelling the confirmation is reported as a cancel, so the caller stays quiet") + func cancelledDropIsACancel() async throws { + let installed = try await install() + defer { uninstall(installed.connection) } + + var cancelled = false + do { + try await UserTypeDropping.drop(type, in: installed.scope, connection: installed.connection, gate: CancellingGate()) + } catch ExecutionGateError.cancelledByUser { + cancelled = true + } + #expect(cancelled) + #expect(installed.pooled.executedQueries.isEmpty) + } + + @Test("A kind the driver has no statement for is refused before the gate is asked") + func undroppableKindIsUnsupported() async throws { + let installed = try await install() + defer { uninstall(installed.connection) } + let composite = UserDefinedTypeInfo(name: "point", kind: .composite, schema: "shop") + + await #expect(throws: DatabaseObjectCommandError.unsupported) { + try await UserTypeDropping.drop( + composite, + in: installed.scope, + connection: installed.connection, + gate: AlwaysAllowGate() + ) + } + #expect(installed.pooled.executedQueries.isEmpty) + } + + @Test("The adapter hands the driver the type it listed, schema and kind included") + func adapterForwardsTheType() { + let connection = TestFixtures.makeConnection(database: "shop", type: .cassandra) + let adapter = PluginDriverAdapter(connection: connection, pluginDriver: TypeDropStubDriver()) + + #expect(adapter.generateDropTypeSQL(type: type) == "DROP TYPE shop.address") + #expect(adapter.generateDropTypeSQL(type: UserDefinedTypeInfo(name: "p", kind: .composite, schema: "s")) == nil) + } + + @Test("Support lists exactly the kinds the driver can drop") + func supportReadsDroppableKinds() { + let connection = TestFixtures.makeConnection(database: "shop", type: .cassandra) + let adapter = PluginDriverAdapter( + connection: connection, + pluginDriver: TypeDropStubDriver(droppableKinds: [.udt, .enumeration]) + ) + + #expect(DatabaseObjectToolEligibility.Support.of(adapter).droppableTypeKinds == [.udt, .enumeration]) + #expect(DatabaseObjectToolEligibility.Support.of(nil).droppableTypeKinds.isEmpty) + } +} diff --git a/apple/TableProTests/Core/MCP/Protocol/Tools/UserDefinedTypeToolSchemaTests.swift b/apple/TableProTests/Core/MCP/Protocol/Tools/UserDefinedTypeToolSchemaTests.swift index 487d629a0c..be2f63fde8 100644 --- a/apple/TableProTests/Core/MCP/Protocol/Tools/UserDefinedTypeToolSchemaTests.swift +++ b/apple/TableProTests/Core/MCP/Protocol/Tools/UserDefinedTypeToolSchemaTests.swift @@ -26,7 +26,7 @@ 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", "aliasType", "tableType", "clrType"]) + #expect(kinds == ["enum", "composite", "domain", "range", "aliasType", "tableType", "clrType", "udt"]) #expect(kinds?.contains("other") == false) } diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift index f7d4b587b9..6b8b555ef2 100644 --- a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift @@ -359,6 +359,13 @@ struct PluginMetadataRegistryCuratedCapabilityTests { #expect(snapshot.capabilities.supportsDatabaseTriggerBrowse) } + @Test("Cassandra and ScyllaDB list user-defined types before their plugin loads", arguments: ["Cassandra", "ScyllaDB"]) + func cassandraBrowsesUserDefinedTypes(typeId: String) throws { + let snapshot = try #require(PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: typeId)) + + #expect(snapshot.capabilities.supportsUserDefinedTypeBrowse) + } + @Test("Cassandra keeps its structure edits when its plugin registers") func cassandraKeepsStructureEdits() { let built = PluginMetadataRegistry.shared.buildMetadataSnapshot(from: MockCassandraPlugin.self) diff --git a/apple/TableProTests/Models/UserDefinedTypeInfoTests.swift b/apple/TableProTests/Models/UserDefinedTypeInfoTests.swift index b123da205c..ac6d15ba08 100644 --- a/apple/TableProTests/Models/UserDefinedTypeInfoTests.swift +++ b/apple/TableProTests/Models/UserDefinedTypeInfoTests.swift @@ -71,12 +71,31 @@ struct UserDefinedTypeInfoTests { @Test("Every named kind maps both ways") func kindMapping() { - for kind in [PluginUserDefinedTypeKind.enumeration, .composite, .domain, .range] { + let kinds: [PluginUserDefinedTypeKind] = [ + .enumeration, .composite, .domain, .range, .aliasType, .tableType, .clrType, .udt + ] + for kind in kinds { #expect(UserDefinedTypeInfo.Kind(kind).pluginKind == kind) } #expect(UserDefinedTypeInfo.Kind.other.pluginKind == nil) } + @Test("A CQL type is named the way CQL names it, lists its fields on hover, and can type a column") + func cqlUserDefinedType() { + let type = UserDefinedTypeInfo( + name: "address", + kind: .udt, + schema: "shop", + fields: [.init(name: "street", type: "text"), .init(name: "city", type: "text")], + columnTypeSpelling: "address" + ) + + #expect(type.kind.displayName == String(localized: "User-Defined Type")) + #expect(type.kind.isUsableAsColumnType) + #expect(UserTypeRowLogic.tooltip(for: type).contains("street text, city text")) + #expect(UserDefinedTypeSuggestions.entries(types: [type], tableSchema: nil) == ["address"]) + } + @Test("A type ref carries the kind so the viewer knows what it opened") func objectRef() { let type = UserDefinedTypeInfo(name: "mood", kind: .enumeration, schema: "app", identity: "16387") diff --git a/apple/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/apple/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index a16844e0bd..1f7a36a9bd 100644 --- a/apple/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/apple/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -997,6 +997,31 @@ struct DatabaseTreeMenuSpecTests { #expect(issued.filter { if case .copyText = $0 { return true } else { return false } }.count == 1) } + @Test("A type row ends with Drop Type, on its own, when the driver can drop its kind") + func typeRowOffersDrop() { + let ref = userTypeRef("mood") + let support = DatabaseObjectToolEligibility.Support(droppableTypeKinds: [.enumeration]) + let sections = DatabaseTreeMenuSpec.sections(for: context(clicked: .userType(ref), objectToolSupport: support)) + + #expect(sections.last.map { commands([$0]) } == [.dropUserType(ref)]) + #expect(sections.last.map { titles([$0]) } == [String(localized: "Drop Type…")]) + } + + @Test("Drop Type is withheld read-only, for a kind the driver cannot drop, and without a statement") + func typeRowWithholdsDrop() { + let ref = userTypeRef("mood") + let enums = DatabaseObjectToolEligibility.Support(droppableTypeKinds: [.enumeration]) + let udtOnly = DatabaseObjectToolEligibility.Support(droppableTypeKinds: [.udt]) + + for menu in [ + context(clicked: .userType(ref), isReadOnly: true, objectToolSupport: enums), + context(clicked: .userType(ref), objectToolSupport: udtOnly), + context(clicked: .userType(ref)) + ] { + #expect(!commands(DatabaseTreeMenuSpec.sections(for: menu)).contains(.dropUserType(ref))) + } + } + @Test("The Types sections offer Refresh and nothing that creates a type") func typesSectionsOfferOnlyRefresh() { let flat = commands(DatabaseTreeMenuSpec.sections(for: context(clicked: .objectKindSection(.type)))) diff --git a/apple/changelog.d/3719.added.1.md b/apple/changelog.d/3719.added.1.md new file mode 100644 index 0000000000..c78c96601e --- /dev/null +++ b/apple/changelog.d/3719.added.1.md @@ -0,0 +1 @@ +Drop Type… on a type row drops it on PostgreSQL, SQL Server, Cassandra and ScyllaDB. diff --git a/apple/changelog.d/3719.added.md b/apple/changelog.d/3719.added.md new file mode 100644 index 0000000000..1978988221 --- /dev/null +++ b/apple/changelog.d/3719.added.md @@ -0,0 +1 @@ +Cassandra and ScyllaDB list user-defined types under Types and offer them in the column type picker. diff --git a/apple/changelog.d/3719.fixed.1.md b/apple/changelog.d/3719.fixed.1.md new file mode 100644 index 0000000000..580e60ce1c --- /dev/null +++ b/apple/changelog.d/3719.fixed.1.md @@ -0,0 +1 @@ +A Cassandra function argument whose name starts or ends with a space or a quote lost it in the definition. diff --git a/apple/changelog.d/3719.fixed.md b/apple/changelog.d/3719.fixed.md new file mode 100644 index 0000000000..11f758f30f --- /dev/null +++ b/apple/changelog.d/3719.fixed.md @@ -0,0 +1 @@ +Cancelling the confirmation of Refresh Materialized View or Edit Comment showed it as an error. diff --git a/apple/scripts/probes/check-cassandra-user-types.sh b/apple/scripts/probes/check-cassandra-user-types.sh new file mode 100755 index 0000000000..d67a4ef7a5 --- /dev/null +++ b/apple/scripts/probes/check-cassandra-user-types.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# +# Check CassandraUserTypes against a live server: the rebuilt CREATE TYPE round-trips, the +# column picker's spelling is accepted and recorded as spelled, the bare name is refused exactly +# where the spelling says frozen, DROP TYPE is refused while a table uses the type, and the +# hard-coded reserved keywords cover the server's own list. +# +# Usage: +# apple/scripts/probes/check-cassandra-user-types.sh [container] +# +# Needs Docker and a Cassandra container, by default named cassandra-probe: +# docker run -d --name cassandra-probe -e MAX_HEAP_SIZE=768M -e HEAP_NEWSIZE=128M cassandra:5.0 +# Exits non-zero on a disagreement, 3 when a prerequisite is missing. + +set -uo pipefail + +CONTAINER="${1:-cassandra-probe}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PLUGIN="$ROOT/Plugins/CassandraDriverPlugin" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +command -v docker > /dev/null || { echo "docker not found" >&2; exit 3; } +docker exec "$CONTAINER" cqlsh -e "SELECT release_version FROM system.local" > /dev/null 2>&1 || { + echo "no Cassandra answering cqlsh in container $CONTAINER" >&2 + exit 3 +} +if [ -z "${DEVELOPER_DIR:-}" ]; then + DEVELOPER_DIR="$(xcode-select -p)" + case "$DEVELOPER_DIR" in + *CommandLineTools*) DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer ;; + esac +fi +export DEVELOPER_DIR + +cat > "$WORK/main.swift" << 'SWIFT' +import Foundation + +let input = FileHandle.standardInput.readDataToEndOfFile() +let rows = (try JSONSerialization.jsonObject(with: input) as? [[String: String]]) ?? [] +let types: [[String: Any]] = rows.map { row in + let name = row["name"] ?? "" + let keyspace = row["keyspace"] ?? "" + let fields = CassandraUserTypes.fields(names: row["names"], types: row["types"]) + return [ + "name": name, + "definition": fields.map { CassandraUserTypes.definition(keyspace: keyspace, name: name, fields: $0) as Any } + ?? NSNull(), + "spelling": CassandraUserTypes.columnTypeSpelling(name: name, fields: fields, releaseVersion: row["release"]), + "bare": CassandraUserTypes.identifier(name), + "drop": CassandraUserTypes.dropStatement(keyspace: keyspace, name: name), + ] +} +let output: [String: Any] = ["types": types, "reserved": CassandraUserTypes.reservedKeywords.sorted()] +let data = try JSONSerialization.data(withJSONObject: output) +print(String(decoding: data, as: UTF8.self)) +SWIFT +xcrun swiftc -o "$WORK/spell" "$WORK/main.swift" "$PLUGIN/CassandraObjectQueries.swift" "$PLUGIN/CassandraUserTypes.swift" \ + > "$WORK/build.log" 2>&1 || { + echo "the probe failed to build:" >&2 + grep -E 'error:' "$WORK/build.log" | sort -u | head -20 >&2 + exit 3 +} + +docker cp "$CONTAINER:/opt/cassandra/lib/." "$WORK/lib" > /dev/null 2>&1 +JAR="$(ls "$WORK"/lib/apache-cassandra-[0-9]*.jar 2> /dev/null | head -1)" +SERVER_RESERVED="" +if [ -n "$JAR" ]; then + SERVER_RESERVED="$(unzip -p "$JAR" org/apache/cassandra/cql3/reserved_keywords.txt 2> /dev/null)" +fi + +CONTAINER="$CONTAINER" SPELL="$WORK/spell" SERVER_RESERVED="$SERVER_RESERVED" RUN_ID="$$" python3 -I - << 'PYTHON' +import json, os, subprocess, sys + +container = os.environ["CONTAINER"] +source = "tp_udt_src_" + os.environ["RUN_ID"] +target = "tp_udt_dst_" + os.environ["RUN_ID"] +failures = [] + + +def cql(statement): + run = subprocess.run(["docker", "exec", container, "cqlsh", "-e", statement], capture_output=True, text=True) + return run.returncode == 0 and "Error" not in run.stderr, (run.stdout + run.stderr).strip() + + +def rows(statement): + ok, out = cql(statement) + if not ok: + sys.exit("read failed: " + out) + # cqlsh prints a backslash in a text value doubled. + return [json.loads(line.strip().replace("\\\\", "\\")) + for line in out.splitlines() if line.strip().startswith("{")] + + +def driver_list(values): + return "[" + ", ".join(values) + "]" + + +def check(condition, message): + if not condition: + failures.append(message) + + +replication = "{'class': 'SimpleStrategy', 'replication_factor': 1}" +try: + for keyspace in (source, target): + cql(f"CREATE KEYSPACE {keyspace} WITH replication = {replication}") + fixture = [ + "CREATE TYPE {k}.address (street text, city text)", + "CREATE TYPE {k}.phone (kind text, number text)", + "CREATE TYPE {k}.contact (home frozen
, phones list>, tags set, extra map)", + "CREATE TYPE {k}.\"MixedCase\" (a int, \"Quoted Field\" text)", + "CREATE TYPE {k}.\"select\" (a int)", + "CREATE TYPE {k}.ref (m frozen<\"MixedCase\">, s frozen<\"select\">)", + "CREATE TYPE {k}.\"weird\" (\"a, b\" int, c text)", + "CREATE TYPE {k}.odd (\"customer'\" text, \" padded \" int, \"say \"\"hi\"\"\" text)", + "CREATE TYPE {k}.commas (\"a,b\" int, c text)", + "CREATE TYPE {k}.angles (\"a<\" int, \"b>\" text)", + "CREATE TYPE {k}.\"My, Type\" (v int)", + "CREATE TYPE {k}.uses_comma (m frozen<\"My, Type\">, n int)", + "CREATE TYPE {k}.\"text\" (v int)", + "CREATE TYPE {k}.\"byte\" (v int)", + ] + for statement in fixture: + ok, out = cql(statement.format(k=source)) + if not ok: + sys.exit("fixture failed: " + out) + if not cql(f"CREATE TYPE {source}.withfrozen (tags frozen>, pair tuple, v vector)")[0]: + cql(f"CREATE TYPE {source}.withfrozen (tags frozen>, pair tuple)") + + release = rows("SELECT JSON release_version FROM system.local")[0]["release_version"] + listed = {row["type_name"]: row for row in rows( + f"SELECT JSON type_name, field_names, field_types FROM system_schema.types WHERE keyspace_name = '{source}'")} + request = [{ + "name": name, + "keyspace": target, + "names": driver_list(row["field_names"]), + "types": driver_list(row["field_types"]), + "release": release, + } for name, row in listed.items()] + spelled = subprocess.run([os.environ["SPELL"]], input=json.dumps(request), capture_output=True, text=True, check=True) + output = json.loads(spelled.stdout) + built = {entry["name"]: entry for entry in output["types"]} + print(f"Cassandra {release}: {len(built)} types") + + check(built["weird"]["definition"] is None, "a field name holding ', ' still produced a definition") + for name, entry in built.items(): + check(entry["definition"] or name == "weird", f"{name}: no definition") + check(built["weird"]["spelling"].startswith("frozen<"), "a type with unreadable fields was not spelled frozen") + + pending = [name for name in built if built[name]["definition"]] + for _ in range(len(pending)): + pending = [name for name in pending if not cql(built[name]["definition"])[0]] + check(not pending, f"rebuilt definitions refused: {pending}") + rebuilt = {row["type_name"]: row for row in rows( + f"SELECT JSON type_name, field_names, field_types FROM system_schema.types WHERE keyspace_name = '{target}'")} + for name, entry in built.items(): + if not entry["definition"]: + continue + original, copy = listed[name], rebuilt.get(name) + check(copy is not None and copy["field_names"] == original["field_names"] + and copy["field_types"] == original["field_types"], + f"{name}: rebuilt as {copy} from {original}") + + cql(f"CREATE TABLE {source}.holder (id int PRIMARY KEY)") + for index, (name, entry) in enumerate(sorted(built.items())): + ok, out = cql(f"ALTER TABLE {source}.holder ADD c{index} {entry['spelling']}") + check(ok, f"{name}: picker spelling {entry['spelling']} refused: {out}") + recorded = rows(f"SELECT JSON type FROM system_schema.columns WHERE keyspace_name = '{source}' " + f"AND table_name = 'holder' AND column_name = 'c{index}'") + if name in ("text", "byte"): + # system_schema records a UDT called "text" as text, so only a UDT literal tells it from the native type. + ok, out = cql(f"INSERT INTO {source}.holder (id, c{index}) VALUES ({index}, {{v: 1}})") + check(ok, f"{name}: spelled {entry['spelling']}, the column is not the type: {out}") + else: + check(recorded and recorded[0]["type"] == entry["spelling"], + f"{name}: spelled {entry['spelling']}, recorded {recorded}") + if entry["spelling"].startswith("frozen<"): + ok, out = cql(f"ALTER TABLE {source}.holder ADD b{index} {entry['bare']}") + check(not ok or name == "weird", f"{name}: spelled frozen, yet the bare name was accepted") + + ok, out = cql(built["phone"]["drop"]) + check(not ok and "still used" in out, f"DROP of a type another type uses was not refused: {out}") + ok, out = cql(built["ref"]["drop"]) + check(ok, f"DROP of an unused type failed: {out}") + cql(f"CREATE TABLE {target}.holder (id int PRIMARY KEY, m frozen<\"MixedCase\">)") + ok, out = cql(built["MixedCase"]["drop"]) + check(not ok and "still used" in out, f"DROP of a type a table uses was not refused: {out}") + cql(f"DROP TABLE {target}.holder") + ok, out = cql(built["MixedCase"]["drop"]) + check(ok, f"DROP of a quoted name failed once nothing used it: {out}") + + server_reserved = set(os.environ["SERVER_RESERVED"].split()) + if server_reserved: + missing = sorted(server_reserved - set(output["reserved"])) + check(not missing, f"reserved keywords the code does not quote: {missing}") + else: + print("reserved_keywords.txt not found in the server jar, skipped") +finally: + for keyspace in (source, target): + cql(f"DROP KEYSPACE IF EXISTS {keyspace}") + +for failure in failures: + print("FAIL " + failure) +print("PASS" if not failures else f"{len(failures)} disagreement(s)") +sys.exit(1 if failures else 0) +PYTHON