Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 41 additions & 22 deletions apple/Plugins/CassandraDriverPlugin/CassandraObjectQueries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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<map<text, int>>` 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<map<text, int>>` 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(
Expand Down
1 change: 1 addition & 0 deletions apple/Plugins/CassandraDriverPlugin/CassandraPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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) }
)
}
}
98 changes: 98 additions & 0 deletions apple/Plugins/CassandraDriverPlugin/CassandraUserTypes.swift
Original file line number Diff line number Diff line change
@@ -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<String> = [
"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<String> = [
"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)
}
}
4 changes: 4 additions & 0 deletions apple/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions apple/Plugins/PostgreSQLDriverTests/PostgreSQLTypeQueryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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\"\"\"")
}
}
}
3 changes: 3 additions & 0 deletions apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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] { [] }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions apple/TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 13 additions & 4 deletions apple/TablePro/Core/Database/DatabaseObjectToolEligibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -16,6 +16,7 @@ enum DatabaseObjectToolEligibility {
struct Support: Equatable {
var canRefreshMaterializedViews = false
var commentableTypes: Set<TableInfo.TableType> = []
var droppableTypeKinds: Set<UserDefinedTypeInfo.Kind> = []

static let none = Support()

Expand All @@ -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)
)
}
}
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading
Loading