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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Data grid jumping to the top on a later reload after a page change failed.
- System databases such as `mysql` missing from the database switcher, the tab database picker and Open Quickly. (#2832)
- SQL Server and ClickHouse system databases listed as user databases once the database switcher finished loading.
- Oracle system schemas such as `SYS` and `XDB` listed with user schemas.
- Dameng `SYSDBA` schema hidden from the sidebar and listed under System.
- TiDB's `INFORMATION_SCHEMA` and `PERFORMANCE_SCHEMA` listed as user databases on a MySQL or MariaDB connection.
- SQL Server database size and table count showing the current database's numbers, and no size at 2 GB or more.
- ClickHouse databases with no tables missing from the database switcher and database statistics.
Expand Down
6 changes: 3 additions & 3 deletions Plugins/DamengDriverPlugin/DamengPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ final class DamengPlugin: NSObject, TableProPlugin, DriverPlugin {
]
static let databaseGroupingStrategy: GroupingStrategy = .hierarchicalSchema
static let pathFieldRole: PathFieldRole = .database
static let systemSchemaNames = ["SYS", "SYSDBA", "SYSAUDITOR", "SYSSSO", "CTISYS"]
static let systemSchemaNames = DamengSystemSchemas.listed
static let supportsCascadeDrop = true
static let supportsDropSchema = true
static let supportsForeignKeyDisable = false
Expand Down Expand Up @@ -332,8 +332,8 @@ final class DamengPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}

func dropSchema(name: String) async throws {
guard !DamengPlugin.systemSchemaNames.contains(name.uppercased()) else {
throw DamengError(message: String(localized: "Dameng system schemas cannot be dropped."))
guard !DamengSystemSchemas.isProtectedFromDrop(name) else {
throw DamengError(message: String(localized: "Dameng's built-in schemas cannot be dropped."))
}
guard name.caseInsensitiveCompare(activeSchema ?? "") != .orderedSame else {
throw DamengError(message: String(localized: "Switch away from a schema before dropping it."))
Expand Down
2 changes: 1 addition & 1 deletion Plugins/DamengDriverPlugin/DamengPluginDriver+Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ extension DamengPluginDriver {
return PluginDatabaseMetadata(
name: database,
tableCount: result.rows.first?.first?.asText.flatMap(Int.init),
isSystemDatabase: DamengPlugin.systemSchemaNames.contains(database.uppercased())
isSystemDatabase: DamengSystemSchemas.listed.contains(database)
)
}

Expand Down
23 changes: 23 additions & 0 deletions Plugins/DamengDriverPlugin/DamengSystemSchemas.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// DamengSystemSchemas.swift
// DamengDriverPlugin
//

import Foundation

/// Two lists, because showing a schema and letting it be dropped are different questions. `listed` is what the
/// sidebar and the pickers treat as system: schemas that hold the engine's own objects. `SYSDBA` is not on it,
/// because it is the administrator login's own default schema and where that login's tables land. The drop guard is
/// wider and matches any spelling: `SYSDBA` also holds a few DM-supplied procedures and `SYSDBO` is the other preset
/// administrator, so a `DROP SCHEMA ... CASCADE` on either is refused even though they list with the user's schemas.
enum DamengSystemSchemas {
static let listed: [String] = ["SYS", "SYSAUDITOR", "SYSSSO", "CTISYS", "SYSJOB", "SYSGEO2"]

static let protectedFromDrop: Set<String> = [
"SYS", "SYSDBA", "SYSDBO", "SYSAUDITOR", "SYSSSO", "CTISYS", "SYSJOB", "SYSGEO2"
]

static func isProtectedFromDrop(_ name: String) -> Bool {
protectedFromDrop.contains(name.uppercased())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// PluginMetadataRegistry+OracleSystemSchemas.swift
// TablePro
//

import Foundation

extension PluginMetadataRegistry {
/// The schemas Oracle creates and maintains itself. The core is the `ORACLE_MAINTAINED = 'Y'` set measured from
/// `ALL_USERS` on Oracle AI Database 26ai Free; the rest are the predefined accounts the 19c and 11.2 Security
/// Guides list for options that image leaves out, since `ORACLE_MAINTAINED` only exists from 12.1.0.2. Schemas
/// named with a release number, such as `APEX_240200`, cannot be listed by name.
static let oracleSystemSchemaNames: [String] = [
"ANONYMOUS", "APEX_PUBLIC_USER", "APPQOSSYS", "ASMSNMP", "AUDSYS", "BAASSYS", "CTXSYS",
"DBSFWUSER", "DBSNMP", "DGPDB_INT", "DIP", "DVF", "DVSYS", "EXFSYS", "FLOWS_FILES",
"GGSHAREDCAP", "GGSYS", "GSMADMIN_INTERNAL", "GSMCATUSER", "GSMROOTUSER", "GSMUSER", "LBACSYS",
"MDDATA", "MDSYS", "MGMT_VIEW", "OJVMSYS", "OLAPSYS", "ORACLE_OCM", "ORDDATA", "ORDPLUGINS",
"ORDSYS", "OUTLN", "OWBSYS", "REMOTE_SCHEDULER_AGENT", "SI_INFORMTN_SCHEMA", "SPATIAL_CSW_ADMIN_USR",
"SPATIAL_WFS_ADMIN_USR", "SYS", "SYS$UMF", "SYSBACKUP", "SYSDG", "SYSKM", "SYSMAN", "SYSRAC",
"SYSTEM", "VECSYS", "WKPROXY", "WKSYS", "WK_TEST", "WMSYS", "XDB", "XS$NULL"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -551,10 +551,8 @@ extension PluginMetadataRegistry {
containerEntityName: "Schema",
defaultPrimaryKeyColumn: nil,
immutableColumns: [],
systemDatabaseNames: [
"SYS", "SYSTEM", "OUTLN", "DBSNMP", "APPQOSSYS", "WMSYS", "XDB"
],
systemSchemaNames: [],
systemDatabaseNames: PluginMetadataRegistry.oracleSystemSchemaNames,
systemSchemaNames: PluginMetadataRegistry.oracleSystemSchemaNames,
fileExtensions: [],
databaseGroupingStrategy: .hierarchicalSchema,
structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment]
Expand Down Expand Up @@ -612,7 +610,7 @@ extension PluginMetadataRegistry {
defaultPrimaryKeyColumn: nil,
immutableColumns: [],
systemDatabaseNames: [],
systemSchemaNames: ["SYS", "SYSDBA", "SYSAUDITOR", "SYSSSO", "CTISYS"],
systemSchemaNames: ["SYS", "SYSAUDITOR", "SYSSSO", "CTISYS", "SYSJOB", "SYSGEO2"],
fileExtensions: [],
databaseGroupingStrategy: .hierarchicalSchema,
structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,31 @@ extension PluginMetadataRegistry {
)
}

/// A name is a system database or schema when either the plugin or the app's curated entry lists it. An installed
/// plugin can predate the app's list or report none at all: every published Oracle plugin lists no system
/// schemas, which left `SYS` and `XDB` among the user schemas whichever plugin version was installed.
static func adoptCuratedSystemNames(
_ snapshot: inout PluginMetadataSnapshot,
registryDefault: PluginMetadataSnapshot
) {
let databases = mergedSystemNames(
reported: snapshot.schema.systemDatabaseNames,
curated: registryDefault.schema.systemDatabaseNames
)
let schemas = mergedSystemNames(
reported: snapshot.schema.systemSchemaNames,
curated: registryDefault.schema.systemSchemaNames
)
guard databases != snapshot.schema.systemDatabaseNames
|| schemas != snapshot.schema.systemSchemaNames else { return }
snapshot = snapshot.withSystemNames(databases: databases, schemas: schemas)
}

private static func mergedSystemNames(reported: [String], curated: [String]) -> [String] {
var seen = Set(reported)
return reported + curated.filter { seen.insert($0).inserted }
}

/// A plugin built before its engine moved to schema-only switching still
/// declares database switching with bySchema grouping. The app's registry
/// default is the ground truth for routing, so its switch fields win.
Expand Down
38 changes: 38 additions & 0 deletions TablePro/Core/Plugins/PluginMetadataRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,43 @@ struct PluginMetadataSnapshot: Sendable {
)
}

func withSystemNames(databases: [String], schemas: [String]) -> PluginMetadataSnapshot {
PluginMetadataSnapshot(
displayName: displayName, iconName: iconName, defaultPort: defaultPort,
requiresAuthentication: requiresAuthentication, supportsForeignKeys: supportsForeignKeys,
supportsSchemaEditing: supportsSchemaEditing, isDownloadable: isDownloadable,
primaryUrlScheme: primaryUrlScheme, parameterStyle: parameterStyle,
navigationModel: navigationModel, explainVariants: explainVariants,
pathFieldRole: pathFieldRole, supportsHealthMonitor: supportsHealthMonitor,
urlSchemes: urlSchemes, postConnectActions: postConnectActions,
brandColorHex: brandColorHex, queryLanguageName: queryLanguageName,
editorLanguage: editorLanguage, connectionMode: connectionMode,
supportsDatabaseSwitching: supportsDatabaseSwitching,
structureEditing: structureEditing,
capabilities: capabilities,
schema: SchemaInfo(
defaultSchemaName: schema.defaultSchemaName,
defaultGroupName: schema.defaultGroupName,
tableEntityName: schema.tableEntityName,
containerEntityName: schema.containerEntityName,
schemaEntityName: schema.schemaEntityName,
defaultPrimaryKeyColumn: schema.defaultPrimaryKeyColumn,
immutableColumns: schema.immutableColumns,
systemDatabaseNames: databases,
systemSchemaNames: schemas,
fileExtensions: schema.fileExtensions,
fileSignatures: schema.fileSignatures,
databaseGroupingStrategy: schema.databaseGroupingStrategy,
structureColumnFields: schema.structureColumnFields,
implicitSchemaName: schema.implicitSchemaName,
rowMatchExcludedTypePrefixes: schema.rowMatchExcludedTypePrefixes,
rowMatchTextTypePrefixes: schema.rowMatchTextTypePrefixes
),
editor: editor,
connection: connection
)
}

func withBranding(from source: PluginMetadataSnapshot) -> PluginMetadataSnapshot {
PluginMetadataSnapshot(
displayName: source.displayName, iconName: source.iconName, defaultPort: defaultPort,
Expand Down Expand Up @@ -455,6 +492,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
if let registryDefault = defaultSnapshots[typeId] {
resolved = resolved.withIsDownloadable(registryDefault.isDownloadable)
Self.adoptCuratedCaseSensitivity(&resolved, registryDefault: registryDefault)
Self.adoptCuratedSystemNames(&resolved, registryDefault: registryDefault)
if Self.declaresLegacySchemaOnlyRouting(resolved, registryDefault: registryDefault) {
Logger(subsystem: "com.TablePro", category: "PluginMetadataRegistry").notice(
"Plugin '\(typeId, privacy: .public)' declares legacy two-tier switching for a schema-only engine; applying the app's switch routing"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//
// PluginMetadataRegistrySystemNameAdoptionTests.swift
// TableProTests
//
// A plugin's own snapshot replaces the curated entry when it loads, so a published plugin that lists fewer system
// names than the app, or none, took them away. No plugin loads under XCTest, so these build the plugin's snapshot
// from the curated one the way a published plugin would report it.
//

import Foundation
@testable import TablePro
import TableProPluginKit
import Testing

@Suite("PluginMetadataRegistry system name adoption")
struct PluginMetadataRegistrySystemNameAdoptionTests {
private func curated(_ typeId: String) -> PluginMetadataSnapshot? {
PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: typeId)
}

@Test("A published Oracle plugin that lists no system schemas keeps the app's")
func emptyPluginSchemaListKeepsCuratedSchemas() {
guard let oracle = curated("Oracle") else {
Issue.record("Registry default for Oracle missing")
return
}
var published = oracle.withSystemNames(
databases: ["SYS", "SYSTEM", "OUTLN", "DBSNMP", "APPQOSSYS", "WMSYS", "XDB"],
schemas: []
)

PluginMetadataRegistry.adoptCuratedSystemNames(&published, registryDefault: oracle)

#expect(published.schema.systemSchemaNames == oracle.schema.systemSchemaNames)
#expect(Set(published.schema.systemDatabaseNames) == Set(oracle.schema.systemDatabaseNames))
}

@Test("A name either side lists counts, the plugin's names first and none twice")
func namesAreUnitedInOrder() {
guard let base = curated("Dameng") else {
Issue.record("Registry default for Dameng missing")
return
}
var plugin = base.withSystemNames(databases: ["b", "a"], schemas: ["S1"])
let registryDefault = base.withSystemNames(databases: ["a", "c"], schemas: ["S2", "S1"])

PluginMetadataRegistry.adoptCuratedSystemNames(&plugin, registryDefault: registryDefault)

#expect(plugin.schema.systemDatabaseNames == ["b", "a", "c"])
#expect(plugin.schema.systemSchemaNames == ["S1", "S2"])
}

@Test("A plugin that already lists every curated name is left as it reported")
func completePluginListIsUnchanged() {
guard let base = curated("Dameng") else {
Issue.record("Registry default for Dameng missing")
return
}
var plugin = base.withSystemNames(databases: [], schemas: ["CTISYS", "SYS", "SYSAUDITOR", "SYSSSO", "SYSJOB", "SYSGEO2"])

PluginMetadataRegistry.adoptCuratedSystemNames(&plugin, registryDefault: base)

#expect(plugin.schema.systemSchemaNames == ["CTISYS", "SYS", "SYSAUDITOR", "SYSSSO", "SYSJOB", "SYSGEO2"])
}

/// Measured on Oracle AI Database 26ai Free: `ALL_USERS` reports these with `ORACLE_MAINTAINED = 'Y'`, and the
/// administrator account a PDB is created with, `PDBADMIN`, with `N`.
@Test("Oracle's own schemas are system schemas, and the accounts users create are not")
func oracleMaintainedSchemasAreSystem() {
let schemas = PluginMetadataRegistry.shared.snapshot(for: .oracle)?.schema.systemSchemaNames ?? []
for name in ["SYS", "SYSTEM", "XDB", "AUDSYS", "MDSYS", "CTXSYS", "GSMADMIN_INTERNAL", "XS$NULL"] {
#expect(schemas.contains(name), "\(name) is Oracle-maintained")
}
for name in ["PDBADMIN", "OPS$ORACLE", "HR", "SCOTT"] {
#expect(!schemas.contains(name), "\(name) is not Oracle-maintained")
}
}

@Test("Oracle names the same schemas whether it is asked for databases or schemas")
func oracleListsAgree() {
let schema = PluginMetadataRegistry.shared.snapshot(for: .oracle)?.schema
#expect(schema?.systemDatabaseNames == schema?.systemSchemaNames)
}
}
23 changes: 21 additions & 2 deletions TableProTests/Core/Plugins/PluginMetadataSnapshotCopyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ struct PluginMetadataSnapshotCopyTests {
("withExplainVariants", original.withExplainVariants([])),
("withBranding", original.withBranding(from: original)),
("withIsDownloadable", original.withIsDownloadable(!original.isDownloadable)),
("withSwitchRouting", original.withSwitchRouting(from: original))
("withSwitchRouting", original.withSwitchRouting(from: original)),
("withSystemNames", original.withSystemNames(databases: [], schemas: []))
]

for (name, copy) in copies {
Expand All @@ -59,14 +60,32 @@ struct PluginMetadataSnapshotCopyTests {
("withExplainVariants", original.withExplainVariants([])),
("withBranding", original.withBranding(from: original)),
("withIsDownloadable", original.withIsDownloadable(!original.isDownloadable)),
("withSwitchRouting", original.withSwitchRouting(from: original))
("withSwitchRouting", original.withSwitchRouting(from: original)),
("withSystemNames", original.withSystemNames(databases: [], schemas: []))
]

for (name, copy) in copies {
#expect(copy.schema.implicitSchemaName == "(default)", "\(name) reset the implicit schema")
}
}

@Test("withSystemNames replaces only the two system name lists")
func withSystemNamesKeepsEveryOtherSchemaField() throws {
let original = try #require(PluginMetadataRegistry.shared.snapshot(for: .mysql))
let copy = original.withSystemNames(databases: ["a"], schemas: ["b"])

#expect(copy.schema.systemDatabaseNames == ["a"])
#expect(copy.schema.systemSchemaNames == ["b"])
#expect(copy.schema.defaultSchemaName == original.schema.defaultSchemaName)
#expect(copy.schema.containerEntityName == original.schema.containerEntityName)
#expect(copy.schema.databaseGroupingStrategy == original.schema.databaseGroupingStrategy)
#expect(copy.schema.structureColumnFields == original.schema.structureColumnFields)
#expect(copy.schema.rowMatchTextTypePrefixes == original.schema.rowMatchTextTypePrefixes)
#expect(copy.schema.fileSignatures.count == original.schema.fileSignatures.count)
#expect(copy.editor.columnTypesByCategory == original.editor.columnTypesByCategory)
#expect(copy.capabilities.supportsSSH == original.capabilities.supportsSSH)
}

/// An engine whose `ALTER TABLE` can add a constraint says so, and is not pushed through a
/// rebuild it does not need.
@Test("An engine with the statements is curated as altering, not rebuilding")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,12 @@ struct DatabaseTreeVisibilityTests {
@Test("The schema being browsed stays listed while system schemas are hidden")
func activeSystemSchemaStaysListed() {
let visible = DatabaseTreeVisibility.visibleSchemas(
["APP", "SYS", "SYSDBA"],
systemSchemas: ["SYS", "SYSDBA"],
activeSchema: "SYSDBA",
["APP", "SYS", "CTISYS"],
systemSchemas: ["SYS", "CTISYS"],
activeSchema: "CTISYS",
showsSystem: false
)
#expect(visible == ["APP", "SYSDBA"])
#expect(visible == ["APP", "CTISYS"])
}

@Test("An empty active schema name is treated as absent")
Expand Down
38 changes: 38 additions & 0 deletions TableProTests/Plugins/DamengSystemSchemasTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// DamengSystemSchemasTests.swift
// TableProTests
//

import Foundation
@testable import TablePro
import Testing

@Suite("Dameng system schemas")
struct DamengSystemSchemasTests {
@Test("The app lists the same system schemas the plugin does")
func curatedListMatchesThePlugin() {
let curated = PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: "Dameng")?.schema.systemSchemaNames
#expect(curated == DamengSystemSchemas.listed)
}

/// DM8 gives every user a default schema named after it, so a `SYSDBA` login's own tables land in `SYSDBA`.
@Test("SYSDBA lists with the user's schemas")
func sysdbaIsNotListedAsSystem() {
#expect(!DamengSystemSchemas.listed.contains("SYSDBA"))
}

@Test("Every listed system schema, SYSDBA and SYSDBO are refused a drop")
func dropGuardCoversListedAndAdministratorSchemas() {
for name in DamengSystemSchemas.listed + ["SYSDBA", "SYSDBO"] {
#expect(DamengSystemSchemas.isProtectedFromDrop(name), "\(name) must not be dropped")
}
#expect(!DamengSystemSchemas.isProtectedFromDrop("APP"))
}

/// A DM8 instance created case-insensitive treats `sys` and `SYS` as one schema.
@Test("The drop guard matches any spelling")
func dropGuardIgnoresCase() {
#expect(DamengSystemSchemas.isProtectedFromDrop("sys"))
#expect(DamengSystemSchemas.isProtectedFromDrop("SysDba"))
}
}
Loading
Loading