diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f8504b8d..3cb585c45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tab rows in Settings > General > Tabs, wrapping the strip instead of scrolling it. (#2438) - Autoscrolling while dragging a tab, so a tab can be moved past the run currently on screen. (#2438) - Move Tab to New Window on a tab's right-click menu, and by dragging a tab out of the strip. (#2438) +- Recognition of SQLite and DuckDB databases by their contents, whatever they are named. (#2476) +- `.parquet` files in Finder's Open With, read through DuckDB. (#2476) +- Prompt to install the driver a file needs, before the file opens. (#2476) ### Changed @@ -24,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - MongoDB editor diagnostics report JavaScript syntax errors rather than unsupported method names. - Editor tab presses handled by AppKit rather than SwiftUI gestures. (#2438) - Connection-first labels with the database or schema on a second line in the connections strip. (#2550) +- File > Open File… as an app command over every file TablePro reads. (#2476) ### Fixed diff --git a/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift b/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift index 6b14ead75..ce20babd4 100644 --- a/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift +++ b/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift @@ -41,6 +41,18 @@ extension AppDelegate: NSMenuItemValidation { WindowOpener.shared.openWelcome() } + /// A database file, a connection share and a plugin all open without a live connection, so + /// this belongs to the app and not to an editor window that may not exist. + @objc func openFile(_ sender: Any?) { + Task { @MainActor in + guard let urls = await FileOpenPanel.present() else { return } + for url in urls { + guard case .some(.success(let intent)) = URLClassifier.classify(url) else { continue } + await LaunchIntentRouter.shared.route(intent) + } + } + } + @objc func compareAndSyncDatabases(_ sender: Any?) { CompareSyncLauncher.open() } diff --git a/TablePro/Core/Menu/FileMenuBuilder.swift b/TablePro/Core/Menu/FileMenuBuilder.swift index 03fb88ffd..bf31f5333 100644 --- a/TablePro/Core/Menu/FileMenuBuilder.swift +++ b/TablePro/Core/Menu/FileMenuBuilder.swift @@ -33,7 +33,7 @@ enum FileMenuBuilder { MenuItemFactory.separator, MenuItemFactory.item( String(localized: "Open File…"), - action: #selector(MainSplitViewController.openSQLFile(_:)), + action: #selector(AppDelegate.openFile(_:)), shortcut: .openFile, keyboard: keyboard ), diff --git a/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift b/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift new file mode 100644 index 000000000..a46bd5dac --- /dev/null +++ b/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift @@ -0,0 +1,54 @@ +// +// MissingDriverPluginPrompt.swift +// TablePro +// + +import AppKit +import os + +/// Asks for the driver a file needs before the file is opened, rather than raising a window +/// headlined "Could not connect" whose only action leaves for the connection list. +@MainActor +internal enum MissingDriverPluginPrompt { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "MissingDriverPluginPrompt") + + /// Whether the open may go ahead. + internal static func ensureInstalled(for type: DatabaseType, opening url: URL) async -> Bool { + /// A plugin without `TableProProvidesDatabaseTypeIds` registers on the eager path, which a + /// Finder open beats to the question: launch intents route on a fixed 150ms timer. Asking + /// before the barrier offers to install a plugin the user already has. + if !PluginManager.shared.isDriverInstalled(for: type) { + await PluginManager.shared.waitForInitialLoad() + } + guard !PluginManager.shared.isDriverInstalled(for: type) else { return true } + /// A plugin that ships inside the app and still did not load is disabled or damaged, and + /// downloading cannot fix either. The connect attempt reports what actually went wrong. + guard type.isDownloadablePlugin else { return true } + + let displayName = PluginMetadataRegistry.shared.snapshot(for: type)?.displayName ?? type.rawValue + let confirmed = await AlertHelper.confirm( + title: String( + format: String(localized: "Install the %@ plugin to open “%@”?"), + displayName, + url.lastPathComponent + ), + message: String(localized: "TablePro reads this file with a driver it downloads from the plugin registry."), + confirmButton: String(localized: "Install") + ) + guard confirmed else { return false } + + do { + try await PluginManager.shared.installMissingPlugin(for: type) { _ in } + logger.info("Installed \(type.rawValue, privacy: .public) to open a file") + return true + } catch { + logger.error("Install failed for \(type.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") + AlertHelper.showErrorSheet( + title: String(localized: "Plugin Installation Failed"), + message: error.localizedDescription, + window: nil + ) + return false + } + } +} diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift index fbb9ba7b5..00ae86da3 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -656,6 +656,7 @@ extension PluginMetadataRegistry { systemDatabaseNames: [], systemSchemaNames: [], fileExtensions: ["db", "db3", "s3db", "sl3", "sqlite", "sqlite3", "sqlitedb"], + fileSignatures: [.magic("SQLite format 3\u{0}")], databaseGroupingStrategy: .flat, structureColumnFields: [ .name, .type, .nullable, .defaultValue, .generated, .generationExpression, diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+DuckDBDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+DuckDBDefaults.swift new file mode 100644 index 000000000..9cb0347c6 --- /dev/null +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+DuckDBDefaults.swift @@ -0,0 +1,86 @@ +// +// PluginMetadataRegistry+DuckDBDefaults.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension PluginMetadataRegistry { + /// The curated snapshot for DuckDB, alongside its connection fields in the sibling file. + func duckdbPluginDefaults( + dialect: SQLDialectDescriptor, + columnTypes: [String: [String]] + ) -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + [ + ("DuckDB", PluginMetadataSnapshot( + displayName: "DuckDB", iconName: "duckdb-icon", defaultPort: 9_494, + requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: true, primaryUrlScheme: "duckdb", parameterStyle: .dollar, + navigationModel: .standard, + explainVariants: [ + ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .indentedText), + ], + pathFieldRole: .database, + supportsHealthMonitor: false, urlSchemes: ["duckdb", "quack"], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#FFD900", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .apiOnly, supportsDatabaseSwitching: true, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: true, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsRenameColumn: true, + supportsConnectionPooling: false, + localFilePathField: .additionalField("duckdbFilePath") + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "main", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: ["system", "temp"], + systemSchemaNames: [], + fileExtensions: ["duckdb", "ddb", "parquet", "csv", "tsv", "json", "ndjson"], + /// Only DuckDB's own storage. The data formats above are recognised by name + /// alone, because `duckdb_open` picks their reader from the extension and + /// refuses a Parquet file called anything else. + /// + /// `DUCK` sits behind the header's eight-byte checksum, followed by the storage + /// version as a little-endian `uint64`. Four bytes on their own are not enough + /// to name a format, and `SELECT 'DUCK';` spells them at exactly that offset, + /// so the version's high six bytes have to be zero as well. Storage versions + /// are still in the sixties, and a version past 65535 would cost recognition by + /// content rather than break it. + fileSignatures: [.magic("DUCK", at: 8).andZeroes(at: 14, count: 6)], + databaseGroupingStrategy: .bySchema, + structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: dialect, + statementCompletions: [], + columnTypesByCategory: columnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: Self.duckdbConnectionFields, + category: .analytical, + tagline: String(localized: "Embedded and remote analytical SQL"), + hidesBuiltInPassword: true, + hidesBuiltInDatabase: true + ) + )) + ] + } +} diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 1c3b991e8..5790cf88f 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -693,63 +693,6 @@ extension PluginMetadataRegistry { tagline: String(localized: "Column-oriented OLAP for big data") ) )), - ("DuckDB", PluginMetadataSnapshot( - displayName: "DuckDB", iconName: "duckdb-icon", defaultPort: 9_494, - requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true, - isDownloadable: true, primaryUrlScheme: "duckdb", parameterStyle: .dollar, - navigationModel: .standard, - explainVariants: [ - ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .indentedText), - ], - pathFieldRole: .database, - supportsHealthMonitor: false, urlSchemes: ["duckdb", "quack"], - postConnectActions: [.selectSchemaFromLastSession], - brandColorHex: "#FFD900", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .apiOnly, supportsDatabaseSwitching: true, - supportsColumnReorder: false, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: true, - supportsImport: true, - supportsExport: true, - supportsSSH: false, - supportsSSL: false, - supportsCascadeDrop: false, - supportsForeignKeyDisable: true, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: false, - supportsRenameColumn: true, - supportsConnectionPooling: false, - localFilePathField: .additionalField("duckdbFilePath") - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "main", - defaultGroupName: "main", - tableEntityName: "Tables", - containerEntityName: "Database", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: ["system", "temp"], - systemSchemaNames: [], - fileExtensions: ["duckdb", "ddb", "parquet", "csv", "tsv", "json", "ndjson"], - databaseGroupingStrategy: .bySchema, - structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: duckdbDialect, - statementCompletions: [], - columnTypesByCategory: duckdbColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: Self.duckdbConnectionFields, - category: .analytical, - tagline: String(localized: "Embedded and remote analytical SQL"), - hidesBuiltInPassword: true, - hidesBuiltInDatabase: true - ) - )), ("Beancount", PluginMetadataSnapshot( displayName: "Beancount", iconName: "beancount-icon", defaultPort: 0, requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false, @@ -1202,6 +1145,7 @@ extension PluginMetadataRegistry { ) )), ] + tursoPluginDefaults(dialect: d1Dialect, columnTypes: d1ColumnTypes) + + duckdbPluginDefaults(dialect: duckdbDialect, columnTypes: duckdbColumnTypes) + cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults() + kafkaPluginDefaults() } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 01f6f10ab..dddf4fbfc 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -124,6 +124,9 @@ struct PluginMetadataSnapshot: Sendable { let systemDatabaseNames: [String] let systemSchemaNames: [String] let fileExtensions: [String] + /// Curated in the app rather than declared by the plugin: claiming a format from the + /// system also needs a `CFBundleDocumentTypes` entry only the app bundle can make. + let fileSignatures: [DatabaseFileSignature] let databaseGroupingStrategy: GroupingStrategy let structureColumnFields: [StructureColumnField] @@ -138,6 +141,7 @@ struct PluginMetadataSnapshot: Sendable { systemDatabaseNames: [String], systemSchemaNames: [String], fileExtensions: [String], + fileSignatures: [DatabaseFileSignature] = [], databaseGroupingStrategy: GroupingStrategy, structureColumnFields: [StructureColumnField] ) { @@ -151,6 +155,7 @@ struct PluginMetadataSnapshot: Sendable { self.systemDatabaseNames = systemDatabaseNames self.systemSchemaNames = systemSchemaNames self.fileExtensions = fileExtensions + self.fileSignatures = fileSignatures self.databaseGroupingStrategy = databaseGroupingStrategy self.structureColumnFields = structureColumnFields } @@ -317,6 +322,7 @@ struct PluginMetadataSnapshot: Sendable { systemDatabaseNames: schema.systemDatabaseNames, systemSchemaNames: schema.systemSchemaNames, fileExtensions: schema.fileExtensions, + fileSignatures: schema.fileSignatures, databaseGroupingStrategy: source.schema.databaseGroupingStrategy, structureColumnFields: schema.structureColumnFields ), @@ -610,6 +616,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { systemDatabaseNames: driverType.systemDatabaseNames, systemSchemaNames: driverType.systemSchemaNames, fileExtensions: driverType.fileExtensions, + fileSignatures: existingSnapshot?.schema.fileSignatures ?? [], databaseGroupingStrategy: driverType.databaseGroupingStrategy, structureColumnFields: driverType.structureColumnFields ), @@ -701,6 +708,16 @@ final class PluginMetadataRegistry: @unchecked Sendable { return result } + func allFileSignatures() -> [String: [DatabaseFileSignature]] { + lock.lock() + defer { lock.unlock() } + var result: [String: [DatabaseFileSignature]] = [:] + for (typeId, snapshot) in snapshots where !snapshot.schema.fileSignatures.isEmpty { + result[typeId] = snapshot.schema.fileSignatures + } + return result + } + func allUrlSchemes() -> [String: String] { lock.lock() defer { lock.unlock() } diff --git a/TablePro/Core/Services/Infrastructure/FileOpenPanel.swift b/TablePro/Core/Services/Infrastructure/FileOpenPanel.swift new file mode 100644 index 000000000..ed2841194 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/FileOpenPanel.swift @@ -0,0 +1,53 @@ +// +// FileOpenPanel.swift +// TablePro +// + +import AppKit + +/// The panel behind File > Open File…, offering everything TablePro can open rather than SQL alone. +@MainActor +internal enum FileOpenPanel { + internal static func present() async -> [URL]? { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.message = String(localized: "Select files to open") + + let filter = OpenableFileFilter() + panel.delegate = filter + let response = await panel.begin() + withExtendedLifetime(filter) {} + + guard response == .OK else { return nil } + return panel.urls + } +} + +/// `allowedContentTypes` can only match a name, so it disables the files this panel exists to +/// reach: a SQLite database saved with no extension, or under someone else's. +@MainActor +private final class OpenableFileFilter: NSObject, NSOpenSavePanelDelegate { + /// The panel asks again on every scroll, and deciding costs a read of the file's head. + private var decisions: [URL: Bool] = [:] + + func panel(_ sender: Any, shouldEnable url: URL) -> Bool { + if let decided = decisions[url] { return decided } + let enabled = isOpenable(url) + decisions[url] = enabled + return enabled + } + + /// A plain folder stays enabled so the panel can be navigated. A package is a file as far as + /// the user is concerned, and `.tableplugin` is one, so it is classified like any other. + /// + /// The name is asked first and settles most of a folder without touching its contents. This + /// delegate runs on the main actor, and reading the head of a file on an unavailable mount or + /// an iCloud placeholder takes as long as that mount does, whatever the sixteen bytes suggest. + private func isOpenable(_ url: URL) -> Bool { + let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isPackageKey]) + if values?.isDirectory == true, values?.isPackage != true { return true } + if case .some(.success) = URLClassifier.classifyByName(url) { return true } + return DatabaseFileClassifier.classify(url) != nil + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift index a2d5372e5..ae2325522 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift @@ -6,10 +6,6 @@ import AppKit extension MainSplitViewController { - @objc func openSQLFile(_ sender: Any?) { - commandActions?.openSQLFile() - } - @objc func saveDocument(_ sender: Any?) { commandActions?.saveChanges() } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index d82aec847..15f7906d1 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -85,8 +85,7 @@ extension MainSplitViewController: NSMenuItemValidation { /// itself, so `hasEditorForFind` only ever decides the unfocused fallback. static func isEnabled(_ selector: Selector, context: MenuValidationContext) -> Bool { switch selector { - case #selector(openSQLFile(_:)), - #selector(exportTables(_:)), + case #selector(exportTables(_:)), #selector(refreshDatabase(_:)), #selector(openQuickSwitcher(_:)), #selector(toggleQueryHistory(_:)), diff --git a/TablePro/Core/Services/Infrastructure/SQLFileService.swift b/TablePro/Core/Services/Infrastructure/SQLFileService.swift index 837c444d3..1c984303d 100644 --- a/TablePro/Core/Services/Infrastructure/SQLFileService.swift +++ b/TablePro/Core/Services/Infrastructure/SQLFileService.swift @@ -37,18 +37,6 @@ enum SQLFileService { }.value } - /// Shows an open panel for .sql files. - @MainActor - static func showOpenPanel() async -> [URL]? { - let panel = NSOpenPanel() - panel.allowedContentTypes = allowedContentTypes - panel.allowsMultipleSelection = true - panel.message = String(localized: "Select SQL files to open") - let response = await panel.begin() - guard response == .OK else { return nil } - return panel.urls - } - /// Shows a save panel for .sql files. @MainActor static func showSavePanel(suggestedName: String = "query.sql") async -> URL? { diff --git a/TablePro/Core/Services/Infrastructure/TabRouter.swift b/TablePro/Core/Services/Infrastructure/TabRouter.swift index 3aff6eca8..88fe65c19 100644 --- a/TablePro/Core/Services/Infrastructure/TabRouter.swift +++ b/TablePro/Core/Services/Infrastructure/TabRouter.swift @@ -10,6 +10,7 @@ import os internal enum TabRouterError: Error, LocalizedError { case connectionNotFound(UUID) case malformedDatabaseURL(URL) + case fileNoLongerExists(URL) case userCancelled case unsupportedIntent(String) @@ -23,6 +24,10 @@ internal enum TabRouterError: Error, LocalizedError { return String( format: String(localized: "Could not parse database URL: %@"), url.sanitizedForLogging ) + case .fileNoLongerExists(let url): + return String( + format: String(localized: "“%@” is no longer at that location."), url.lastPathComponent + ) case .userCancelled: return String(localized: "Cancelled by user.") case .unsupportedIntent(let detail): @@ -340,26 +345,42 @@ internal final class TabRouter { // MARK: - Database File + /// A driver that opens a local file keeps the path in whichever field it declares, and DuckDB + /// and libSQL leave `database` empty. Reading it directly missed a live session on the same + /// file, and a second `duckdb_open` is an independent read-write instance whose writes the + /// first one never sees. private func openDatabaseFile(_ url: URL, type: DatabaseType) async throws { let filePath = url.path(percentEncoded: false) let connectionName = url.deletingPathExtension().lastPathComponent + let pathField = PluginManager.shared.localFilePathField(for: type) ?? .database for (sessionId, session) in DatabaseManager.shared.activeSessions where session.connection.type == type - && session.connection.database == filePath + && session.connection.localFilePath(in: pathField) == filePath && session.driver != nil { bringConnectionWindowToFront(sessionId) return } + guard await MissingDriverPluginPrompt.ensureInstalled(for: type, opening: url) else { + throw TabRouterError.userCancelled + } + + /// Installing the driver can take long enough for the file to be renamed or removed under + /// us, and both engines create a database at a path that no longer exists. An empty one + /// left where the original stood is worse than reporting that it is gone. + guard FileManager.default.fileExists(atPath: filePath) else { + throw TabRouterError.fileNoLongerExists(url) + } + let connection = DatabaseConnection( name: connectionName, host: "", port: 0, - database: filePath, + database: "", username: "", type: type - ) + ).substitutingLocalFilePath(filePath, in: pathField) let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) DatabaseManager.shared.registerPendingSession(connection) diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index fc37eba48..45a80eb62 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -28,12 +28,27 @@ private final class EditorWindow: NSWindow, NSDraggingDestination { TabWindowController.applyTitlebarChrome(to: self) } + /// Deciding what a drag carries reads the head of each file, and `draggingUpdated:` fires on + /// every pointer move. A drag session's pasteboard cannot change while it is in flight. + private var dragSessionOperation: NSDragOperation? + func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { - FileDropDestination.acceptedURLs(from: sender.draggingPasteboard).isEmpty ? [] : .copy + let operation: NSDragOperation = + FileDropDestination.acceptedURLs(from: sender.draggingPasteboard).isEmpty ? [] : .copy + dragSessionOperation = operation + return operation } func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { - draggingEntered(sender) + dragSessionOperation ?? draggingEntered(sender) + } + + func draggingExited(_ sender: (any NSDraggingInfo)?) { + dragSessionOperation = nil + } + + func draggingEnded(_ sender: any NSDraggingInfo) { + dragSessionOperation = nil } func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { diff --git a/TablePro/Core/Services/Infrastructure/URLClassifier.swift b/TablePro/Core/Services/Infrastructure/URLClassifier.swift index 096c7b473..8c157a4ce 100644 --- a/TablePro/Core/Services/Infrastructure/URLClassifier.swift +++ b/TablePro/Core/Services/Infrastructure/URLClassifier.swift @@ -22,6 +22,31 @@ internal enum URLClassifier { private static func classifyFile(_ url: URL) -> Result? { let ext = url.pathExtension.lowercased() + /// TablePro's own two are decided by name, and neither carries a signature to read. + if ext == "tableplugin" { + return .success(.installPlugin(url)) + } + if ext == "tablepro" { + return .success(.openConnectionShare(url)) + } + /// Ahead of every other extension, so a database still reaches its driver when it is named + /// `store.bin`, carries no extension, or wears another engine's. + if let dbType = DatabaseFileClassifier.classify(url) { + return .success(.openDatabaseFile(url, dbType)) + } + return classifyByName(url, extension: ext) + } + + /// What the name alone says, with nothing read from disk. The open panel asks this first, so + /// browsing a folder of files it already recognises never touches a network volume. + internal static func classifyByName(_ url: URL) -> Result? { + classifyByName(url, extension: url.pathExtension.lowercased()) + } + + private static func classifyByName( + _ url: URL, + extension ext: String + ) -> Result? { if ext == "tableplugin" { return .success(.installPlugin(url)) } diff --git a/TablePro/Core/Utilities/File/DatabaseFileClassifier.swift b/TablePro/Core/Utilities/File/DatabaseFileClassifier.swift new file mode 100644 index 000000000..4c156d851 --- /dev/null +++ b/TablePro/Core/Utilities/File/DatabaseFileClassifier.swift @@ -0,0 +1,55 @@ +// +// DatabaseFileClassifier.swift +// TablePro +// + +import Foundation + +/// Names the database type that wrote a file, from the file's own bytes rather than its name. +/// +/// Reads only as far as the longest declared signature reaches. Anything that is not a regular +/// file, or is empty, or cannot be opened is unidentified rather than an error, and the caller +/// falls back to the extension. +internal enum DatabaseFileClassifier { + internal static func classify(_ url: URL) -> DatabaseType? { + classify(url, candidates: PluginMetadataRegistry.shared.allFileSignatures()) + } + + internal static func classify( + _ url: URL, + candidates: [String: [DatabaseFileSignature]] + ) -> DatabaseType? { + guard url.isFileURL, !candidates.isEmpty else { return nil } + guard let size = regularFileSize(of: url), size > 0 else { return nil } + + let prefixLength = candidates.values + .flatMap { $0 } + .map(\.requiredPrefixLength) + .max() ?? 0 + guard prefixLength > 0, let prefix = readPrefix(of: url, length: prefixLength) else { return nil } + + /// The longest match wins, and the type id breaks a tie, so the answer never depends on + /// the order the registry's dictionary happens to iterate in. + let matches = candidates.flatMap { typeId, signatures in + signatures.filter { $0.matches(prefix) }.map { (typeId: typeId, weight: $0.totalMarkerLength) } + } + let winner = matches.max { lhs, rhs in + lhs.weight == rhs.weight ? lhs.typeId > rhs.typeId : lhs.weight < rhs.weight + } + return winner.map { DatabaseType(rawValue: $0.typeId) } + } + + /// A FIFO or a device node would block in `open`, so both are refused before anything is opened. + private static func regularFileSize(of url: URL) -> Int? { + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true else { return nil } + return values.fileSize + } + + private static func readPrefix(of url: URL, length: Int) -> [UInt8]? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + guard let head = try? handle.read(upToCount: length), !head.isEmpty else { return nil } + return [UInt8](head) + } +} diff --git a/TablePro/Core/Utilities/File/DatabaseFileSignature.swift b/TablePro/Core/Utilities/File/DatabaseFileSignature.swift new file mode 100644 index 000000000..e7c7ee4f0 --- /dev/null +++ b/TablePro/Core/Utilities/File/DatabaseFileSignature.swift @@ -0,0 +1,51 @@ +// +// DatabaseFileSignature.swift +// TablePro +// + +import Foundation + +/// The byte patterns a file must carry, at fixed offsets from its start, to be one an engine wrote. +/// +/// Declared only for a format whose driver opens the file by path whatever it is named, because +/// that is the only case where recognising it changes anything. Measured against DuckDB 1.5.4: its +/// own storage opens from `warehouse.db`, `plain` and `thing.bin` alike, while a Parquet file under +/// any name but `.parquet` is refused with "not a valid DuckDB database file". sqlite3 never reads +/// the name at all. +/// +/// Every marker has to match, which is what keeps a short one honest: `DUCK` alone is four bytes +/// that `SELECT 'DUCK';` also spells at offset 8. +internal struct DatabaseFileSignature: Sendable, Hashable { + internal struct Marker: Sendable, Hashable { + internal let offset: Int + internal let bytes: [UInt8] + } + + internal let markers: [Marker] + + internal static func magic(_ ascii: String, at offset: Int = 0) -> DatabaseFileSignature { + DatabaseFileSignature(markers: [Marker(offset: offset, bytes: Array(ascii.utf8))]) + } + + internal func andZeroes(at offset: Int, count: Int) -> DatabaseFileSignature { + DatabaseFileSignature( + markers: markers + [Marker(offset: offset, bytes: [UInt8](repeating: 0, count: count))] + ) + } + + internal var requiredPrefixLength: Int { + markers.reduce(0) { max($0, $1.offset + $1.bytes.count) } + } + + internal var totalMarkerLength: Int { + markers.reduce(0) { $0 + $1.bytes.count } + } + + internal func matches(_ prefix: [UInt8]) -> Bool { + markers.allSatisfy { marker in + let end = marker.offset + marker.bytes.count + guard marker.offset >= 0, prefix.count >= end else { return false } + return Array(prefix[marker.offset ..< end]) == marker.bytes + } + } +} diff --git a/TablePro/Core/Utilities/UI/AlertHelper.swift b/TablePro/Core/Utilities/UI/AlertHelper.swift index b9e76225f..3e0801b82 100644 --- a/TablePro/Core/Utilities/UI/AlertHelper.swift +++ b/TablePro/Core/Utilities/UI/AlertHelper.swift @@ -80,6 +80,26 @@ final class AlertHelper { } } + // MARK: - Confirmations + + /// A question whose confirming button keeps Return. `confirmDestructive` takes it off on + /// purpose, which is right for destroying something and wrong for a step already asked for. + static func confirm( + title: String, + message: String, + confirmButton: String, + cancelButton: String = String(localized: "Cancel"), + window: NSWindow? = nil + ) async -> Bool { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .informational + alert.addButton(withTitle: confirmButton) + Self.addCancelButton(to: alert, title: cancelButton) + return await run(alert, in: window) == .alertFirstButtonReturn + } + // MARK: - Destructive Confirmations static func confirmDestructive( diff --git a/TablePro/Info.plist b/TablePro/Info.plist index 0318102b1..7868481a6 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -109,6 +109,24 @@ org.duckdb.duckdb-database + + CFBundleTypeIconSystemGenerated + + CFBundleTypeName + Apache Parquet File + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + + parquet + + LSItemContentTypes + + org.apache.parquet + + CFBundleTypeName Beancount Ledger @@ -222,6 +240,24 @@ + + UTTypeIdentifier + org.apache.parquet + UTTypeDescription + Apache Parquet File + UTTypeConformsTo + + public.database + public.data + + UTTypeTagSpecification + + public.filename-extension + + parquet + + + UTExportedTypeDeclarations diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index d25210649..d44c60ca9 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -1012,13 +1012,6 @@ final class MainContentCommandActions { } } - func openSQLFile() { - Task { - guard let urls = await SQLFileService.showOpenPanel() else { return } - AppCommands.shared.openSQLFiles.send(urls) - } - } - func explainQuery() { coordinator?.runExplain() } diff --git a/TableProTests/Core/Plugins/DocumentTypeDeclarationTests.swift b/TableProTests/Core/Plugins/DocumentTypeDeclarationTests.swift new file mode 100644 index 000000000..3e1cad6ef --- /dev/null +++ b/TableProTests/Core/Plugins/DocumentTypeDeclarationTests.swift @@ -0,0 +1,67 @@ +// +// DocumentTypeDeclarationTests.swift +// TableProTests +// +// Every file format the app claims from LaunchServices has to be declared twice, as a document +// type and as the imported UTI it names, or Finder never offers TablePro for it. +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +@Suite("Bundle document type declarations") +struct DocumentTypeDeclarationTests { + private func infoPlist() throws -> [String: Any] { + let plistURL = Bundle(for: AppDelegate.self) + .bundleURL + .appendingPathComponent("Contents/Info.plist") + let data = try Data(contentsOf: plistURL) + let plistObject = try PropertyListSerialization.propertyList(from: data, format: nil) + return try #require(plistObject as? [String: Any]) + } + + private func documentType(forContentType contentType: String) throws -> [String: Any] { + let documentTypes = try #require(try infoPlist()["CFBundleDocumentTypes"] as? [[String: Any]]) + return try #require(documentTypes.first { + ($0["LSItemContentTypes"] as? [String])?.contains(contentType) == true + }) + } + + private func importedType(_ identifier: String) throws -> [String: Any] { + let imported = try #require(try infoPlist()["UTImportedTypeDeclarations"] as? [[String: Any]]) + return try #require(imported.first { $0["UTTypeIdentifier"] as? String == identifier }) + } + + @Test("Parquet is claimed as a read-only alternate handler") + func claimsParquet() throws { + let documentType = try documentType(forContentType: "org.apache.parquet") + + #expect(documentType["CFBundleTypeExtensions"] as? [String] == ["parquet"]) + /// DuckDB opens a Parquet file as read-only views, so Viewer is the honest role, and no + /// app owns the format, so Alternate leaves a dedicated tool its association. + #expect(documentType["CFBundleTypeRole"] as? String == "Viewer") + #expect(documentType["LSHandlerRank"] as? String == "Alternate") + + let tags = try #require(try importedType("org.apache.parquet")["UTTypeTagSpecification"] as? [String: Any]) + #expect(tags["public.filename-extension"] as? [String] == ["parquet"]) + } + + @Test("Every extension a signature-bearing driver declares is claimed by the bundle", arguments: [ + ("org.sqlite.sqlite", "SQLite"), + ("org.duckdb.duckdb-database", "DuckDB") + ]) + func claimsEveryDeclaredExtension(contentType: String, typeId: String) throws { + let claimed = try #require(try documentType(forContentType: contentType)["CFBundleTypeExtensions"] as? [String]) + let tags = try #require(try importedType(contentType)["UTTypeTagSpecification"] as? [String: Any]) + + #expect(tags["public.filename-extension"] as? [String] == claimed) + #expect(!claimed.isEmpty) + + let declared = PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: typeId)?.schema.fileExtensions ?? [] + for fileExtension in claimed { + #expect(declared.contains(fileExtension), "the driver does not open .\(fileExtension)") + } + } +} diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift index 0d7268c25..f4f625ead 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift @@ -74,6 +74,18 @@ struct PluginMetadataRegistryCuratedCapabilityTests { ) } + @Test("DuckDB keeps its file signatures when its plugin registers") + func duckDBKeepsItsFileSignatures() { + let registry = PluginMetadataRegistry.shared + + let built = registry.buildMetadataSnapshot(from: MockDuckDBPlugin.self) + + #expect( + built.schema.fileSignatures == [.magic("DUCK", at: 8).andZeroes(at: 14, count: 6)], + "No DriverPlugin declares a signature, so loading the plugin would otherwise erase it" + ) + } + @Test("MongoDB keeps its database-scoped authentication when its plugin registers") func mongoDBKeepsDatabaseScopedAuthentication() { let registry = PluginMetadataRegistry.shared diff --git a/TableProTests/Core/Services/FileDropDestinationTests.swift b/TableProTests/Core/Services/FileDropDestinationTests.swift index 9a7015810..955413511 100644 --- a/TableProTests/Core/Services/FileDropDestinationTests.swift +++ b/TableProTests/Core/Services/FileDropDestinationTests.swift @@ -36,6 +36,20 @@ struct FileDropDestinationTests { #expect(FileDropDestination.acceptedURLs(from: pasteboard).isEmpty) } + @Test("A SQLite database with no extension is openable") + func extensionlessDatabaseIsOpenable() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("FileDropDestinationTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let header = Array("SQLite format 3\u{0}".utf8) + let url = directory.appendingPathComponent("ledger") + try Data(header + [UInt8](repeating: 0, count: 512 - header.count)).write(to: url) + + #expect(FileDropDestination.isOpenable(url)) + } + @Test("A pasteboard of mixed files yields only the openable ones") func mixedPasteboardIsFiltered() { let pasteboard = NSPasteboard(name: NSPasteboard.Name("com.TablePro.tests.filedrop.mixed")) diff --git a/TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift b/TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift index a8c97c752..798040695 100644 --- a/TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift +++ b/TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift @@ -97,4 +97,109 @@ struct URLClassifierTests { let intent = URLClassifier.classify(URL(fileURLWithPath: "/tmp/file.xyz")) #expect(intent == nil) } + + // MARK: - Contents + + private func withDatabaseFile( + named name: String, + bytes: [UInt8], + body: (URL) throws -> T + ) throws -> T { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("URLClassifierTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent(name) + try Data(bytes).write(to: url) + return try body(url) + } + + private var sqliteBytes: [UInt8] { + let header = Array("SQLite format 3\u{0}".utf8) + return header + [UInt8](repeating: 0, count: 512 - header.count) + } + + private var parquetBytes: [UInt8] { + Array("PAR1".utf8) + [UInt8](repeating: 0x2A, count: 128) + Array("PAR1".utf8) + } + + @Test("A SQLite database opens by its contents whatever it is named", arguments: [ + "ledger", "store.bin", "notes.csv", "query.sql" + ]) + func contentsIdentifyASQLiteDatabase(name: String) throws { + try withDatabaseFile(named: name, bytes: sqliteBytes) { url in + let intent = withInspectorState(lazy: ["csv": URL(fileURLWithPath: "/tmp/stub.tableplugin")]) { + URLClassifier.classify(url) + } + guard case .some(.success(.openDatabaseFile(let routed, let dbType))) = intent else { + Issue.record("Expected .openDatabaseFile, got \(String(describing: intent))") + return + } + #expect(routed == url) + #expect(dbType == .sqlite) + } + } + + /// The DuckDB driver picks a data format's reader from the extension, so a Parquet file has + /// to keep its name to be openable at all. Claiming one by its `PAR1` markers would route a + /// file that then fails at connect with "not a valid DuckDB database file". + @Test("A Parquet file under another name is not claimed") + func parquetIsNotClaimedByContents() throws { + try withDatabaseFile(named: "export.bin", bytes: parquetBytes) { url in + #expect(URLClassifier.classify(url) == nil) + } + } + + @Test("A Parquet file keeping its extension still routes to DuckDB") + func parquetRoutesByExtension() throws { + try withDatabaseFile(named: "export.parquet", bytes: parquetBytes) { url in + let intent = withInspectorState(lazy: [:]) { URLClassifier.classify(url) } + guard case .some(.success(.openDatabaseFile(_, let dbType))) = intent else { + Issue.record("Expected .openDatabaseFile, got \(String(describing: intent))") + return + } + #expect(dbType == .duckdb) + } + } + + @Test("A SQL file whose text spells DUCK at offset 8 still opens in the editor") + func sqlSpellingTheDuckDBMarkerStillRoutesToTheEditor() throws { + try withDatabaseFile(named: "query.sql", bytes: Array("SELECT 'DUCK';\n".utf8)) { url in + guard case .some(.success(.openSQLFile(let routed))) = URLClassifier.classify(url) else { + Issue.record("Expected .openSQLFile") + return + } + #expect(routed == url) + } + } + + @Test("Name-only classification never reads the file") + func classifyByNameAnswersFromTheNameAlone() throws { + try withDatabaseFile(named: "ledger", bytes: sqliteBytes) { url in + #expect(URLClassifier.classifyByName(url) == nil) + } + try withDatabaseFile(named: "report.sql", bytes: sqliteBytes) { url in + guard case .some(.success(.openSQLFile)) = URLClassifier.classifyByName(url) else { + Issue.record("Expected .openSQLFile from the name alone") + return + } + } + } + + @Test("A file TablePro cannot read is still unrecognised") + func contentsDoNotRescueAnUnknownBinary() throws { + try withDatabaseFile(named: "photo.jpeg", bytes: [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]) { url in + #expect(URLClassifier.classify(url) == nil) + } + } + + @Test("TablePro's own file types are decided by name, not contents") + func ownTypesKeepTheirExtensions() throws { + try withDatabaseFile(named: "shared.tablepro", bytes: sqliteBytes) { url in + guard case .some(.success(.openConnectionShare)) = URLClassifier.classify(url) else { + Issue.record("Expected .openConnectionShare") + return + } + } + } } diff --git a/TableProTests/Core/Utilities/DatabaseFileClassifierTests.swift b/TableProTests/Core/Utilities/DatabaseFileClassifierTests.swift new file mode 100644 index 000000000..e40c3046f --- /dev/null +++ b/TableProTests/Core/Utilities/DatabaseFileClassifierTests.swift @@ -0,0 +1,143 @@ +// +// DatabaseFileClassifierTests.swift +// TableProTests +// +// The magic bytes were read off files the engines wrote, not off documentation: sqlite3 3.43 and +// DuckDB 1.5.4, which also produced the Parquet file the negative case uses. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Database file classifier") +struct DatabaseFileClassifierTests { + private static let sqliteHeader = Array("SQLite format 3\u{0}".utf8) + + private static let candidates: [String: [DatabaseFileSignature]] = [ + "SQLite": [.magic("SQLite format 3\u{0}")], + "DuckDB": [.magic("DUCK", at: 8).andZeroes(at: 14, count: 6)] + ] + + private func withTemporaryDirectory(_ body: (URL) throws -> T) throws -> T { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("DatabaseFileClassifierTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + return try body(directory) + } + + private func write(_ bytes: [UInt8], named name: String, in directory: URL) throws -> URL { + let url = directory.appendingPathComponent(name) + try Data(bytes).write(to: url) + return url + } + + private func sqliteBytes() -> [UInt8] { + Self.sqliteHeader + [UInt8](repeating: 0, count: 512 - Self.sqliteHeader.count) + } + + /// The header DuckDB 1.5.4 actually wrote: eight checksum bytes, `DUCK`, then storage version + /// 64 as a little-endian `uint64`, then the flags. + private func duckdbBytes() -> [UInt8] { + let checksum: [UInt8] = [0x8B, 0x88, 0x5D, 0xF0, 0xBA, 0x46, 0x13, 0x98] + let version: [UInt8] = [0x40, 0, 0, 0, 0, 0, 0, 0] + return checksum + Array("DUCK".utf8) + version + [UInt8](repeating: 0, count: 64) + } + + private func parquetBytes() -> [UInt8] { + Array("PAR1".utf8) + [UInt8](repeating: 0x2A, count: 128) + Array("PAR1".utf8) + } + + private func classify(_ url: URL) -> DatabaseType? { + DatabaseFileClassifier.classify(url, candidates: Self.candidates) + } + + @Test("A SQLite database is identified whatever it is called", arguments: [ + "data.sqlite", "app.sqlite3", "store.bin", "ledger", "notes.csv", "query.sql" + ]) + func identifiesSQLiteByContents(name: String) throws { + try withTemporaryDirectory { directory in + let url = try write(sqliteBytes(), named: name, in: directory) + #expect(classify(url) == .sqlite) + } + } + + @Test("A DuckDB database named .db is identified as DuckDB, not SQLite") + func identifiesDuckDBUnderABorrowedExtension() throws { + try withTemporaryDirectory { directory in + let url = try write(duckdbBytes(), named: "warehouse.db", in: directory) + #expect(classify(url) == .duckdb) + } + } + + /// `duckdb_open` picks the reader for a data format from the extension, so a Parquet file + /// under any other name is refused with "not a valid DuckDB database file". Recognising one by + /// its `PAR1` markers would route a file the driver then cannot open. Measured on DuckDB 1.5.4. + @Test("A Parquet file is left to its extension, because DuckDB cannot open one without it") + func doesNotClaimParquetByContents() throws { + try withTemporaryDirectory { directory in + let renamed = try write(parquetBytes(), named: "export.bin", in: directory) + let named = try write(parquetBytes(), named: "export.parquet", in: directory) + #expect(classify(renamed) == nil) + #expect(classify(named) == nil) + } + } + + /// `DUCK` alone is four bytes that ordinary SQL spells at exactly that offset, which is why + /// the signature also requires the storage version's high bytes to be zero. + @Test("SQL that happens to spell DUCK at offset 8 is not a DuckDB database", arguments: [ + "SELECT 'DUCK';\n", "SELECT 'DUCK' AS bird FROM flock WHERE id = 1;\n" + ]) + func doesNotMistakeSQLForDuckDB(sql: String) throws { + try withTemporaryDirectory { directory in + let url = try write(Array(sql.utf8), named: "query.sql", in: directory) + #expect(classify(url) == nil) + } + } + + @Test("Text, an empty file and a directory are all unidentified") + func leavesEverythingElseToTheExtension() throws { + try withTemporaryDirectory { directory in + let text = try write(Array("SELECT 1;\n".utf8), named: "query.sql", in: directory) + let empty = try write([], named: "empty.sqlite", in: directory) + #expect(classify(text) == nil) + #expect(classify(empty) == nil) + #expect(classify(directory) == nil) + } + } + + @Test("A file shorter than the signature it would match is unidentified") + func rejectsATruncatedHeader() throws { + try withTemporaryDirectory { directory in + let url = try write(Array("SQLite fo".utf8), named: "truncated.sqlite", in: directory) + #expect(classify(url) == nil) + } + } + + @Test("A file that does not exist is unidentified rather than an error") + func missingFileIsUnidentified() { + #expect(classify(URL(fileURLWithPath: "/tmp/does-not-exist-\(UUID().uuidString).sqlite")) == nil) + } + + @Test("A remote URL is never read") + func remoteURLIsUnidentified() throws { + let url = try #require(URL(string: "https://example.com/data.sqlite")) + #expect(classify(url) == nil) + } + + @Test("With nothing declaring a signature, no file is identified") + func noCandidatesIdentifiesNothing() throws { + try withTemporaryDirectory { directory in + let url = try write(sqliteBytes(), named: "data.sqlite", in: directory) + #expect(DatabaseFileClassifier.classify(url, candidates: [:]) == nil) + } + } + + @Test("SQLite and DuckDB are what the registry actually declares") + func registryDeclaresTheShippedSignatures() { + let signatures = PluginMetadataRegistry.shared.allFileSignatures() + #expect(signatures["SQLite"] == [.magic("SQLite format 3\u{0}")]) + #expect(signatures["DuckDB"] == [.magic("DUCK", at: 8).andZeroes(at: 14, count: 6)]) + } +} diff --git a/docs/databases/duckdb.mdx b/docs/databases/duckdb.mdx index a5cbbf3ea..ddd004b65 100644 --- a/docs/databases/duckdb.mdx +++ b/docs/databases/duckdb.mdx @@ -36,7 +36,11 @@ Pick a `.duckdb` file to open a database, or a `.parquet`, `.csv`, `.tsv`, `.jso | `.parquet`, `.csv`, `.tsv`, `.json`, `.ndjson` | An in-memory database holding two read-only views over the file, one named `file` and one named after the file. A path that does not exist is reported instead of created | | `:memory:` | An empty database, discarded on disconnect | -In Local File mode the path is the whole connection, and it is required; the form shows no host, port or Database field. Double-click a `.duckdb` or `.ddb` file in Finder to open it in TablePro; the data formats are not registered with Finder. +In Local File mode the path is the whole connection, and it is required; the form shows no host, port or Database field. + +Double-click a `.duckdb` or `.ddb` file in Finder to open it here. `.parquet` files list TablePro under **Open With** rather than taking the association; to make a double-click open them here, select one in Finder, press `Cmd+I`, set **Open with** to TablePro, and click **Change All…**. `.csv`, `.tsv`, `.json` and `.ndjson` open from the connection form only. + +A DuckDB database carries `DUCK` eight bytes in, so a database opens under any name: drag `warehouse.db` onto a window and it reaches this driver rather than SQLite. The data formats need their extension either way, because the reader is chosen from it. Installing the plugin is offered first when it is not already there. ## Remote (Quack) diff --git a/docs/databases/sqlite.mdx b/docs/databases/sqlite.mdx index edf20eb76..ad394fe54 100644 --- a/docs/databases/sqlite.mdx +++ b/docs/databases/sqlite.mdx @@ -11,6 +11,8 @@ Click **Create Connection…**, select **SQLite**, pick the file with **Browse `.db`, `.db3`, `.s3db`, `.sl3`, `.sqlite`, `.sqlite3`, and `.sqlitedb` files list TablePro under Finder's **Open With**, as an alternate handler rather than the default one. To make a double-click open them here, select one in Finder, press `Cmd+I`, set **Open with** to TablePro, and click **Change All…**. +A database saved under some other name still opens. Drag it onto a TablePro window, pick it in **File > Open File…**, or drop it on the Dock icon, and the first sixteen bytes decide the driver: a file that starts `SQLite format 3` opens here whether it is called `store.bin`, `export`, or `data.csv`. Finder's double-click is the one route that still needs a name it knows. + SQLite connection form with file path field SQLite connection form with file path field diff --git a/docs/features/sql-files.mdx b/docs/features/sql-files.mdx index 13cb2820e..87d3312b6 100644 --- a/docs/features/sql-files.mdx +++ b/docs/features/sql-files.mdx @@ -5,6 +5,8 @@ description: Open .sql files as query tabs, save back to them, and resolve edits Three ways in: double-click in Finder, **File > Open File…** (`Cmd+O`), or drag onto the Dock icon. `.sql`, `.psql` and `.pgsql` all work, and what you get is an ordinary query tab with a file behind it. +`Cmd+O` is not restricted to those three. It offers every file TablePro reads, and each one goes where its type belongs: a query tab for SQL, a connection window for a database file, the [CSV inspector](/features/csv-inspector) for a `.csv`. A file whose name says nothing is enabled on its contents, so a SQLite database saved without an extension is selectable. The command works with no connection open, from the welcome window too. + ## Opening Each file opens in a new tab. Opening one that is already open focuses its existing tab instead of making a second, and files opened before any connection exists wait in a queue until you connect.