diff --git a/CHANGELOG.md b/CHANGELOG.md index 533ba88690..fad92e618e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Types section in the sidebar for PostgreSQL enums, composites, domains and ranges, with a definition tab and enum label editing. (#2484) +- User-defined types in the structure editor's type picker. (#2484) +- `list_types` MCP tool. - Value picker on a foreign key cell, listing rows from the referenced table with a label beside the key. (#2511) - Breakdown of a query's time into server, first row and transfer, behind the toolbar's duration readout. (#2503) - Exclude the AUTO_INCREMENT counter and Exclude DEFINER clauses in the SQL export, both on by default. (#2516) diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift index 97d14b5f10..9ae3b4ae29 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift @@ -10,7 +10,23 @@ struct PostgreSQLCapabilities: Sendable, Equatable { static let unknown = PostgreSQLCapabilities(serverVersion: 0) + /// libpq answers 0 for a handle it has not connected. A catalog query built for that has to + /// assume a current server, or it emits the legacy projection on every server that exists. + static func assumingModernWhenUnknown(_ serverVersion: Int32) -> PostgreSQLCapabilities { + PostgreSQLCapabilities(serverVersion: serverVersion <= 0 ? Int32.max : serverVersion) + } + var hasMaterializedViewsCatalog: Bool { serverVersion >= 90_300 } + var hasRangeTypes: Bool { serverVersion >= 90_200 } + var hasJsonBuildObject: Bool { serverVersion >= 90_400 } + var hasEnumLabelPlacement: Bool { serverVersion >= 90_100 } + /// ADD VALUE IF NOT EXISTS landed in 9.3. With it an add is idempotent, which is what makes + /// the driver's one reconnect-and-resend safe for a label that already landed. + var hasEnumAddValueIfNotExists: Bool { serverVersion >= 90_300 } + /// ALTER TYPE ... RENAME VALUE landed in 10; before it a label can only be added. + var hasRenameEnumValue: Bool { serverVersion >= 100_000 } + /// Every range gained a companion multirange in 14, with a name the creator may choose. + var hasMultirangeTypes: Bool { serverVersion >= 140_000 } var hasForeignTablesCatalog: Bool { serverVersion >= 90_100 } var hasSequencesCatalog: Bool { serverVersion >= 90_500 } var hasBypassRLS: Bool { serverVersion >= 90_500 } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift index c44b598e2a..b7b5f544eb 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift @@ -2,16 +2,38 @@ // PostgreSQLObjectQueries.swift // PostgreSQLDriverPlugin // -// Catalog SQL for routines and triggers. Pure, so it is testable without a server. +// Catalog SQL for routines, triggers and user-defined types. Pure, so it is testable without a +// server. // import Foundation +import TableProPluginKit public enum PostgreSQLObjectQueries { public static func escapeLiteral(_ value: String) -> String { value.replacingOccurrences(of: "'", with: "''") } + /// A literal that reads the same whatever `standard_conforming_strings` is set to. Doubling the + /// quote is enough while the value has no backslash; with one, a server running the legacy + /// setting would let `\'` swallow a doubled quote and close the literal early, so such a value + /// is written as an `E''` string, where a backslash is always an escape and is doubled here. + public static func quoteLiteral(_ value: String) -> String { + guard value.contains("\\") else { return "'\(escapeLiteral(value))'" } + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "''") + return "E'\(escaped)'" + } + + public static func quoteIdentifier(_ name: String) -> String { + "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + public static func qualifiedName(schema: String, name: String) -> String { + "\(quoteIdentifier(schema)).\(quoteIdentifier(name))" + } + /// `prokind` arrived in PostgreSQL 11, which is also the first release with procedures. public static let prokindMinimumServerVersion: Int32 = 110_000 @@ -121,4 +143,147 @@ public enum PostgreSQLObjectQueries { ORDER BY c.relname, t.tgname """ } + + /// The named types a user created, with everything the CREATE statement needs. + /// + /// Every table owns a composite type of its own row, so composites are kept only where the + /// backing relation is a stand-alone type (`relkind = 'c'`). Types an extension installed are + /// excluded the way extension routines are. Arrays and the multirange PostgreSQL 14 creates + /// beside every range are other `typtype`s and never match. PostgreSQL 17 records a domain's + /// NOT NULL as a constraint row too, so the constraint list keeps CHECK constraints alone and + /// NOT NULL comes from `typnotnull`. The projection order is `PostgreSQLTypeDefinition.Column`. + /// + /// A listing names a schema; a reload names an oid and no schema, because a type keeps its oid + /// when it is moved. Naming neither would list the whole database, so a caller passes one. + public static func userDefinedTypeList(schema: String?, identity: String?, serverVersionNumber: Int32) -> String { + let capabilities = PostgreSQLCapabilities.assumingModernWhenUnknown(serverVersionNumber) + let schemaPredicate = schema.map { "AND n.nspname = \(quoteLiteral($0))" } ?? "" + let identityPredicate = identity.flatMap { UInt32($0) }.map { "AND t.oid = \($0)::oid" } ?? "" + let kinds = capabilities.hasRangeTypes ? "('e', 'c', 'd', 'r')" : "('e', 'c', 'd')" + let fields = capabilities.hasJsonBuildObject ? """ + (SELECT json_agg(json_build_object( + 'name', a.attname, + 'type', pg_catalog.format_type(a.atttypid, a.atttypmod), + 'collation', CASE WHEN a.attcollation <> 0 AND a.attcollation <> ty.typcollation + THEN \(collationName("a.attcollation")) END) ORDER BY a.attnum) + FROM pg_catalog.pg_attribute a + JOIN pg_catalog.pg_type ty ON ty.oid = a.atttypid + WHERE a.attrelid = t.typrelid AND a.attnum > 0 AND NOT a.attisdropped)::text + """ : "NULL::text" + let constraints = capabilities.hasJsonBuildObject ? """ + (SELECT json_agg(json_build_object('name', con.conname, 'definition', pg_catalog.pg_get_constraintdef(con.oid)) ORDER BY con.conname) + FROM pg_catalog.pg_constraint con + WHERE con.contypid = t.oid AND con.contype = 'c')::text + """ : "NULL::text" + let rangeJoin = capabilities.hasRangeTypes ? "LEFT JOIN pg_catalog.pg_range r ON r.rngtypid = t.oid" : "" + let rangeSubtype = capabilities.hasRangeTypes + ? "CASE WHEN t.typtype = 'r' THEN pg_catalog.format_type(r.rngsubtype, NULL) END" + : "NULL::text" + let rangeCanonical = capabilities.hasRangeTypes + ? "CASE WHEN t.typtype = 'r' AND r.rngcanonical <> 0 THEN r.rngcanonical::regproc::text END" + : "NULL::text" + let rangeSubtypeDiff = capabilities.hasRangeTypes + ? "CASE WHEN t.typtype = 'r' AND r.rngsubdiff <> 0 THEN r.rngsubdiff::regproc::text END" + : "NULL::text" + let rangeOpclass = capabilities.hasRangeTypes ? """ + CASE WHEN t.typtype = 'r' THEN + (SELECT pg_catalog.quote_ident(opn.nspname) || '.' || pg_catalog.quote_ident(opc.opcname) + FROM pg_catalog.pg_opclass opc + JOIN pg_catalog.pg_namespace opn ON opn.oid = opc.opcnamespace + WHERE opc.oid = r.rngsubopc AND NOT opc.opcdefault) + END + """ : "NULL::text" + let rangeCollation = capabilities.hasRangeTypes ? """ + CASE WHEN t.typtype = 'r' AND r.rngcollation <> 0 + AND r.rngcollation <> (SELECT st.typcollation FROM pg_catalog.pg_type st WHERE st.oid = r.rngsubtype) + THEN \(collationName("r.rngcollation")) + END + """ : "NULL::text" + let rangeMultirange = capabilities.hasMultirangeTypes ? """ + CASE WHEN t.typtype = 'r' THEN + (SELECT pg_catalog.quote_ident(mn.nspname) || '.' || pg_catalog.quote_ident(m.typname) + FROM pg_catalog.pg_type m + JOIN pg_catalog.pg_namespace mn ON mn.oid = m.typnamespace + WHERE m.oid = r.rngmultitypid) + END + """ : "NULL::text" + return """ + SELECT + t.oid::text AS identity, + t.typname AS name, + n.nspname AS schema, + t.typtype::text AS kind, + pg_catalog.pg_get_userbyid(t.typowner) AS owner, + pg_catalog.obj_description(t.oid, 'pg_type') AS comment, + (SELECT json_agg(e.enumlabel ORDER BY e.enumsortorder) + FROM pg_catalog.pg_enum e WHERE e.enumtypid = t.oid)::text AS enum_labels, + \(fields) AS fields, + CASE WHEN t.typtype = 'd' THEN pg_catalog.format_type(t.typbasetype, t.typtypmod) END AS base_type, + CASE WHEN t.typtype = 'd' AND t.typcollation <> 0 + AND t.typcollation <> (SELECT b.typcollation FROM pg_catalog.pg_type b WHERE b.oid = t.typbasetype) + THEN \(collationName("t.typcollation")) + END AS collation, + t.typnotnull::text AS not_null, + t.typdefault AS default_value, + \(constraints) AS constraints, + \(rangeSubtype) AS range_subtype, + \(rangeCanonical) AS range_canonical, + \(rangeSubtypeDiff) AS range_subtype_diff, + \(rangeOpclass) AS range_opclass, + \(rangeCollation) AS range_collation, + \(rangeMultirange) AS range_multirange, + pg_catalog.quote_ident(n.nspname) || '.' || pg_catalog.quote_ident(t.typname) AS spelling + FROM pg_catalog.pg_type t + JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace + LEFT JOIN pg_catalog.pg_class c ON c.oid = t.typrelid + \(rangeJoin) + WHERE t.typtype IN \(kinds) + AND (t.typtype <> 'c' OR c.relkind = 'c') + AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_depend d + WHERE d.classid = 'pg_catalog.pg_type'::regclass AND d.objid = t.oid AND d.deptype = 'e' + ) + \(schemaPredicate) + \(identityPredicate) + ORDER BY t.typname + """ + } + + /// The qualified, quoted name of a collation oid, spelled the way a COLLATE clause takes it. + private static func collationName(_ oidExpression: String) -> String { + """ + (SELECT pg_catalog.quote_ident(cn.nspname) || '.' || pg_catalog.quote_ident(co.collname) + FROM pg_catalog.pg_collation co + JOIN pg_catalog.pg_namespace cn ON cn.oid = co.collnamespace + WHERE co.oid = \(oidExpression)) + """ + } + + public static func addEnumLabel( + schema: String, + name: String, + label: String, + placement: PluginEnumLabelPlacement?, + ifNotExists: Bool + ) -> String { + let clause = ifNotExists ? "ADD VALUE IF NOT EXISTS" : "ADD VALUE" + var statement = "ALTER TYPE \(qualifiedName(schema: schema, name: name)) \(clause) \(quoteLiteral(label))" + if let placement { + statement += " \(placement.placesBefore ? "BEFORE" : "AFTER") \(quoteLiteral(placement.anchor))" + } + return statement + } + + public static func renameEnumLabel(schema: String, name: String, from oldLabel: String, to newLabel: String) -> String { + "ALTER TYPE \(qualifiedName(schema: schema, name: name)) RENAME VALUE \(quoteLiteral(oldLabel)) TO \(quoteLiteral(newLabel))" + } + + public static func createTypeTemplate(schema: String) -> String { + """ + CREATE TYPE \(qualifiedName(schema: schema, name: "type_name")) AS ENUM ( + 'value_1', + 'value_2' + ); + """ + } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index 66c7a5d67e..2b6aa1a396 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -90,6 +90,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsTriggers = true static let supportsRoutines = true static let supportsDatabaseTriggerBrowse = true + static let supportsUserDefinedTypeBrowse = true static let supportsTriggerEditing = true static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue, .generated, .generationExpression, .autoIncrement, .comment] diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Types.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Types.swift new file mode 100644 index 0000000000..121ed60f43 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Types.swift @@ -0,0 +1,89 @@ +// +// PostgreSQLPluginDriver+Types.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension PostgreSQLPluginDriver { + func fetchUserDefinedTypes(schema: String?) async throws -> [PluginUserDefinedTypeInfo] { + let resolvedSchema = schema ?? currentSchema ?? "public" + let query = PostgreSQLObjectQueries.userDefinedTypeList( + schema: resolvedSchema, + identity: nil, + serverVersionNumber: serverVersionNumber + ) + let result = try await execute(query: query) + return result.rows + .compactMap(PostgreSQLTypeDefinition.record(from:)) + .map(PostgreSQLTypeDefinition.info(from:)) + } + + /// An identity is the oid this driver handed out, so one that is not an oid is refused rather + /// than widened into a schema listing that would hand back whichever type sorts first. The + /// oid is looked up without a schema predicate, because a type moved to another schema keeps + /// it, and the row that comes back is checked against it before it is believed. + func fetchUserDefinedType(_ type: PluginUserDefinedTypeInfo) async throws -> PluginUserDefinedTypeInfo { + if let identity = type.identity { + guard let oid = UInt32(identity) else { throw PluginObjectSourceError.notFound(type.name) } + let query = PostgreSQLObjectQueries.userDefinedTypeList( + schema: nil, + identity: String(oid), + serverVersionNumber: serverVersionNumber + ) + let result = try await execute(query: query) + let match = result.rows + .compactMap(PostgreSQLTypeDefinition.record(from:)) + .first { $0.identity == String(oid) } + guard let match else { throw PluginObjectSourceError.notFound(type.name) } + return PostgreSQLTypeDefinition.info(from: match) + } + let resolvedSchema = type.schema ?? currentSchema ?? "public" + let query = PostgreSQLObjectQueries.userDefinedTypeList( + schema: resolvedSchema, + identity: nil, + serverVersionNumber: serverVersionNumber + ) + let result = try await execute(query: query) + let match = result.rows + .compactMap(PostgreSQLTypeDefinition.record(from:)) + .first { $0.name == type.name } + guard let match else { throw PluginObjectSourceError.notFound(type.name) } + return PostgreSQLTypeDefinition.info(from: match) + } + + func createTypeTemplate(schema: String?) -> String? { + PostgreSQLObjectQueries.createTypeTemplate(schema: schema ?? currentSchema ?? "public") + } + + func generateAddEnumLabelSQL( + type: PluginUserDefinedTypeInfo, + label: String, + placement: PluginEnumLabelPlacement? + ) -> String? { + guard type.kind == .enumeration else { return nil } + if placement != nil, !versionedCapabilities.hasEnumLabelPlacement { return nil } + return PostgreSQLObjectQueries.addEnumLabel( + schema: type.schema ?? currentSchema ?? "public", + name: type.name, + label: label, + placement: placement, + ifNotExists: versionedCapabilities.hasEnumAddValueIfNotExists + ) + } + + func generateRenameEnumLabelSQL( + type: PluginUserDefinedTypeInfo, + from oldLabel: String, + to newLabel: String + ) -> String? { + guard type.kind == .enumeration, versionedCapabilities.hasRenameEnumValue else { return nil } + return PostgreSQLObjectQueries.renameEnumLabel( + schema: type.schema ?? currentSchema ?? "public", + name: type.name, + from: oldLabel, + to: newLabel + ) + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLTypeDefinition.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTypeDefinition.swift new file mode 100644 index 0000000000..397b9661a9 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTypeDefinition.swift @@ -0,0 +1,316 @@ +// +// PostgreSQLTypeDefinition.swift +// PostgreSQLDriverPlugin +// +// PostgreSQL has no pg_get_typedef, so a type's CREATE statement is rebuilt from the catalog row +// the listing already read. Pure, so the shape of every statement is pinned by a test. +// + +import Foundation +import TableProPluginKit + +public struct PostgreSQLDomainConstraint: Sendable, Equatable { + public let name: String + public let definition: String + + public init(name: String, definition: String) { + self.name = name + self.definition = definition + } +} + +public struct PostgreSQLUserDefinedTypeRecord: Sendable, Equatable { + public enum Kind: String, Sendable { + case enumeration = "e" + case composite = "c" + case domain = "d" + case range = "r" + } + + public let identity: String + public let name: String + public let schema: String + public let kind: Kind + public let owner: String? + public let comment: String? + public let enumLabels: [String] + public let fields: [PluginUserDefinedTypeField] + public let baseType: String? + + /// A domain's collation, only when it differs from the base type's own. Already quoted. + public let collation: String? + public let isNotNull: Bool + public let defaultValue: String? + public let constraints: [PostgreSQLDomainConstraint] + public let rangeSubtype: String? + public let rangeCanonical: String? + public let rangeSubtypeDiff: String? + + /// The subtype's operator class, qualified and quoted, only when it is not the default one. + public let rangeOpclass: String? + + /// The range's collation, only when it differs from the subtype's own. + public let rangeCollation: String? + + /// The companion multirange's qualified name, on PostgreSQL 14 and later. + public let rangeMultirange: String? + + /// The engine's own qualified, quoted spelling of the type, for a column definition. + public let spelling: String? + + public init( + identity: String, + name: String, + schema: String, + kind: Kind, + owner: String? = nil, + comment: String? = nil, + enumLabels: [String] = [], + fields: [PluginUserDefinedTypeField] = [], + baseType: String? = nil, + collation: String? = nil, + isNotNull: Bool = false, + defaultValue: String? = nil, + constraints: [PostgreSQLDomainConstraint] = [], + rangeSubtype: String? = nil, + rangeCanonical: String? = nil, + rangeSubtypeDiff: String? = nil, + rangeOpclass: String? = nil, + rangeCollation: String? = nil, + rangeMultirange: String? = nil, + spelling: String? = nil + ) { + self.identity = identity + self.name = name + self.schema = schema + self.kind = kind + self.owner = owner + self.comment = comment + self.enumLabels = enumLabels + self.fields = fields + self.baseType = baseType + self.collation = collation + self.isNotNull = isNotNull + self.defaultValue = defaultValue + self.constraints = constraints + self.rangeSubtype = rangeSubtype + self.rangeCanonical = rangeCanonical + self.rangeSubtypeDiff = rangeSubtypeDiff + self.rangeOpclass = rangeOpclass + self.rangeCollation = rangeCollation + self.rangeMultirange = rangeMultirange + self.spelling = spelling + } +} + +public enum PostgreSQLTypeDefinition { + /// The projection `PostgreSQLObjectQueries.userDefinedTypeList` selects, in order. The parser + /// reads by these positions, so the query and the parser cannot drift apart silently. + public enum Column: Int, CaseIterable { + case identity + case name + case schema + case kind + case owner + case comment + case enumLabels + case fields + case baseType + case collation + case isNotNull + case defaultValue + case constraints + case rangeSubtype + case rangeCanonical + case rangeSubtypeDiff + case rangeOpclass + case rangeCollation + case rangeMultirange + case spelling + } + + public static func record(from row: [PluginCellValue]) -> PostgreSQLUserDefinedTypeRecord? { + guard let identity = text(row, .identity), + let name = text(row, .name), + let schema = text(row, .schema), + let kind = text(row, .kind).flatMap(PostgreSQLUserDefinedTypeRecord.Kind.init(rawValue:)) + else { return nil } + return PostgreSQLUserDefinedTypeRecord( + identity: identity, + name: name, + schema: schema, + kind: kind, + owner: text(row, .owner), + comment: text(row, .comment), + enumLabels: jsonStrings(text(row, .enumLabels)), + fields: jsonObjects(text(row, .fields)).compactMap { object in + guard let name = object["name"], let type = object["type"] else { return nil } + return PluginUserDefinedTypeField(name: name, type: type, collation: object["collation"]) + }, + baseType: text(row, .baseType), + collation: text(row, .collation), + isNotNull: text(row, .isNotNull) == "t" || text(row, .isNotNull) == "true", + defaultValue: text(row, .defaultValue), + constraints: jsonObjects(text(row, .constraints)).compactMap { object in + guard let name = object["name"], let definition = object["definition"] else { return nil } + return PostgreSQLDomainConstraint(name: name, definition: definition) + }, + rangeSubtype: text(row, .rangeSubtype), + rangeCanonical: text(row, .rangeCanonical), + rangeSubtypeDiff: text(row, .rangeSubtypeDiff), + rangeOpclass: text(row, .rangeOpclass), + rangeCollation: text(row, .rangeCollation), + rangeMultirange: text(row, .rangeMultirange), + spelling: text(row, .spelling) + ) + } + + public static func info(from record: PostgreSQLUserDefinedTypeRecord) -> PluginUserDefinedTypeInfo { + PluginUserDefinedTypeInfo( + name: record.name, + kind: kind(of: record), + schema: record.schema, + identity: record.identity, + enumLabels: record.enumLabels, + fields: record.fields, + baseType: record.baseType ?? record.rangeSubtype, + columnTypeSpelling: record.spelling, + definition: ddl(for: record), + attributes: attributes(of: record) + ) + } + + /// What PostgreSQL names the companion multirange when the creator did not: the last `range` + /// in the name becomes `multirange`, and a name without one gains `_multirange`. + public static func defaultMultirangeName(for rangeName: String) -> String { + guard let range = rangeName.range(of: "range", options: .backwards) else { + return rangeName + "_multirange" + } + return rangeName.replacingCharacters(in: range, with: "multirange") + } + + public static func ddl(for record: PostgreSQLUserDefinedTypeRecord) -> String { + let qualifiedName = qualifiedName(of: record) + switch record.kind { + case .enumeration: + let labels = record.enumLabels + .map { " \(PostgreSQLObjectQueries.quoteLiteral($0))" } + .joined(separator: ",\n") + return "CREATE TYPE \(qualifiedName) AS ENUM (\n\(labels)\n);" + case .composite: + let fields = record.fields + .map { field -> String in + var line = " \(PostgreSQLObjectQueries.quoteIdentifier(field.name)) \(field.type)" + if let collation = field.collation, !collation.isEmpty { line += " COLLATE \(collation)" } + return line + } + .joined(separator: ",\n") + return "CREATE TYPE \(qualifiedName) AS (\n\(fields)\n);" + case .domain: + return domainDDL(for: record, qualifiedName: qualifiedName) + case .range: + return rangeDDL(for: record, qualifiedName: qualifiedName) + } + } + + private static func domainDDL(for record: PostgreSQLUserDefinedTypeRecord, qualifiedName: String) -> String { + var clauses: [String] = [] + if let defaultValue = record.defaultValue, !defaultValue.isEmpty { + clauses.append("DEFAULT \(defaultValue)") + } + if record.isNotNull { + clauses.append("NOT NULL") + } + clauses += record.constraints.map { + "CONSTRAINT \(PostgreSQLObjectQueries.quoteIdentifier($0.name)) \($0.definition)" + } + var head = "CREATE DOMAIN \(qualifiedName) AS \(record.baseType ?? "")" + if let collation = record.collation, !collation.isEmpty { + head += " COLLATE \(collation)" + } + guard !clauses.isEmpty else { return "\(head);" } + return head + "\n" + clauses.map { " \($0)" }.joined(separator: "\n") + ";" + } + + private static func rangeDDL(for record: PostgreSQLUserDefinedTypeRecord, qualifiedName: String) -> String { + var options: [String] = ["subtype = \(record.rangeSubtype ?? "")"] + if let opclass = record.rangeOpclass, !opclass.isEmpty { + options.append("subtype_opclass = \(opclass)") + } + if let collation = record.rangeCollation, !collation.isEmpty { + options.append("collation = \(collation)") + } + if let canonical = record.rangeCanonical, !canonical.isEmpty { + options.append("canonical = \(canonical)") + } + if let subtypeDiff = record.rangeSubtypeDiff, !subtypeDiff.isEmpty { + options.append("subtype_diff = \(subtypeDiff)") + } + let defaultMultirange = PostgreSQLObjectQueries.qualifiedName( + schema: record.schema, name: defaultMultirangeName(for: record.name) + ) + if let multirange = record.rangeMultirange, !multirange.isEmpty, + multirange != defaultMultirange, multirange != unquoted(defaultMultirange) { + options.append("multirange_type_name = \(multirange)") + } + let body = options.map { " \($0)" }.joined(separator: ",\n") + return "CREATE TYPE \(qualifiedName) AS RANGE (\n\(body)\n);" + } + + private static func kind(of record: PostgreSQLUserDefinedTypeRecord) -> PluginUserDefinedTypeKind { + switch record.kind { + case .enumeration: return .enumeration + case .composite: return .composite + case .domain: return .domain + case .range: return .range + } + } + + private static func attributes(of record: PostgreSQLUserDefinedTypeRecord) -> [PluginObjectAttribute] { + var attributes: [PluginObjectAttribute] = [] + if let baseType = record.baseType, !baseType.isEmpty { + attributes.append(PluginObjectAttribute(label: "Base Type", value: baseType)) + } + if let subtype = record.rangeSubtype, !subtype.isEmpty { + attributes.append(PluginObjectAttribute(label: "Subtype", value: subtype)) + } + if let owner = record.owner, !owner.isEmpty { + attributes.append(PluginObjectAttribute(label: "Owner", value: owner)) + } + if let comment = record.comment, !comment.isEmpty { + attributes.append(PluginObjectAttribute(label: "Comment", value: comment)) + } + return attributes + } + + /// `quote_ident` on the server leaves a plain lower-case name bare, so the catalog's spelling + /// of the default multirange has to be compared in both forms. + private static func unquoted(_ qualifiedName: String) -> String { + qualifiedName.replacingOccurrences(of: "\"", with: "") + } + + private static func qualifiedName(of record: PostgreSQLUserDefinedTypeRecord) -> String { + "\(PostgreSQLObjectQueries.quoteIdentifier(record.schema)).\(PostgreSQLObjectQueries.quoteIdentifier(record.name))" + } + + private static func text(_ row: [PluginCellValue], _ column: Column) -> String? { + guard column.rawValue < row.count, let value = row[column.rawValue].asText, !value.isEmpty else { return nil } + return value + } + + private static func jsonStrings(_ json: String?) -> [String] { + guard let data = json?.data(using: .utf8), + let array = try? JSONSerialization.jsonObject(with: data) as? [Any] + else { return [] } + return array.compactMap { $0 as? String } + } + + private static func jsonObjects(_ json: String?) -> [[String: String]] { + guard let data = json?.data(using: .utf8), + let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { return [] } + return array.map { object in + object.compactMapValues { $0 as? String } + } + } +} diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index 7a18be51ef..d0efd341a5 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -30,6 +30,7 @@ public protocol DriverPlugin: TableProPlugin { static var supportsGeneratedColumns: Bool { get } static var supportsRoutines: Bool { get } static var supportsDatabaseTriggerBrowse: Bool { get } + static var supportsUserDefinedTypeBrowse: Bool { get } static var supportsSchemaEditing: Bool { get } static var supportsDatabaseSwitching: Bool { get } static var supportsSchemaSwitching: Bool { get } @@ -106,6 +107,7 @@ public extension DriverPlugin { /// declares nothing and returns routines anyway still gets its section. static var supportsRoutines: Bool { false } static var supportsDatabaseTriggerBrowse: Bool { supportsTriggers } + static var supportsUserDefinedTypeBrowse: Bool { false } static var supportsSchemaEditing: Bool { true } static var supportsDatabaseSwitching: Bool { true } static var supportsSchemaSwitching: Bool { false } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 071c269c29..dcc325fe4d 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -104,6 +104,11 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func fetchTriggerDDL(_ trigger: PluginTriggerInfo) async throws -> String func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String + func fetchUserDefinedTypes(schema: String?) async throws -> [PluginUserDefinedTypeInfo] + + /// Reads one type again, definition included. The type must be one this driver listed, + /// because its `identity` is the driver's own key for finding it. + func fetchUserDefinedType(_ type: PluginUserDefinedTypeInfo) async throws -> PluginUserDefinedTypeInfo func fetchTableDDL(table: String, schema: String?) async throws -> String func fetchViewDefinition(view: String, schema: String?) async throws -> String func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata @@ -289,6 +294,15 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { var triggerEditUsesReplace: Bool { get } var supportsTransactionalDDL: Bool { get } + // User-defined type editing (optional: return nil when unsupported) + func createTypeTemplate(schema: String?) -> String? + func generateAddEnumLabelSQL( + type: PluginUserDefinedTypeInfo, + label: String, + placement: PluginEnumLabelPlacement? + ) -> String? + func generateRenameEnumLabelSQL(type: PluginUserDefinedTypeInfo, from oldLabel: String, to newLabel: String) -> String? + // All-tables metadata SQL (optional — returns nil for non-SQL databases) func allTablesMetadataSQL(schema: String?) -> String? @@ -359,6 +373,29 @@ public extension PluginDatabaseDriver { } } + func fetchUserDefinedTypes(schema: String?) async throws -> [PluginUserDefinedTypeInfo] { [] } + + func fetchUserDefinedType(_ type: PluginUserDefinedTypeInfo) async throws -> PluginUserDefinedTypeInfo { + guard let definition = type.definition, !definition.isEmpty else { + throw PluginObjectSourceError.unsupported(type.name) + } + return type + } + + func createTypeTemplate(schema: String?) -> String? { nil } + + func generateAddEnumLabelSQL( + type: PluginUserDefinedTypeInfo, + label: String, + placement: PluginEnumLabelPlacement? + ) -> String? { nil } + + func generateRenameEnumLabelSQL( + type: PluginUserDefinedTypeInfo, + from oldLabel: String, + to newLabel: String + ) -> 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/Plugins/TableProPluginKit/PluginUserDefinedTypeInfo.swift b/Plugins/TableProPluginKit/PluginUserDefinedTypeInfo.swift new file mode 100644 index 0000000000..cbcef22985 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginUserDefinedTypeInfo.swift @@ -0,0 +1,95 @@ +// +// 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. +// + +import Foundation + +public enum PluginUserDefinedTypeKind: String, Codable, Sendable { + case enumeration = "enum" + case composite + case domain + case range +} + +public struct PluginUserDefinedTypeField: Codable, Sendable, Hashable { + public let name: String + public let type: String + + /// A collation of the field's own, already quoted, when it differs from the type's default. + public let collation: String? + + public init(name: String, type: String, collation: String? = nil) { + self.name = name + self.type = type + self.collation = collation + } +} + +/// Where a new enum label goes. PostgreSQL appends by default and takes one neighbour to place it +/// before or after; it never reorders an existing label. +public struct PluginEnumLabelPlacement: Codable, Sendable, Hashable { + public let anchor: String + public let placesBefore: Bool + + public init(anchor: String, placesBefore: Bool) { + self.anchor = anchor + self.placesBefore = placesBefore + } +} + +public struct PluginUserDefinedTypeInfo: Codable, Sendable { + public let name: String + public let schema: String? + public let kind: PluginUserDefinedTypeKind + + /// Whatever the driver needs to address this exact type again: a PostgreSQL oid. Opaque to + /// the app, which only ever hands it back. + public let identity: String? + + /// The labels in declaration order. Enums only. + public let enumLabels: [String] + + /// The fields in declaration order. Composites only. + public let fields: [PluginUserDefinedTypeField] + + /// A domain's base type, or a range's subtype, spelled the way the engine spells it. + public let baseType: String? + + /// How a column definition names this type, qualified and quoted by the engine itself. The + /// engine knows its own reserved words and folding rules; nothing above it should guess. + public let columnTypeSpelling: String? + + /// The CREATE statement, when the same read that listed the type already produced it. Never + /// part of the type's identity. + public let definition: String? + + public let attributes: [PluginObjectAttribute] + + public init( + name: String, + kind: PluginUserDefinedTypeKind, + schema: String? = nil, + identity: String? = nil, + enumLabels: [String] = [], + fields: [PluginUserDefinedTypeField] = [], + baseType: String? = nil, + columnTypeSpelling: String? = nil, + definition: String? = nil, + attributes: [PluginObjectAttribute] = [] + ) { + self.name = name + self.kind = kind + self.schema = schema + self.identity = identity + self.enumLabels = enumLabels + self.fields = fields + self.baseType = baseType + self.columnTypeSpelling = columnTypeSpelling + self.definition = definition + self.attributes = attributes + } +} diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 567feffebd..5c1183b42b 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -179,6 +179,17 @@ protocol DatabaseDriver: AnyObject, Sendable { /// `identity` is the driver's own key for finding it again. func fetchRoutineDDL(_ routine: RoutineInfo) async throws -> String + /// Fetch every named type the user created in the given schema, or the current schema if nil. + func fetchUserDefinedTypes(schema: String?) async throws -> [UserDefinedTypeInfo] + + /// Read one type again, definition and labels included. The type must be one this driver + /// listed, because its `identity` is the driver's own key for finding it again. + func fetchUserDefinedType(_ type: UserDefinedTypeInfo) async throws -> UserDefinedTypeInfo + + func createTypeTemplate(schema: String?) -> String? + func generateAddEnumLabelSQL(type: UserDefinedTypeInfo, label: String, placement: EnumLabelPlacement?) -> String? + func generateRenameEnumLabelSQL(type: UserDefinedTypeInfo, from oldLabel: String, to newLabel: String) -> String? + /// Fetch every trigger in the given schema, across all its tables. func fetchAllTriggers(schema: String?) async throws -> [TriggerInfo] @@ -537,6 +548,25 @@ extension DatabaseDriver { throw PluginObjectSourceError.unsupported(routine.name) } + func fetchUserDefinedTypes(schema: String?) async throws -> [UserDefinedTypeInfo] { [] } + + func fetchUserDefinedType(_ type: UserDefinedTypeInfo) async throws -> UserDefinedTypeInfo { + guard let definition = type.definition, !definition.isEmpty else { + throw PluginObjectSourceError.unsupported(type.name) + } + return type + } + + func createTypeTemplate(schema: String?) -> String? { nil } + + func generateAddEnumLabelSQL(type: UserDefinedTypeInfo, label: String, placement: EnumLabelPlacement?) -> String? { + nil + } + + func generateRenameEnumLabelSQL(type: UserDefinedTypeInfo, from oldLabel: String, to newLabel: String) -> String? { + nil + } + func fetchAllTriggers(schema: String?) async throws -> [TriggerInfo] { [] } func fetchTriggerDDL(_ trigger: TriggerInfo) async throws -> String { diff --git a/TablePro/Core/Database/EnumLabelEditor.swift b/TablePro/Core/Database/EnumLabelEditor.swift new file mode 100644 index 0000000000..fd6ff9d740 --- /dev/null +++ b/TablePro/Core/Database/EnumLabelEditor.swift @@ -0,0 +1,138 @@ +// +// EnumLabelEditor.swift +// TablePro +// + +import Combine +import Foundation +import os + +enum EnumLabelEditingError: LocalizedError { + 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 change an enum's labels") + case let .denied(reason): reason + } + } +} + +/// The two edits PostgreSQL allows on an enum, each run as its own statement the moment the +/// user commits it. `ALTER TYPE … ADD VALUE` refuses to run inside a transaction block before +/// PostgreSQL 12, and a label added inside one cannot be used until it commits on later servers, +/// so there is nothing to stage: one edit, one statement, one authorization. +@MainActor +struct EnumLabelEditor { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "EnumLabelEditor") + + let connection: DatabaseConnection + let objectRef: DatabaseObjectRef + + private var scope: DatabaseScope { + DatabaseScope(connectionId: connection.id, database: objectRef.database, schema: objectRef.schema) + } + + private var driver: DatabaseDriver? { + DatabaseManager.shared.driver(for: connection.id) + } + + /// Whether the engine can add a label at all. Safe Mode is asked again when the statement + /// runs, so this only decides whether the controls are drawn. + var canEdit: Bool { + guard !connection.safeModeLevel.blocksAllWrites, let type = objectRef.userType else { return false } + return driver?.generateAddEnumLabelSQL(type: type, label: "label", placement: nil) != nil + } + + func canRename(_ type: UserDefinedTypeInfo) -> Bool { + driver?.generateRenameEnumLabelSQL(type: type, from: "old", to: "new") != nil + } + + func add(label: String, placement: EnumLabelPlacement?, to type: UserDefinedTypeInfo) async throws { + guard let driver else { throw EnumLabelEditingError.notConnected } + guard let sql = driver.generateAddEnumLabelSQL(type: type, label: label, placement: placement) else { + throw EnumLabelEditingError.unsupported + } + try await run(sql, description: String(localized: "Add Enum Label")) + } + + func rename(_ oldLabel: String, to newLabel: String, in type: UserDefinedTypeInfo) async throws { + guard let driver else { throw EnumLabelEditingError.notConnected } + guard let sql = driver.generateRenameEnumLabelSQL(type: type, from: oldLabel, to: newLabel) else { + throw EnumLabelEditingError.unsupported + } + try await run(sql, description: String(localized: "Rename Enum Label")) + } + + private func run(_ sql: String, description: String) async throws { + let decision = await ExecutionGateProvider.shared.authorize( + OperationRequest( + connectionId: connection.id, + databaseType: connection.type, + sql: sql, + kind: .schemaMutation, + caller: .userInterface, + capabilities: .interactiveUser, + operationDescription: description + ) + ) + guard case .authorized = decision else { + throw EnumLabelEditingError.denied(decision.deniedReason ?? String(localized: "Operation not permitted")) + } + + /// Not the session driver: that one holds whatever transaction the user opened in a query + /// tab, and a label added inside it is unusable until the commit and gone on a rollback, + /// while the listing reloads over other connections and cannot see it at all. The + /// metadata route is a dedicated autocommit connection wherever the engine can pool one. + let startedAt = Date() + let scope = scope + try await DatabaseManager.shared.withScopedDriver( + scope: scope, + route: DatabaseManager.shared.metadataRoute(for: scope), + cancellation: .protectedWrite + ) { driver in + _ = try await driver.execute(query: sql) + } + await recordHistory(sql, executionTime: Date().timeIntervalSince(startedAt)) + await refreshListings() + AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) + } + + private func recordHistory(_ sql: String, executionTime: TimeInterval) async { + await DatabaseManager.shared.historyRecorder.record( + QueryHistoryRecordRequest( + query: sql, + connectionId: connection.id, + databaseName: objectRef.database, + databaseType: connection.type, + source: .structureDDL, + executionTime: executionTime, + rowCount: -1, + wasSuccessful: true + ) + ) + } + + /// The sidebar row's tooltip and the quick switcher both carry the labels the listing read, + /// so both listings are reloaded rather than left describing the type as it was. The open + /// grids get the same refresh a trigger edit sends, because a column of this enum offers its + /// labels as a picker and would go on offering the old set. + private func refreshListings() async { + let connectionId = connection.id + await DatabaseTreeMetadataService.shared.refreshUserDefinedTypeObjects( + connectionId: connectionId, + database: objectRef.database, + schema: objectRef.schema + ) + do { + try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connectionId) { driver in + await SchemaService.shared.reloadUserDefinedTypes(connectionId: connectionId, driver: driver) + } + } catch { + Self.logger.warning("type listing refresh failed: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift b/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift index 3de2dde0ed..45f26941fc 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift @@ -256,6 +256,44 @@ extension MCPConnectionBridge { return .object(["routines": .array(payload)]) } + func listUserDefinedTypes(scope: DatabaseScope, kind: String?) async throws -> JsonValue { + try await ensureConnected(scope.connectionId) + let schema = scope.schema + let types = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + let all = try await driver.fetchUserDefinedTypes(schema: schema) + guard let kind else { return all } + return all.filter { $0.kind.rawValue == kind } + } + let payload = types + .sorted { $0.qualifiedName < $1.qualifiedName } + .map { type -> JsonValue in + var fields: [String: JsonValue] = [ + "name": .string(type.name), + "kind": .string(type.kind.rawValue), + "qualified_name": .string(type.qualifiedName) + ] + if let schema = type.schema { + fields["schema"] = .string(schema) + } + if !type.enumLabels.isEmpty { + fields["labels"] = .array(type.enumLabels.map(JsonValue.string)) + } + if !type.fields.isEmpty { + fields["fields"] = .array(type.fields.map { + .object(["name": .string($0.name), "type": .string($0.type)]) + }) + } + if let baseType = type.baseType { + fields["base_type"] = .string(baseType) + } + if let definition = type.definition { + fields["definition"] = .string(definition) + } + return .object(fields) + } + return .object(["types": .array(payload)]) + } + func listPartitions(scope: DatabaseScope, table: String) async throws -> JsonValue { try await ensureConnected(scope.connectionId) let schema = scope.schema diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPToolRegistry.swift b/TablePro/Core/MCP/Protocol/Tools/MCPToolRegistry.swift index 0f7c707acf..c71626bff4 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPToolRegistry.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPToolRegistry.swift @@ -40,6 +40,7 @@ public enum MCPToolRegistry { ListSessionContextsTool(), ListTablesTool(), ListTriggersTool(), + ListUserDefinedTypesTool(), OpenConnectionWindowTool(), OpenTableTabTool(), QuoteIdentifiersTool(), diff --git a/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift b/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift index c2d82bc1b7..3c0a31ec92 100644 --- a/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift +++ b/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift @@ -311,6 +311,87 @@ public struct ListRoutinesTool: MCPToolImplementation { } } +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." + ) + public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "List Types"), + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) + + public static let kinds = UserDefinedTypeInfo.Kind.allCases.filter { $0 != .other }.map(\.rawValue) + + public static let inputSchema = MCPToolSchema.object( + properties: [ + "connection_id": MCPToolSchema.connectionId, + "kind": MCPToolSchema.string( + String(localized: "Restrict to one kind. Omit for every kind."), + enumValues: kinds + ), + "database": MCPToolSchema.database, + "schema": MCPToolSchema.schema + ], + required: ["connection_id"] + ) + + public static let outputSchema: JsonValue? = MCPToolSchema.object( + properties: [ + "types": MCPToolSchema.array( + String(localized: "Types, sorted by qualified name"), + of: MCPToolSchema.object( + properties: [ + "name": MCPToolSchema.string(String(localized: "Type name")), + "kind": MCPToolSchema.string(String(localized: "enum, composite, domain or range")), + "schema": MCPToolSchema.string(String(localized: "Schema the type lives in")), + "qualified_name": MCPToolSchema.string(String(localized: "Schema-qualified name")), + "labels": MCPToolSchema.array( + String(localized: "An enum's labels in declaration order"), + of: MCPToolSchema.string(String(localized: "Label")) + ), + "fields": MCPToolSchema.array( + String(localized: "A composite's fields in declaration order"), + of: MCPToolSchema.object( + properties: [ + "name": MCPToolSchema.string(String(localized: "Field name")), + "type": MCPToolSchema.string(String(localized: "Field type")) + ], + required: ["name", "type"] + ) + ), + "base_type": MCPToolSchema.string( + String(localized: "A domain's base type, or a range's subtype") + ), + "definition": MCPToolSchema.string(String(localized: "The CREATE statement")) + ], + required: ["name", "kind", "qualified_name"] + ) + ) + ], + required: ["types"] + ) + + public init() {} + + public func perform( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + try MCPArgumentDecoder.rejectUnknownKeys(arguments, allowed: MCPScopeArguments.keys.union(["kind"])) + let kind = try MCPArgumentDecoder.optionalEnum(arguments, key: "kind", allowed: Self.kinds) + let scope = try await MCPScopeArguments.resolve(arguments, services: services) + let payload = try await services.connectionBridge.listUserDefinedTypes(scope: scope, kind: kind) + return .structured(payload) + } +} + public struct ListPartitionsTool: MCPToolImplementation { public static let name = "list_partitions" public static let title: String? = String(localized: "List Partitions") diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 16415bdfc7..4178cdcff0 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -449,6 +449,34 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor try await pluginDriver.fetchRoutineDDL(routine.pluginRoutine) } + func fetchUserDefinedTypes(schema: String?) async throws -> [UserDefinedTypeInfo] { + let resolvedSchema = schema ?? pluginDriver.currentSchema + do { + return try await pluginDriver.fetchUserDefinedTypes(schema: resolvedSchema) + .map(UserDefinedTypeInfo.init) + .sorted { $0.name < $1.name } + } catch { + Self.logger.warning("fetchUserDefinedTypes failed: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + func fetchUserDefinedType(_ type: UserDefinedTypeInfo) async throws -> UserDefinedTypeInfo { + UserDefinedTypeInfo(try await pluginDriver.fetchUserDefinedType(type.pluginType)) + } + + func createTypeTemplate(schema: String?) -> String? { + pluginDriver.createTypeTemplate(schema: schema ?? pluginDriver.currentSchema) + } + + func generateAddEnumLabelSQL(type: UserDefinedTypeInfo, label: String, placement: EnumLabelPlacement?) -> String? { + pluginDriver.generateAddEnumLabelSQL(type: type.pluginType, label: label, placement: placement?.pluginPlacement) + } + + func generateRenameEnumLabelSQL(type: UserDefinedTypeInfo, from oldLabel: String, to newLabel: String) -> String? { + pluginDriver.generateRenameEnumLabelSQL(type: type.pluginType, from: oldLabel, to: newLabel) + } + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { let pluginMeta = try await pluginDriver.fetchDatabaseMetadata(database) return DatabaseMetadata( diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift index 41dc491045..6705149aa4 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -378,6 +378,7 @@ extension PluginMetadataRegistry { supportsGeneratedColumns: true, supportsRoutines: true, supportsDatabaseTriggerBrowse: true, + supportsUserDefinedTypeBrowse: true, defaultSSLMode: .preferred ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -572,6 +573,7 @@ extension PluginMetadataRegistry { supportsCheckConstraints: true, supportsCheckConstraintEditing: true, supportsGeneratedColumns: true, + supportsUserDefinedTypeBrowse: true, defaultSSLMode: .disabled, supportsCloudflareTunnel: false, supportsConnectionPooling: false diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index bbf763c009..c1ece16d7b 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -67,6 +67,7 @@ struct PluginMetadataSnapshot: Sendable { var supportsGeneratedColumns: Bool = false var supportsRoutines: Bool = false var supportsDatabaseTriggerBrowse: Bool = false + var supportsUserDefinedTypeBrowse: Bool = false var defaultSSLMode: SSLMode = .disabled var supportsOpportunisticTLS: Bool = true var supportsCloudflareTunnel: Bool = true @@ -594,6 +595,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsGeneratedColumns: driverType.supportsGeneratedColumns, supportsRoutines: driverType.supportsRoutines, supportsDatabaseTriggerBrowse: driverType.supportsDatabaseTriggerBrowse, + supportsUserDefinedTypeBrowse: driverType.supportsUserDefinedTypeBrowse, defaultSSLMode: existingSnapshot?.capabilities.defaultSSLMode ?? .disabled, supportsOpportunisticTLS: existingSnapshot?.capabilities.supportsOpportunisticTLS ?? true, supportsCloudflareTunnel: driverType.supportsSSH, diff --git a/TablePro/Core/Plugins/PluginObjectMapping.swift b/TablePro/Core/Plugins/PluginObjectMapping.swift index d99d6fd0bd..3a1675ebd4 100644 --- a/TablePro/Core/Plugins/PluginObjectMapping.swift +++ b/TablePro/Core/Plugins/PluginObjectMapping.swift @@ -2,7 +2,7 @@ // PluginObjectMapping.swift // TablePro // -// The single crossing between PluginKit's routine and trigger transfer types and the app's. +// The single crossing between PluginKit's routine, trigger and type transfer types and the app's. // import Foundation @@ -101,3 +101,78 @@ extension TriggerInfo { ) } } + +extension UserDefinedTypeInfo.Kind { + /// A plugin built against a later PluginKit can hand back a kind this build has no case for. + /// Reading it as `other` keeps the type listed with its definition rather than dropping it. + init(_ kind: PluginUserDefinedTypeKind) { + switch kind { + case .enumeration: self = .enumeration + case .composite: self = .composite + case .domain: self = .domain + case .range: self = .range + @unknown default: self = .other + } + } + + var pluginKind: PluginUserDefinedTypeKind? { + switch self { + case .enumeration: return .enumeration + case .composite: return .composite + case .domain: return .domain + case .range: return .range + case .other: return nil + } + } +} + +extension UserDefinedTypeInfo.Field { + init(_ field: PluginUserDefinedTypeField) { + self.init(name: field.name, type: field.type, collation: field.collation) + } + + var pluginField: PluginUserDefinedTypeField { + PluginUserDefinedTypeField(name: name, type: type, collation: collation) + } +} + +extension EnumLabelPlacement { + var pluginPlacement: PluginEnumLabelPlacement { + PluginEnumLabelPlacement(anchor: anchor, placesBefore: placesBefore) + } +} + +extension UserDefinedTypeInfo { + init(_ type: PluginUserDefinedTypeInfo) { + self.init( + name: type.name, + kind: Kind(type.kind), + schema: type.schema, + identity: type.identity, + enumLabels: type.enumLabels, + fields: type.fields.map(Field.init), + baseType: type.baseType, + columnTypeSpelling: type.columnTypeSpelling, + definition: type.definition, + attributes: type.attributes.map(ObjectAttribute.init) + ) + } + + /// Handed straight back to the driver that produced it, so `identity` survives the round trip. + /// A kind this build read as `other` goes back as the plugin's first kind only because the + /// transfer type needs one; the driver addresses the type by identity and name, never by kind. + var pluginType: PluginUserDefinedTypeInfo { + PluginUserDefinedTypeInfo( + name: name, + kind: kind.pluginKind ?? .composite, + schema: schema, + identity: identity, + enumLabels: enumLabels, + fields: fields.map(\.pluginField), + baseType: baseType, + columnTypeSpelling: columnTypeSpelling, + definition: definition, + attributes: attributes.map(\.pluginAttribute) + ) + } +} diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index bf3095a0da..2570a7003a 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -35,6 +35,7 @@ final class DatabaseTreeMetadataService { private(set) var tablesState: [ObjectsKey: MetadataLoadState<[TableInfo]>] = [:] private(set) var routinesState: [ObjectsKey: MetadataLoadState<[RoutineInfo]>] = [:] private(set) var triggersState: [ObjectsKey: MetadataLoadState<[TriggerInfo]>] = [:] + private(set) var typesState: [ObjectsKey: MetadataLoadState<[UserDefinedTypeInfo]>] = [:] private(set) var partitionsState: [PartitionsKey: MetadataLoadState<[TableInfo]>] = [:] @ObservationIgnored private let databaseDedup = OnceTask() @@ -42,6 +43,7 @@ final class DatabaseTreeMetadataService { @ObservationIgnored private let tablesDedup = OnceTask() @ObservationIgnored private let routinesDedup = OnceTask() @ObservationIgnored private let triggersDedup = OnceTask() + @ObservationIgnored private let typesDedup = OnceTask() @ObservationIgnored private let partitionsDedup = OnceTask() @ObservationIgnored nonisolated private static let logger = Logger( @@ -92,6 +94,16 @@ final class DatabaseTreeMetadataService { triggersState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value ?? [] } + func typesLoadState( + connectionId: UUID, database: String, schema: String? + ) -> MetadataLoadState<[UserDefinedTypeInfo]> { + typesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)] ?? .idle + } + + func userDefinedTypes(connectionId: UUID, database: String, schema: String?) -> [UserDefinedTypeInfo] { + typesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value ?? [] + } + func partitionsLoadState( connectionId: UUID, database: String, schema: String?, table: String ) -> MetadataLoadState<[TableInfo]> { @@ -254,6 +266,40 @@ final class DatabaseTreeMetadataService { } } + func loadUserDefinedTypes(connectionId: UUID, database: String, schema: String?) async { + guard isConnected(connectionId), browsesUserDefinedTypes(connectionId) else { return } + let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema) + switch typesState[key] ?? .idle { + case .loaded, .loading: return + case .idle, .failed: break + } + typesState[key] = .loading + do { + typesState[key] = .loaded(try await fetchTypeList(key)) + } catch is CancellationError { + if case .loading = typesState[key] { typesState[key] = .idle } + } catch { + typesState[key] = .failed(error.localizedDescription) + Self.logger.warning( + "types load failed db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + } + } + + private func fetchTypeList(_ key: ObjectsKey) async throws -> [UserDefinedTypeInfo] { + let schema = key.schema + return try await typesDedup.execute(key: key) { [self] in + try await withDriver( + connectionId: key.connectionId, + database: key.database, + schema: schema, + workload: .bulk + ) { driver in + try await driver.fetchUserDefinedTypes(schema: schema) + } + } + } + func loadPartitions(connectionId: UUID, database: String, schema: String?, table: String) async { guard isConnected(connectionId) else { return } let key = Self.partitionsKey(connectionId: connectionId, database: database, schema: schema, table: table) @@ -329,12 +375,15 @@ final class DatabaseTreeMetadataService { async let tables: Void = refreshTableObjects(connectionId: connectionId, database: database, schema: schema) async let routines: Void = refreshRoutineObjects(connectionId: connectionId, database: database, schema: schema) async let triggers: Void = refreshTriggerObjects(connectionId: connectionId, database: database, schema: schema) - _ = await (tables, routines, triggers) + async let types: Void = refreshUserDefinedTypeObjects( + connectionId: connectionId, database: database, schema: schema + ) + _ = await (tables, routines, triggers, types) } - /// Tables, routines and triggers are three separate fetches behind three separate states, so a - /// row that stands for one kind refreshes only the fetch its kind comes from. Partitions ride - /// with the tables, because a partition row is drawn as a child of the table it belongs to. + /// Tables, routines, triggers and types are four separate fetches behind four separate states, + /// so a row that stands for one kind refreshes only the fetch its kind comes from. Partitions + /// ride with the tables, because a partition row is drawn as a child of the table it belongs to. func refreshTableObjects(connectionId: UUID, database: String, schema: String?) async { let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema) await tablesDedup.cancel(key: key) @@ -355,6 +404,29 @@ final class DatabaseTreeMetadataService { await refreshTriggers(key) } + func refreshUserDefinedTypeObjects(connectionId: UUID, database: String, schema: String?) async { + let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema) + await typesDedup.cancel(key: key) + await refreshUserDefinedTypes(key) + } + + private func refreshUserDefinedTypes(_ key: ObjectsKey) async { + guard case .loaded = typesState[key] ?? .idle else { + typesState.removeValue(forKey: key) + await loadUserDefinedTypes(connectionId: key.connectionId, database: key.database, schema: key.schema) + return + } + guard isConnected(key.connectionId) else { return } + do { + typesState[key] = .loaded(try await fetchTypeList(key)) + } catch is CancellationError { + } catch { + Self.logger.warning( + "types refresh failed db=\(key.database, privacy: .public) schema=\(key.schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + } + } + private func refreshTables(_ key: ObjectsKey) async { guard case .loaded = tablesState[key] ?? .idle else { tablesState.removeValue(forKey: key) @@ -494,6 +566,7 @@ final class DatabaseTreeMetadataService { tableKeys: tablesState.keys, routineKeys: routinesState.keys, triggerKeys: triggersState.keys, + typeKeys: typesState.keys, connectionId: connectionId ) await databaseDedup.cancel(key: connectionId) @@ -502,6 +575,7 @@ final class DatabaseTreeMetadataService { await tablesDedup.cancel(key: key) await routinesDedup.cancel(key: key) await triggersDedup.cancel(key: key) + await typesDedup.cancel(key: key) } for key in connectionPartitionKeys(connectionId) { await partitionsDedup.cancel(key: key) @@ -511,6 +585,7 @@ final class DatabaseTreeMetadataService { tablesState = tablesState.filter { $0.key.connectionId != connectionId } routinesState = routinesState.filter { $0.key.connectionId != connectionId } triggersState = triggersState.filter { $0.key.connectionId != connectionId } + typesState = typesState.filter { $0.key.connectionId != connectionId } partitionsState = partitionsState.filter { $0.key.connectionId != connectionId } } @@ -522,6 +597,7 @@ final class DatabaseTreeMetadataService { tableKeys: tablesState.keys, routineKeys: routinesState.keys, triggerKeys: triggersState.keys, + typeKeys: typesState.keys, connectionId: connectionId ) @@ -535,6 +611,7 @@ final class DatabaseTreeMetadataService { if isPending(tablesState[key]) { await tablesDedup.cancel(key: key) } if isPending(routinesState[key]) { await routinesDedup.cancel(key: key) } if isPending(triggersState[key]) { await triggersDedup.cancel(key: key) } + if isPending(typesState[key]) { await typesDedup.cancel(key: key) } } let partitionKeys = connectionPartitionKeys(connectionId) for key in partitionKeys where isPending(partitionsState[key]) { @@ -547,6 +624,7 @@ final class DatabaseTreeMetadataService { if isPending(tablesState[key]) { tablesState[key] = .idle } if isPending(routinesState[key]) { routinesState[key] = .idle } if isPending(triggersState[key]) { triggersState[key] = .idle } + if isPending(typesState[key]) { typesState[key] = .idle } } for key in partitionKeys where isPending(partitionsState[key]) { partitionsState[key] = .idle } } @@ -570,6 +648,11 @@ final class DatabaseTreeMetadataService { .connection.type.supportsDatabaseTriggerBrowse ?? false } + private func browsesUserDefinedTypes(_ connectionId: UUID) -> Bool { + DatabaseManager.shared.session(for: connectionId)? + .connection.type.supportsUserDefinedTypeBrowse ?? false + } + /// Always routes through a scoped driver. Reusing the session driver when the target /// looked like the browsed database used to be safe; it is not now that a tab's /// execution moves that driver without writing session state. @@ -618,8 +701,10 @@ final class DatabaseTreeMetadataService { tableKeys: some Sequence, routineKeys: some Sequence, triggerKeys: some Sequence, + typeKeys: some Sequence = [], connectionId: UUID ) -> [ObjectsKey] { - Array(Set(tableKeys).union(routineKeys).union(triggerKeys)).filter { $0.connectionId == connectionId } + Array(Set(tableKeys).union(routineKeys).union(triggerKeys).union(typeKeys)) + .filter { $0.connectionId == connectionId } } } diff --git a/TablePro/Core/Services/Query/SchemaRefreshService.swift b/TablePro/Core/Services/Query/SchemaRefreshService.swift index 3df975d22c..643bbe0a3b 100644 --- a/TablePro/Core/Services/Query/SchemaRefreshService.swift +++ b/TablePro/Core/Services/Query/SchemaRefreshService.swift @@ -207,18 +207,23 @@ final class SchemaRefreshService { guard let scope = metadataDriverProvider.browseScope(for: connectionId) else { throw DatabaseError.notConnected } - let browsesTriggers = databaseManager?.session(for: connectionId)? - .connection.type.supportsDatabaseTriggerBrowse ?? false + let connectionType = databaseManager?.session(for: connectionId)?.connection.type + let browsesTriggers = connectionType?.supportsDatabaseTriggerBrowse ?? false + let browsesTypes = connectionType?.supportsUserDefinedTypeBrowse ?? false let reloaded = try await metadataDriverProvider.withMetadataDriver( scope: scope, workload: .bulk ) { [schemaService] driver in - /// Both run, and neither short circuits the other: a failed routine fetch must - /// not skip the trigger fetch that would still have succeeded. + /// All run, and none short circuits another: a failed routine fetch must not skip + /// the trigger fetch that would still have succeeded. let routines = await schemaService.reloadRoutines(connectionId: connectionId, driver: driver) - guard browsesTriggers else { return routines } - let triggers = await schemaService.reloadTriggers(connectionId: connectionId, driver: driver) - return routines && triggers + let triggers = browsesTriggers + ? await schemaService.reloadTriggers(connectionId: connectionId, driver: driver) + : true + let types = browsesTypes + ? await schemaService.reloadUserDefinedTypes(connectionId: connectionId, driver: driver) + : true + return routines && triggers && types } /// Recording the new scope says the loaded routines belong to it. A reload that failed /// left the previous schema's routines in place, so claiming coverage there would pin diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index 6ee94f6bb0..532d0c9a27 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -15,6 +15,7 @@ final class SchemaService { private(set) var states: [UUID: SchemaState] = [:] private(set) var routines: [UUID: [RoutineInfo]] = [:] private(set) var triggers: [UUID: [TriggerInfo]] = [:] + private(set) var userDefinedTypes: [UUID: [UserDefinedTypeInfo]] = [:] private(set) var schemasInOrder: [UUID: [String]] = [:] private(set) var perSchemaStates: [UUID: [String: SchemaState]] = [:] private(set) var generations: [UUID: Int] = [:] @@ -32,6 +33,7 @@ final class SchemaService { @ObservationIgnored private let loadDedup = OnceTask() @ObservationIgnored private let routinesDedup = OnceTask() @ObservationIgnored private let triggersDedup = OnceTask() + @ObservationIgnored private let typesDedup = OnceTask() @ObservationIgnored private let schemasDedup = OnceTask() @ObservationIgnored private let perSchemaDedup = OnceTask() @@ -134,6 +136,10 @@ final class SchemaService { triggers[connectionId] ?? [] } + func userDefinedTypes(for connectionId: UUID) -> [UserDefinedTypeInfo] { + userDefinedTypes[connectionId] ?? [] + } + func schemas(for connectionId: UUID) -> [String] { schemasInOrder[connectionId] ?? [] } @@ -276,6 +282,25 @@ final class SchemaService { } } + @discardableResult + func reloadUserDefinedTypes(connectionId: UUID, driver: DatabaseDriver) async -> Bool { + do { + let loaded = try await typesDedup.execute(key: connectionId) { + try await driver.fetchUserDefinedTypes(schema: nil) + } + userDefinedTypes[connectionId] = loaded + bumpGeneration(connectionId) + return true + } catch is CancellationError { + return false + } catch { + Self.logger.warning( + "[schema] types reload failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + return false + } + } + /// Cancels in-flight fetches while keeping cached content on screen, so a /// refresh never blanks a sidebar that already has valid data. func prepareForReload(connectionId: UUID) async { @@ -286,6 +311,7 @@ final class SchemaService { await loadDedup.cancel { $0.connectionId == connectionId } await routinesDedup.cancel(key: connectionId) await triggersDedup.cancel(key: connectionId) + await typesDedup.cancel(key: connectionId) await schemasDedup.cancel(key: connectionId) await perSchemaDedup.cancel { $0.connectionId == connectionId } } @@ -297,6 +323,7 @@ final class SchemaService { states.removeValue(forKey: connectionId) routines.removeValue(forKey: connectionId) triggers.removeValue(forKey: connectionId) + userDefinedTypes.removeValue(forKey: connectionId) schemasInOrder.removeValue(forKey: connectionId) perSchemaStates.removeValue(forKey: connectionId) generations.removeValue(forKey: connectionId) @@ -358,6 +385,7 @@ final class SchemaService { connectionId: connectionId, driver: driver, browsesTriggers: connection.type.supportsDatabaseTriggerBrowse, + browsesTypes: connection.type.supportsUserDefinedTypeBrowse, generation: generation, scope: scope ) @@ -384,6 +412,15 @@ final class SchemaService { fetch: { try await driver.fetchAllTriggers(schema: nil) } ) : nil + let browsesTypes = connection.type.supportsUserDefinedTypeBrowse + async let typesTask: [UserDefinedTypeInfo]? = browsesTypes + ? Self.fetchObjectsSafely( + connectionId: connectionId, + label: "types", + dedup: typesDedup, + fetch: { try await driver.fetchUserDefinedTypes(schema: nil) } + ) + : nil async let schemasTask: [String]? = supportsSchemas ? Self.fetchSchemasSafely( connectionId: connectionId, @@ -419,6 +456,16 @@ final class SchemaService { triggers.removeValue(forKey: connectionId) } + let loadedTypes = await typesTask + guard isCurrentLoadGeneration(generation, for: connectionId, phase: "types-loaded") else { + return + } + if let loadedTypes { + userDefinedTypes[connectionId] = loadedTypes + } else if scopeChanged { + userDefinedTypes.removeValue(forKey: connectionId) + } + if let loadedSchemas = await schemasTask { guard isCurrentLoadGeneration(generation, for: connectionId, phase: "schemas-loaded") else { return @@ -449,6 +496,7 @@ final class SchemaService { connectionId: UUID, driver: DatabaseDriver, browsesTriggers: Bool, + browsesTypes: Bool, generation: Int, scope: DatabaseScope? ) async { @@ -467,9 +515,18 @@ final class SchemaService { fetch: { try await driver.fetchAllTriggers(schema: nil) } ) : nil + async let typesTask: [UserDefinedTypeInfo]? = browsesTypes + ? Self.fetchObjectsSafely( + connectionId: connectionId, + label: "types", + dedup: typesDedup, + fetch: { try await driver.fetchUserDefinedTypes(schema: nil) } + ) + : nil let loadedRoutines = await routinesTask let loadedTriggers = await triggersTask + let loadedTypes = await typesTask let loadedSchemas: [String] do { @@ -503,6 +560,11 @@ final class SchemaService { } else if scopeChanged { triggers.removeValue(forKey: connectionId) } + if let loadedTypes { + userDefinedTypes[connectionId] = loadedTypes + } else if scopeChanged { + userDefinedTypes.removeValue(forKey: connectionId) + } states[connectionId] = .loaded([]) if let scope { loadedScopes[connectionId] = scope diff --git a/TablePro/Core/Services/Query/UserDefinedTypeSuggestions.swift b/TablePro/Core/Services/Query/UserDefinedTypeSuggestions.swift new file mode 100644 index 0000000000..5f72970454 --- /dev/null +++ b/TablePro/Core/Services/Query/UserDefinedTypeSuggestions.swift @@ -0,0 +1,81 @@ +// +// UserDefinedTypeSuggestions.swift +// TablePro +// + +import Foundation +import os + +/// The user-defined types a column type picker offers, spelled as a column definition names them. +/// +/// The sidebar's caches are the wrong source: `SchemaService` holds the browsed scope, which need +/// not be the table's, and `DatabaseTreeMetadataService` fills only on tree expansion. The picker +/// fetches for the table's own database and keeps nothing, because a schema's types change rarely +/// and one catalog read per popover is cheaper than a cache nothing else invalidates. +enum UserDefinedTypeSuggestions { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "TypePicker") + + /// Every schema-bound type is qualified, its own schema included: PostgreSQL searches + /// `pg_catalog` before the search path, so a bare `text` names the built-in even where the + /// 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. + static func entries(types: [UserDefinedTypeInfo], tableSchema: String?) -> [String] { + types + .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) } + return "\(identifier(schema)).\(identifier(type.name))" + } + .sorted { sortKey($0).localizedCaseInsensitiveCompare(sortKey($1)) == .orderedAscending } + } + + /// Quotes are spelling, not order: `app."select"` belongs between `app.mood` and `app.zeta`, + /// not ahead of every bare name because a quote sorts before a letter. + private static func sortKey(_ entry: String) -> String { + entry.replacingOccurrences(of: "\"", with: "") + } + + /// Bare only for a name PostgreSQL would read back unchanged: lower-case letters, digits and + /// underscores, not starting with a digit. Anything else is double-quoted, quotes doubled. + static func identifier(_ name: String) -> String { + let scalars = name.unicodeScalars + let isPlain = !scalars.isEmpty + && !(scalars.first.map { CharacterSet.decimalDigits.contains($0) } ?? false) + && scalars.allSatisfy { Self.plainIdentifierScalars.contains($0) } + guard !isPlain else { return name } + return "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + private static let plainIdentifierScalars = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789_") + + /// Every schema the table can reach, not only its own: a column may use `sales.status` from a + /// table in `public`, and the picker is where that spelling is learned. + @MainActor + static func load(scope: DatabaseScope) async -> [String] { + guard let session = DatabaseManager.shared.session(for: scope.connectionId), + session.connection.type.supportsUserDefinedTypeBrowse + else { return [] } + let systemSchemas = Set(PluginManager.shared.systemSchemaNames(for: session.connection.type)) + do { + let types = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + let schemas = try await driver.fetchSchemas().filter { !systemSchemas.contains($0) } + let targets: [String?] = schemas.isEmpty ? [scope.schema] : schemas + return try await withThrowingTaskGroup(of: [UserDefinedTypeInfo].self) { group in + for schema in targets { + group.addTask { try await driver.fetchUserDefinedTypes(schema: schema) } + } + var all: [UserDefinedTypeInfo] = [] + for try await types in group { all += types } + return all + } + } + return entries(types: types, tableSchema: scope.schema) + } catch is CancellationError { + return [] + } catch { + logger.warning("user-defined type fetch failed: \(error.localizedDescription, privacy: .public)") + return [] + } + } +} diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 92ecf4a7b4..fc3bc4fe9f 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -230,6 +230,11 @@ extension DatabaseType { .capabilities.supportsDatabaseTriggerBrowse ?? false } + var supportsUserDefinedTypeBrowse: Bool { + PluginMetadataRegistry.shared.snapshot(for: self)? + .capabilities.supportsUserDefinedTypeBrowse ?? false + } + /// The object kinds the sidebar should offer a section for even before any have been fetched. /// It never subtracts: a kind whose driver returned rows is listed whatever this says. var declaredObjectKinds: Set { @@ -241,6 +246,9 @@ extension DatabaseType { if supportsDatabaseTriggerBrowse { kinds.insert(.trigger) } + if supportsUserDefinedTypeBrowse { + kinds.insert(.type) + } return kinds } diff --git a/TablePro/Models/Query/DatabaseObjectRef.swift b/TablePro/Models/Query/DatabaseObjectRef.swift index 60a9846285..fcbfb0fa7a 100644 --- a/TablePro/Models/Query/DatabaseObjectRef.swift +++ b/TablePro/Models/Query/DatabaseObjectRef.swift @@ -2,7 +2,7 @@ // DatabaseObjectRef.swift // TablePro // -// Everything needed to find one routine or trigger again and read its source. +// Everything needed to find one routine, trigger or type again and read its source. // import Foundation @@ -11,12 +11,14 @@ enum DatabaseObjectKind: String, Codable, Sendable, Hashable { case procedure case function case trigger + case userType var sidebarObjectKind: SidebarObjectKind { switch self { case .procedure: return .procedure case .function: return .function case .trigger: return .trigger + case .userType: return .type } } @@ -41,10 +43,14 @@ struct DatabaseObjectRef: Hashable, Codable, Sendable { /// The owning table. Triggers only. let table: String? - /// The driver's own key for this routine, opaque here. Routines only. + /// The driver's own key for this routine or type, opaque here. let identity: String? let argumentSignature: String? + /// Which kind of named type this is. Types only, and optional so a tab persisted before types + /// existed still decodes. + let typeKind: UserDefinedTypeInfo.Kind? + /// What the sidebar's listing already learned about the object. Carried so the viewer does not /// re-list an entire schema to recover it, and short enough to persist with the tab. let attributes: [ObjectAttribute] @@ -57,6 +63,7 @@ struct DatabaseObjectRef: Hashable, Codable, Sendable { table: String? = nil, identity: String? = nil, argumentSignature: String? = nil, + typeKind: UserDefinedTypeInfo.Kind? = nil, attributes: [ObjectAttribute] = [] ) { self.kind = kind @@ -66,6 +73,7 @@ struct DatabaseObjectRef: Hashable, Codable, Sendable { self.table = table self.identity = identity self.argumentSignature = argumentSignature + self.typeKind = typeKind self.attributes = attributes } @@ -92,6 +100,18 @@ struct DatabaseObjectRef: Hashable, Codable, Sendable { ) } + init(userType: UserDefinedTypeInfo, database: String) { + self.init( + kind: .userType, + name: userType.name, + database: database, + schema: userType.schema, + identity: userType.identity, + typeKind: userType.kind, + attributes: userType.attributes + ) + } + /// What the tab is titled and what the viewer's header shows: enough to tell two overloads /// apart, and enough to tell two same-named triggers on different tables apart. var displayIdentity: String { @@ -102,9 +122,23 @@ struct DatabaseObjectRef: Hashable, Codable, Sendable { case .trigger: guard let table, !table.isEmpty else { return qualifiedName } return String(format: String(localized: "%1$@ on %2$@"), qualifiedName, table) + case .userType: + return qualifiedName } } + /// The kind's name for the header capsule. A type says which kind of type it is, because an + /// enum and a domain are edited differently and the reader should know which one opened. + var kindDisplayName: String { + guard kind == .userType else { return kind.displayName } + return (typeKind ?? .other).displayName + } + + var kindIconName: String { + guard kind == .userType else { return kind.iconName } + return (typeKind ?? .other).iconName + } + var qualifiedName: String { guard let schema, !schema.isEmpty else { return name } return "\(schema).\(name)" @@ -122,6 +156,7 @@ struct DatabaseObjectRef: Hashable, Codable, Sendable { table: table, identity: identity, argumentSignature: argumentSignature, + typeKind: typeKind, attributes: attributes ) } @@ -136,7 +171,7 @@ struct DatabaseObjectRef: Hashable, Codable, Sendable { argumentSignature: argumentSignature, identity: identity ) - case .trigger: + case .trigger, .userType: return nil } } @@ -146,6 +181,17 @@ struct DatabaseObjectRef: Hashable, Codable, Sendable { return TriggerInfo(name: name, timing: "", event: "", statement: "", table: table, schema: schema) } + var userType: UserDefinedTypeInfo? { + guard kind == .userType else { return nil } + return UserDefinedTypeInfo( + name: name, + kind: typeKind ?? .other, + schema: schema, + identity: identity, + attributes: attributes + ) + } + /// A file name for Export, safe on every filesystem the save panel can reach. var suggestedFileName: String { let base = [schema, table, name].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: "_") diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index af178a4e87..0931847def 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -418,6 +418,7 @@ final class QueryTabManager { case .procedure: format = String(localized: "Procedure: %@") case .function: format = String(localized: "Function: %@") case .trigger: format = String(localized: "Trigger: %@") + case .userType: format = String(localized: "Type: %@") } return String(format: format, objectRef.displayIdentity) } diff --git a/TablePro/Models/Query/UserDefinedTypeInfo.swift b/TablePro/Models/Query/UserDefinedTypeInfo.swift new file mode 100644 index 0000000000..ee9fc66044 --- /dev/null +++ b/TablePro/Models/Query/UserDefinedTypeInfo.swift @@ -0,0 +1,117 @@ +// +// UserDefinedTypeInfo.swift +// TablePro +// + +import Foundation + +/// Where a new enum label goes relative to an existing one. Absent, the label is appended. +struct EnumLabelPlacement: Hashable, Sendable { + let anchor: String + let placesBefore: Bool +} + +struct UserDefinedTypeInfo: Identifiable, Hashable, Sendable { + enum Kind: String, Codable, Sendable, CaseIterable { + case enumeration = "enum" + case composite + case domain + case range + case other + + 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 .other: return String(localized: "Type") + } + } + + var iconName: String { + switch self { + case .enumeration: return "list.bullet" + case .composite: return "rectangle.split.3x1" + case .domain: return "checkmark.seal" + case .range: return "arrow.left.and.right.square" + case .other: return SidebarObjectKind.type.iconName + } + } + } + + struct Field: Hashable, Sendable { + let name: String + let type: String + let collation: String? + + init(name: String, type: String, collation: String? = nil) { + self.name = name + self.type = type + self.collation = collation + } + } + + let name: String + let schema: String? + let kind: Kind + + /// The driver's own key for re-addressing this type when asked for its definition. Opaque here. + let identity: String? + let enumLabels: [String] + let fields: [Field] + + /// A domain's base type, or a range's subtype. + let baseType: String? + + /// How a column definition names this type, as the engine itself spells it. + let columnTypeSpelling: String? + + /// The CREATE statement, when the listing already returned it. Never part of `id`. + let definition: String? + let attributes: [ObjectAttribute] + + init( + name: String, + kind: Kind, + schema: String? = nil, + identity: String? = nil, + enumLabels: [String] = [], + fields: [Field] = [], + baseType: String? = nil, + columnTypeSpelling: String? = nil, + definition: String? = nil, + attributes: [ObjectAttribute] = [] + ) { + self.name = name + self.kind = kind + self.schema = schema + self.identity = identity + self.enumLabels = enumLabels + self.fields = fields + self.baseType = baseType + self.columnTypeSpelling = columnTypeSpelling + self.definition = definition + self.attributes = attributes + } + + var qualifiedName: String { + guard let schema, !schema.isEmpty else { return name } + return "\(schema).\(name)" + } + + /// A type name is unique within its schema on every engine that has named types, so the + /// qualified name is the whole identity. The definition and the labels are deliberately left + /// out: an edited enum must still be the same row. + var id: String { + "type_\(qualifiedName)" + } + + static func == (lhs: UserDefinedTypeInfo, rhs: UserDefinedTypeInfo) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/TablePro/Models/Sidebar/SidebarObjectKind.swift b/TablePro/Models/Sidebar/SidebarObjectKind.swift index 1218fec379..d520156a3d 100644 --- a/TablePro/Models/Sidebar/SidebarObjectKind.swift +++ b/TablePro/Models/Sidebar/SidebarObjectKind.swift @@ -12,6 +12,7 @@ enum SidebarObjectCategory: Sendable, Hashable { case table case routine case trigger + case type } enum SidebarObjectKind: String, CaseIterable, Sendable, Hashable { @@ -22,6 +23,7 @@ enum SidebarObjectKind: String, CaseIterable, Sendable, Hashable { case procedure case function case trigger + case type var displayName: String { switch self { @@ -32,6 +34,7 @@ enum SidebarObjectKind: String, CaseIterable, Sendable, Hashable { case .procedure: return String(localized: "Procedure") case .function: return String(localized: "Function") case .trigger: return String(localized: "Trigger") + case .type: return String(localized: "Type") } } @@ -44,6 +47,7 @@ enum SidebarObjectKind: String, CaseIterable, Sendable, Hashable { case .procedure: return String(localized: "Procedures") case .function: return String(localized: "Functions") case .trigger: return String(localized: "Triggers") + case .type: return String(localized: "Types") } } @@ -56,6 +60,7 @@ enum SidebarObjectKind: String, CaseIterable, Sendable, Hashable { case .procedure: return String(localized: "No procedures") case .function: return String(localized: "No functions") case .trigger: return String(localized: "No triggers") + case .type: return String(localized: "No types") } } @@ -76,6 +81,7 @@ enum SidebarObjectKind: String, CaseIterable, Sendable, Hashable { case .procedure: return "curlybraces.square" case .function: return "function" case .trigger: return "bolt" + case .type: return "cube" } } @@ -84,6 +90,7 @@ enum SidebarObjectKind: String, CaseIterable, Sendable, Hashable { case .table, .view, .materializedView, .foreignTable: return .table case .procedure, .function: return .routine case .trigger: return .trigger + case .type: return .type } } diff --git a/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift b/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift index 35976023f8..345431d3f1 100644 --- a/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift +++ b/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift @@ -22,12 +22,13 @@ internal enum SidebarObjectListPresentation: Equatable { case empty case list + /// `hasSideObjects` is whether any non-table kind returned rows: routines, triggers, types. + /// A database with no tables but a stored procedure is a list, not an empty state. internal static func resolve( state: SchemaState, hasActiveFilter: Bool, hasAnyMatch: Bool, - hasRoutines: Bool, - hasTriggers: Bool, + hasSideObjects: Bool, hasOutlastedGrace: Bool = true ) -> SidebarObjectListPresentation { switch state { @@ -39,7 +40,7 @@ internal enum SidebarObjectListPresentation: Equatable { if hasActiveFilter, !hasAnyMatch { return .noMatch } - if tables.isEmpty, !hasRoutines, !hasTriggers { + if tables.isEmpty, !hasSideObjects { return .empty } return .list diff --git a/TablePro/Models/UI/InspectorContext.swift b/TablePro/Models/UI/InspectorContext.swift index f47cafaa58..3ac2f3d87f 100644 --- a/TablePro/Models/UI/InspectorContext.swift +++ b/TablePro/Models/UI/InspectorContext.swift @@ -20,6 +20,10 @@ struct InspectorContext { /// The same row the details tab shows, carried as raw cell values for the JSON tab. let jsonRow: JSONRowSnapshot? + /// The table a structure row belongs to, for the column type picker's user-defined types. + /// Nil on every tab that is not editing a table's structure. + var userDefinedTypeScope: DatabaseScope? + static let empty = InspectorContext( tableName: nil, tableMetadata: nil, diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift index 1d371da4c2..b37a3d67e0 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -18,6 +18,7 @@ internal enum QuickSwitcherItemKind: String, Hashable, Sendable { case procedure case function case trigger + case userType case savedQuery case queryHistory } @@ -141,6 +142,7 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { case .procedure: return "curlybraces.square" case .function: return "function" case .trigger: return "bolt" + case .userType: return SidebarObjectKind.type.iconName case .savedQuery: return "star" case .queryHistory: return "clock.arrow.circlepath" } @@ -157,6 +159,7 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { case .procedure: return String(localized: "Procedure") case .function: return String(localized: "Function") case .trigger: return String(localized: "Trigger") + case .userType: return String(localized: "Type") case .savedQuery: return String(localized: "Saved Query") case .queryHistory: return String(localized: "History") } diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index af1e3b6f92..dfdcc9ba30 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -285,6 +285,7 @@ internal final class QuickSwitcherViewModel { items += routineItems(connectionId: connectionId, database: activeDatabase) items += triggerItems(connectionId: connectionId, database: activeDatabase) + items += userTypeItems(connectionId: connectionId, database: activeDatabase) let favorites = await services.sqlFavoriteManager.fetchFavorites(connectionId: connectionId) for favorite in favorites { @@ -977,6 +978,19 @@ internal final class QuickSwitcherViewModel { } } + private func userTypeItems(connectionId: UUID, database: String?) -> [QuickSwitcherItem] { + SchemaService.shared.userDefinedTypes(for: connectionId).map { type in + QuickSwitcherItem( + id: "usertype_\(type.id)", + name: type.name, + kind: .userType, + subtitle: type.schema ?? database ?? "", + schemaName: type.schema, + objectRef: DatabaseObjectRef(userType: type, database: database ?? "") + ) + } + } + nonisolated static func databaseDisplayName( _ databaseName: String?, pathFieldRole: PathFieldRole @@ -990,7 +1004,7 @@ internal final class QuickSwitcherViewModel { private extension QuickSwitcherItemKind { static let displayOrder: [QuickSwitcherItemKind] = [ .table, .view, .systemTable, .database, .schema, - .procedure, .function, .trigger, .savedQuery, .queryHistory + .procedure, .function, .trigger, .userType, .savedQuery, .queryHistory ] var rankWeight: Double { @@ -1003,6 +1017,7 @@ private extension QuickSwitcherItemKind { case .procedure: return 0.92 case .function: return 0.92 case .trigger: return 0.91 + case .userType: return 0.91 case .savedQuery: return 0.9 case .queryHistory: return 0.7 } @@ -1018,6 +1033,7 @@ private extension QuickSwitcherItemKind { case .procedure: return String(localized: "Procedures") case .function: return String(localized: "Functions") case .trigger: return String(localized: "Triggers") + case .userType: return String(localized: "Types") case .savedQuery: return String(localized: "Saved Queries") case .queryHistory: return String(localized: "Recent Queries") } diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index df683d6071..f22df59ed0 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -361,6 +361,8 @@ final class SidebarViewModel { @ObservationIgnored private var cachedFilteredRoutinesFingerprint: (count: Int, generation: Int, query: String)? @ObservationIgnored private var cachedFilteredTriggers: [TriggerInfo] = [] @ObservationIgnored private var cachedFilteredTriggersFingerprint: (count: Int, generation: Int, query: String)? + @ObservationIgnored private var cachedFilteredUserTypes: [UserDefinedTypeInfo] = [] + @ObservationIgnored private var cachedFilteredUserTypesFingerprint: (count: Int, generation: Int, query: String)? private var schemaGeneration: Int { SchemaService.shared.generationToken(for: connectionId) @@ -429,6 +431,18 @@ final class SidebarViewModel { return cachedFilteredTriggers } + func filteredUserTypes(from types: [UserDefinedTypeInfo]) -> [UserDefinedTypeInfo] { + let query = filterQuery + let fingerprint = (count: types.count, generation: schemaGeneration, query: query) + if cachedFilteredUserTypesFingerprint?.count != fingerprint.count + || cachedFilteredUserTypesFingerprint?.generation != fingerprint.generation + || cachedFilteredUserTypesFingerprint?.query != fingerprint.query { + cachedFilteredUserTypes = DatabaseTreeFilter.filteredUserTypes(types, searchText: query) + cachedFilteredUserTypesFingerprint = fingerprint + } + return cachedFilteredUserTypes + } + func effectiveExpanded(kind: SidebarObjectKind, hasMatches: Bool) -> Bool { if !filterQuery.isEmpty && hasMatches { return true } return expanded[kind] diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index c2ff19db67..39505b265e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -86,7 +86,7 @@ extension MainContentCoordinator { await switchSchema(to: item.name) } - case .procedure, .function, .trigger: + case .procedure, .function, .trigger, .userType: guard let objectRef = item.objectRef else { return } showObjectSource(objectRef) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index d1d3ca60ec..15de38cda1 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -115,6 +115,25 @@ extension MainContentCoordinator { WindowManager.shared.openTab(payload: payload) } + /// Opens the engine's CREATE TYPE template in a query tab, the way Create New View does. A type + /// has no form of its own: its shape is the statement, and the editor is where that is written. + func createType(database: String?, schema: String?) { + guard !safeModeLevel.blocksAllWrites else { return } + guard let driver = DatabaseManager.shared.driver(for: connection.id), + let template = driver.createTypeTemplate(schema: schema ?? toolbarState.currentSchema) + else { return } + + let targetDatabase = database.flatMap { $0.isEmpty ? nil : $0 } ?? browseDatabaseName + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .query, + databaseName: targetDatabase, + schemaName: schema, + initialQuery: template + ) + WindowManager.shared.openTab(payload: payload) + } + func editViewDefinition(_ viewName: String) { Task { do { diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index bfae81ed40..545ebc5c44 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -73,7 +73,20 @@ extension MainContentView { isRowDeleted: isSelectedRowDeleted, currentQuery: coordinator.tabManager.selectedTab?.content.query, queryResults: cachedQueryResultsSummary(), - jsonRow: jsonRowSnapshotForSidebar + jsonRow: jsonRowSnapshotForSidebar, + userDefinedTypeScope: structureTypeScope + ) + } + + /// The scope a structure row's type picker looks types up in: the tab's own database and + /// schema, never the sidebar's, because a tab bound to another database edits that one. + private var structureTypeScope: DatabaseScope? { + guard let tab = currentTab, tab.tabType == .table || tab.tabType == .createTable else { return nil } + let database = tab.tableContext.databaseName ?? coordinator.browseDatabaseName + return DatabaseScope( + connectionId: coordinator.connection.id, + database: database, + schema: tab.tableContext.schemaName ) } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index acd151ac69..e83aa5dc72 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -809,6 +809,13 @@ final class MainContentCoordinator { } } + func refreshUserDefinedTypes() async { + guard connection.type.supportsUserDefinedTypeBrowse else { return } + try? await services.databaseManager.withBrowseMetadataDriver(connectionId: connectionId) { [services, connectionId] driver in + _ = await services.schemaService.reloadUserDefinedTypes(connectionId: connectionId, driver: driver) + } + } + /// Opens the viewer rather than fetching here. Inspecting an object should not put its source /// into an editable query buffer, where the next Cmd+Return runs it, and the viewer refetches /// on its own so a restored tab shows the current definition instead of a stale one. diff --git a/TablePro/Views/ObjectSource/EnumLabelListView.swift b/TablePro/Views/ObjectSource/EnumLabelListView.swift new file mode 100644 index 0000000000..95bd37351b --- /dev/null +++ b/TablePro/Views/ObjectSource/EnumLabelListView.swift @@ -0,0 +1,225 @@ +// +// EnumLabelListView.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// The labels of one enum type in server order, with the two edits PostgreSQL allows: add a +/// label, at the end or beside an existing one, and rename one. A label cannot be removed or +/// moved once it exists, so neither is offered. +/// +/// Each edit is one statement that runs when the field commits, because `ALTER TYPE … ADD VALUE` +/// cannot be batched into a transaction on every server that supports it. +struct EnumLabelListView: View { + let labels: [String] + let canEdit: Bool + let canRename: Bool + let onAdd: (String, EnumLabelPlacement?) async throws -> Void + let onRename: (String, String) async throws -> Void + + private enum Draft: Equatable { + case adding(placement: EnumLabelPlacement?) + case renaming(String) + } + + private struct Row: Identifiable { + enum Content: Equatable { + case label(String) + case draft + } + + let id: String + let content: Content + } + + @State private var draft: Draft? + @State private var draftText = "" + @State private var isApplying = false + @State private var errorMessage: String? + @State private var selection: String? + @FocusState private var isDraftFocused: Bool + + private static let rowHeight: CGFloat = 24 + private static let maxListHeight: CGFloat = 220 + + var body: some View { + VStack(spacing: 0) { + titleRow + List(selection: $selection) { + ForEach(rows) { row in + rowView(row) + .tag(row.id) + } + } + .listStyle(.inset) + .environment(\.defaultMinListRowHeight, Self.rowHeight) + .frame(height: listHeight) + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.bottom, 6) + } + if canEdit { + Divider() + editBar + } + } + .background(Color(nsColor: .controlBackgroundColor)) + } + + private var titleRow: some View { + HStack { + Text("Labels") + .font(.subheadline.weight(.semibold)) + Spacer() + Text("\(labels.count)") + .font(.subheadline) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .padding(.horizontal) + .padding(.vertical, 6) + } + + private var editBar: some View { + HStack(spacing: 4) { + Button(String(localized: "Add Label"), systemImage: "plus") { + beginAdding(placement: nil) + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .disabled(draft != nil || isApplying) + .help(String(localized: "Add a label at the end")) + if isApplying { + ProgressView() + .controlSize(.small) + } + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + + private var rows: [Row] { + var rows = labels.map { Row(id: "label:\($0)", content: .label($0)) } + switch draft { + case .adding(let placement): + let draftRow = Row(id: "draft", content: .draft) + guard let placement, let anchor = rows.firstIndex(where: { $0.content == .label(placement.anchor) }) + else { + rows.append(draftRow) + return rows + } + rows.insert(draftRow, at: placement.placesBefore ? anchor : anchor + 1) + case .renaming(let label): + guard let index = rows.firstIndex(where: { $0.content == .label(label) }) else { return rows } + rows[index] = Row(id: "draft", content: .draft) + case nil: + break + } + return rows + } + + private var listHeight: CGFloat { + min(CGFloat(rows.count) * Self.rowHeight + 8, Self.maxListHeight) + } + + @ViewBuilder + private func rowView(_ row: Row) -> some View { + switch row.content { + case .draft: + TextField(String(localized: "Label"), text: $draftText) + .textFieldStyle(.roundedBorder) + .font(ThemeEngine.shared.valueFontSwiftUI) + .focused($isDraftFocused) + .disabled(isApplying) + .onSubmit { commitDraft() } + .onExitCommand { cancelDraft() } + .onAppear { isDraftFocused = true } + case .label(let label): + Text(label) + .font(ThemeEngine.shared.valueFontSwiftUI) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + guard NSApp.currentEvent?.clickCount == 2 else { return } + beginRenaming(label) + } + .contextMenu { labelMenu(label) } + } + } + + @ViewBuilder + private func labelMenu(_ label: String) -> some View { + Button(String(localized: "Copy Label")) { + ClipboardService.shared.writeText(label) + } + if canEdit { + Divider() + Button(String(localized: "Add Label Before…")) { + beginAdding(placement: EnumLabelPlacement(anchor: label, placesBefore: true)) + } + Button(String(localized: "Add Label After…")) { + beginAdding(placement: EnumLabelPlacement(anchor: label, placesBefore: false)) + } + if canRename { + Button(String(localized: "Rename…")) { + beginRenaming(label) + } + } + } + } + + private func beginAdding(placement: EnumLabelPlacement?) { + guard canEdit, !isApplying else { return } + errorMessage = nil + draftText = "" + draft = .adding(placement: placement) + } + + private func beginRenaming(_ label: String) { + guard canEdit, canRename, !isApplying else { return } + errorMessage = nil + draftText = label + draft = .renaming(label) + } + + private func cancelDraft() { + draft = nil + draftText = "" + } + + private func commitDraft() { + guard let draft else { return } + let text = draftText + guard !text.isEmpty else { + cancelDraft() + return + } + if case .renaming(let original) = draft, original == text { + cancelDraft() + return + } + isApplying = true + errorMessage = nil + Task { + defer { isApplying = false } + do { + switch draft { + case .adding(let placement): + try await onAdd(text, placement) + case .renaming(let original): + try await onRename(original, text) + } + cancelDraft() + } catch { + errorMessage = error.localizedDescription + } + } + } +} diff --git a/TablePro/Views/ObjectSource/ObjectSourceTabView.swift b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift index 15435a323c..ddd69ec985 100644 --- a/TablePro/Views/ObjectSource/ObjectSourceTabView.swift +++ b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift @@ -2,7 +2,7 @@ // ObjectSourceTabView.swift // TablePro // -// Tab showing the source of one stored procedure, function or trigger. +// Tab showing the source of one stored procedure, function, trigger or user-defined type. // import SwiftUI @@ -13,12 +13,23 @@ import TableProPluginKit final class ObjectSourceLoader { enum State { case loading - case loaded(source: String, attributes: [ObjectAttribute]) + case loaded(source: String, attributes: [ObjectAttribute], enumLabels: [String]) case failed(String) } + private struct Fetched: Sendable { + let source: String + let attributes: [ObjectAttribute] + let enumLabels: [String] + let userType: UserDefinedTypeInfo? + } + private(set) var state: State = .loading + /// The type as the server last described it, so an edit addresses the object on screen rather + /// than whatever the sidebar listed when the tab was opened. + private(set) var userType: UserDefinedTypeInfo? + private let connectionId: UUID private let objectRef: DatabaseObjectRef @@ -28,12 +39,17 @@ final class ObjectSourceLoader { } var source: String { - if case .loaded(let source, _) = state { return source } + if case .loaded(let source, _, _) = state { return source } return "" } var attributes: [ObjectAttribute] { - if case .loaded(_, let attributes) = state { return attributes } + if case .loaded(_, let attributes, _) = state { return attributes } + return [] + } + + var enumLabels: [String] { + if case .loaded(_, _, let labels) = state { return labels } return [] } @@ -43,7 +59,8 @@ final class ObjectSourceLoader { if !isRefresh { state = .loading } do { let fetched = try await fetch() - state = .loaded(source: fetched.source, attributes: fetched.attributes) + userType = fetched.userType + state = .loaded(source: fetched.source, attributes: fetched.attributes, enumLabels: fetched.enumLabels) } catch is CancellationError { } catch { guard case .loaded = state, isRefresh else { @@ -54,28 +71,44 @@ final class ObjectSourceLoader { } /// One round trip. Re-listing the schema to recover the attributes cost a full catalog scan - /// per open and per reload, for values the sidebar's listing already carried into the ref. - private func fetch() async throws -> (source: String, attributes: [ObjectAttribute]) { + /// per open and per reload, for values the sidebar's listing already carried into the ref. A + /// type is the exception: its labels are what the viewer edits, so they are read fresh. + private func fetch() async throws -> Fetched { let scope = DatabaseScope( connectionId: connectionId, database: objectRef.database, schema: objectRef.schema ) - let source = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { [objectRef] driver in + return try await DatabaseManager.shared.withMetadataDriver(scope: scope) { [objectRef] driver in switch objectRef.kind { case .procedure, .function: guard let routine = objectRef.routine else { throw PluginObjectSourceError.unsupported(objectRef.name) } - return try await driver.fetchRoutineDDL(routine) + let source = try await driver.fetchRoutineDDL(routine) + return Fetched(source: source, attributes: objectRef.attributes, enumLabels: [], userType: nil) case .trigger: guard let trigger = objectRef.trigger else { throw PluginObjectSourceError.unsupported(objectRef.name) } - return try await driver.fetchTriggerDDL(trigger) + let source = try await driver.fetchTriggerDDL(trigger) + return Fetched(source: source, attributes: objectRef.attributes, enumLabels: [], userType: nil) + case .userType: + guard let type = objectRef.userType else { + throw PluginObjectSourceError.unsupported(objectRef.name) + } + let fetched = try await driver.fetchUserDefinedType(type) + guard let source = fetched.definition, !source.isEmpty else { + throw PluginObjectSourceError.unsupported(objectRef.name) + } + return Fetched( + source: source, + attributes: fetched.attributes, + enumLabels: fetched.enumLabels, + userType: fetched + ) } } - return (source, objectRef.attributes) } } @@ -109,25 +142,36 @@ struct ObjectSourceTabView: View { .task { await loader.load() } } + /// Only an enum has anything to edit in place: a label is appended or renamed with one + /// statement. Every other object stays read-only here and is edited in a query tab. + private var enumEditor: EnumLabelEditor? { + guard objectRef.kind == .userType, objectRef.typeKind == .enumeration, + let connection = DatabaseManager.shared.session(for: connectionId)?.connection + else { return nil } + return EnumLabelEditor(connection: connection, objectRef: objectRef) + } + private var header: some View { HStack(spacing: 8) { - Image(systemName: objectRef.kind.iconName) + Image(systemName: objectRef.kindIconName) .foregroundStyle(Color.accentColor) Text(objectRef.displayIdentity) .font(.headline) .textSelection(.enabled) .lineLimit(1) .truncationMode(.middle) - Text(objectRef.kind.displayName) + Text(objectRef.kindDisplayName) .font(.caption) .foregroundStyle(.secondary) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color(nsColor: .quaternaryLabelColor), in: Capsule()) Spacer() - Text("Read Only") - .font(.caption) - .foregroundStyle(.secondary) + if enumEditor?.canEdit != true { + Text("Read Only") + .font(.caption) + .foregroundStyle(.secondary) + } Button { Task { await loader.load(isRefresh: true) } } label: { @@ -161,13 +205,31 @@ struct ObjectSourceTabView: View { } .background(Color(nsColor: .textBackgroundColor)) case .loaded: - ObjectSourceView( - source: loader.source, - databaseType: databaseType, - exportFileName: objectRef.suggestedFileName, - attributes: loader.attributes, - onOpenInEditor: { onOpenInEditor(loader.source) } - ) + VStack(spacing: 0) { + if let editor = enumEditor, let type = loader.userType { + EnumLabelListView( + labels: loader.enumLabels, + canEdit: editor.canEdit, + canRename: editor.canRename(type), + onAdd: { label, placement in + try await editor.add(label: label, placement: placement, to: type) + await loader.load(isRefresh: true) + }, + onRename: { oldLabel, newLabel in + try await editor.rename(oldLabel, to: newLabel, in: type) + await loader.load(isRefresh: true) + } + ) + Divider() + } + ObjectSourceView( + source: loader.source, + databaseType: databaseType, + exportFileName: objectRef.suggestedFileName, + attributes: loader.attributes, + onOpenInEditor: { onOpenInEditor(loader.source) } + ) + } } } } diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 315032f5a1..9b0e70db04 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -359,6 +359,8 @@ struct QuickSwitcherPanelContent: View { return String(localized: "Switch") case .procedure, .function, .trigger: return String(localized: "Show DDL") + case .userType: + return String(localized: "Show Definition") case .savedQuery, .queryHistory: return String(localized: "Load Query") } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index 137315decd..f73305985a 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -150,6 +150,7 @@ extension TableViewCoordinator { let currentValue = cellValue(at: row, column: columnIndex) ?? "" let dbType = databaseType ?? .mysql + let scope = userDefinedTypeScope let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) dismissActiveCellEditorPopover() @@ -157,15 +158,25 @@ extension TableViewCoordinator { relativeTo: cellRect, of: tableView ) { [weak self] dismiss in - TypePickerContentView( - databaseType: dbType, - currentValue: currentValue, - onCommit: { newValue in - guard let self else { return } - self.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) - }, - onDismiss: dismiss - ) + UserDefinedTypeAwarePicker(scope: scope) { userDefinedTypes in + TypePickerContentView( + databaseType: dbType, + currentValue: currentValue, + userDefinedTypes: userDefinedTypes, + onCommit: { newValue in + guard let self else { return } + self.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) + }, + onDismiss: dismiss + ) + } } } + + /// The table the structure grid edits, as the scope a type lookup runs against. Nil where the + /// grid has no connection behind it, which is every grid that is not a structure grid. + private var userDefinedTypeScope: DatabaseScope? { + guard let connectionId, tabType == .table || tabType == .createTable else { return nil } + return DatabaseScope(connectionId: connectionId, database: databaseName ?? "", schema: schemaName) + } } diff --git a/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift b/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift index c7dca4bb31..d76875f351 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift @@ -22,6 +22,9 @@ internal struct FieldEditorContext { let allowsNullAndDefault: Bool let showsTypeBadge: Bool + /// The table a column type picker offers user-defined types for. Nil outside a structure row. + let userDefinedTypeScope: DatabaseScope? + init( columnName: String, columnType: ColumnType, @@ -33,7 +36,8 @@ internal struct FieldEditorContext { commitBytes: ((Data) -> Void)? = nil, editor: FieldEditorKind? = nil, allowsNullAndDefault: Bool = true, - showsTypeBadge: Bool = true + showsTypeBadge: Bool = true, + userDefinedTypeScope: DatabaseScope? = nil ) { self.columnName = columnName self.columnType = columnType @@ -46,6 +50,7 @@ internal struct FieldEditorContext { self.editor = editor self.allowsNullAndDefault = allowsNullAndDefault self.showsTypeBadge = showsTypeBadge + self.userDefinedTypeScope = userDefinedTypeScope } var placeholderText: String { diff --git a/TablePro/Views/RightSidebar/FieldEditors/TypePickerFieldView.swift b/TablePro/Views/RightSidebar/FieldEditors/TypePickerFieldView.swift index 05e8f6d56a..a24b299cd4 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/TypePickerFieldView.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/TypePickerFieldView.swift @@ -28,12 +28,15 @@ internal struct TypePickerFieldView: View { .disabled(context.isReadOnly) .accessibilityLabel(String(localized: "Choose Type")) .popover(isPresented: $isPickerPresented) { - TypePickerContentView( - databaseType: databaseType, - currentValue: context.value.wrappedValue, - onCommit: { context.value.wrappedValue = $0 }, - onDismiss: { isPickerPresented = false } - ) + UserDefinedTypeAwarePicker(scope: context.userDefinedTypeScope) { userDefinedTypes in + TypePickerContentView( + databaseType: databaseType, + currentValue: context.value.wrappedValue, + userDefinedTypes: userDefinedTypes, + onCommit: { context.value.wrappedValue = $0 }, + onDismiss: { isPickerPresented = false } + ) + } } } } diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index 6a252e98b5..8088621859 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -16,6 +16,7 @@ struct RightSidebarView: View { var editState: MultiRowEditState let databaseType: DatabaseType + var userDefinedTypeScope: DatabaseScope? @State private var searchText: String = "" @State private var expandedJsonColumnIndex: Int? @@ -352,7 +353,8 @@ struct RightSidebarView: View { commitBytes: isEditable ? { data in editState.setFieldToBytes(at: index, data: data) } : nil, editor: kind, allowsNullAndDefault: !field.isSchemaField, - showsTypeBadge: !field.isSchemaField + showsTypeBadge: !field.isSchemaField, + userDefinedTypeScope: field.isSchemaField ? userDefinedTypeScope : nil ), isPendingNull: field.isPendingNull, isPendingDefault: field.isPendingDefault, diff --git a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift index df6e33bee4..696d05bb8c 100644 --- a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift +++ b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift @@ -173,7 +173,8 @@ struct UnifiedRightPanelView: View { isEditable: ctx.isEditable, isRowDeleted: ctx.isRowDeleted, editState: state.editState, - databaseType: connection.type + databaseType: connection.type, + userDefinedTypeScope: ctx.userDefinedTypeScope ) } diff --git a/TablePro/Views/Sidebar/DatabaseTreeCellView.swift b/TablePro/Views/Sidebar/DatabaseTreeCellView.swift index 07fe25a879..45a43296b7 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeCellView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeCellView.swift @@ -38,7 +38,7 @@ final class DatabaseTreeCellView: RenamableSidebarCellView return metadata.isSystemDatabase ? "gearshape" : "cylinder" case .schema: return "folder" - case .routine, .trigger, .status, .recentSection, .objectKindSection, + case .routine, .trigger, .userType, .status, .recentSection, .objectKindSection, .containerObjectKindSection, .hierarchicalSchemaSection, .redisKeysSection, .redisNode: return "tablecells" } diff --git a/TablePro/Views/Sidebar/DatabaseTreeDoubleClickIntent.swift b/TablePro/Views/Sidebar/DatabaseTreeDoubleClickIntent.swift index e3dfdc3e3a..b1808d59ad 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeDoubleClickIntent.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeDoubleClickIntent.swift @@ -14,8 +14,8 @@ import Foundation internal enum DatabaseTreeDoubleClickIntent: Equatable { /// Open the table in a tab the next sidebar click will not replace. case openPermanently(DatabaseTreeTableRef) - /// Open a routine's or trigger's source. Selection alone does not open one, because fetching a - /// definition is a round trip, and arrowing through a section would fire one per row. + /// Open a routine's, trigger's or type's source. Selection alone does not open one, because + /// fetching a definition is a round trip, and arrowing through a section would fire one per row. case openObjectSource(DatabaseObjectRef) /// Expand or collapse a container row. case toggleDisclosure @@ -35,6 +35,8 @@ internal enum DatabaseTreeDoubleClickResolver { return .openObjectSource(ref.objectRef) case .trigger(let ref): return .openObjectSource(ref.objectRef) + case .userType(let ref): + return .openObjectSource(ref.objectRef) default: return node.isExpandable ? .toggleDisclosure : .ignore } diff --git a/TablePro/Views/Sidebar/DatabaseTreeFilter.swift b/TablePro/Views/Sidebar/DatabaseTreeFilter.swift index cb70c9e9aa..667a6bd5e6 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeFilter.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeFilter.swift @@ -19,9 +19,25 @@ struct DatabaseTreeObjectBuckets { let tables: [SidebarObjectKind: [TableInfo]] let routines: [SidebarObjectKind: [RoutineInfo]] let triggers: [TriggerInfo] + let userTypes: [UserDefinedTypeInfo] + + init( + tables: [SidebarObjectKind: [TableInfo]], + routines: [SidebarObjectKind: [RoutineInfo]], + triggers: [TriggerInfo], + userTypes: [UserDefinedTypeInfo] = [] + ) { + self.tables = tables + self.routines = routines + self.triggers = triggers + self.userTypes = userTypes + } var isEmpty: Bool { - tables.values.allSatisfy(\.isEmpty) && routines.values.allSatisfy(\.isEmpty) && triggers.isEmpty + tables.values.allSatisfy(\.isEmpty) + && routines.values.allSatisfy(\.isEmpty) + && triggers.isEmpty + && userTypes.isEmpty } var itemCounts: [SidebarObjectKind: Int] { @@ -30,6 +46,9 @@ struct DatabaseTreeObjectBuckets { counts[kind, default: 0] += list.count } counts[.trigger, default: 0] += triggers.count + if !userTypes.isEmpty { + counts[.type, default: 0] += userTypes.count + } return counts } } @@ -62,10 +81,16 @@ enum DatabaseTreeFilter { return deduplicated(matched + byTable, by: \.id) } + static func filteredUserTypes(_ types: [UserDefinedTypeInfo], searchText: String) -> [UserDefinedTypeInfo] { + let matched = SidebarNameFilter.ranked(types, query: searchText, name: { $0.name }) + return deduplicated(matched, by: \.id) + } + static func objectBuckets( tables: [TableInfo], routines: [RoutineInfo], triggers: [TriggerInfo], + userTypes: [UserDefinedTypeInfo] = [], searchText: String ) -> DatabaseTreeObjectBuckets { var tableBuckets: [SidebarObjectKind: [TableInfo]] = [:] @@ -79,7 +104,8 @@ enum DatabaseTreeFilter { return DatabaseTreeObjectBuckets( tables: tableBuckets, routines: routineBuckets, - triggers: filteredTriggers(triggers, searchText: searchText) + triggers: filteredTriggers(triggers, searchText: searchText), + userTypes: filteredUserTypes(userTypes, searchText: searchText) ) } diff --git a/TablePro/Views/Sidebar/DatabaseTreeNode.swift b/TablePro/Views/Sidebar/DatabaseTreeNode.swift index 1948278552..27c654f3e8 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeNode.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeNode.swift @@ -40,6 +40,7 @@ final class DatabaseTreeNode: SidebarOutlineNode { case table(DatabaseTreeTableRef) case routine(DatabaseTreeRoutineRef) case trigger(DatabaseTreeTriggerRef) + case userType(DatabaseTreeUserTypeRef) case status(Status) /// Flat shape: one collapsible section per object kind. @@ -71,7 +72,7 @@ final class DatabaseTreeNode: SidebarOutlineNode { case .redisNode(let node): guard case .namespace = node else { return false } return true - case .recentTable, .routine, .trigger, .status: + case .recentTable, .routine, .trigger, .userType, .status: return false } } @@ -98,7 +99,7 @@ final class DatabaseTreeNode: SidebarOutlineNode { return true case .database, .schema, .containerObjectKindSection, .hierarchicalSchemaSection, .recentTable, .table, - .routine, .trigger, .status, .redisNode: + .routine, .trigger, .userType, .status, .redisNode: return false } } @@ -107,7 +108,7 @@ final class DatabaseTreeNode: SidebarOutlineNode { switch kind { case .database, .schema: return true - case .recentSection, .recentTable, .table, .routine, .trigger, .status, + case .recentSection, .recentTable, .table, .routine, .trigger, .userType, .status, .objectKindSection, .containerObjectKindSection, .hierarchicalSchemaSection, .redisKeysSection, .redisNode: return false @@ -120,7 +121,7 @@ final class DatabaseTreeNode: SidebarOutlineNode { return .database(metadata.name, isSystem: metadata.isSystemDatabase) case .schema(let database, let schema): return .schema(database: database, schema: schema, isSystem: systemSchemas.contains(schema)) - case .recentSection, .recentTable, .table, .routine, .trigger, .status, + case .recentSection, .recentTable, .table, .routine, .trigger, .userType, .status, .objectKindSection, .containerObjectKindSection, .hierarchicalSchemaSection, .redisKeysSection, .redisNode: return nil @@ -134,6 +135,7 @@ final class DatabaseTreeNode: SidebarOutlineNode { static func recentTableId(_ ref: DatabaseTreeTableRef) -> String { "recent\u{1}table\u{1}\(ref.id)" } static func routineId(_ ref: DatabaseTreeRoutineRef) -> String { "routine\u{1}\(ref.id)" } static func triggerId(_ ref: DatabaseTreeTriggerRef) -> String { "trigger\u{1}\(ref.id)" } + static func userTypeId(_ ref: DatabaseTreeUserTypeRef) -> String { "usertype\u{1}\(ref.id)" } static func statusId(parentId: String, status: Status) -> String { switch status { case .loading: return "\(parentId)\u{1}status.loading" diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift index f83a7f7cca..54a3a6e819 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift @@ -17,6 +17,8 @@ extension DatabaseTreeOutlineCoordinator { mainCoordinator?.createNewTable() case .createView: mainCoordinator?.createView() + case .createType(let database, let schema): + mainCoordinator?.createType(database: database, schema: schema) case .filterDatabases: mainCoordinator?.splitViewController?.presentDatabaseFilter() case .showAllDatabases: diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift index 009673edc2..367c26ed54 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift @@ -111,7 +111,7 @@ extension DatabaseTreeOutlineCoordinator { restoreObjectGroupExpansion(under: node) case .objectKindSection, .containerObjectKindSection, .hierarchicalSchemaSection: restorePartitionExpansion(under: node) - case .recentSection, .recentTable, .database, .table, .routine, .trigger, .status, + case .recentSection, .recentTable, .database, .table, .routine, .trigger, .userType, .status, .redisKeysSection, .redisNode: break } @@ -162,7 +162,7 @@ extension DatabaseTreeOutlineCoordinator { } else { windowState?.expandedTreeTables.remove(key) } - case .recentTable, .routine, .trigger, .status, .redisNode: + case .recentTable, .routine, .trigger, .userType, .status, .redisNode: break } } @@ -184,7 +184,7 @@ extension DatabaseTreeOutlineCoordinator { loadPartitions(ref) case .hierarchicalSchemaSection(let schema): loadHierarchicalSchemaTables(schema) - case .recentSection, .recentTable, .routine, .trigger, .status, + case .recentSection, .recentTable, .routine, .trigger, .userType, .status, .objectKindSection, .containerObjectKindSection, .redisKeysSection, .redisNode: break @@ -237,6 +237,9 @@ extension DatabaseTreeOutlineCoordinator { if isIdle(service.triggersLoadState(connectionId: connectionId, database: database, schema: schema)) { Task { await service.loadTriggers(connectionId: connectionId, database: database, schema: schema) } } + if isIdle(service.typesLoadState(connectionId: connectionId, database: database, schema: schema)) { + Task { await service.loadUserDefinedTypes(connectionId: connectionId, database: database, schema: schema) } + } } private func isIdle(_ state: MetadataLoadState) -> Bool { diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift index bec2fb90ea..f356c2788a 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift @@ -84,7 +84,8 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { editorLanguage: PluginManager.shared.editorLanguage(for: databaseType), supportsDatabaseSwitching: PluginManager.shared.supportsDatabaseSwitching(for: databaseType), isReadOnly: mainCoordinator?.safeModeLevel.blocksAllWrites ?? false - ) + ), + canCreateType: DatabaseManager.shared.driver(for: connectionId)?.createTypeTemplate(schema: nil) != nil ) } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift index e1a9c6a68d..fc71248f59 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift @@ -52,7 +52,7 @@ extension DatabaseTreeOutlineCoordinator { return redisChildren(of: nil) case .redisNode(let redisNode): return redisChildren(of: redisNode) - case .recentTable, .routine, .trigger, .status: + case .recentTable, .routine, .trigger, .userType, .status: return [] } } @@ -165,6 +165,8 @@ extension DatabaseTreeOutlineCoordinator { return viewModel.filteredRoutines(of: kind, from: schemaService.routines(for: connectionId)).count case .trigger: return viewModel.filteredTriggers(from: schemaService.triggers(for: connectionId)).count + case .type: + return viewModel.filteredUserTypes(from: schemaService.userDefinedTypes(for: connectionId)).count } } @@ -187,6 +189,12 @@ extension DatabaseTreeOutlineCoordinator { let ref = DatabaseTreeTriggerRef(database: database, schema: trigger.schema, trigger: trigger) return node(id: DatabaseTreeNode.triggerId(ref), kind: .trigger(ref)) } + case .type: + return viewModel.filteredUserTypes(from: schemaService.userDefinedTypes(for: connectionId)) + .map { type in + let ref = DatabaseTreeUserTypeRef(database: database, schema: type.schema, type: type) + return node(id: DatabaseTreeNode.userTypeId(ref), kind: .userType(ref)) + } } } @@ -314,17 +322,31 @@ extension DatabaseTreeOutlineCoordinator { tables: service.tables(connectionId: connectionId, database: database, schema: schema), routines: service.routines(connectionId: connectionId, database: database, schema: schema), triggers: service.triggers(connectionId: connectionId, database: database, schema: schema), + userTypes: service.userDefinedTypes(connectionId: connectionId, database: database, schema: schema), searchText: searchText ) objectBucketsCache[key] = buckets return buckets } + /// A fetch the engine never runs stays idle for good, and idle is not loaded: counting it + /// would hold every empty container on a spinner for a list that is never coming. So only the + /// kinds this engine declares take part in deciding between "empty" and "loading". + private func sideLoadStates(database: String, schema: String?) -> [MetadataLoadPhase] { + var states = [service.routinesLoadState(connectionId: connectionId, database: database, schema: schema).erased] + let declared = declaredObjectKinds + if declared.contains(.trigger) { + states.append(service.triggersLoadState(connectionId: connectionId, database: database, schema: schema).erased) + } + if declared.contains(.type) { + states.append(service.typesLoadState(connectionId: connectionId, database: database, schema: schema).erased) + } + return states + } + private func loadedObjectNodes(database: String, schema: String?, parentId: String) -> [DatabaseTreeNode] { let buckets = objectBuckets(database: database, schema: schema) - let routinesState = service.routinesLoadState(connectionId: connectionId, database: database, schema: schema) - let triggersState = service.triggersLoadState(connectionId: connectionId, database: database, schema: schema) - let sideStates = [routinesState.erased, triggersState.erased] + let sideStates = sideLoadStates(database: database, schema: schema) let sideFailure = sideStates.compactMap(\.failureMessage).first guard !buckets.isEmpty else { @@ -383,6 +405,14 @@ extension DatabaseTreeOutlineCoordinator { ) return node(id: DatabaseTreeNode.triggerId(ref), kind: .trigger(ref)) } + case .type: + guard !buckets.userTypes.isEmpty else { + return [statusNode(parentId: emptyId, status: .empty)] + } + return buckets.userTypes.map { type in + let ref = DatabaseTreeUserTypeRef(database: group.database, schema: group.schema, type: type) + return node(id: DatabaseTreeNode.userTypeId(ref), kind: .userType(ref)) + } } } @@ -423,6 +453,8 @@ extension DatabaseTreeOutlineCoordinator { let tables = service.tables(connectionId: connectionId, database: database, schema: schema) if tables.contains(where: { DatabaseTreeFilter.matches(searchText, $0.name) }) { return true } let routines = service.routines(connectionId: connectionId, database: database, schema: schema) - return routines.contains { DatabaseTreeFilter.matches(searchText, $0.name) } + if routines.contains(where: { DatabaseTreeFilter.matches(searchText, $0.name) }) { return true } + let types = service.userDefinedTypes(connectionId: connectionId, database: database, schema: schema) + return types.contains { DatabaseTreeFilter.matches(searchText, $0.name) } } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 13c009b7fd..951d346e7d 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -214,17 +214,19 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { _ = service.tablesLoadState(connectionId: connectionId, database: metadata.name, schema: nil) _ = service.routinesLoadState(connectionId: connectionId, database: metadata.name, schema: nil) _ = service.triggersLoadState(connectionId: connectionId, database: metadata.name, schema: nil) + _ = service.typesLoadState(connectionId: connectionId, database: metadata.name, schema: nil) case .schema(let database, let schema): _ = service.tablesLoadState(connectionId: connectionId, database: database, schema: schema) _ = service.routinesLoadState(connectionId: connectionId, database: database, schema: schema) _ = service.triggersLoadState(connectionId: connectionId, database: database, schema: schema) + _ = service.typesLoadState(connectionId: connectionId, database: database, schema: schema) case .hierarchicalSchemaSection(let schema): _ = schemaService.schemaState(for: connectionId, schema: schema) case .table(let ref) where ref.table.type == .partitionedTable: _ = service.partitionsLoadState( connectionId: connectionId, database: ref.database ?? "", schema: ref.schema, table: ref.table.name ) - case .recentSection, .recentTable, .table, .routine, .trigger, .status, + case .recentSection, .recentTable, .table, .routine, .trigger, .userType, .status, .objectKindSection, .containerObjectKindSection, .redisKeysSection, .redisNode: break @@ -546,6 +548,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { switch kind { case .procedure, .function: Task { await mainCoordinator.refreshRoutines() } case .trigger: Task { await mainCoordinator.refreshTriggers() } + case .type: Task { await mainCoordinator.refreshUserDefinedTypes() } case .table, .view, .materializedView, .foreignTable: Task { await mainCoordinator.refreshTables() } } } @@ -572,6 +575,12 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { database: group.database, schema: group.schema ) + case .type: + await service.refreshUserDefinedTypeObjects( + connectionId: connectionId, + database: group.database, + schema: group.schema + ) } } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift index 23e357bc19..3b4dd676a8 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -88,6 +88,8 @@ struct DatabaseTreeRowView: View { RoutineRowView(routine: ref.routine, displayLabel: context.routineDisplayLabel(ref)) case .trigger(let ref): TriggerRowView(trigger: ref.trigger) + case .userType(let ref): + UserTypeRowView(type: ref.type) case .status(let status): statusRow(status) case .objectKindSection(let kind): diff --git a/TablePro/Views/Sidebar/DatabaseTreeSelection.swift b/TablePro/Views/Sidebar/DatabaseTreeSelection.swift index b473f92157..b9e25d1b7a 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeSelection.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeSelection.swift @@ -17,7 +17,7 @@ internal enum DatabaseTreeSelection { switch kind { case .status, .recentSection, .objectKindSection, .hierarchicalSchemaSection, .redisKeysSection: return false - case .database, .schema, .table, .routine, .trigger, .recentTable, + case .database, .schema, .table, .routine, .trigger, .userType, .recentTable, .containerObjectKindSection, .redisNode: return true } diff --git a/TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift b/TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift index 445ec96ddb..f9aad31597 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift @@ -66,6 +66,8 @@ internal enum DatabaseTreeTypeSelect { return ref.routine.name case .trigger(let ref): return ref.trigger.name + case .userType(let ref): + return ref.type.name case .hierarchicalSchemaSection(let schema): return schema case .redisNode(let node): diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 4da8634b0d..17e9136579 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -34,6 +34,20 @@ struct DatabaseTreeTriggerRef: Identifiable, Equatable { } } +struct DatabaseTreeUserTypeRef: Identifiable, Equatable { + let database: String? + let schema: String? + let type: UserDefinedTypeInfo + + var id: String { + "\(database ?? "")|\(schema ?? "")|\(type.id)" + } + + var objectRef: DatabaseObjectRef { + DatabaseObjectRef(userType: type, database: database ?? "") + } +} + struct DatabaseTreeView: View { @Bindable private var treeService = DatabaseTreeMetadataService.shared diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index 97ad31907d..af6bb6d9e6 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -45,6 +45,8 @@ internal struct DatabaseTreeMenuContext { internal var canCopyObjects: Bool = false /// Duplicating means creating a database, which is the same test the New Database command uses. internal var canDuplicateDatabase: Bool = false + /// Whether the driver can offer a CREATE TYPE template. Read-only mode still hides the item. + internal var canCreateType: Bool = false } internal enum DatabaseTreeMenuSpec { @@ -84,10 +86,13 @@ internal enum DatabaseTreeMenuSpec { return routineItems(ref) case .trigger(let ref): return triggerItems(ref) + case .userType(let ref): + return userTypeItems(ref) case .objectKindSection(let kind): return objectKindItems(kind, context: context) case .containerObjectKindSection(let group): return [.command(String(localized: "Refresh"), .refreshContainerObjectKind(group))] + + createTypeItems(kind: group.kind, database: group.database, schema: group.schema, context: context) case .hierarchicalSchemaSection(let schema): return hierarchicalSchemaItems(schema, context: context) case .redisNode(let node): @@ -214,6 +219,31 @@ internal enum DatabaseTreeMenuSpec { return items } + private static func userTypeItems(_ ref: DatabaseTreeUserTypeRef) -> [DatabaseTreeMenuItem] { + var items: [DatabaseTreeMenuItem] = [.command(String(localized: "Copy Name"), .copyText(ref.type.name))] + if ref.type.qualifiedName != ref.type.name { + items.append(.command(String(localized: "Copy Qualified Name"), .copyText(ref.type.qualifiedName))) + } + items.append(.separator) + items.append(.command(String(localized: "Show Definition"), .showObjectSource(ref.objectRef))) + return items + } + + /// Only the Types section offers it, and only where the engine can hand over a template. It is + /// omitted rather than disabled in read-only mode, the way Create New View is on a table row. + private static func createTypeItems( + kind: SidebarObjectKind, + database: String?, + schema: String?, + context: DatabaseTreeMenuContext + ) -> [DatabaseTreeMenuItem] { + guard kind == .type, context.canCreateType, !context.isReadOnly else { return [] } + return [ + .separator, + .command(String(localized: "Create New Type…"), .createType(database: database, schema: schema)) + ] + } + private static func redisItems(_ node: RedisKeyNode) -> [DatabaseTreeMenuItem] { switch node { case .namespace(_, let fullPrefix, _, _): @@ -239,6 +269,9 @@ internal enum DatabaseTreeMenuSpec { )) } items.append(.command(String(localized: "Refresh"), .refreshObjectKind(kind))) + items += createTypeItems( + kind: kind, database: context.activeDatabase, schema: context.activeSchema, context: context + ) return items } diff --git a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift index 0c7c679e3d..31544a71ef 100644 --- a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift +++ b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift @@ -15,6 +15,10 @@ import TableProPluginKit internal enum SidebarMenuCommand: Equatable { case createTable case createView + /// Carries the database and schema of the section it was raised from, because a tree lists + /// every database and schema, and a template opened against the browsed one would create the + /// type somewhere else: PostgreSQL cannot reach another database by qualifying the name. + case createType(database: String?, schema: String?) case filterDatabases case showAllDatabases case openInNewTab(DatabaseTreeTableRef) diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 5f8aa872f6..908df419da 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -35,6 +35,10 @@ struct SidebarView: View { schemaService.triggers(for: connectionId) } + private var userDefinedTypes: [UserDefinedTypeInfo] { + schemaService.userDefinedTypes(for: connectionId) + } + private var hasAnyMatch: Bool { SidebarObjectKind.allCases.contains { kind in countFor(kind: kind) > 0 @@ -241,8 +245,7 @@ struct SidebarView: View { state: schemaService.state(for: connectionId), hasActiveFilter: !viewModel.filterQuery.isEmpty, hasAnyMatch: hasAnyMatch, - hasRoutines: !routines.isEmpty, - hasTriggers: !triggers.isEmpty, + hasSideObjects: !routines.isEmpty || !triggers.isEmpty || !userDefinedTypes.isEmpty, hasOutlastedGrace: showsSchemaProgress ) } @@ -356,6 +359,7 @@ struct SidebarView: View { case .table: return viewModel.filteredTables(of: kind, from: tables).count case .routine: return viewModel.filteredRoutines(of: kind, from: routines).count case .trigger: return viewModel.filteredTriggers(from: triggers).count + case .type: return viewModel.filteredUserTypes(from: userDefinedTypes).count } } } diff --git a/TablePro/Views/Sidebar/UserTypeRowView.swift b/TablePro/Views/Sidebar/UserTypeRowView.swift new file mode 100644 index 0000000000..0fef2255d5 --- /dev/null +++ b/TablePro/Views/Sidebar/UserTypeRowView.swift @@ -0,0 +1,50 @@ +// +// UserTypeRowView.swift +// TablePro +// + +import SwiftUI + +enum UserTypeRowLogic { + static func accessibilityLabel(for type: UserDefinedTypeInfo) -> String { + "\(type.kind.displayName): \(type.name)" + } + + /// The row shows the name; what the type is made of goes here, because that is what tells an + /// enum from a domain before the reader opens either. + static func tooltip(for type: UserDefinedTypeInfo) -> String { + var lines = [type.qualifiedName, type.kind.displayName] + 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: + if let baseType = type.baseType, !baseType.isEmpty { lines.append(baseType) } + default: + break + } + lines.append(contentsOf: type.attributes.map { "\($0.label): \($0.value)" }) + return lines.joined(separator: "\n") + } +} + +struct UserTypeRowView: View { + let type: UserDefinedTypeInfo + + var body: some View { + Label { + Text(type.name) + .lineLimit(1) + .truncationMode(.tail) + } icon: { + Image(systemName: type.kind.iconName) + .selectionAwareTint(Color.accentColor) + .frame(width: 16) + } + .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) + .accessibilityElement(children: .combine) + .accessibilityLabel(UserTypeRowLogic.accessibilityLabel(for: type)) + .help(UserTypeRowLogic.tooltip(for: type)) + } +} diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 3b89afa1f0..4218dd1b6a 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -295,7 +295,10 @@ struct CreateTableView: View { typePickerColumns: provider.typePickerColumns, customDropdownOptions: provider.customDropdownOptions, connectionId: connection.id, - databaseType: connection.type + databaseType: connection.type, + databaseName: DatabaseManager.shared.browseDatabaseName(for: connection), + schemaName: coordinator?.toolbarState.currentSchema, + tabType: .createTable ), delegate: gridDelegate, selectedRowIndices: $selectedRows, diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 2952593373..d847f82972 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -510,7 +510,11 @@ struct TableStructureView: View { typePickerColumns: provider.typePickerColumns, customDropdownOptions: customOptions.isEmpty ? nil : customOptions, connectionId: connection.id, - databaseType: connection.type + databaseType: connection.type, + tableName: tableName, + databaseName: databaseName, + schemaName: schemaName, + tabType: .table ), delegate: gridDelegate, rowReorder: DataGridRowReorder( diff --git a/TablePro/Views/Structure/TypePickerContentView.swift b/TablePro/Views/Structure/TypePickerContentView.swift index da568d51b3..8fdf2fc067 100644 --- a/TablePro/Views/Structure/TypePickerContentView.swift +++ b/TablePro/Views/Structure/TypePickerContentView.swift @@ -10,6 +10,10 @@ import SwiftUI struct TypePickerContentView: View { let databaseType: DatabaseType let currentValue: String + /// The types the user created in the table's own database, already spelled the way a column + /// definition names them. Listed first, because a column that uses one is why the picker is + /// open more often than not once a schema has any. + var userDefinedTypes: [String] = [] let onCommit: (String) -> Void let onDismiss: () -> Void @@ -21,9 +25,11 @@ struct TypePickerContentView: View { private static let maxTotalHeight: CGFloat = 360 private var allCategories: [(name: String, types: [String])] { - PluginManager.shared.columnTypesByCategory(for: databaseType) + let engineCategories = PluginManager.shared.columnTypesByCategory(for: databaseType) .sorted { $0.key < $1.key } .map { (name: $0.key, types: $0.value) } + guard !userDefinedTypes.isEmpty else { return engineCategories } + return [(name: String(localized: "User-Defined"), types: userDefinedTypes)] + engineCategories } private var visibleCategories: [(name: String, types: [String])] { diff --git a/TablePro/Views/Structure/UserDefinedTypeAwarePicker.swift b/TablePro/Views/Structure/UserDefinedTypeAwarePicker.swift new file mode 100644 index 0000000000..b5efe4c9a3 --- /dev/null +++ b/TablePro/Views/Structure/UserDefinedTypeAwarePicker.swift @@ -0,0 +1,24 @@ +// +// UserDefinedTypeAwarePicker.swift +// TablePro +// + +import SwiftUI + +/// Loads the user-defined types of one scope and hands them to the picker it wraps. The picker +/// opens at once with the engine's own types and gains the user's once the catalog answers, so a +/// slow server never holds the popover closed. +struct UserDefinedTypeAwarePicker: View { + let scope: DatabaseScope? + @ViewBuilder let content: ([String]) -> Content + + @State private var userDefinedTypes: [String] = [] + + var body: some View { + content(userDefinedTypes) + .task(id: scope) { + guard let scope else { return } + userDefinedTypes = await UserDefinedTypeSuggestions.load(scope: scope) + } + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/UserDefinedTypeToolSchemaTests.swift b/TableProTests/Core/MCP/Protocol/Tools/UserDefinedTypeToolSchemaTests.swift new file mode 100644 index 0000000000..a762284225 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/UserDefinedTypeToolSchemaTests.swift @@ -0,0 +1,38 @@ +// +// UserDefinedTypeToolSchemaTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("list_types tool schema") +struct UserDefinedTypeToolSchemaTests { + @Test("list_types declares every field the bridge emits") + func outputSchemaIsComplete() throws { + let output = try #require(ListUserDefinedTypesTool.outputSchema) + let items = try #require(output["properties"]?["types"]?["items"]) + let properties = try #require(items["properties"]?.objectValue) + #expect(Set(properties.keys).isSuperset(of: [ + "name", "kind", "schema", "qualified_name", "labels", "fields", "base_type", "definition" + ])) + let required = Set(items["required"]?.arrayValue?.compactMap(\.stringValue) ?? []) + #expect(required == ["name", "kind", "qualified_name"]) + } + + @Test("list_types requires only a connection and restricts kind to the named kinds") + func inputSchema() throws { + 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"]) + } + + @Test("list_types is registered as a read-only tool") + func registered() { + #expect(MCPToolRegistry.tool(named: ListUserDefinedTypesTool.name) != nil) + #expect(ListUserDefinedTypesTool.annotations.readOnlyHint == true) + #expect(ListUserDefinedTypesTool.requiredScopes == [.toolsRead]) + } +} diff --git a/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift b/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift index f6e70da511..33ef1a660e 100644 --- a/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift +++ b/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift @@ -25,6 +25,23 @@ struct DatabaseTreeMetadataServiceTests { #expect(Set(keys) == [tableOnly, routineOnly, shared]) } + @Test("connectionObjectKeys includes a type key with no matching table, routine or trigger key") + func includesOrphanTypeKey() { + let connectionId = UUID() + let typeOnly = ObjectsKey(connectionId: connectionId, database: "shop", schema: "public") + let otherConnection = ObjectsKey(connectionId: UUID(), database: "shop", schema: "public") + + let keys = DatabaseTreeMetadataService.connectionObjectKeys( + tableKeys: [ObjectsKey](), + routineKeys: [ObjectsKey](), + triggerKeys: [ObjectsKey](), + typeKeys: [typeOnly, otherConnection], + connectionId: connectionId + ) + + #expect(keys == [typeOnly]) + } + @Test("connectionObjectKeys includes a routine key with no matching table key") func includesOrphanRoutineKey() { let connectionId = UUID() diff --git a/TableProTests/Models/DatabaseObjectRefCodingTests.swift b/TableProTests/Models/DatabaseObjectRefCodingTests.swift new file mode 100644 index 0000000000..8fbe91a0e5 --- /dev/null +++ b/TableProTests/Models/DatabaseObjectRefCodingTests.swift @@ -0,0 +1,48 @@ +// +// DatabaseObjectRefCodingTests.swift +// TableProTests +// +// A viewer tab persists its ref, so a ref written by one build has to decode on the next. +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("DatabaseObjectRef coding") +struct DatabaseObjectRefCodingTests { + @Test("A type ref round-trips with its kind") + func userTypeRoundTrip() throws { + let ref = DatabaseObjectRef( + userType: UserDefinedTypeInfo(name: "mood", kind: .domain, schema: "app", identity: "16387"), + database: "shop" + ) + let data = try JSONEncoder().encode(ref) + let decoded = try JSONDecoder().decode(DatabaseObjectRef.self, from: data) + #expect(decoded == ref) + #expect(decoded.typeKind == .domain) + } + + /// A tab persisted before types existed carries no `typeKind` at all. + @Test("A routine ref written without typeKind still decodes") + func legacyRefDecodes() throws { + let json = """ + {"kind":"function","name":"transform","database":"shop","schema":"public","attributes":[]} + """ + let decoded = try JSONDecoder().decode(DatabaseObjectRef.self, from: Data(json.utf8)) + #expect(decoded.kind == .function) + #expect(decoded.typeKind == nil) + #expect(decoded.routine?.name == "transform") + } + + @Test("Resolving the database keeps the type kind") + func resolvingDatabaseKeepsTypeKind() { + let ref = DatabaseObjectRef( + userType: UserDefinedTypeInfo(name: "mood", kind: .range, schema: "app"), + database: "" + ) + #expect(ref.resolvingDatabase("shop").typeKind == .range) + #expect(ref.resolvingDatabase("shop").database == "shop") + } +} diff --git a/TableProTests/Models/Sidebar/SidebarObjectKindTests.swift b/TableProTests/Models/Sidebar/SidebarObjectKindTests.swift index a10e338452..28909e8af1 100644 --- a/TableProTests/Models/Sidebar/SidebarObjectKindTests.swift +++ b/TableProTests/Models/Sidebar/SidebarObjectKindTests.swift @@ -11,7 +11,7 @@ import Testing struct SidebarObjectKindTests { private let everyKind: [SidebarObjectKind: Int] = [ .table: 2, .view: 1, .materializedView: 1, .foreignTable: 1, - .procedure: 1, .function: 1, .trigger: 1, + .procedure: 1, .function: 1, .trigger: 1, .type: 1, ] /// The bug: a driver can return materialized views, foreign tables, procedures or functions that @@ -25,7 +25,7 @@ struct SidebarObjectKindTests { includingEmptyTables: includingEmptyTables ) #expect( - visible == [.table, .view, .materializedView, .foreignTable, .procedure, .function, .trigger] + visible == [.table, .view, .materializedView, .foreignTable, .procedure, .function, .trigger, .type] ) } } @@ -128,6 +128,7 @@ struct SidebarObjectKindTests { func categoriesPartitionTheKinds() { #expect(SidebarObjectKind.allCases.filter { $0.category == .routine } == [.procedure, .function]) #expect(SidebarObjectKind.allCases.filter { $0.category == .trigger } == [.trigger]) + #expect(SidebarObjectKind.allCases.filter { $0.category == .type } == [.type]) #expect( SidebarObjectKind.allCases.filter { $0.category == .table } == [.table, .view, .materializedView, .foreignTable] diff --git a/TableProTests/Models/SidebarObjectListPresentationTests.swift b/TableProTests/Models/SidebarObjectListPresentationTests.swift index 576fc67059..5f89e95b9b 100644 --- a/TableProTests/Models/SidebarObjectListPresentationTests.swift +++ b/TableProTests/Models/SidebarObjectListPresentationTests.swift @@ -13,16 +13,14 @@ struct SidebarObjectListPresentationTests { _ state: SchemaState, hasActiveFilter: Bool = false, hasAnyMatch: Bool = true, - hasRoutines: Bool = false, - hasTriggers: Bool = false, + hasSideObjects: Bool = false, hasOutlastedGrace: Bool = true ) -> SidebarObjectListPresentation { SidebarObjectListPresentation.resolve( state: state, hasActiveFilter: hasActiveFilter, hasAnyMatch: hasAnyMatch, - hasRoutines: hasRoutines, - hasTriggers: hasTriggers, + hasSideObjects: hasSideObjects, hasOutlastedGrace: hasOutlastedGrace ) } @@ -42,11 +40,10 @@ struct SidebarObjectListPresentationTests { #expect(resolve(.loaded([])) == .empty) } - @Test("A database with only routines is not empty") - func routinesAloneAreNotEmpty() { - #expect(resolve(.loaded([]), hasRoutines: true) == .list) - /// A schema whose only objects are triggers is not an empty schema. - #expect(resolve(.loaded([]), hasTriggers: true) == .list) + @Test("A database whose only objects are routines, triggers or types is not empty") + func sideObjectsAloneAreNotEmpty() { + #expect(resolve(.loaded([]), hasSideObjects: true) == .list) + #expect(resolve(.loaded([]), hasSideObjects: false) == .empty) } @Test("Loaded objects render the list") diff --git a/TableProTests/Models/UserDefinedTypeInfoTests.swift b/TableProTests/Models/UserDefinedTypeInfoTests.swift new file mode 100644 index 0000000000..5c909a9c7a --- /dev/null +++ b/TableProTests/Models/UserDefinedTypeInfoTests.swift @@ -0,0 +1,96 @@ +// +// UserDefinedTypeInfoTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("UserDefinedTypeInfo") +struct UserDefinedTypeInfoTests { + @Test("Identity is the qualified name, so an edited enum is still the same row") + func identityIgnoresLabelsAndDefinition() { + let before = UserDefinedTypeInfo(name: "mood", kind: .enumeration, schema: "app", enumLabels: ["sad"]) + let after = UserDefinedTypeInfo( + name: "mood", kind: .enumeration, schema: "app", enumLabels: ["sad", "ok"], definition: "CREATE TYPE …" + ) + #expect(before == after) + #expect(before.id == "type_app.mood") + #expect(Set([before, after]).count == 1) + } + + @Test("Two schemas may each hold a type of the same name") + func schemaSeparatesSameName() { + let one = UserDefinedTypeInfo(name: "mood", kind: .enumeration, schema: "app") + let two = UserDefinedTypeInfo(name: "mood", kind: .enumeration, schema: "sales") + #expect(one != two) + #expect(one.qualifiedName == "app.mood") + } + + @Test("A type with no schema is named bare") + func bareName() { + let type = UserDefinedTypeInfo(name: "mood", kind: .enumeration) + #expect(type.qualifiedName == "mood") + #expect(type.id == "type_mood") + } + + @Test("The plugin transfer type round-trips, identity and labels included") + func pluginRoundTrip() { + let plugin = PluginUserDefinedTypeInfo( + name: "point", kind: .composite, schema: "app", identity: "16395", + fields: [PluginUserDefinedTypeField(name: "x", type: "text", collation: "pg_catalog.\"C\"")], + columnTypeSpelling: "app.point", + definition: "CREATE TYPE …", + attributes: [PluginObjectAttribute(label: "Owner", value: "postgres")] + ) + let app = UserDefinedTypeInfo(plugin) + #expect(app.kind == .composite) + #expect(app.identity == "16395") + #expect(app.fields == [UserDefinedTypeInfo.Field(name: "x", type: "text", collation: "pg_catalog.\"C\"")]) + #expect(app.columnTypeSpelling == "app.point") + #expect(app.attributes == [ObjectAttribute(label: "Owner", value: "postgres")]) + + let back = app.pluginType + #expect(back.kind == .composite) + #expect(back.identity == "16395") + #expect(back.fields == plugin.fields) + #expect(back.columnTypeSpelling == "app.point") + #expect(back.definition == "CREATE TYPE …") + } + + @Test("Every named kind maps both ways") + func kindMapping() { + for kind in [PluginUserDefinedTypeKind.enumeration, .composite, .domain, .range] { + #expect(UserDefinedTypeInfo.Kind(kind).pluginKind == kind) + } + #expect(UserDefinedTypeInfo.Kind.other.pluginKind == nil) + } + + @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") + let ref = DatabaseObjectRef(userType: type, database: "shop") + #expect(ref.kind == .userType) + #expect(ref.typeKind == .enumeration) + #expect(ref.identity == "16387") + #expect(ref.displayIdentity == "app.mood") + #expect(ref.kindDisplayName == UserDefinedTypeInfo.Kind.enumeration.displayName) + #expect(ref.userType == type) + #expect(ref.routine == nil) + #expect(ref.trigger == nil) + #expect(ref.suggestedFileName == "app_mood.sql") + } + + @Test("Sidebar kind and title follow the object kind") + @MainActor + func sidebarKind() { + #expect(DatabaseObjectKind.userType.sidebarObjectKind == .type) + #expect(SidebarObjectKind.type.category == .type) + #expect(QueryTabManager.objectSourceTitle( + for: DatabaseObjectRef(userType: UserDefinedTypeInfo(name: "mood", kind: .enumeration), database: "") + ).contains("mood")) + } +} diff --git a/TableProTests/Plugins/PostgreSQLTypeDefinitionTests.swift b/TableProTests/Plugins/PostgreSQLTypeDefinitionTests.swift new file mode 100644 index 0000000000..e2b042486a --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLTypeDefinitionTests.swift @@ -0,0 +1,286 @@ +// +// PostgreSQLTypeDefinitionTests.swift +// TableProTests +// +// PostgreSQL has no pg_get_typedef, so the CREATE statement the viewer shows is rebuilt from the +// catalog row. These pin the shape of every statement and of the row parsing behind it. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("PostgreSQL type definitions") +struct PostgreSQLTypeDefinitionTests { + private func record( + name: String = "mood", + kind: PostgreSQLUserDefinedTypeRecord.Kind, + enumLabels: [String] = [], + fields: [PluginUserDefinedTypeField] = [], + baseType: String? = nil, + isNotNull: Bool = false, + defaultValue: String? = nil, + constraints: [PostgreSQLDomainConstraint] = [], + rangeSubtype: String? = nil, + rangeCanonical: String? = nil, + rangeSubtypeDiff: String? = nil, + rangeOpclass: String? = nil, + rangeCollation: String? = nil, + rangeMultirange: String? = nil + ) -> PostgreSQLUserDefinedTypeRecord { + PostgreSQLUserDefinedTypeRecord( + identity: "16387", + name: name, + schema: "app", + kind: kind, + owner: "postgres", + comment: nil, + enumLabels: enumLabels, + fields: fields, + baseType: baseType, + isNotNull: isNotNull, + defaultValue: defaultValue, + constraints: constraints, + rangeSubtype: rangeSubtype, + rangeCanonical: rangeCanonical, + rangeSubtypeDiff: rangeSubtypeDiff, + rangeOpclass: rangeOpclass, + rangeCollation: rangeCollation, + rangeMultirange: rangeMultirange + ) + } + + @Test("An enum lists its labels in server order, one per line, quoted as literals") + func enumDDL() { + let ddl = PostgreSQLTypeDefinition.ddl(for: record(kind: .enumeration, enumLabels: ["sad", "ok", "it's"])) + #expect(ddl == """ + CREATE TYPE "app"."mood" AS ENUM ( + 'sad', + 'ok', + 'it''s' + ); + """) + } + + @Test("A composite quotes every field name and keeps the formatted type") + func compositeDDL() { + let fields = [ + PluginUserDefinedTypeField(name: "x", type: "double precision"), + PluginUserDefinedTypeField(name: "label text", type: "character varying(20)") + ] + let ddl = PostgreSQLTypeDefinition.ddl(for: record(name: "point", kind: .composite, fields: fields)) + #expect(ddl == """ + CREATE TYPE "app"."point" AS ( + "x" double precision, + "label text" character varying(20) + ); + """) + } + + @Test("A domain carries its default, NOT NULL and every CHECK in that order") + func domainDDL() { + let ddl = PostgreSQLTypeDefinition.ddl(for: record( + name: "email", + kind: .domain, + baseType: "text", + isNotNull: true, + defaultValue: "'nobody@example.com'::text", + constraints: [ + PostgreSQLDomainConstraint(name: "email_check", definition: "CHECK ((VALUE ~ '@'::text))"), + PostgreSQLDomainConstraint(name: "email_check1", definition: "CHECK ((length(VALUE) < 200))") + ] + )) + #expect(ddl == """ + CREATE DOMAIN "app"."email" AS text + DEFAULT 'nobody@example.com'::text + NOT NULL + CONSTRAINT "email_check" CHECK ((VALUE ~ '@'::text)) + CONSTRAINT "email_check1" CHECK ((length(VALUE) < 200)); + """) + } + + @Test("A bare domain is one line") + func bareDomainDDL() { + let ddl = PostgreSQLTypeDefinition.ddl(for: record(name: "score", kind: .domain, baseType: "integer")) + #expect(ddl == "CREATE DOMAIN \"app\".\"score\" AS integer;") + } + + /// A domain over a collatable base type may carry its own collation, and a definition that + /// drops it rebuilds a domain that sorts and compares differently. + @Test("A domain keeps a collation of its own") + func domainCollationDDL() { + let ddl = PostgreSQLTypeDefinition.ddl(for: PostgreSQLUserDefinedTypeRecord( + identity: "1", name: "ci_text", schema: "app", kind: .domain, + baseType: "text", collation: "pg_catalog.\"C\"", isNotNull: true + )) + #expect(ddl == """ + CREATE DOMAIN "app"."ci_text" AS text COLLATE pg_catalog."C" + NOT NULL; + """) + } + + @Test("A range names its subtype and only the options that were set") + func rangeDDL() { + let ddl = PostgreSQLTypeDefinition.ddl(for: record( + name: "floatrange", + kind: .range, + rangeSubtype: "double precision", + rangeSubtypeDiff: "float8mi", + rangeMultirange: "app.floatmultirange" + )) + #expect(ddl == """ + CREATE TYPE "app"."floatrange" AS RANGE ( + subtype = double precision, + subtype_diff = float8mi + ); + """) + } + + /// A collation, a non-default operator class and a chosen multirange name each change how the + /// range behaves, so a definition that dropped them would rebuild a different type. + @Test("A range keeps its collation, its operator class and a multirange name of its own") + func rangeOptionsDDL() { + let ddl = PostgreSQLTypeDefinition.ddl(for: record( + name: "r2", + kind: .range, + rangeSubtype: "text", + rangeOpclass: "pg_catalog.text_pattern_ops", + rangeCollation: "pg_catalog.\"C\"", + rangeMultirange: "app.r2_multi" + )) + #expect(ddl == """ + CREATE TYPE "app"."r2" AS RANGE ( + subtype = text, + subtype_opclass = pg_catalog.text_pattern_ops, + collation = pg_catalog."C", + multirange_type_name = app.r2_multi + ); + """) + } + + @Test("PostgreSQL's default multirange name replaces a trailing range or appends one") + func defaultMultirangeName() { + #expect(PostgreSQLTypeDefinition.defaultMultirangeName(for: "floatrange") == "floatmultirange") + #expect(PostgreSQLTypeDefinition.defaultMultirangeName(for: "r2") == "r2_multirange") + #expect(PostgreSQLTypeDefinition.defaultMultirangeName(for: "range_of_ranges") == "range_of_multiranges") + } + + @Test("A composite keeps a field's own collation") + func compositeCollationDDL() { + let ddl = PostgreSQLTypeDefinition.ddl(for: record( + name: "ci", + kind: .composite, + fields: [ + PluginUserDefinedTypeField(name: "name", type: "text", collation: "pg_catalog.\"C\""), + PluginUserDefinedTypeField(name: "n", type: "integer") + ] + )) + #expect(ddl == """ + CREATE TYPE "app"."ci" AS ( + "name" text COLLATE pg_catalog."C", + "n" integer + ); + """) + } + + @Test("A schema or type name with a quote is doubled inside its identifier") + func identifierQuoting() { + let ddl = PostgreSQLTypeDefinition.ddl(for: PostgreSQLUserDefinedTypeRecord( + identity: "1", name: "Weird \"Name\"", schema: "my schema", kind: .enumeration, enumLabels: ["x"] + )) + #expect(ddl.hasPrefix("CREATE TYPE \"my schema\".\"Weird \"\"Name\"\"\" AS ENUM (")) + } + + @Test("A catalog row parses by projection position, JSON columns included") + func rowParsing() throws { + var row = [PluginCellValue](repeating: .null, count: PostgreSQLTypeDefinition.Column.allCases.count) + row[PostgreSQLTypeDefinition.Column.identity.rawValue] = .text("16397") + row[PostgreSQLTypeDefinition.Column.name.rawValue] = .text("email") + row[PostgreSQLTypeDefinition.Column.schema.rawValue] = .text("app") + row[PostgreSQLTypeDefinition.Column.kind.rawValue] = .text("d") + row[PostgreSQLTypeDefinition.Column.owner.rawValue] = .text("postgres") + row[PostgreSQLTypeDefinition.Column.comment.rawValue] = .text("Mail") + row[PostgreSQLTypeDefinition.Column.baseType.rawValue] = .text("text") + row[PostgreSQLTypeDefinition.Column.collation.rawValue] = .text("pg_catalog.\"C\"") + row[PostgreSQLTypeDefinition.Column.isNotNull.rawValue] = .text("true") + row[PostgreSQLTypeDefinition.Column.defaultValue.rawValue] = .text("'x'::text") + row[PostgreSQLTypeDefinition.Column.constraints.rawValue] = .text( + #"[{"name" : "email_check", "definition" : "CHECK ((VALUE ~ '@'::text))"}]"# + ) + + let parsed = try #require(PostgreSQLTypeDefinition.record(from: row)) + #expect(parsed.kind == .domain) + #expect(parsed.baseType == "text") + #expect(parsed.collation == "pg_catalog.\"C\"") + #expect(parsed.isNotNull) + #expect(parsed.defaultValue == "'x'::text") + #expect(parsed.constraints == [ + PostgreSQLDomainConstraint(name: "email_check", definition: "CHECK ((VALUE ~ '@'::text))") + ]) + #expect(parsed.comment == "Mail") + } + + @Test("Enum labels and composite fields parse from their JSON aggregates in order") + func jsonAggregatesParse() throws { + var enumRow = [PluginCellValue](repeating: .null, count: PostgreSQLTypeDefinition.Column.allCases.count) + enumRow[PostgreSQLTypeDefinition.Column.identity.rawValue] = .text("1") + enumRow[PostgreSQLTypeDefinition.Column.name.rawValue] = .text("mood") + enumRow[PostgreSQLTypeDefinition.Column.schema.rawValue] = .text("app") + enumRow[PostgreSQLTypeDefinition.Column.kind.rawValue] = .text("e") + enumRow[PostgreSQLTypeDefinition.Column.enumLabels.rawValue] = .text(#"["sad", "ok", "it's"]"#) + let parsedEnum = try #require(PostgreSQLTypeDefinition.record(from: enumRow)) + #expect(parsedEnum.enumLabels == ["sad", "ok", "it's"]) + + var compositeRow = enumRow + compositeRow[PostgreSQLTypeDefinition.Column.kind.rawValue] = .text("c") + compositeRow[PostgreSQLTypeDefinition.Column.enumLabels.rawValue] = .null + compositeRow[PostgreSQLTypeDefinition.Column.fields.rawValue] = .text( + #"[{"name" : "x", "type" : "text", "collation" : "pg_catalog.\"C\""}, {"name" : "y", "type" : "integer", "collation" : null}]"# + ) + compositeRow[PostgreSQLTypeDefinition.Column.spelling.rawValue] = .text("app.\"Mood\"") + let parsedComposite = try #require(PostgreSQLTypeDefinition.record(from: compositeRow)) + #expect(parsedComposite.fields == [ + PluginUserDefinedTypeField(name: "x", type: "text", collation: "pg_catalog.\"C\""), + PluginUserDefinedTypeField(name: "y", type: "integer") + ]) + #expect(parsedComposite.spelling == "app.\"Mood\"") + #expect(PostgreSQLTypeDefinition.info(from: parsedComposite).columnTypeSpelling == "app.\"Mood\"") + } + + @Test("A row of an unknown kind is skipped rather than mislabelled") + func unknownKindIsSkipped() { + var row = [PluginCellValue](repeating: .null, count: PostgreSQLTypeDefinition.Column.allCases.count) + row[PostgreSQLTypeDefinition.Column.identity.rawValue] = .text("1") + row[PostgreSQLTypeDefinition.Column.name.rawValue] = .text("m") + row[PostgreSQLTypeDefinition.Column.schema.rawValue] = .text("app") + row[PostgreSQLTypeDefinition.Column.kind.rawValue] = .text("m") + #expect(PostgreSQLTypeDefinition.record(from: row) == nil) + } + + @Test("The transfer type carries the definition, the labels and the attributes the viewer shows") + func infoMapping() { + let info = PostgreSQLTypeDefinition.info(from: PostgreSQLUserDefinedTypeRecord( + identity: "7", name: "mood", schema: "app", kind: .enumeration, + owner: "postgres", comment: "How someone feels", enumLabels: ["sad", "ok"] + )) + #expect(info.kind == .enumeration) + #expect(info.identity == "7") + #expect(info.schema == "app") + #expect(info.enumLabels == ["sad", "ok"]) + #expect(info.definition?.hasPrefix("CREATE TYPE \"app\".\"mood\" AS ENUM") == true) + #expect(info.attributes.map(\.label) == ["Owner", "Comment"]) + } + + @Test("A domain reports its base type and a range its subtype through one field") + func baseTypeMapping() { + let domain = PostgreSQLTypeDefinition.info(from: record(kind: .domain, baseType: "integer")) + #expect(domain.baseType == "integer") + #expect(domain.attributes.first?.label == "Base Type") + + let range = PostgreSQLTypeDefinition.info(from: record(kind: .range, rangeSubtype: "numeric")) + #expect(range.baseType == "numeric") + #expect(range.attributes.first?.label == "Subtype") + } +} diff --git a/TableProTests/Plugins/PostgreSQLTypeQueryTests.swift b/TableProTests/Plugins/PostgreSQLTypeQueryTests.swift new file mode 100644 index 0000000000..7573b9a321 --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLTypeQueryTests.swift @@ -0,0 +1,219 @@ +// +// PostgreSQLTypeQueryTests.swift +// TableProTests +// +// The catalog SQL that lists user-defined types and the statements that edit an enum. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("PostgreSQL type catalog queries") +struct PostgreSQLTypeQueryTests { + @Test("The listing reads pg_type for enums, composites, domains and ranges in one schema") + func listReadsPgType() { + let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, serverVersionNumber: 170_000) + #expect(sql.contains("FROM pg_catalog.pg_type t")) + #expect(sql.contains("t.typtype IN ('e', 'c', 'd', 'r')")) + #expect(sql.contains("AND n.nspname = 'app'")) + #expect(!sql.contains("information_schema")) + } + + /// Every table owns a composite type of its own row shape, and an extension's types belong to + /// the extension. Neither is a type the user created. + @Test("Table row types and extension members are excluded") + func excludesRowTypesAndExtensionMembers() { + let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, serverVersionNumber: 170_000) + #expect(sql.contains("(t.typtype <> 'c' OR c.relkind = 'c')")) + #expect(sql.contains("d.deptype = 'e'")) + } + + /// PostgreSQL 17 files a domain's NOT NULL as a constraint row; emitting it beside NOT NULL + /// would state the same thing twice in the definition. + @Test("Only CHECK constraints are collected for a domain") + func domainConstraintsAreChecksOnly() { + let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, serverVersionNumber: 170_000) + #expect(sql.contains("con.contype = 'c'")) + } + + @Test("The projection matches the parser's column order") + func projectionOrderMatchesParser() { + let sql = PostgreSQLObjectQueries.userDefinedTypeList(schema: "app", identity: nil, serverVersionNumber: 170_000) + let aliases = [ + "AS identity", "AS name", "AS schema", "AS kind", "AS owner", "AS comment", "AS enum_labels", + "AS fields", "AS base_type", "AS collation", "AS not_null", "AS default_value", "AS constraints", + "AS range_subtype", "AS range_canonical", "AS range_subtype_diff", "AS range_opclass", + "AS range_collation", "AS range_multirange", "AS spelling" + ] + #expect(aliases.count == PostgreSQLTypeDefinition.Column.allCases.count) + var searchStart = sql.startIndex + for alias in aliases { + let range = sql.range(of: alias, range: searchStart.. Bool { true } + func applyQueryTimeout(_ seconds: Int) async throws {} + + func execute(query: String) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func fetchTables() async throws -> [TableInfo] { + [TestFixtures.makeTableInfo(name: "users")] + } + + func fetchColumns(table: String) async throws -> [ColumnInfo] { [] } + func fetchIndexes(table: String) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { [] } + func fetchApproximateRowCount(table: String) async throws -> Int? { nil } + func fetchTableDDL(table: String) async throws -> String { "" } + func fetchViewDefinition(view: String) async throws -> String { "" } + + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + TableMetadata( + tableName: tableName, dataSize: nil, indexSize: nil, totalSize: nil, + avgRowLength: nil, rowCount: nil, comment: nil, engine: nil, + collation: nil, createTime: nil, updateTime: nil + ) + } + + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + DatabaseMetadata( + id: database, name: database, tableCount: nil, sizeBytes: nil, + lastAccessed: nil, isSystemDatabase: false, icon: "cylinder" + ) + } + + func cancelQuery() throws {} + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} + + func fetchUserDefinedTypes(schema: String?) async throws -> [UserDefinedTypeInfo] { + typesCallCount += 1 + if typesShouldFail { + throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "boom"]) + } + return typesToReturn + } +} + +@Suite("SchemaService user-defined types") +@MainActor +struct SchemaServiceUserDefinedTypesTests { + private let mood = UserDefinedTypeInfo(name: "mood", kind: .enumeration, schema: "public", enumLabels: ["sad", "ok"]) + + @Test("load caches the types of an engine that declares them") + func loadCachesTypes() async { + let service = SchemaService() + let connectionId = UUID() + let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) + let driver = TypeMockDriver(connection: connection) + driver.typesToReturn = [mood] + + await service.load(connectionId: connectionId, driver: driver, connection: connection) + + #expect(service.userDefinedTypes(for: connectionId) == [mood]) + #expect(service.userDefinedTypes(for: connectionId).first?.enumLabels == ["sad", "ok"]) + #expect(driver.typesCallCount == 1) + } + + /// The capability gates the query, never the display. An engine with no named types must not + /// pay a catalog read that can only answer empty. + @Test("load never asks an engine that declares no types") + func loadSkipsUndeclaredEngines() async { + let service = SchemaService() + let connectionId = UUID() + let connection = TestFixtures.makeConnection(id: connectionId, type: .mysql) + let driver = TypeMockDriver(connection: connection) + driver.typesToReturn = [mood] + + await service.load(connectionId: connectionId, driver: driver, connection: connection) + + #expect(service.userDefinedTypes(for: connectionId).isEmpty) + #expect(driver.typesCallCount == 0) + } + + @Test("A failed type fetch leaves tables loaded and types empty") + func failingTypesDoNotBlockTables() async { + let service = SchemaService() + let connectionId = UUID() + let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) + let driver = TypeMockDriver(connection: connection) + driver.typesShouldFail = true + + await service.load(connectionId: connectionId, driver: driver, connection: connection) + + #expect(service.tables(for: connectionId).map(\.name) == ["users"]) + #expect(service.userDefinedTypes(for: connectionId).isEmpty) + guard case .loaded = service.state(for: connectionId) else { + Issue.record("expected loaded state when only the type fetch fails") + return + } + } + + @Test("A reload replaces the cached types and reports success") + func reloadReplacesTypes() async { + let service = SchemaService() + let connectionId = UUID() + let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) + let driver = TypeMockDriver(connection: connection) + driver.typesToReturn = [mood] + await service.load(connectionId: connectionId, driver: driver, connection: connection) + + let status = UserDefinedTypeInfo(name: "status", kind: .domain, schema: "public", baseType: "text") + driver.typesToReturn = [mood, status] + let reloaded = await service.reloadUserDefinedTypes(connectionId: connectionId, driver: driver) + + #expect(reloaded) + #expect(service.userDefinedTypes(for: connectionId).map(\.name) == ["mood", "status"]) + } + + /// A refresh never clears the cache it is refreshing. + @Test("A failed reload keeps the previous types and reports failure") + func failedReloadKeepsTypes() async { + let service = SchemaService() + let connectionId = UUID() + let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) + let driver = TypeMockDriver(connection: connection) + driver.typesToReturn = [mood] + await service.load(connectionId: connectionId, driver: driver, connection: connection) + + driver.typesShouldFail = true + let reloaded = await service.reloadUserDefinedTypes(connectionId: connectionId, driver: driver) + + #expect(!reloaded) + #expect(service.userDefinedTypes(for: connectionId) == [mood]) + } + + @Test("invalidate drops the cached types with everything else") + func invalidateDropsTypes() async { + let service = SchemaService() + let connectionId = UUID() + let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) + let driver = TypeMockDriver(connection: connection) + driver.typesToReturn = [mood] + await service.load(connectionId: connectionId, driver: driver, connection: connection) + + await service.invalidate(connectionId: connectionId) + + #expect(service.userDefinedTypes(for: connectionId).isEmpty) + } +} diff --git a/TableProTests/Services/UserDefinedTypeSuggestionsTests.swift b/TableProTests/Services/UserDefinedTypeSuggestionsTests.swift new file mode 100644 index 0000000000..8709e3ba59 --- /dev/null +++ b/TableProTests/Services/UserDefinedTypeSuggestionsTests.swift @@ -0,0 +1,72 @@ +// +// UserDefinedTypeSuggestionsTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("User-defined type suggestions") +struct UserDefinedTypeSuggestionsTests { + private func type(_ name: String, schema: String?, spelling: String? = nil) -> UserDefinedTypeInfo { + UserDefinedTypeInfo(name: name, kind: .enumeration, schema: schema, columnTypeSpelling: spelling) + } + + /// PostgreSQL searches `pg_catalog` ahead of the search path, so a bare `text` names the + /// built-in even where the table's own schema holds a domain called `text`. Every + /// schema-bound type is therefore qualified, the table's own schema included. + @Test("Every schema-bound type is qualified, the table's own schema included") + func qualifiesEverySchema() { + let entries = UserDefinedTypeSuggestions.entries( + types: [type("text", schema: "public"), type("status", schema: "sales")], + tableSchema: "public" + ) + #expect(entries == ["public.text", "sales.status"]) + } + + @Test("A type with no schema is offered bare") + func bareWithoutSchema() { + let entries = UserDefinedTypeSuggestions.entries(types: [type("plain", schema: nil)], tableSchema: nil) + #expect(entries == ["plain"]) + } + + @Test("Entries sort case-insensitively so the picker reads as one list") + func sortsCaseInsensitively() { + let entries = UserDefinedTypeSuggestions.entries( + types: [type("zeta", schema: "app"), type("alpha", schema: "app"), type("beta", schema: "app")], + tableSchema: "app" + ) + #expect(entries == ["app.alpha", "app.beta", "app.zeta"]) + } + + /// The engine knows which names it folds and which it reserves; its own spelling wins over + /// anything the app could work out from the characters. + @Test("The engine's own spelling is used when the driver supplied one") + func prefersEngineSpelling() { + let entries = UserDefinedTypeSuggestions.entries( + types: [type("select", schema: "app", spelling: "app.\"select\""), type("mood", schema: "app", spelling: "app.mood")], + tableSchema: "app" + ) + #expect(entries == ["app.mood", "app.\"select\""]) + } + + /// Without an engine spelling, a name the server would fold to lower case, or refuse bare, + /// arrives already quoted. + @Test("The fallback quotes a name that is not a plain lower-case identifier, in each part") + func quotesNamesThatNeedIt() { + #expect(UserDefinedTypeSuggestions.identifier("mood") == "mood") + #expect(UserDefinedTypeSuggestions.identifier("order_status2") == "order_status2") + #expect(UserDefinedTypeSuggestions.identifier("Mood") == "\"Mood\"") + #expect(UserDefinedTypeSuggestions.identifier("Weird Name") == "\"Weird Name\"") + #expect(UserDefinedTypeSuggestions.identifier("2fast") == "\"2fast\"") + #expect(UserDefinedTypeSuggestions.identifier("a\"b") == "\"a\"\"b\"") + + let entries = UserDefinedTypeSuggestions.entries( + types: [type("Weird Name", schema: "My Schema"), type("mood", schema: "My Schema")], + tableSchema: "public" + ) + #expect(entries == ["\"My Schema\".mood", "\"My Schema\".\"Weird Name\""]) + } +} diff --git a/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift index 233305bfa0..f79bf9c7e7 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift @@ -41,6 +41,43 @@ struct DatabaseTreeFilterTests { #expect(DatabaseTreeFilter.filteredRoutines(routines, searchText: "audit").map(\.name) == ["audit_log"]) } + private func userType(_ name: String) -> UserDefinedTypeInfo { + UserDefinedTypeInfo(name: name, kind: .enumeration, schema: "public") + } + + @Test("filteredUserTypes deduplicates and substring matches") + func filteredUserTypesSearch() { + let types = [userType("mood"), userType("status"), userType("mood")] + #expect(DatabaseTreeFilter.filteredUserTypes(types, searchText: "").map(\.name) == ["mood", "status"]) + #expect(DatabaseTreeFilter.filteredUserTypes(types, searchText: "stat").map(\.name) == ["status"]) + } + + @Test("Object buckets count types under the Types kind and keep a type-only container non-empty") + func objectBucketsCountTypes() { + let buckets = DatabaseTreeFilter.objectBuckets( + tables: [], + routines: [], + triggers: [], + userTypes: [userType("mood"), userType("status")], + searchText: "" + ) + #expect(!buckets.isEmpty) + #expect(buckets.itemCounts[.type] == 2) + #expect(buckets.userTypes.map(\.name) == ["mood", "status"]) + + let filtered = DatabaseTreeFilter.objectBuckets( + tables: [], routines: [], triggers: [], userTypes: [userType("mood")], searchText: "zzz" + ) + #expect(filtered.isEmpty) + } + + @Test("A declared Types kind is listed even before any type has loaded") + func declaredTypesKindIsVisible() { + let visible = SidebarObjectKind.visible(itemCounts: [:], declaredKinds: [.type], includingEmptyTables: false) + #expect(visible == [.type]) + #expect(SidebarObjectKind.allCases.last == .type) + } + @Test("visibleSchemas drops system schemas and deduplicates") func visibleSchemasNoSearch() { let schemas = ["public", "pg_catalog", "public", "sales"] diff --git a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index 780dba16e3..5ea42a7cce 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -33,7 +33,8 @@ struct DatabaseTreeMenuSpecTests { hasDatabaseFilter: Bool = false, supportsRename: Bool = true, canCopyObjects: Bool = true, - canDuplicateDatabase: Bool = true + canDuplicateDatabase: Bool = true, + canCreateType: Bool = false ) -> DatabaseTreeMenuContext { DatabaseTreeMenuContext( clicked: clicked, @@ -76,7 +77,8 @@ struct DatabaseTreeMenuSpecTests { canFilterDatabases: canFilterDatabases, hasDatabaseFilter: hasDatabaseFilter, canCopyObjects: canCopyObjects, - canDuplicateDatabase: canDuplicateDatabase + canDuplicateDatabase: canDuplicateDatabase, + canCreateType: canCreateType ) } @@ -509,6 +511,7 @@ struct DatabaseTreeMenuSpecTests { database: "app", schema: "public", routine: RoutineInfo(name: "do_thing", kind: .function, schema: "public") )), + .userType(userTypeRef("mood")), .status(.loading), .recentSection, .redisKeysSection @@ -519,6 +522,66 @@ struct DatabaseTreeMenuSpecTests { } } + // MARK: - Types + + private func userTypeRef(_ name: String, schema: String? = "public") -> DatabaseTreeUserTypeRef { + DatabaseTreeUserTypeRef( + database: "app", schema: schema, + type: UserDefinedTypeInfo(name: name, kind: .enumeration, schema: schema) + ) + } + + @Test("A type row copies its name, its qualified name, and shows its definition") + func typeRowItems() { + let ref = userTypeRef("mood") + let issued = commands(DatabaseTreeMenuSpec.items(for: context(clicked: .userType(ref)))) + + #expect(issued.contains(.copyText("mood"))) + #expect(issued.contains(.copyText("public.mood"))) + #expect(issued.contains(.showObjectSource(ref.objectRef))) + } + + @Test("A type with no schema offers no qualified copy") + func bareTypeRowHasNoQualifiedCopy() { + let ref = userTypeRef("mood", schema: nil) + let issued = commands(DatabaseTreeMenuSpec.items(for: context(clicked: .userType(ref)))) + + #expect(issued.filter { if case .copyText = $0 { return true } else { return false } }.count == 1) + } + + @Test("The Types section offers Create New Type when the driver has a template and writes are allowed") + func typesSectionOffersCreate() { + let flat = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .objectKindSection(.type), activeSchema: "sales", canCreateType: true) + )) + #expect(flat.contains(.createType(database: "app", schema: "sales"))) + + /// A tree lists every database, so the section names its own rather than the browsed one: + /// PostgreSQL cannot reach another database by qualifying the type name. + let group = DatabaseTreeObjectGroup(database: "warehouse", schema: "billing", kind: .type) + let tree = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .containerObjectKindSection(group), canCreateType: true) + )) + #expect(tree.contains(.createType(database: "warehouse", schema: "billing"))) + #expect(tree.contains(.refreshContainerObjectKind(group))) + } + + @Test("Create New Type is omitted in read-only mode, without a template, and on other sections") + func createTypeIsOmittedWhereItCannotRun() { + let readOnly = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .objectKindSection(.type), isReadOnly: true, canCreateType: true) + )) + #expect(!readOnly.contains { if case .createType = $0 { return true } else { return false } }) + + let noTemplate = commands(DatabaseTreeMenuSpec.items(for: context(clicked: .objectKindSection(.type)))) + #expect(!noTemplate.contains { if case .createType = $0 { return true } else { return false } }) + + let functions = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .objectKindSection(.function), canCreateType: true) + )) + #expect(!functions.contains { if case .createType = $0 { return true } else { return false } }) + } + // MARK: - Copying private func databaseKind(_ name: String, isSystem: Bool = false) -> DatabaseTreeNode.Kind { diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index 8cca499cd7..d98bbd3dff 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -62,6 +62,10 @@ An array column opens one of two editors, decided by its element type: In the list editor, reorder rows with the arrows, add and remove elements, and set a single element to NULL; an empty array and a NULL column stay distinct. Enum elements pick from the labels the type declares, and a label the type no longer lists stays selectable and is flagged. **Edit as Text** switches to the raw literal at any time. +## User-defined types + +Enums, composites, domains and ranges are listed under **Types** in each schema, the `CREATE` statement rebuilt from `pg_type`. An enum's labels are edited in place with `ALTER TYPE … ADD VALUE` and, from PostgreSQL 10, `RENAME VALUE`; PostgreSQL has no statement that drops or reorders a label. The structure editor's type picker offers the schema's types under **User-Defined**. See [User-Defined Types](/features/user-defined-types). + ## Cross-database tabs PostgreSQL has no in-place `USE`, so a tab bound to a database other than the connection's active one runs on a second connection opened for that database. It shares no temp tables, session variables, or open transaction with the query editor on the main connection: keep a multi-statement transaction or a `CREATE TEMP TABLE` on tabs bound to one database. Binding itself is on [Tabs](/features/tabs#where-a-tab-points). diff --git a/docs/docs.json b/docs/docs.json index 7d10ba71da..003e329eda 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -204,6 +204,7 @@ "pages": [ "features/table-structure", "features/routines-triggers", + "features/user-defined-types", "features/table-operations", "features/er-diagram", "features/explain-visualization", diff --git a/docs/external-api/mcp-tools.mdx b/docs/external-api/mcp-tools.mdx index fdeb9be445..c5666f376c 100644 --- a/docs/external-api/mcp-tools.mdx +++ b/docs/external-api/mcp-tools.mdx @@ -1,9 +1,9 @@ --- title: MCP Tools -description: The 46 tools TablePro's MCP server exposes, with arguments, defaults, result shapes, and scope requirements +description: The 47 tools TablePro's MCP server exposes, with arguments, defaults, result shapes, and scope requirements --- -If a dedicated tool covers the job, it beats hand-written SQL. It quotes identifiers for the engine in front of it, applies the user's row limits, and runs on connections where raw execution is refused. All 46 publish a JSON Schema for input and output, so a client can validate both sides without reading this page. For the wire format, headers and error codes see [MCP Protocol](/external-api/mcp-protocol). +If a dedicated tool covers the job, it beats hand-written SQL. It quotes identifiers for the engine in front of it, applies the user's row limits, and runs on connections where raw execution is refused. All 47 publish a JSON Schema for input and output, so a client can validate both sides without reading this page. For the wire format, headers and error codes see [MCP Protocol](/external-api/mcp-protocol). ## How to read this page @@ -22,7 +22,7 @@ Row limits default to the server's **Default row limit** setting (500) and are c | Group | Tools | |-------|-------| | [Connections](#connections) | `list_connections` `connect` `disconnect` `get_connection_status` `switch_database` `switch_schema` | -| [Schema discovery](#schema-discovery) | `list_databases` `list_schemas` `list_tables` `describe_table` `get_table_ddl` `search_schema` `list_indexes` `list_foreign_keys` `list_triggers` `get_view_definition` `list_routines` `list_partitions` `get_table_statistics` `get_database_statistics` | +| [Schema discovery](#schema-discovery) | `list_databases` `list_schemas` `list_tables` `describe_table` `get_table_ddl` `search_schema` `list_indexes` `list_foreign_keys` `list_triggers` `get_view_definition` `list_routines` `list_types` `list_partitions` `get_table_statistics` `get_database_statistics` | | [Reading data](#reading-data) | `browse_table` `count_rows` `execute_query` `explain_query` `export_data` `quote_identifiers` | | [Writing data](#writing-data) | `insert_rows` `confirm_destructive_operation` `transaction_control` | | [Databases and objects](#databases-and-objects) | `describe_create_database_options` `create_database` `drop_database_object` `list_maintenance_operations` `run_maintenance` | @@ -31,7 +31,7 @@ Row limits default to the server's **Default row limit** setting (500) and are c ## Scopes and gates -Fourteen tools need `tools:write`: `connect`, `disconnect`, `switch_database`, `switch_schema`, `insert_rows`, `confirm_destructive_operation`, `transaction_control`, `create_database`, `drop_database_object`, `run_maintenance`, `stop_server_session`, `focus_query_tab`, `open_connection_window`, `open_table_tab`. The other 32 need `tools:read`, `execute_query` among them until the statement writes. +Fourteen tools need `tools:write`: `connect`, `disconnect`, `switch_database`, `switch_schema`, `insert_rows`, `confirm_destructive_operation`, `transaction_control`, `create_database`, `drop_database_object`, `run_maintenance`, `stop_server_session`, `focus_query_tab`, `open_connection_window`, `open_table_tab`. The other 33 need `tools:read`, `execute_query` among them until the statement writes. Past the scope, a call clears three more gates: @@ -73,6 +73,7 @@ The two `switch_` tools move what the user sees in TablePro. To run one statemen | `list_triggers` | `connection_id` (`table`, `database`, `schema`) | `triggers[]` (`name`, `table`, `schema`, `timing`, `event`, `orientation`, `statement`, `definition`, `is_enabled`), sorted by table then name, plus `table` when one was named | | `get_view_definition` | `connection_id`, `view` (`database`, `schema`) | `view`, `schema`, `definition` | | `list_routines` | `connection_id` (`kind`, `database`, `schema`) | `routines[]` (`name`, `kind`, `schema`, `qualified_name`, `signature`, `return_type`, `language`) | +| `list_types` | `connection_id` (`kind`, `database`, `schema`) | `types[]` (`name`, `kind`, `schema`, `qualified_name`, `labels`, `fields`, `base_type`, `definition`), sorted by qualified name | | `list_partitions` | `connection_id`, `table` (`database`, `schema`) | `table`, `partitions[]`, shaped like `list_tables` entries | | `get_table_statistics` | `connection_id`, `table` (`database`, `schema`) | `table` plus whatever the engine records: `data_size_bytes`, `index_size_bytes`, `total_size_bytes`, `average_row_length`, `row_count`, `comment`, `engine`, `collation`, `created_at`, `updated_at` | | `get_database_statistics` | `connection_id` (`database`) | `databases[]` (`name`, `table_count`, `size_bytes`, `is_system_database`), sorted by name | @@ -81,7 +82,7 @@ The two `switch_` tools move what the user sees in TablePro. To run one statemen `include_row_counts` defaults to `false`. Counts come from engine statistics rather than `COUNT(*)`, and are fetched one table at a time, so `list_tables` skips them when the schema holds more than 200 objects. -`search_schema` locates a column without describing every table; its `limit` runs 1 to 500, default 50. `list_routines` takes `kind` as `procedure` or `function`, omitted for both, and its `signature` is the argument list, not the return type. `list_triggers` takes `table` to scope to one table, omitted for every trigger in the schema. +`search_schema` locates a column without describing every table; its `limit` runs 1 to 500, default 50. `list_routines` takes `kind` as `procedure` or `function`, omitted for both, and its `signature` is the argument list, not the return type. `list_triggers` takes `table` to scope to one table, omitted for every trigger in the schema. `list_types` takes `kind` as `enum`, `composite`, `domain` or `range`, omitted for all four; `labels` is set for an enum, `fields` for a composite and `base_type` for a domain or range, and only PostgreSQL and PGlite answer with rows. ## Reading data diff --git a/docs/features/routines-triggers.mdx b/docs/features/routines-triggers.mdx index 9e4d215d1e..97bd135259 100644 --- a/docs/features/routines-triggers.mdx +++ b/docs/features/routines-triggers.mdx @@ -3,7 +3,7 @@ title: Procedures, Functions, and Triggers description: Browse stored procedures, functions, and triggers per schema and read their source --- -Expand a schema and **Procedures**, **Functions**, and **Triggers** sit beside **Tables**. Select one, press Return, and its source opens in a read-only tab. +Expand a schema and **Procedures**, **Functions**, and **Triggers** sit beside **Tables**. Select one, press Return, and its source opens in a read-only tab. Named types have a section of their own; see [User-Defined Types](/features/user-defined-types). Sidebar tree with Procedures, Functions, and Triggers expanded, and a function's source in a tab beside it @@ -35,7 +35,7 @@ A section appears only where the engine has that kind of object. An engine that A trigger row carries the table it fires for beside its name. The sidebar filter matches a trigger by its own name or by that table; type-select matches the name only. Triggers also appear on their table's **Structure** tab. -`Cmd+P` indexes all three kinds alongside tables and views. Choosing one opens its source. +**File > Open Quickly…** (`Cmd+Shift+O`) indexes all three kinds alongside tables and views. Choosing one opens its source. ### Overloaded names diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index 98a054d562..abd9646b40 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -18,7 +18,7 @@ The tabs are **Columns**, **Indexes**, **Foreign Keys**, **Constraints**, **Trig ## Columns tab -Columns are edited in place. **Nullable**, **Primary Key**, and **Auto Inc** are YES/NO dropdowns; **Primary Key** set to YES forces **Nullable** to NO and holds it there until the key comes back off. **Type** opens a picker of the engine's types by category: search to filter, or type a parametric value such as `VARCHAR(255)` and press Return to use it as written. +Columns are edited in place. **Nullable**, **Primary Key**, and **Auto Inc** are YES/NO dropdowns; **Primary Key** set to YES forces **Nullable** to NO and holds it there until the key comes back off. **Type** opens a picker of the engine's types by category: search to filter, or type a parametric value such as `VARCHAR(255)` and press Return to use it as written. On PostgreSQL and PGlite the picker opens with a **User-Defined** group of the database's enums, composites, domains and ranges, each listed schema-qualified as `sales.status`. See [User-Defined Types](/features/user-defined-types). Which remaining columns appear is the driver's choice. **Comment** is there on most engines and writes the column comment. MySQL and MariaDB add **Charset**, **Collation**, and **On Update**; **On Update** set to YES on a `TIMESTAMP` or `DATETIME` column adds `ON UPDATE CURRENT_TIMESTAMP` at that column's own precision, so `TIMESTAMP(6)` gets `ON UPDATE CURRENT_TIMESTAMP(6)`. diff --git a/docs/features/user-defined-types.mdx b/docs/features/user-defined-types.mdx new file mode 100644 index 0000000000..5ca845316e --- /dev/null +++ b/docs/features/user-defined-types.mdx @@ -0,0 +1,68 @@ +--- +title: User-Defined Types +description: Browse a schema's enums, composites, domains and ranges, read each definition, and add or rename an enum's labels in place +--- + +Expand a schema and **Types** sits beside **Functions** and **Triggers**, one row per enum, composite, domain and range the schema declares. Select a row and press Return, or double-click it, and the type's `CREATE` statement opens in a tab. + + + Sidebar tree with the Types section expanded under a PostgreSQL schema, and a tab showing an enum's labels with the rebuilt CREATE TYPE statement under them + Sidebar tree with the Types section expanded under a PostgreSQL schema, and a tab showing an enum's labels with the rebuilt CREATE TYPE statement under them + + +## Engine support + +| Engine | Types listed | Enum labels editable | +|---|---|---| +| PostgreSQL, PGlite | Enums, composites, domains, ranges | Yes | +| 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. + +## Reading a definition + +The definition is rebuilt from the catalog rather than read back as typed: comments inside the original statement are gone, names are quoted, and a domain's constraints appear in name order. The tab carries the same toolbar as a routine's source, **Copy**, **Export…**, **Open in Editor** and **Reload**, and beside the statement sit the properties the catalog reports: a domain's base type, a range's subtype, the owner, and the comment. + +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. + +## 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. + + + + Click **+** under the list to append one, or right-click a label and choose **Add Label Before…** or **Add Label After…** to place it. Type the label and press Return. Escape discards it. + + + Double-click a label, or right-click it and choose **Rename…**, edit it and press Return. + + + + +Renaming needs PostgreSQL 10 or later. On an older server the **Rename…** item is absent and a double-click does nothing. + + +Safe Mode applies as it does to any schema change. A connection set to read-only shows the labels without the controls, and Confirm Writes asks before the statement runs. + +### What cannot change + +PostgreSQL has no statement that removes a label or moves an existing one. To drop a label, create a new type without it, switch every column over, and drop the old type; **Open in Editor** puts the current definition in a query tab as the starting point. + +## Creating a type + +Right-click the **Types** section and choose **Create New Type…**. A query tab opens with a `CREATE TYPE … AS ENUM` template addressed to that schema. Edit it, run it, then choose **Refresh** on the section. + +## Using a type in a column + +In a table's **Structure** tab, the column type picker opens with a **User-Defined** group listing the database's types ahead of the engine's. Every entry is schema-qualified and quoted where the name needs it, `sales.status` or `app."Order Status"`, so the column definition names the type and never a built-in of the same name. See [Table Structure](/features/table-structure#columns-tab). + +## Finding a type + +The sidebar filter matches a type by name. **File > Open Quickly…** (`Cmd+Shift+O`) indexes types alongside tables, routines and triggers, and choosing one opens its definition. + +## When the definition is missing + +| Message | What it means | What to do | +|---|---|---| +| … no longer exists | The type was dropped after the section was listed | Right-click the section and choose **Refresh** | +| This database cannot show the source of … | The driver listed the type but could not rebuild its statement | Read it from the properties beside the empty source, or query `pg_type` in a query tab | diff --git a/docs/images/user-defined-types-sidebar-dark.png b/docs/images/user-defined-types-sidebar-dark.png new file mode 100644 index 0000000000..0b6c0be238 Binary files /dev/null and b/docs/images/user-defined-types-sidebar-dark.png differ diff --git a/docs/images/user-defined-types-sidebar.png b/docs/images/user-defined-types-sidebar.png new file mode 100644 index 0000000000..aa0a85b6d6 Binary files /dev/null and b/docs/images/user-defined-types-sidebar.png differ diff --git a/project.yml b/project.yml index 388c3f60fe..0925fbc6db 100644 --- a/project.yml +++ b/project.yml @@ -429,6 +429,7 @@ targets: - Plugins/PostgreSQLDriverPlugin/LibPQByteaDecoder.swift - Plugins/PostgreSQLDriverPlugin/LibPQSSLMapping.swift - Plugins/PostgreSQLDriverPlugin/PostGISSpatialRewrite.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLCheckConstraintDefinition.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift @@ -436,6 +437,7 @@ targets: - Plugins/PostgreSQLDriverPlugin/PostgreSQLSystemDatabases.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLTableListingLadder.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLTransactionStatement.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLTypeDefinition.swift - Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift - Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift - Plugins/KafkaDriverPlugin/KafkaApiKey.swift