diff --git a/Sources/Simpleton/Panels/SQL/SQLDatabasePicker.swift b/Sources/Simpleton/Panels/SQL/SQLDatabasePicker.swift index b1da5fa..8440907 100644 --- a/Sources/Simpleton/Panels/SQL/SQLDatabasePicker.swift +++ b/Sources/Simpleton/Panels/SQL/SQLDatabasePicker.swift @@ -31,3 +31,30 @@ struct SQLDatabasePicker: View { ) } } + +/// The active-schema switcher (Postgres `search_path`), shown only for engines that expose a schema +/// layer distinct from the database — MySQL/SQLite report no schemas, so it stays hidden. Selecting a +/// schema calls `model.selectSchema`, which switches live and reloads the schema tree. +struct SQLSchemaPicker: View { + @ObservedObject var model: SQLPanelModel + + var body: some View { + if model.isConnected && model.schemas.count > 1 { + Picker("", selection: selection) { + ForEach(model.schemas, id: \.self) { s in Text(s).tag(s) } + } + .labelsHidden().fixedSize().help("Active schema") + .disabled(model.isConnecting) + } + } + + private var selection: Binding { + Binding( + get: { + let current = model.selectedSchema ?? "" + return model.schemas.contains(current) ? current : (model.schemas.first ?? "") + }, + set: { newValue in Task { await model.selectSchema(newValue) } } + ) + } +} diff --git a/Sources/Simpleton/Panels/SQL/SQLPanelModel.swift b/Sources/Simpleton/Panels/SQL/SQLPanelModel.swift index e39e3a7..7dd38a5 100644 --- a/Sources/Simpleton/Panels/SQL/SQLPanelModel.swift +++ b/Sources/Simpleton/Panels/SQL/SQLPanelModel.swift @@ -54,6 +54,11 @@ final class SQLPanelModel: ObservableObject { /// The active database. Seeded from the connection's `database` param on connect, updated by /// `selectDatabase`. nil when unknown. @Published var selectedDatabase: String? + /// The schemas in the current database, for engines with a schema layer (Postgres). Empty for + /// MySQL/SQLite, which hides the schema switcher. Reloaded on connect and on a database switch. + @Published var schemas: [String] = [] + /// The active schema (Postgres `search_path`). nil when the engine has no schema layer. + @Published var selectedSchema: String? /// Non-nil exactly when the current result is a single-table SELECT with a primary key we can /// UPDATE by. The grid shows the edit affordance only while this is set. Recomputed on every /// `runQuery`; cleared on disconnect or any non-editable result. @@ -174,9 +179,28 @@ final class SQLPanelModel: ObservableObject { savedQueries = await savedStore.saved(for: connectionID) databases = (try? await d.databases()) ?? [] selectedDatabase = activeDatabase ?? databases.first + schemas = (try? await d.schemas()) ?? [] + // The driver starts at its default schema ("public" for Postgres); preselect it when present. + selectedSchema = schemas.isEmpty ? nil : (schemas.contains("public") ? "public" : schemas.first) await loadSchema() } + /// Switch the active schema on the live connection (Postgres `search_path`) and reload the schema + /// tree. A no-op for engines without a schema layer, when the target is already active, or with no + /// live driver. + func selectSchema(_ name: String) async { + guard let driver, name != selectedSchema else { return } + errorMessage = nil + do { + if try await driver.useSchema(name) { + selectedSchema = name + await loadSchema() + } + } catch { + errorMessage = Self.describe(error) + } + } + /// Switch the active database. Engines that can switch a live connection (MySQL `USE`) do so and /// reload the schema; engines that cannot (Postgres — isolated databases) reconnect to `name`. A /// no-op when the target is already active or there is no live driver. @@ -229,6 +253,8 @@ final class SQLPanelModel: ObservableObject { savedQueries = [] databases = [] selectedDatabase = nil + schemas = [] + selectedSchema = nil tables = [] columnsByTable = [:] } diff --git a/Sources/Simpleton/Panels/SQL/SQLPanelView.swift b/Sources/Simpleton/Panels/SQL/SQLPanelView.swift index 0c29ddd..cf180c3 100644 --- a/Sources/Simpleton/Panels/SQL/SQLPanelView.swift +++ b/Sources/Simpleton/Panels/SQL/SQLPanelView.swift @@ -89,6 +89,7 @@ struct SQLPanelView: View { .labelsHidden() .onChange(of: model.selectedID) { Task { await model.connectSelected() } } SQLDatabasePicker(model: model) + SQLSchemaPicker(model: model) Button(model.isConnected ? "Disconnect" : "Connect") { Task { model.isConnected ? await model.disconnect() : await model.connect() } } diff --git a/Sources/Simpleton/Panels/SQL/SQLWorkspaceView.swift b/Sources/Simpleton/Panels/SQL/SQLWorkspaceView.swift index 3194f30..b37f62d 100644 --- a/Sources/Simpleton/Panels/SQL/SQLWorkspaceView.swift +++ b/Sources/Simpleton/Panels/SQL/SQLWorkspaceView.swift @@ -71,6 +71,7 @@ struct SQLWorkspaceView: View { .onChange(of: model.selectedID) { Task { await model.connectSelected() } } SQLDatabasePicker(model: model) + SQLSchemaPicker(model: model) Button(model.isConnected ? "Disconnect" : "Connect") { Task { model.isConnected ? await model.disconnect() : await model.connect() } diff --git a/Sources/SimpletonSQL/MySQLDriver.swift b/Sources/SimpletonSQL/MySQLDriver.swift index df4261c..a63793d 100644 --- a/Sources/SimpletonSQL/MySQLDriver.swift +++ b/Sources/SimpletonSQL/MySQLDriver.swift @@ -107,6 +107,11 @@ public final class MySQLDriver: SQLDriver, @unchecked Sendable { } } + /// In MySQL a schema *is* a database, so the database switcher already covers it — there is no + /// separate schema axis to expose. + public func schemas() async throws -> [String] { [] } + public func useSchema(_ name: String) async throws -> Bool { false } + public func tables(in database: String?) async throws -> [TableInfo] { guard case .rows(_, let rows) = try await run("SHOW FULL TABLES") else { return [] } return rows.compactMap { row in diff --git a/Sources/SimpletonSQL/PostgresDriver.swift b/Sources/SimpletonSQL/PostgresDriver.swift index 267e484..063df1e 100644 --- a/Sources/SimpletonSQL/PostgresDriver.swift +++ b/Sources/SimpletonSQL/PostgresDriver.swift @@ -11,6 +11,9 @@ public final class PostgresDriver: SQLDriver, @unchecked Sendable { private let config: PostgresConnection.Configuration private let logger = Logger(label: "com.simpleton.sql.postgres") private var connection: PostgresConnection? + /// The active schema, driving `search_path` (set on switch) and the schema-scoped introspection + /// below. Defaults to "public" — the connection's own default — until `useSchema` changes it. + private var currentSchema = "public" public init(connection c: Connection, secret: ConnectionSecret?) throws { let tls: PostgresConnection.Configuration.TLS = @@ -101,16 +104,47 @@ public final class PostgresDriver: SQLDriver, @unchecked Sendable { /// caller reconnects with the target `database` param, so this always returns `false`. public func useDatabase(_ name: String) async throws -> Bool { false } - public func tables(in database: String?) async throws -> [TableInfo] { + /// User-visible schemas in the current database (system `pg_*` schemas excluded). `information_schema` + /// is kept so it can be browsed like any other schema. + public func schemas() async throws -> [String] { guard case .rows(_, let rows) = try await run( - "SELECT table_name, table_type FROM information_schema.tables " - + "WHERE table_schema = 'public' ORDER BY table_name") + "SELECT schema_name FROM information_schema.schemata " + + "WHERE schema_name NOT LIKE 'pg\\_%' ORDER BY schema_name") else { return [] } - return rows.compactMap { row in - guard row.count >= 2 else { return nil } - let kind: TableKind = row[1].displayString.uppercased().contains("VIEW") ? .view : .table - return TableInfo(name: row[0].displayString, kind: kind) + return rows.compactMap { $0.first?.displayString } + } + + /// Switch the active schema live via `search_path`. `SET` takes no bind parameters, so the schema + /// identifier is quoted (internal quotes doubled) — the name comes from `schemas()` (the catalog), + /// never user free-text. This redirects both raw editor queries and the introspection below. + public func useSchema(_ name: String) async throws -> Bool { + _ = try await run("SET search_path TO " + dialect.quoteIdentifier(name)) + currentSchema = name + return true + } + + public func tables(in database: String?) async throws -> [TableInfo] { + guard let connection else { throw SQLDriverError.notConnected } + do { + // The active schema is bound as a typed parameter ($1), never interpolated into SQL. + let schema = currentSchema + let seq = try await connection.query( + """ + SELECT table_name, table_type FROM information_schema.tables \ + WHERE table_schema = \(schema) ORDER BY table_name + """, logger: logger) + var out: [TableInfo] = [] + for try await row in seq { + let cells = Array(row) + guard cells.count >= 2 else { continue } + let name = (try? cells[0].decode(String.self)) ?? "" + let type = (try? cells[1].decode(String.self)) ?? "" + out.append(TableInfo(name: name, kind: type.uppercased().contains("VIEW") ? .view : .table)) + } + return out + } catch { + throw SQLDriverError.queryFailed("\(error)") } } @@ -118,13 +152,15 @@ public final class PostgresDriver: SQLDriver, @unchecked Sendable { guard let connection else { throw SQLDriverError.notConnected } do { let primaryKeys = try await primaryKeyColumns(of: table, on: connection) - // `table` is bound as a parameter ($1) via PostgresQuery string interpolation — NOT raw - // SQL — so no manual escaping and no injection surface (contrast `run`'s unsafeSQL path, - // which intentionally executes the user's own IDE queries). + // `table` and the active `schema` are bound as parameters ($1/$2) via PostgresQuery string + // interpolation — NOT raw SQL — so no manual escaping and no injection surface (contrast + // `run`'s unsafeSQL path, which intentionally executes the user's own IDE queries). Scoping + // by schema stops a same-named table in another schema from merging its columns in. + let schema = currentSchema let seq = try await connection.query( """ SELECT column_name, data_type, is_nullable FROM information_schema.columns \ - WHERE table_name = \(table) ORDER BY ordinal_position + WHERE table_name = \(table) AND table_schema = \(schema) ORDER BY ordinal_position """, logger: logger) var out: [ColumnInfo] = [] for try await row in seq { @@ -150,7 +186,8 @@ public final class PostgresDriver: SQLDriver, @unchecked Sendable { // Join the three information_schema views that describe a foreign-key constraint: the // owning column (key_column_usage), the referenced column (constraint_column_usage), and // the constraint metadata (referential_constraints ties the two together by name). - // `table` is bound as `$1` (a typed parameter), never interpolated into SQL. + // `table` and the active `schema` are bound as typed parameters, never interpolated. + let schema = currentSchema let seq = try await connection.query( """ SELECT kcu.column_name, ccu.table_name AS referenced_table, \ @@ -160,7 +197,7 @@ public final class PostgresDriver: SQLDriver, @unchecked Sendable { ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema \ JOIN information_schema.constraint_column_usage ccu \ ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema \ - WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public' \ + WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = \(schema) \ AND tc.table_name = \(table) ORDER BY kcu.ordinal_position """, logger: logger) var out: [ForeignKeyInfo] = [] diff --git a/Sources/SimpletonSQL/SQLDriver.swift b/Sources/SimpletonSQL/SQLDriver.swift index 6e2f740..dfed882 100644 --- a/Sources/SimpletonSQL/SQLDriver.swift +++ b/Sources/SimpletonSQL/SQLDriver.swift @@ -28,6 +28,14 @@ public protocol SQLDriver: AnyObject, Sendable { /// single file) return `false`, signalling the caller to reconnect to reach `name` instead. `name` /// comes from `databases()` (the engine's own catalog), and is identifier-quoted, not interpolated. func useDatabase(_ name: String) async throws -> Bool + /// The schemas within the current database, for engines with a schema layer distinct from the + /// database (Postgres). Engines where a schema *is* the database (MySQL) or has none (SQLite) + /// return `[]`, which hides the schema switcher. + func schemas() async throws -> [String] + /// Switch the active schema on the *live* connection when the engine has a schema layer (Postgres + /// `SET search_path`), returning `true`; others return `false`. `name` comes from `schemas()` and + /// is identifier-quoted, not interpolated as a value. Introspection then follows the new schema. + func useSchema(_ name: String) async throws -> Bool /// The placeholder dialect for `execute`, so callers build `UPDATE … WHERE …` with the correct /// placeholder syntax and identifier quoting for this engine. var dialect: SQLDialect { get } diff --git a/Sources/SimpletonSQL/SQLiteDriver.swift b/Sources/SimpletonSQL/SQLiteDriver.swift index b564efb..dc90cfa 100644 --- a/Sources/SimpletonSQL/SQLiteDriver.swift +++ b/Sources/SimpletonSQL/SQLiteDriver.swift @@ -62,6 +62,10 @@ public final class SQLiteDriver: SQLDriver, @unchecked Sendable { /// by opening a different connection, so this always returns `false`. public func useDatabase(_ name: String) async throws -> Bool { false } + /// SQLite has no schema layer distinct from the database, so there is nothing to switch. + public func schemas() async throws -> [String] { [] } + public func useSchema(_ name: String) async throws -> Bool { false } + public func tables(in database: String?) async throws -> [TableInfo] { let result = try await run( "SELECT name, type FROM sqlite_master WHERE type IN ('table','view') ORDER BY name") diff --git a/Tests/CoreChecks/SQLDriverChecks.swift b/Tests/CoreChecks/SQLDriverChecks.swift index 0439f0e..9a1a53e 100644 --- a/Tests/CoreChecks/SQLDriverChecks.swift +++ b/Tests/CoreChecks/SQLDriverChecks.swift @@ -105,6 +105,9 @@ func runSQLDriverChecks(_ t: TestRunner) async { // the caller would reconnect to reach another database. let switched = try await driver.useDatabase("main") t.expect(!switched, "SQLite cannot switch a live connection") + // SQLite has no schema layer, so the schema switcher is inert. + t.expect(try await driver.schemas().isEmpty, "SQLite exposes no schemas") + t.expect(try await driver.useSchema("anything") == false, "SQLite cannot switch schema") await driver.close() } catch { t.expect(false, "unexpected error: \(error)") @@ -250,6 +253,24 @@ func runSQLDriverChecks(_ t: TestRunner) async { } await d2.close() } + // Schema switch (search_path): seed a table in `public` and one in a new schema, then + // prove useSchema redirects introspection to the active schema and back. + _ = try await driver.run("CREATE SCHEMA IF NOT EXISTS simp_test") + _ = try await driver.run("CREATE TABLE IF NOT EXISTS public.simp_pub (id int)") + _ = try await driver.run("CREATE TABLE IF NOT EXISTS simp_test.simp_sales (id int)") + let schemaList = try await driver.schemas() + t.expect(schemaList.contains("simp_test"), "new schema listed") + t.expect(schemaList.contains("public"), "public schema listed") + let pubTables = try await driver.tables(in: nil) + t.expect(pubTables.contains { $0.name == "simp_pub" }, "public table visible before switch") + t.expect(!pubTables.contains { $0.name == "simp_sales" }, "other-schema table hidden in public") + t.expect(try await driver.useSchema("simp_test"), "useSchema returns true (live search_path)") + let salesTables = try await driver.tables(in: nil) + t.expect(salesTables.contains { $0.name == "simp_sales" }, "schema table visible after switch") + t.expect(!salesTables.contains { $0.name == "simp_pub" }, "public table hidden after switch") + _ = try await driver.run("DROP TABLE IF EXISTS simp_test.simp_sales") + _ = try await driver.run("DROP SCHEMA IF EXISTS simp_test CASCADE") + _ = try await driver.run("DROP TABLE IF EXISTS public.simp_pub") await driver.close() } catch { t.expect(false, "unexpected error: \(error)")