diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dbc18fb7..ba027b98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Stale error banner over a pinned result after clearing the results of a failed query. +- `DROP TABLE` and `TRUNCATE TABLE` generated for Elasticsearch, Kafka, Weaviate and etcd, which have no SQL. (#2884) +- Delete and Truncate offered on engines that have no statement for them. +- Truncate on a Redis database emptying whichever database the connection was on. +- Base64 text instead of the request in the Typesense drop and truncate confirmation. - Imported connections pointing at an SSH profile that is not on the importing Mac. - Syntax highlighting falling a second or two behind while typing quickly in the SQL editor. - Beep and a question-mark badge when pressing `Ctrl+Cmd+J` in the SQL editor. diff --git a/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateOperations.swift b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateOperations.swift new file mode 100644 index 000000000..e8867215f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateOperations.swift @@ -0,0 +1,31 @@ +// +// WeaviateOperations.swift +// TableProWeaviateCore +// + +import Foundation + +public enum WeaviateOperations { + /// The console request that removes one collection, in the text `WeaviateConsoleParser` reads. + /// + /// Plain console text rather than the tagged write form, because the confirmation dialog shows + /// the statement verbatim and `QueryClassifier` reads the leading verb to tier it destructive. + /// What the user approves, what the gate classifies and what runs are then one string. + public static func deleteCollection(named name: String, objectType: String) -> String? { + guard isCollectionObject(objectType), isClassName(name) else { return nil } + return "DELETE /v1/schema/\(name)" + } + + /// Weaviate has only collections, so any other kind the app asks about is not something this + /// engine drops, and answering anyway would delete the collection of that name instead. + public static func isCollectionObject(_ objectType: String) -> Bool { + objectType.uppercased() == "TABLE" + } + + /// A Weaviate class name is a GraphQL identifier. A name holding anything else did not come + /// from the schema listing, so it is refused rather than pasted into the request path. + public static func isClassName(_ name: String) -> Bool { + guard let first = name.first, first.isLetter else { return false } + return name.allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" } + } +} diff --git a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateOperationsTests.swift b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateOperationsTests.swift new file mode 100644 index 000000000..c85fd4495 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateOperationsTests.swift @@ -0,0 +1,34 @@ +import Foundation +@testable import TableProWeaviateCore +import Testing + +@Suite("Weaviate object operations") +struct WeaviateOperationsTests { + @Test("Deleting a collection is the native REST request") + func deleteCollectionIsNative() { + #expect(WeaviateOperations.deleteCollection(named: "Article", objectType: "TABLE") == "DELETE /v1/schema/Article") + } + + /// The statement is shown in the confirmation and then run, so one the driver's own parser + /// rejects is the #2884 defect in another engine. + @Test("The generated statement parses as the request it names") + func statementRoundTrips() throws { + let statement = try #require(WeaviateOperations.deleteCollection(named: "Article", objectType: "TABLE")) + let request = try #require(WeaviateConsoleParser.parse(statement)) + #expect(request.method == "DELETE") + #expect(request.path == "/v1/schema/Article") + #expect(request.body == nil) + } + + @Test("A name that is not a class name is refused", arguments: [ + "", "1Article", "_Article", "Article/x", "Article Name", "Article,Other", "*", + ]) + func nonClassNamesRefused(name: String) { + #expect(WeaviateOperations.deleteCollection(named: name, objectType: "TABLE") == nil) + } + + @Test("Only a collection is droppable", arguments: ["VIEW", "MATERIALIZED VIEW", "SYSTEM TABLE"]) + func onlyCollectionsAreDroppable(objectType: String) { + #expect(WeaviateOperations.deleteCollection(named: "Article", objectType: objectType) == nil) + } +} diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchOperations.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchOperations.swift new file mode 100644 index 000000000..7fcb6afde --- /dev/null +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchOperations.swift @@ -0,0 +1,48 @@ +// +// ElasticsearchOperations.swift +// ElasticsearchDriverPlugin +// +// The object operations the app offers, as the console requests the driver already runs. +// + +import Foundation + +enum ElasticsearchOperations { + /// The request that deletes one index, in the console's own text. + /// + /// Plain `DELETE /` rather than the tagged, base64 form `ElasticsearchStatementGenerator` + /// uses for row writes, and deliberately: the confirmation dialog shows the statement verbatim, + /// so the tagged form would ask the user to approve unreadable base64, and `QueryClassifier` + /// reads the leading verb to tier a statement as destructive. The console parser accepts this, + /// so what is shown, what is classified and what runs are the same string. + static func deleteIndex(named name: String, objectType: String) -> String? { + guard isIndexObject(objectType), let path = singleIndexPath(name) else { return nil } + return "DELETE \(path)" + } + + /// Only an index. Elasticsearch has no views or materialized views, so anything else the app + /// asks about is not something this engine can drop. + static func isIndexObject(_ objectType: String) -> Bool { + objectType.uppercased() == "TABLE" + } + + /// The path for exactly one index, or nil where the name could name more than one. + /// + /// `DELETE /` takes a comma-separated list and wildcards, and a cluster that has turned + /// `action.destructive_requires_name` off will act on every match, so `DELETE /*` empties it. + /// A real index name can hold none of these characters, so refusing them costs nothing and + /// means a name that arrived from somewhere other than the index listing cannot widen the + /// request. + static func singleIndexPath(_ name: String) -> String? { + guard !name.isEmpty, name != "_all", !name.hasPrefix("-") else { return nil } + guard name.rangeOfCharacter(from: forbiddenInSingleIndex) == nil else { return nil } + let encoded = name.addingPercentEncoding(withAllowedCharacters: pathComponentAllowed) ?? name + return "/\(encoded)" + } + + private static let forbiddenInSingleIndex = CharacterSet(charactersIn: "*,?/\\<>|\"# ") + .union(.whitespacesAndNewlines) + + private static let pathComponentAllowed: CharacterSet = + .urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")) +} diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift index e5e60d61f..d3d923e01 100644 --- a/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift @@ -202,6 +202,20 @@ internal final class ElasticsearchPluginDriver: PluginDatabaseDriver, @unchecked ) } + // MARK: - Table Operations + + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { + ElasticsearchOperations.deleteIndex(named: name, objectType: objectType) + } + + /// Elasticsearch has no truncate. `_delete_by_query` runs asynchronously, reports version + /// conflicts per document and leaves the mapping behind, so it is a bulk delete rather than the + /// operation the app's Truncate promises. Answering nil keeps the command off the menu instead + /// of offering one that means something else. + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { + nil + } + // MARK: - Statement Generation func generateStatements( diff --git a/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift b/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift index 44a06ce49..1ee95d2fa 100644 --- a/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift +++ b/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift @@ -60,7 +60,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return "get \(escapeArgument(prefix)) --prefix" } - func truncateTableStatements(table: String, cascade: Bool) -> [String]? { + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { let prefix = resolvedPrefix(for: table) if prefix.isEmpty { return ["del \"\" --prefix"] @@ -68,7 +68,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return ["del \(escapeArgument(prefix)) --prefix"] } - func dropObjectStatement(name: String, type: String) -> String? { + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { let prefix = resolvedPrefix(for: name) if prefix.isEmpty { return "del \"\" --prefix" @@ -146,7 +146,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Streaming func streamRows(query: String) -> AsyncThrowingStream { - return AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in + AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in let streamTask = Task { do { try await self.performStreamRows(query: query, continuation: continuation) @@ -388,7 +388,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - return PluginTableMetadata( + PluginTableMetadata( tableName: table, engine: "etcd v3" ) diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 840f2e1a1..a8309676e 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -662,6 +662,12 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { "db.getCollection(\"\(escapeJsonString(name))\").drop()" } + /// `deleteMany({})` empties the collection and leaves it, its indexes and its options in place, + /// which is what Truncate means. `drop()` would take all three. + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { + ["db.getCollection(\"\(escapeJsonString(table))\").deleteMany({})"] + } + func dropDatabase(name: String) async throws { guard let conn = mongoConnection else { throw MongoDBPluginError.notConnected diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 703c6aa24..fd2729864 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -398,14 +398,24 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Table Operations + /// `FLUSHDB` empties whichever database the session is on and names none of its own, so it is + /// only the right statement for the row the session already points at. The rows here are the + /// server's databases, and the connection does not switch between them + /// (`supportsDatabaseSwitching` is false), so a `FLUSHDB` staged from another row emptied the + /// current database and reported success. Refusing it is what `DatabaseManager.pin` already + /// does for a tab on a database the session cannot reach. func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { - ["FLUSHDB"] + guard let conn = redisConnection else { return nil } + guard conn.supportsDatabaseSelection else { + return table == Self.clusterDatabaseName ? ["FLUSHDB"] : nil + } + guard let index = RedisDatabaseIndex.parse(table), index == conn.currentDatabase() else { return nil } + return ["FLUSHDB"] } + /// Redis databases are pre-allocated, so there is nothing to drop and no statement to write. func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { - // Redis databases are pre-allocated and cannot be dropped. - // Return empty string to prevent adapter from synthesizing SQL DROP. - "" + nil } // MARK: - EXPLAIN diff --git a/Plugins/TypesenseDriverPlugin/TypesenseOperations.swift b/Plugins/TypesenseDriverPlugin/TypesenseOperations.swift index 10cf051f7..55fdd1dbe 100644 --- a/Plugins/TypesenseDriverPlugin/TypesenseOperations.swift +++ b/Plugins/TypesenseDriverPlugin/TypesenseOperations.swift @@ -49,6 +49,12 @@ enum TypesenseOperations { ) } + /// A request as the console would be typed, which is what the driver's own parser reads and + /// what the confirmation dialog can show a person. Only for a request that carries no body. + static func consoleText(_ request: TypesenseWriteRequest) -> String { + "\(request.method) \(request.path)" + } + /// `truncate=true` empties the collection and keeps its schema, which is what TRUNCATE means. /// Deleting by a match-everything filter would drop the schema's learned fields with it. static func truncateCollection(named name: String) -> TypesenseWriteRequest { diff --git a/Plugins/TypesenseDriverPlugin/TypesensePluginDriver.swift b/Plugins/TypesenseDriverPlugin/TypesensePluginDriver.swift index e57d75e16..623b2f8b3 100644 --- a/Plugins/TypesenseDriverPlugin/TypesensePluginDriver.swift +++ b/Plugins/TypesenseDriverPlugin/TypesensePluginDriver.swift @@ -211,13 +211,18 @@ internal final class TypesensePluginDriver: PluginDatabaseDriver, @unchecked Sen TypesenseOperations.encodeExport(collection: table) } + /// The console's own text, not the tagged form `TypesenseStatementGenerator` uses for row + /// writes. The confirmation dialog shows the statement verbatim, so the tagged form asked the + /// user to approve base64, and `QueryClassifier` reads the leading verb to tier a statement as + /// destructive, which a tagged blob defeated: a collection drop classified as an ordinary write + /// and skipped the destructive gate. func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { TypesenseOperations.dropCollection(named: name, objectType: objectType) - .map(TypesenseStatementGenerator.encode) + .map(TypesenseOperations.consoleText) } func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { - [TypesenseStatementGenerator.encode(TypesenseOperations.truncateCollection(named: table))] + [TypesenseOperations.consoleText(TypesenseOperations.truncateCollection(named: table))] } // MARK: - Statement Generation diff --git a/Plugins/WeaviateDriverPlugin/WeaviatePluginDriver.swift b/Plugins/WeaviateDriverPlugin/WeaviatePluginDriver.swift index 7f869e31f..2706908e0 100644 --- a/Plugins/WeaviateDriverPlugin/WeaviatePluginDriver.swift +++ b/Plugins/WeaviateDriverPlugin/WeaviatePluginDriver.swift @@ -109,6 +109,19 @@ internal final class WeaviatePluginDriver: PluginDatabaseDriver, @unchecked Send ) } + // MARK: - Table Operations + + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { + WeaviateOperations.deleteCollection(named: name, objectType: objectType) + } + + /// Weaviate empties a collection by deleting its objects by filter, which needs a `where` the + /// app has no way to supply here, so Truncate is not offered rather than offered as a delete + /// that removes the collection too. + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { + nil + } + func typeName(for column: String, collection: WeaviateCollection) -> String { switch column { case WeaviateSchema.uuidColumn: diff --git a/TablePro/Core/DataWrite/DataWriteError.swift b/TablePro/Core/DataWrite/DataWriteError.swift index 9f206c5a1..5d911c4d2 100644 --- a/TablePro/Core/DataWrite/DataWriteError.swift +++ b/TablePro/Core/DataWrite/DataWriteError.swift @@ -8,6 +8,7 @@ import Foundation enum DataWriteError: LocalizedError, Equatable { case statementGenerationUnavailable(String) case statementGenerationFailed(String) + case objectOperationUnsupported(String) case rowsNotIdentifiable(String, RowWriteKind) case identityNotPreservable(String) case tooManyRowsAffected(table: String, expected: Int, actual: Int) @@ -26,6 +27,11 @@ enum DataWriteError: LocalizedError, Equatable { format: String(localized: "Could not generate SQL for '%@'."), table ) + case .objectOperationUnsupported(let object): + return String( + format: String(localized: "This database has no statement for '%@'."), + object + ) case .rowsNotIdentifiable(let table, let kind): switch kind { case .update: diff --git a/TablePro/Core/Database/TableOperationSQLBuilder.swift b/TablePro/Core/Database/TableOperationSQLBuilder.swift index 9c484cbe8..472a9849f 100644 --- a/TablePro/Core/Database/TableOperationSQLBuilder.swift +++ b/TablePro/Core/Database/TableOperationSQLBuilder.swift @@ -15,6 +15,11 @@ struct TableOperationSQLBuilder { /// Every statement is built from the queued reference alone. /// + /// Throws rather than skipping a ref the engine has no statement for. The menus already keep + /// one off the queue, but a driver answers from live session state, so a queued Redis truncate + /// can stop being expressible between staging and Save. Dropping it silently would run the + /// rest of the plan and report every staged object as done, including the one nothing touched. + /// /// The schema and the object's keyword used to be looked up in the connection's flat table /// cache, which publishes nothing at all on an engine whose tree is per-schema: on Oracle, /// Dameng, Trino, Snowflake and BigQuery every drop came out unqualified and typed `TABLE`, @@ -25,7 +30,7 @@ struct TableOperationSQLBuilder { deletes: Set, options: [DatabaseTreeTableRef: TableOperationOptions], includeFKHandling: Bool = true - ) -> [String] { + ) throws -> [String] { var statements: [String] = [] let sortedTruncates = truncates.sorted { $0.id < $1.id } let sortedDeletes = deletes.sorted { $0.id < $1.id } @@ -39,16 +44,18 @@ struct TableOperationSQLBuilder { } for ref in sortedTruncates { - statements.append(contentsOf: truncateStatements( - ref, options: options[ref] ?? TableOperationOptions() - )) + guard let staged = truncateStatements(ref, options: options[ref] ?? TableOperationOptions()) else { + throw DataWriteError.objectOperationUnsupported(ref.table.name) + } + statements.append(contentsOf: staged) } for ref in sortedDeletes { - let stmt = dropObjectStatement(ref, options: options[ref] ?? TableOperationOptions()) - if !stmt.isEmpty { - statements.append(stmt) + guard let stmt = dropObjectStatement(ref, options: options[ref] ?? TableOperationOptions()) else { + throw DataWriteError.objectOperationUnsupported(ref.table.name) } + guard !stmt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + statements.append(stmt) } if needsDisableFK { @@ -68,8 +75,8 @@ struct TableOperationSQLBuilder { private func truncateStatements( _ ref: DatabaseTreeTableRef, options: TableOperationOptions - ) -> [String] { - guard let adapter = adapterProvider() else { return [] } + ) -> [String]? { + guard let adapter = adapterProvider() else { return nil } return adapter.truncateTableStatements( table: ref.table.name, schema: ref.qualifyingSchema, cascade: options.cascade ) @@ -77,8 +84,8 @@ struct TableOperationSQLBuilder { private func dropObjectStatement( _ ref: DatabaseTreeTableRef, options: TableOperationOptions - ) -> String { - guard let adapter = adapterProvider() else { return "" } + ) -> String? { + guard let adapter = adapterProvider() else { return nil } return adapter.dropObjectStatement( name: ref.table.name, objectType: TableObjectKeyword.forDDL(ref.table.type), diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 9eec7ae34..09e916a6e 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -794,24 +794,65 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor // MARK: - Table Operations - func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String] { + /// Nil where the engine has no way to say it. The driver's own answer wins; the app builds the + /// statement only for an engine whose DDL it can actually write, per `SQLDDLFallbackPolicy`. + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { if let stmts = pluginDriver.truncateTableStatements(table: table, schema: schema, cascade: cascade) { return stmts } + guard allowsGeneratedDDL else { return nil } let name = qualifiedName(table, schema: schema) let cascadeSuffix = cascade ? " CASCADE" : "" return ["TRUNCATE TABLE \(name)\(cascadeSuffix)"] } - func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String { + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { if let stmt = pluginDriver.dropObjectStatement(name: name, objectType: objectType, schema: schema, cascade: cascade) { return stmt } + guard allowsGeneratedDDL else { return nil } let qualName = qualifiedName(name, schema: schema) let cascadeSuffix = cascade ? " CASCADE" : "" return "DROP \(objectType) \(qualName)\(cascadeSuffix)" } + private var allowsGeneratedDDL: Bool { + SQLDDLFallbackPolicy.allowsGeneratedDDL(for: connection.type) + } + + /// Which of these objects this connection has a drop or truncate statement for. + /// + /// Resolved per object rather than per engine, because a plugin answers per object: Typesense + /// has a statement for a collection and none for anything else, and Elasticsearch has none for + /// an index name carrying a wildcard. Every menu that offers either operation asks this, so the + /// answer and the statement it leads to come from one place. + func tableOperationEligibility( + for refs: some Collection, + isReadOnly: Bool + ) -> TableOperationEligibility.Context { + guard !isReadOnly else { return .unavailable } + var droppable: Set = [] + var truncatable: Set = [] + for ref in refs { + if dropObjectStatement( + name: ref.table.name, + objectType: TableObjectKeyword.forDDL(ref.table.type), + schema: ref.qualifyingSchema, + cascade: false + ) != nil { + droppable.insert(ref) + } + if truncateTableStatements( + table: ref.table.name, schema: ref.qualifyingSchema, cascade: false + ) != nil { + truncatable.insert(ref) + } + } + return TableOperationEligibility.Context( + droppable: droppable, truncatable: truncatable, isReadOnly: false + ) + } + func foreignKeyDisableStatements() -> [String]? { pluginDriver.foreignKeyDisableStatements() } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 09c9a10e6..5fa6ef4a4 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -44,6 +44,13 @@ struct MenuValidationContext: Equatable { /// Whether every selected object is one the engine can truncate. Separate from /// `hasTableSelection` because a view is a perfectly good selection and a hopeless truncate. var canTruncateSelectedTables = false + /// Whether every selected object is one the engine has a drop statement for. An engine with + /// no DDL for it must not be offered Delete, or the app invents SQL it cannot run. + var canDropSelectedTables = false + /// An editable tab only answers Delete when a row is selected. Without the row check the item + /// stayed enabled over a grid with no selection, fell through to the sidebar's drop path and + /// did nothing there. + var canDeleteSelectedRows: Bool { isCurrentTabEditable && hasRowSelection } /// Whether the window-level `paste:` fallback would actually paste. AppKit hands a disabled /// item its key equivalent regardless, so an item enabled over a handler that returns at its /// first guard swallows Command+V with no feedback. @@ -221,7 +228,7 @@ extension MainSplitViewController: NSMenuItemValidation { case #selector(paste(_:)): return context.isConnected && context.canPasteRows case #selector(delete(_:)): - return context.isConnected && (context.isCurrentTabEditable || context.hasTableSelection) + return context.isConnected && (context.canDeleteSelectedRows || context.canDropSelectedTables) case #selector(createNewTable(_:)), #selector(createNewView(_:)): return context.isConnected && !context.isReadOnly @@ -359,6 +366,7 @@ extension MainSplitViewController: NSMenuItemValidation { hasDataGridRowSelection: actions.hasDataGridRowSelection, hasTableSelection: actions.hasTableSelection, canTruncateSelectedTables: actions.canTruncateSelectedTables, + canDropSelectedTables: actions.canDropSelectedTables, canPasteRows: actions.canPasteRows, canCloseOtherTabs: actions.canCloseOtherTabs, canCloseTabsForOtherDatabases: actions.canCloseTabsForOtherDatabases, diff --git a/TablePro/Models/Connection/DatabaseType.swift b/TablePro/Models/Connection/DatabaseType.swift index 1d2901a06..55dc82ab4 100644 --- a/TablePro/Models/Connection/DatabaseType.swift +++ b/TablePro/Models/Connection/DatabaseType.swift @@ -51,6 +51,7 @@ extension DatabaseType { static let teradata = DatabaseType(rawValue: "Teradata") static let trino = DatabaseType(rawValue: "Trino") static let weaviate = DatabaseType(rawValue: "Weaviate") + static let kafka = DatabaseType(rawValue: "Kafka") } extension DatabaseType: Codable { diff --git a/TablePro/Models/Database/SQLDDLFallbackPolicy.swift b/TablePro/Models/Database/SQLDDLFallbackPolicy.swift new file mode 100644 index 000000000..d06628552 --- /dev/null +++ b/TablePro/Models/Database/SQLDDLFallbackPolicy.swift @@ -0,0 +1,48 @@ +// +// SQLDDLFallbackPolicy.swift +// TablePro +// + +import Foundation + +/// Whether the app may build `DROP ` or `TRUNCATE TABLE ` itself when an +/// engine's plugin returned no statement of its own. +/// +/// `PluginDatabaseDriver` defaults both table operations to nil, and nil says two different things. +/// On MySQL it means the generic DDL is right, which is why eleven SQL plugins never implement +/// either hook. On Elasticsearch it means the engine has no such statement at all, and there the +/// generic DDL is text the driver rejects: `DROP TABLE "test_index"` reached a cluster as a console +/// request and came back "Enter a request like: GET /my-index/_search" (#2884). +/// +/// Neither of the engine's other language facts can answer it. DynamoDB writes PartiQL and so +/// declares an editor language of `.sql`, yet PartiQL has no DDL and its driver returns nil from +/// both hooks deliberately. The SQL dialect descriptor cannot answer it either, because it defaults +/// to nil and several engines with ordinary `DROP TABLE` never curate one. So the answer is stated +/// here per engine, and two tests hold it: one walks `DatabaseType.allKnownTypes` so a new engine +/// cannot inherit an answer by accident, and one reads the plugin sources so an engine whose plugin +/// declares a non-SQL editor language can never be missing from this list. +enum SQLDDLFallbackPolicy { + /// Engines with no SQL DDL to fall back on. + /// + /// Most of these have a plugin that answers both hooks, so the app never reaches the fallback + /// for them. They are listed anyway, because a plugin answers per object kind: Typesense + /// returns nil for anything that is not a collection, and without this the app would answer + /// that with `DROP VIEW`. + static let enginesWithoutSQLDDL: Set = [ + .elasticsearch, + .typesense, + .weaviate, + .kafka, + .etcd, + .redis, + .mongodb, + .surrealdb, + .dynamodb, + .beancount, + ] + + /// True when `DROP`/`TRUNCATE` built by the app is something this engine could run. + static func allowsGeneratedDDL(for databaseType: DatabaseType) -> Bool { + !enginesWithoutSQLDDL.contains(databaseType) + } +} diff --git a/TablePro/Models/Database/TableOperationEligibility.swift b/TablePro/Models/Database/TableOperationEligibility.swift index e4385403c..5a642f383 100644 --- a/TablePro/Models/Database/TableOperationEligibility.swift +++ b/TablePro/Models/Database/TableOperationEligibility.swift @@ -13,6 +13,25 @@ import TableProPluginKit /// `TRUNCATE` against a view. It sits beside the models because the menu-bar validator in `Core/` /// has to reach it and must not depend on a `Views/` file. enum TableOperationEligibility { + /// What the connection's engine can express, alongside what the object's kind allows. + /// + /// Kind alone was the whole test, so both items were offered on every engine and an + /// Elasticsearch index was handed `DROP TABLE "test_index"` (#2884). The driver is asked once + /// when the menu is built, the same way `maintenanceOperations` is, rather than curated per + /// engine in the metadata registry: the statement and the answer then come from one place and + /// cannot drift apart. + struct Context { + /// The refs the driver answered with a statement for, resolved per name because a plugin + /// may refuse one object and not another: Elasticsearch has no statement for an index + /// name carrying a wildcard, which would otherwise reach the cluster as `DELETE /*`. + let droppable: Set + let truncatable: Set + let isReadOnly: Bool + + /// For callers with no driver to ask, such as a window whose session has gone. + static let unavailable = Context(droppable: [], truncatable: [], isReadOnly: true) + } + /// A kind whose rows the engine will not let you replace or remove in place. A view holds no /// rows of its own, a foreign or external table proxies rows on another server, and a system /// table belongs to the catalog. @@ -37,6 +56,22 @@ enum TableOperationEligibility { return targets.allSatisfy { canTruncate($0.table.type) } } + static func canTruncate(_ targets: some Collection, context: Context) -> Bool { + guard !context.isReadOnly, canTruncate(targets) else { return false } + return targets.allSatisfy { context.truncatable.contains($0) } + } + + /// Whether Delete may be offered. + /// + /// All or nothing over the selection, for the reason Truncate already gives: a command that + /// drops part of what the user selected is worse than one that declines. The menu never offers + /// what the driver would refuse, the rule the rename path states in + /// `PluginDriverUnsupportedOperation`. + static func canDrop(_ targets: some Collection, context: Context) -> Bool { + guard !context.isReadOnly, !targets.isEmpty else { return false } + return targets.allSatisfy { context.droppable.contains($0) } + } + /// The driver's vocabulary for the same kind. Spelled out rather than taken from `rawValue` so /// adding a case to `TableInfo.TableType` stops compiling here instead of silently producing a /// kind no driver declares, which would read as "no maintenance applies". diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 9506f3798..8938a949f 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -280,15 +280,23 @@ final class SidebarViewModel { func batchToggleTruncate(refs: [DatabaseTreeTableRef]? = nil) { let targets = refs ?? Array(selectedTables) guard !targets.isEmpty else { return } + /// Unstaging comes first: a queued operation must always be removable, even once the + /// engine can no longer express it. Validating ahead of this left a Redis truncate stuck + /// in the queue after a `SELECT` moved the session to another database. + guard !targets.allSatisfy({ pendingTruncates.contains($0) }) else { + unstage(targets, from: &pendingTruncatesBinding.wrappedValue) + return + } + /// The last gate before the queue, refusing the whole batch the way both menus now do /// rather than truncating the part of a selection that happens to qualify. guard TableOperationEligibility.canTruncate(targets) else { Self.logger.warning("Refused to stage a truncate against an object that holds no rows of its own") return } - - guard !targets.allSatisfy({ pendingTruncates.contains($0) }) else { - unstage(targets, from: &pendingTruncatesBinding.wrappedValue) + if let eligibility = tableOperationEligibility(for: targets), + !TableOperationEligibility.canTruncate(targets, context: eligibility) { + Self.logger.warning("Refused to stage a truncate the engine has no statement for") return } pendingOperationType = .truncate @@ -299,16 +307,35 @@ final class SidebarViewModel { func batchToggleDelete(refs: [DatabaseTreeTableRef]? = nil) { let targets = refs ?? Array(selectedTables) guard !targets.isEmpty else { return } - guard !targets.allSatisfy({ pendingDeletes.contains($0) }) else { unstage(targets, from: &pendingDeletesBinding.wrappedValue) return } + + /// The same last gate Truncate has. Without it a queued drop the engine cannot express + /// reached Save and was rejected there, after the dialog had already promised it. + if let eligibility = tableOperationEligibility(for: targets), + !TableOperationEligibility.canDrop(targets, context: eligibility) { + Self.logger.warning("Refused to stage a drop the engine has no statement for") + return + } pendingOperationType = .drop pendingOperationTables = targets showOperationDialog = true } + /// Nil when there is no driver to ask, which is not the same as "refused": with no session + /// nothing can run anyway, and answering `.unavailable` there would make the view model + /// untestable and silently refuse every staging call. + private func tableOperationEligibility( + for targets: [DatabaseTreeTableRef] + ) -> TableOperationEligibility.Context? { + guard let adapter = DatabaseManager.shared.driver(for: connectionId) as? PluginDriverAdapter else { + return nil + } + return adapter.tableOperationEligibility(for: targets, isReadOnly: false) + } + private func unstage(_ targets: [DatabaseTreeTableRef], from queue: inout Set) { var options = tableOperationOptions for ref in targets { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift index d59084a2d..cd6215f3e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift @@ -118,7 +118,7 @@ extension MainContentCoordinator { } if hasPendingTableOps { - let tableOpStatements = generateTableOperationSQL( + let tableOpStatements = try generateTableOperationSQL( truncates: pendingTruncates, deletes: pendingDeletes, options: tableOperationOptions, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift index f74790974..b19b9b167 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift @@ -19,8 +19,8 @@ extension MainContentCoordinator { deletes: Set, options: [DatabaseTreeTableRef: TableOperationOptions], includeFKHandling: Bool = true - ) -> [String] { - tableOperationBuilder.generate( + ) throws -> [String] { + try tableOperationBuilder.generate( truncates: truncates, deletes: deletes, options: options, diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index e256c5eda..da8a83118 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -504,7 +504,25 @@ final class MainContentCommandActions { /// A selection can be perfectly valid and still hold nothing truncatable, so the menu bar asks /// this rather than `hasTableSelection`, which is what let it stage a `TRUNCATE` on a view. var canTruncateSelectedTables: Bool { - TableOperationEligibility.canTruncate(selectedTables.wrappedValue) + TableOperationEligibility.canTruncate( + selectedTables.wrappedValue, context: tableOperationEligibility + ) + } + + /// The same question the sidebar's own Delete item asks, so the two agree. Without it the menu + /// bar offered Delete on an engine with no statement for it and the sidebar did not. + var canDropSelectedTables: Bool { + TableOperationEligibility.canDrop(selectedTables.wrappedValue, context: tableOperationEligibility) + } + + private var tableOperationEligibility: TableOperationEligibility.Context { + guard let coordinator, + let adapter = DatabaseManager.shared.driver(for: coordinator.connectionId) as? PluginDriverAdapter + else { return .unavailable } + return adapter.tableOperationEligibility( + for: selectedTables.wrappedValue, + isReadOnly: coordinator.safeModeLevel.blocksAllWrites + ) } /// The one selected object with the database and schema it lives in, or nil when the selection diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift index be390ed1f..4981f4bb0 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift @@ -34,9 +34,10 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { let clicked = clickedNode() let clickedRef = clicked.flatMap(DatabaseTreeSelection.tableRef) let settings = AppSettingsManager.shared.general + let selected = Set(selectedRefs()) return DatabaseTreeMenuContext( clicked: clicked?.kind, - selectedTables: Set(selectedRefs()), + selectedTables: selected, selectedContainers: selectedContainerRefs(), activeDatabase: activeDatabase, activeSchema: activeSchema, @@ -62,6 +63,9 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { supportsRenameSchema: PluginManager.shared.supportsRenameSchema(for: databaseType), isReadOnly: mainCoordinator?.safeModeLevel.blocksAllWrites ?? false ), + tableOperationEligibility: tableOperationEligibility( + candidates: selected.union(clickedRef.map { [$0] } ?? []) + ), containerEntityName: PluginManager.shared.containerEntityName(for: databaseType), containerEntityNamePlural: PluginManager.shared.containerEntityNamePlural(for: databaseType), schemaEntityName: PluginManager.shared.schemaEntityName(for: databaseType), @@ -106,6 +110,17 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { ) } + private func tableOperationEligibility(candidates: Set) + -> TableOperationEligibility.Context { + guard let adapter = DatabaseManager.shared.driver(for: connectionId) as? PluginDriverAdapter else { + return .unavailable + } + return adapter.tableOperationEligibility( + for: candidates, + isReadOnly: mainCoordinator?.safeModeLevel.blocksAllWrites ?? false + ) + } + private func objectKindTitles() -> [SidebarObjectKind: String] { let tableEntityName = PluginManager.shared.tableEntityName(for: databaseType) var titles: [SidebarObjectKind: String] = [:] diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift index 3c2659073..daf85c97f 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift @@ -27,6 +27,7 @@ internal struct DatabaseTreeMenuContext { internal let maintenanceOperations: [PluginMaintenanceOperation] internal let dropEligibility: ContainerDropEligibility.Context internal let renameEligibility: ObjectRenameEligibility.Context + internal let tableOperationEligibility: TableOperationEligibility.Context internal let containerEntityName: String internal let containerEntityNamePlural: String internal let schemaEntityName: String diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index 3265e8e30..be7758172 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -201,13 +201,17 @@ internal enum DatabaseTreeMenuSpec { if ObjectRenameEligibility.canRename(table: ref.table, context: context.renameEligibility) { items.append(.command(String(localized: "Rename"), .beginRenameTable(ref: ref, isRecentRow: isRecentRow))) } - if SidebarContextMenuLogic.truncateVisible(targets: targets) { + if SidebarContextMenuLogic.truncateVisible( + targets: targets, context: context.tableOperationEligibility + ) { items.append(.command(String(localized: "Truncate"), .truncateTables(targets: targets, ref: ref))) } - items.append(.command( - SidebarContextMenuLogic.deleteLabel(for: ref.table.type), - .dropTables(targets: targets, ref: ref) - )) + if TableOperationEligibility.canDrop(targets, context: context.tableOperationEligibility) { + items.append(.command( + SidebarContextMenuLogic.deleteLabel(for: ref.table.type), + .dropTables(targets: targets, ref: ref) + )) + } return items } diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index 49913bc20..9faa1720d 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -23,8 +23,11 @@ enum SidebarContextMenuLogic { /// Asked of every row the command would act on, not just the one under the pointer. Right /// clicking a table inside a selection that also held a view offered Truncate and staged it /// for the view as well. - static func truncateVisible(targets: some Collection) -> Bool { - TableOperationEligibility.canTruncate(targets) + static func truncateVisible( + targets: some Collection, + context: TableOperationEligibility.Context + ) -> Bool { + TableOperationEligibility.canTruncate(targets, context: context) } static func deleteLabel(for type: TableInfo.TableType?) -> String { diff --git a/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift b/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift index e6518b9b2..ab0e0b3b6 100644 --- a/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift +++ b/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift @@ -101,9 +101,41 @@ struct TableOperationSQLBuilderTests { return TableOperationSQLBuilder(adapterProvider: { adapter }) } + private func makeNonSQLBuilder() -> TableOperationSQLBuilder { + let connection = DatabaseConnection(name: "Test", type: .elasticsearch) + let adapter = PluginDriverAdapter(connection: connection, pluginDriver: StubDropDriver()) + return TableOperationSQLBuilder(adapterProvider: { adapter }) + } + + /// Skipping it instead would run the rest of the plan while the coordinator still recorded + /// every staged object as done, so an object nothing touched was reported as deleted. + @Test("A drop the engine cannot express rejects the batch") + func rejectsInexpressibleDrop() { + #expect(throws: DataWriteError.objectOperationUnsupported("test_index")) { + try makeNonSQLBuilder().generate(truncates: [], deletes: [ref("test_index")], options: [:]) + } + } + + @Test("A truncate the engine cannot express rejects the batch") + func rejectsInexpressibleTruncate() { + #expect(throws: DataWriteError.objectOperationUnsupported("test_index")) { + try makeNonSQLBuilder().generate(truncates: [ref("test_index")], deletes: [], options: [:]) + } + } + + /// A rejection is all or nothing: the other staged object must not run either. + @Test("One inexpressible ref rejects the whole batch") + func oneInexpressibleRefRejectsEverything() { + #expect(throws: (any Error).self) { + try makeNonSQLBuilder().generate( + truncates: [], deletes: [ref("orders"), ref("test_index")], options: [:] + ) + } + } + @Test("Materialized view drops with DROP MATERIALIZED VIEW") - func dropsMaterializedView() { - let stmts = makeBuilder().generate( + func dropsMaterializedView() throws { + let stmts = try makeBuilder().generate( truncates: [], deletes: [ref("daily_sales", .materializedView, schema: "public")], options: [:], includeFKHandling: false ) @@ -111,40 +143,40 @@ struct TableOperationSQLBuilderTests { } @Test("View drops with DROP VIEW") - func dropsView() { - let stmts = makeBuilder().generate( + func dropsView() throws { + let stmts = try makeBuilder().generate( truncates: [], deletes: [ref("active_users", .view)], options: [:], includeFKHandling: false ) #expect(stmts == ["DROP VIEW \"active_users\""]) } @Test("Foreign table drops with DROP FOREIGN TABLE") - func dropsForeignTable() { - let stmts = makeBuilder().generate( + func dropsForeignTable() throws { + let stmts = try makeBuilder().generate( truncates: [], deletes: [ref("remote_orders", .foreignTable)], options: [:], includeFKHandling: false ) #expect(stmts == ["DROP FOREIGN TABLE \"remote_orders\""]) } @Test("External table drops with DROP TABLE") - func dropsExternalTable() { - let stmts = makeBuilder().generate( + func dropsExternalTable() throws { + let stmts = try makeBuilder().generate( truncates: [], deletes: [ref("customers", .externalTable)], options: [:], includeFKHandling: false ) #expect(stmts == ["DROP TABLE \"customers\""]) } @Test("Plain table drops with DROP TABLE") - func dropsTable() { - let stmts = makeBuilder().generate( + func dropsTable() throws { + let stmts = try makeBuilder().generate( truncates: [], deletes: [ref("orders")], options: [:], includeFKHandling: false ) #expect(stmts == ["DROP TABLE \"orders\""]) } @Test("System table drops with DROP TABLE") - func dropsSystemTable() { - let stmts = makeBuilder().generate( + func dropsSystemTable() throws { + let stmts = try makeBuilder().generate( truncates: [], deletes: [ref("pg_stats", .systemTable)], options: [:], includeFKHandling: false ) #expect(stmts == ["DROP TABLE \"pg_stats\""]) @@ -156,8 +188,8 @@ struct TableOperationSQLBuilderTests { /// `DROP TABLE` and raised ORA-00942, and a table of the same name in the login schema was a /// live target. @Test("A row under a schema node drops qualified and typed with no table cache") - func hierarchicalRowKeepsTypeAndSchema() { - let stmts = makeBuilder().generate( + func hierarchicalRowKeepsTypeAndSchema() throws { + let stmts = try makeBuilder().generate( truncates: [], deletes: [ref("EMP_VIEW", .view, rowSchema: "HR")], options: [:], @@ -167,9 +199,9 @@ struct TableOperationSQLBuilderTests { } @Test("Cascade applies to materialized view drops") - func cascadeAppliesToMaterializedView() { + func cascadeAppliesToMaterializedView() throws { let target = ref("daily_sales", .materializedView) - let stmts = makeBuilder().generate( + let stmts = try makeBuilder().generate( truncates: [], deletes: [target], options: [target: TableOperationOptions(cascade: true)], includeFKHandling: false ) @@ -177,16 +209,16 @@ struct TableOperationSQLBuilderTests { } @Test("Drop qualifies schema when TableInfo carries one") - func qualifiesSchema() { - let stmts = makeBuilder().generate( + func qualifiesSchema() throws { + let stmts = try makeBuilder().generate( truncates: [], deletes: [ref("orders", schema: "sales")], options: [:], includeFKHandling: false ) #expect(stmts == ["DROP TABLE \"sales\".\"orders\""]) } @Test("Truncate qualifies schema when TableInfo carries one") - func truncateQualifiesSchema() { - let stmts = makeBuilder().generate( + func truncateQualifiesSchema() throws { + let stmts = try makeBuilder().generate( truncates: [ref("orders", schema: "sales")], deletes: [], options: [:], includeFKHandling: false ) #expect(stmts == ["TRUNCATE TABLE \"sales\".\"orders\""]) @@ -198,7 +230,7 @@ struct TableOperationSQLBuilderTests { /// used to be a built-in fallback here and a suite asserting it; the fallback was deleted and /// the suite went on asserting it, which is what put twelve cases in the quarantine file. @Test("Foreign key statements come from the driver") - func foreignKeyStatementsComeFromTheDriver() { + func foreignKeyStatementsComeFromTheDriver() throws { let builder = makeForeignKeyBuilder() #expect(builder.foreignKeyDisableStatements() == ["SET FOREIGN_KEY_CHECKS=0"]) #expect(builder.foreignKeyEnableStatements() == ["SET FOREIGN_KEY_CHECKS=1"]) @@ -208,7 +240,7 @@ struct TableOperationSQLBuilderTests { /// is the contract: the builder has no way to know a database's syntax that the driver does not /// tell it. @Test("A driver with no foreign key support produces no statements") - func noForeignKeySupportProducesNothing() { + func noForeignKeySupportProducesNothing() throws { let builder = makeBuilder() #expect(builder.foreignKeyDisableStatements().isEmpty) #expect(builder.foreignKeyEnableStatements().isEmpty) @@ -222,10 +254,10 @@ struct TableOperationSQLBuilderTests { /// other way round is what produced a suite of tests asserting MySQL backticks from a builder /// that has never known what MySQL is. @Test("Foreign key handling wraps the sorted truncates and drops") - func foreignKeyHandlingWrapsSortedWork() { + func foreignKeyHandlingWrapsSortedWork() throws { let apple = ref("apple") let zebra = ref("zebra") - let stmts = makeForeignKeyBuilder().generate( + let stmts = try makeForeignKeyBuilder().generate( truncates: [zebra, apple], deletes: [ref("yak"), ref("bee")], options: [ diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift index 29086169a..213a89d83 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterTableOpsTests.swift @@ -147,4 +147,68 @@ struct PluginDriverAdapterTableOpsTests { let result = adapter.truncateTableStatements(table: "users", schema: nil, cascade: false) #expect(result == ["DELETE FROM `users`", "ALTER TABLE `users` AUTO_INCREMENT = 1"]) } + + // MARK: - Engines with no SQL DDL + + private func makeNonSQLAdapter(driver: StubTableOpsDriver) -> PluginDriverAdapter { + let connection = DatabaseConnection(name: "Test", type: .elasticsearch) + return PluginDriverAdapter(connection: connection, pluginDriver: driver) + } + + /// #2884: the fallback answered an Elasticsearch index with `DROP TABLE "test_index"`, which the + /// driver's console parser rejected. Nil is the honest answer, and the menu reads it. + @Test("No drop is invented for an engine with no SQL DDL") + func dropWithheldForNonSQLEngine() { + let adapter = makeNonSQLAdapter(driver: StubTableOpsDriver()) + #expect(adapter.dropObjectStatement( + name: "test_index", objectType: "TABLE", schema: nil, cascade: false + ) == nil) + } + + @Test("No truncate is invented for an engine with no SQL DDL") + func truncateWithheldForNonSQLEngine() { + let adapter = makeNonSQLAdapter(driver: StubTableOpsDriver()) + #expect(adapter.truncateTableStatements(table: "test_index", schema: nil, cascade: false) == nil) + } + + @Test("A plugin that answers still wins on an engine with no SQL DDL") + func nonSQLPluginOverrideIsUsed() { + let driver = StubTableOpsDriver() + driver.dropOverride = { name, _, _, _ in "DELETE /\(name)" } + let adapter = makeNonSQLAdapter(driver: driver) + #expect(adapter.dropObjectStatement( + name: "test_index", objectType: "TABLE", schema: nil, cascade: false + ) == "DELETE /test_index") + } + + @Test("Eligibility reports exactly what the driver answered for") + func eligibilityFollowsTheDriver() { + let driver = StubTableOpsDriver() + driver.dropOverride = { name, _, _, _ in name == "keep" ? "DELETE /keep" : nil } + let adapter = makeNonSQLAdapter(driver: driver) + let keep = DatabaseTreeTableRef( + database: nil, schema: nil, + table: TableInfo(name: "keep", type: .table, rowCount: nil, schema: nil) + ) + let skip = DatabaseTreeTableRef( + database: nil, schema: nil, + table: TableInfo(name: "skip", type: .table, rowCount: nil, schema: nil) + ) + let context = adapter.tableOperationEligibility(for: [keep, skip], isReadOnly: false) + #expect(context.droppable == [keep]) + #expect(context.truncatable.isEmpty) + } + + @Test("Read-only reports nothing as eligible") + func eligibilityIsEmptyWhenReadOnly() { + let adapter = makeAdapter(driver: StubTableOpsDriver()) + let ref = DatabaseTreeTableRef( + database: nil, schema: nil, + table: TableInfo(name: "users", type: .table, rowCount: nil, schema: nil) + ) + let context = adapter.tableOperationEligibility(for: [ref], isReadOnly: true) + #expect(context.droppable.isEmpty) + #expect(context.truncatable.isEmpty) + #expect(context.isReadOnly) + } } diff --git a/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift b/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift new file mode 100644 index 000000000..6d3139928 --- /dev/null +++ b/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift @@ -0,0 +1,129 @@ +// +// SQLDDLFallbackPolicyTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("SQL DDL fallback policy") +struct SQLDDLFallbackPolicyTests { + @Test("Engines with SQL DDL keep the generated statement") + func sqlEnginesFabricate() { + for type in [ + DatabaseType.mysql, .postgresql, .sqlite, .mssql, .oracle, + .duckdb, .cassandra, .trino, .teradata, .clickhouse, .bigQuery, + ] { + #expect(SQLDDLFallbackPolicy.allowsGeneratedDDL(for: type), "\(type.rawValue) should fabricate") + } + } + + @Test("Engines with no SQL DDL never get a generated statement") + func nonSQLEnginesRefuse() { + for type in [ + DatabaseType.elasticsearch, .kafka, .weaviate, .etcd, + .redis, .mongodb, .typesense, .surrealdb, .dynamodb, .beancount, + ] { + #expect(!SQLDDLFallbackPolicy.allowsGeneratedDDL(for: type), "\(type.rawValue) should refuse") + } + } + + /// DynamoDB writes PartiQL and so declares an editor language of `.sql`, which is why the + /// policy cannot be derived from that. Losing this case brings `TRUNCATE TABLE "orders"` back. + @Test("PartiQL is not SQL DDL") + @MainActor + func dynamoDBIsExcludedDespiteSQLEditorLanguage() { + #expect(PluginManager.shared.editorLanguage(for: .dynamodb) == .sql) + #expect(!SQLDDLFallbackPolicy.allowsGeneratedDDL(for: .dynamodb)) + } + + /// Every engine `DatabaseType` declares must be named in one of the two lists, so an engine + /// added without a decision fails here rather than inheriting one. + /// + /// The declared constants are read from the type's own source rather than from + /// `DatabaseType.allKnownTypes`, which reads the live `PluginMetadataRegistry`: other suites + /// register synthetic snapshots and never remove them, so under a different test order those + /// ids would arrive here and fail a test about shipping engines. + @Test("Every declared engine is classified deliberately") + func everyDeclaredTypeIsClassified() throws { + let declared = try declaredDatabaseTypes() + #expect(declared.count > 30, "Parsed only \(declared.count) types; the scan is not reading the file") + + let named = Self.fabricatingTypes.union(SQLDDLFallbackPolicy.enginesWithoutSQLDDL) + let unclassified = declared.subtracting(named.map(\.rawValue)) + #expect( + unclassified.isEmpty, + "Classify these in SQLDDLFallbackPolicyTests or the policy: \(unclassified.sorted())" + ) + + let unknown = Set(named.map(\.rawValue)).subtracting(declared) + #expect(unknown.isEmpty, "Named but not declared by DatabaseType: \(unknown.sorted())") + } + + private func declaredDatabaseTypes() throws -> Set { + let root = try #require(repositoryRoot) + let source = try String( + contentsOf: root.appendingPathComponent("TablePro/Models/Connection/DatabaseType.swift"), + encoding: .utf8 + ) + var found: Set = [] + for line in source.components(separatedBy: .newlines) { + guard line.contains("static let"), let range = line.range(of: "DatabaseType(rawValue: \"") else { continue } + let rest = line[range.upperBound...] + guard let end = rest.firstIndex(of: "\"") else { continue } + found.insert(String(rest[.. = [ + .mysql, .mariadb, .tidb, .databend, .oceanbase, .postgresql, .sqlite, .redshift, + .cockroachdb, .pglite, .mssql, .oracle, .snowflake, .dameng, .clickhouse, .duckdb, + .cassandra, .scylladb, .cloudflareD1, .cloudflareR2SQL, .bigQuery, .spanner, + .libsql, .turso, .teradata, .trino, + ] + + /// The policy lives in the app and the statement lives in the plugin, so nothing but this + /// forces them to agree. An engine whose plugin declares a non-SQL editor language has no SQL + /// DDL by construction and must never reach the fabricating branch. + @Test("No plugin declaring a non-SQL editor language is left fabricating") + func nonSQLPluginsAreAllExcluded() throws { + let pluginsDirectory = try #require(repositoryRoot?.appendingPathComponent("Plugins")) + let files = FileManager.default.enumerator(at: pluginsDirectory, includingPropertiesForKeys: nil) + var offenders: [String] = [] + + while let url = files?.nextObject() as? URL { + guard url.pathExtension == "swift", + let source = try? String(contentsOf: url, encoding: .utf8), + source.contains("editorLanguage: EditorLanguage = ."), + !source.contains("editorLanguage: EditorLanguage = .sql"), + let typeId = declaredDatabaseTypeId(in: source) + else { continue } + if SQLDDLFallbackPolicy.allowsGeneratedDDL(for: DatabaseType(rawValue: typeId)) { + offenders.append(typeId) + } + } + + #expect(offenders.isEmpty, "Add to SQLDDLFallbackPolicy.enginesWithoutSQLDDL: \(offenders.sorted())") + } + + private func declaredDatabaseTypeId(in source: String) -> String? { + guard let range = source.range(of: "databaseTypeId = \"") else { return nil } + let rest = source[range.upperBound...] + guard let end = rest.firstIndex(of: "\"") else { return nil } + return String(rest[.. 1 { + url.deleteLastPathComponent() + if FileManager.default.fileExists(atPath: url.appendingPathComponent("project.yml").path) { + return url + } + } + return nil + } +} diff --git a/TableProTests/Models/Database/TableOperationEligibilityEngineTests.swift b/TableProTests/Models/Database/TableOperationEligibilityEngineTests.swift new file mode 100644 index 000000000..b24b57c45 --- /dev/null +++ b/TableProTests/Models/Database/TableOperationEligibilityEngineTests.swift @@ -0,0 +1,78 @@ +// +// TableOperationEligibilityEngineTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Table operation eligibility, engine dimension") +struct TableOperationEligibilityEngineTests { + private func ref(_ name: String, type: TableInfo.TableType = .table) -> DatabaseTreeTableRef { + DatabaseTreeTableRef( + database: "app", + schema: nil, + table: TableInfo(name: name, type: type, rowCount: nil, schema: nil) + ) + } + + private func context( + droppable: [DatabaseTreeTableRef] = [], + truncatable: [DatabaseTreeTableRef] = [], + isReadOnly: Bool = false + ) -> TableOperationEligibility.Context { + TableOperationEligibility.Context( + droppable: Set(droppable), truncatable: Set(truncatable), isReadOnly: isReadOnly + ) + } + + @Test("Delete is offered when the engine has a statement for every target") + func dropOfferedWhenExpressible() { + let users = ref("users") + #expect(TableOperationEligibility.canDrop([users], context: context(droppable: [users]))) + } + + /// The reported bug: Elasticsearch has no statement, so the item must not be offered at all + /// rather than offered and answered with fabricated SQL. + @Test("Delete is withheld when the engine has no statement") + func dropWithheldWhenInexpressible() { + #expect(!TableOperationEligibility.canDrop([ref("test_index")], context: context())) + } + + @Test("Delete is all or nothing across a selection") + func dropRefusesMixedSelection() { + let good = ref("orders") + let bad = ref("logs-*") + #expect(!TableOperationEligibility.canDrop([good, bad], context: context(droppable: [good]))) + } + + @Test("Delete is withheld on an empty selection") + func dropRefusesEmptySelection() { + #expect(!TableOperationEligibility.canDrop([DatabaseTreeTableRef](), context: context())) + } + + @Test("Delete is withheld in read-only mode") + func dropRefusesReadOnly() { + let users = ref("users") + #expect(!TableOperationEligibility.canDrop([users], context: context(droppable: [users], isReadOnly: true))) + } + + @Test("Truncate still refuses a view even where the engine could express it") + func truncateRefusesView() { + let view = ref("active_users", type: .view) + #expect(!TableOperationEligibility.canTruncate([view], context: context(truncatable: [view]))) + } + + @Test("Truncate is withheld when the engine has no statement") + func truncateWithheldWhenInexpressible() { + #expect(!TableOperationEligibility.canTruncate([ref("test_index")], context: context())) + } + + @Test("Truncate is offered for a table the engine can empty") + func truncateOfferedWhenExpressible() { + let users = ref("users") + #expect(TableOperationEligibility.canTruncate([users], context: context(truncatable: [users]))) + } +} diff --git a/TableProTests/Plugins/ElasticsearchOperationsTests.swift b/TableProTests/Plugins/ElasticsearchOperationsTests.swift new file mode 100644 index 000000000..7fcec6701 --- /dev/null +++ b/TableProTests/Plugins/ElasticsearchOperationsTests.swift @@ -0,0 +1,52 @@ +// +// ElasticsearchOperationsTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Elasticsearch object operations") +struct ElasticsearchOperationsTests { + @Test("Deleting an index is the native REST request") + func deleteIndexIsNative() { + #expect(ElasticsearchOperations.deleteIndex(named: "test_index", objectType: "TABLE") == "DELETE /test_index") + } + + /// The confirmation dialog shows the statement and the driver then runs it, so a statement the + /// driver's own parser rejects is exactly the reported bug. Round-tripping is what stops the + /// two drifting apart again. + @Test("The generated statement parses as the request it names") + func generatedStatementRoundTrips() throws { + let statement = try #require( + ElasticsearchOperations.deleteIndex(named: "test_index", objectType: "TABLE") + ) + let request = try #require(ElasticsearchConsoleParser.parse(statement)) + #expect(request.method == "DELETE") + #expect(request.path == "/test_index") + #expect(request.body == nil) + } + + @Test("A name that could match more than one index is refused", arguments: [ + "*", "logs-*", "a,b", "_all", "-old", "with/slash", "with space", "back\\slash", "", + ]) + func multiTargetNamesRefused(name: String) { + #expect(ElasticsearchOperations.deleteIndex(named: name, objectType: "TABLE") == nil) + } + + @Test("A percent-encodable name survives into the path") + func unicodeNameIsEncoded() throws { + let statement = try #require(ElasticsearchOperations.deleteIndex(named: "índice", objectType: "TABLE")) + let request = try #require(ElasticsearchConsoleParser.parse(statement)) + #expect(request.method == "DELETE") + #expect(request.path.hasPrefix("/")) + } + + /// Elasticsearch has only indices, so anything the app types differently is not something this + /// engine drops, and answering with a statement would delete an index of that name instead. + @Test("Only an index is droppable", arguments: ["VIEW", "MATERIALIZED VIEW", "FOREIGN TABLE"]) + func onlyIndicesAreDroppable(objectType: String) { + #expect(ElasticsearchOperations.deleteIndex(named: "test_index", objectType: objectType) == nil) + } +} diff --git a/TableProTests/Plugins/TypesenseOperationsConsoleTextTests.swift b/TableProTests/Plugins/TypesenseOperationsConsoleTextTests.swift new file mode 100644 index 000000000..acaa26bba --- /dev/null +++ b/TableProTests/Plugins/TypesenseOperationsConsoleTextTests.swift @@ -0,0 +1,55 @@ +// +// TypesenseOperationsConsoleTextTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Typesense object operations as console text") +struct TypesenseOperationsConsoleTextTests { + @Test("Dropping a collection is the native request") + func dropIsNative() throws { + let request = try #require(TypesenseOperations.dropCollection(named: "books", objectType: "TABLE")) + #expect(TypesenseOperations.consoleText(request) == "DELETE /collections/books") + } + + @Test("Truncating a collection keeps the truncate flag") + func truncateKeepsFlag() { + let text = TypesenseOperations.consoleText(TypesenseOperations.truncateCollection(named: "books")) + #expect(text == "DELETE /collections/books/documents?truncate=true") + } + + /// The statement is shown in the confirmation and then run, so a statement the driver's own + /// parser rejects would be the #2884 defect in a second engine. + @Test("Both statements parse as the requests they name") + func statementsRoundTrip() throws { + let drop = try #require(TypesenseOperations.dropCollection(named: "books", objectType: "TABLE")) + let parsedDrop = try #require(TypesenseConsoleParser.parse(TypesenseOperations.consoleText(drop))) + #expect(parsedDrop.method == "DELETE") + #expect(parsedDrop.path == "/collections/books") + + let truncate = TypesenseOperations.truncateCollection(named: "books") + let parsedTruncate = try #require(TypesenseConsoleParser.parse(TypesenseOperations.consoleText(truncate))) + #expect(parsedTruncate.method == "DELETE") + #expect(parsedTruncate.path == "/collections/books/documents?truncate=true") + } + + /// A tagged blob has no leading verb, so `QueryClassifier` tiered a collection drop as an + /// ordinary write and it skipped the destructive gate. + @Test("A drop reads as destructive") + func dropClassifiesDestructive() throws { + let request = try #require(TypesenseOperations.dropCollection(named: "books", objectType: "TABLE")) + let classification = QueryClassifier.classify( + TypesenseOperations.consoleText(request), databaseType: .typesense + ) + #expect(classification.tier == .destructive) + } + + @Test("A truncate reads as destructive") + func truncateClassifiesDestructive() { + let text = TypesenseOperations.consoleText(TypesenseOperations.truncateCollection(named: "books")) + #expect(QueryClassifier.classify(text, databaseType: .typesense).tier == .destructive) + } +} diff --git a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index 295a276e4..da345e593 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -19,6 +19,22 @@ struct DatabaseTreeMenuSpecTests { ) } + /// The same candidate set the outline coordinator resolves: the selection plus the clicked + /// row, because the spec aims at the clicked row when nothing is selected. + private func tableOperationEligibility( + clicked: DatabaseTreeNode.Kind?, + selectedTables: Set, + canExpress: Bool, + isReadOnly: Bool + ) -> TableOperationEligibility.Context { + guard canExpress else { return .unavailable } + var candidates = selectedTables + if case .table(let ref) = clicked { candidates.insert(ref) } + return TableOperationEligibility.Context( + droppable: candidates, truncatable: candidates, isReadOnly: isReadOnly + ) + } + private func context( clicked: DatabaseTreeNode.Kind?, selectedTables: Set = [], @@ -35,6 +51,7 @@ struct DatabaseTreeMenuSpecTests { canCopyObjects: Bool = true, canDuplicateDatabase: Bool = true, canCreateType: Bool = false, + canExpressTableOperations: Bool = true, objectToolSupport: DatabaseObjectToolEligibility.Support = .none ) -> DatabaseTreeMenuContext { DatabaseTreeMenuContext( @@ -65,6 +82,12 @@ struct DatabaseTreeMenuSpecTests { supportsRenameSchema: supportsRename, isReadOnly: isReadOnly ), + tableOperationEligibility: tableOperationEligibility( + clicked: clicked, + selectedTables: selectedTables, + canExpress: canExpressTableOperations, + isReadOnly: isReadOnly + ), containerEntityName: "Database", containerEntityNamePlural: "Databases", schemaEntityName: "Schema", @@ -318,6 +341,28 @@ struct DatabaseTreeMenuSpecTests { #expect(!issued.contains(.dropTables(targets: [clicked, elsewhere], ref: clicked))) } + /// #2884: Elasticsearch has no statement for either operation, and offering them anyway is + /// what let the app answer an index with `DROP TABLE "test_index"`. + @Test("Neither Delete nor Truncate is offered where the engine has no statement") + func tableOperationsHiddenWhenInexpressible() { + let clicked = tableRef("test_index") + let issued = commands(DatabaseTreeMenuSpec.sections( + for: context(clicked: .table(clicked), canExpressTableOperations: false) + )) + + #expect(!issued.contains(.dropTables(targets: [clicked], ref: clicked))) + #expect(!issued.contains(.truncateTables(targets: [clicked], ref: clicked))) + } + + @Test("Delete and Truncate are offered where the engine has a statement") + func tableOperationsOfferedWhenExpressible() { + let clicked = tableRef("orders") + let issued = commands(DatabaseTreeMenuSpec.sections(for: context(clicked: .table(clicked)))) + + #expect(issued.contains(.dropTables(targets: [clicked], ref: clicked))) + #expect(issued.contains(.truncateTables(targets: [clicked], ref: clicked))) + } + @Test("A table row offers Rename where the engine can do it") func tableOffersRename() { let clicked = tableRef("orders") diff --git a/TableProTests/Views/SidebarContextMenuLogicTests.swift b/TableProTests/Views/SidebarContextMenuLogicTests.swift index 93bb0c96f..65d6e5f76 100644 --- a/TableProTests/Views/SidebarContextMenuLogicTests.swift +++ b/TableProTests/Views/SidebarContextMenuLogicTests.swift @@ -68,31 +68,31 @@ struct SidebarContextMenuLogicTests { @Test("Truncate visible for table") func truncateVisibleForTable() { let table = TestFixtures.makeTableInfo(name: "t", type: .table) - #expect(SidebarContextMenuLogic.truncateVisible(targets: [Self.ref(table)])) + #expect(SidebarContextMenuLogic.truncateVisible(targets: [Self.ref(table)], context: Self.expressible([Self.ref(table)]))) } @Test("Truncate hidden for view") func truncateHiddenForView() { let view = TestFixtures.makeTableInfo(name: "v", type: .view) - #expect(!SidebarContextMenuLogic.truncateVisible(targets: [Self.ref(view)])) + #expect(!SidebarContextMenuLogic.truncateVisible(targets: [Self.ref(view)], context: Self.expressible([Self.ref(view)]))) } @Test("Truncate hidden for materialized view") func truncateHiddenForMaterializedView() { let mv = TestFixtures.makeTableInfo(name: "mv", type: .materializedView) - #expect(!SidebarContextMenuLogic.truncateVisible(targets: [Self.ref(mv)])) + #expect(!SidebarContextMenuLogic.truncateVisible(targets: [Self.ref(mv)], context: Self.expressible([Self.ref(mv)]))) } @Test("Truncate hidden for foreign table") func truncateHiddenForForeignTable() { let ft = TestFixtures.makeTableInfo(name: "ft", type: .foreignTable) - #expect(!SidebarContextMenuLogic.truncateVisible(targets: [Self.ref(ft)])) + #expect(!SidebarContextMenuLogic.truncateVisible(targets: [Self.ref(ft)], context: Self.expressible([Self.ref(ft)]))) } @Test("Truncate hidden for system table") func truncateHiddenForSystemTable() { let sys = TestFixtures.makeTableInfo(name: "s", type: .systemTable) - #expect(!SidebarContextMenuLogic.truncateVisible(targets: [Self.ref(sys)])) + #expect(!SidebarContextMenuLogic.truncateVisible(targets: [Self.ref(sys)], context: Self.expressible([Self.ref(sys)]))) } // MARK: - Delete Label per Kind @@ -180,7 +180,7 @@ struct SidebarContextMenuLogicTests { @Test("Truncate is hidden for an external table") func truncateHiddenForExternalTable() { let table = TableInfo(name: "customers", type: .externalTable, rowCount: nil) - #expect(!SidebarContextMenuLogic.truncateVisible(targets: [Self.ref(table)])) + #expect(!SidebarContextMenuLogic.truncateVisible(targets: [Self.ref(table)], context: Self.expressible([Self.ref(table)]))) } @Test("External table drop label names the object kind") @@ -193,15 +193,36 @@ struct SidebarContextMenuLogicTests { DatabaseTreeTableRef(database: "app", schema: "public", table: table) } + /// An engine that has a truncate statement for everything asked about, so these cases keep + /// testing the object-kind rule rather than the engine one. + private static func expressible( + _ targets: [DatabaseTreeTableRef] + ) -> TableOperationEligibility.Context { + TableOperationEligibility.Context( + droppable: Set(targets), truncatable: Set(targets), isReadOnly: false + ) + } + @Test("Truncate is hidden when a selection mixes a table with a view") func truncateHiddenForMixedSelection() { let table = TableInfo(name: "orders", type: .table, rowCount: nil) let view = TableInfo(name: "summary", type: .view, rowCount: nil) - #expect(!SidebarContextMenuLogic.truncateVisible(targets: [Self.ref(table), Self.ref(view)])) + #expect(!SidebarContextMenuLogic.truncateVisible(targets: [Self.ref(table), Self.ref(view)], context: Self.expressible([Self.ref(table), Self.ref(view)]))) + } + + /// #2884: the engine gate is the other half. Elasticsearch has an index, which is a + /// truncatable kind, and no statement to truncate it with. + @Test("Truncate is hidden when the engine has no statement for it") + func truncateHiddenWhenEngineCannotExpressIt() { + let table = TableInfo(name: "test_index", type: .table, rowCount: nil) + #expect(!SidebarContextMenuLogic.truncateVisible( + targets: [Self.ref(table)], + context: TableOperationEligibility.Context(droppable: [], truncatable: [], isReadOnly: false) + )) } @Test("Truncate is hidden for an empty selection") func truncateHiddenForEmptySelection() { - #expect(!SidebarContextMenuLogic.truncateVisible(targets: [DatabaseTreeTableRef]())) + #expect(!SidebarContextMenuLogic.truncateVisible(targets: [DatabaseTreeTableRef](), context: Self.expressible([DatabaseTreeTableRef]()))) } } diff --git a/docs/databases/elasticsearch.mdx b/docs/databases/elasticsearch.mdx index d89823e2c..7467ade36 100644 --- a/docs/databases/elasticsearch.mdx +++ b/docs/databases/elasticsearch.mdx @@ -45,6 +45,8 @@ Column filters translate to `term`, `range`, `wildcard`, `terms` and `exists`. T Grid edits become REST calls keyed by `_id`: `POST /index/_update/{id}`, `PUT /index/_doc/{id}` and `DELETE /index/_doc/{id}`. +Deleting an index from the sidebar sends `DELETE /`, the request the confirmation shows. + ## Query DSL console @@ -74,6 +76,7 @@ New connections start on **Disabled**, plain HTTP with no fallback to anything e - Sorting on `_id`, or on a `text` field with no `.keyword` subfield, does nothing: the column is dropped from the sort and the rows keep the order they had. Add the subfield, or sort a keyword field. - Paging past 10,000 documents switches to `search_after` over a point-in-time. That threshold is fixed, so an index with a lowered `max_result_window` errors before the switch; raise the index setting back to 10,000. - An array or object cell is cut at 10,000 characters and ends in `...`. Saving an edit to a cut cell stores the fragment; change long values in the console. +- Truncate is not offered. The nearest thing Elasticsearch has is `_delete_by_query`, which runs asynchronously and reports conflicts per document. Run it in the console. - No mapping or schema editing, no transactions, no import, no [SSH tunnel](/connections/ssh-tunneling). - OpenSearch is not supported. diff --git a/project.yml b/project.yml index f2b3332a8..39c9494bf 100644 --- a/project.yml +++ b/project.yml @@ -426,6 +426,7 @@ targets: - Plugins/DynamoDBDriverPlugin/DynamoDBStatementGenerator.swift - Plugins/ElasticsearchDriverPlugin/ElasticsearchConsoleParser.swift - Plugins/ElasticsearchDriverPlugin/ElasticsearchMappingFlattener.swift + - Plugins/ElasticsearchDriverPlugin/ElasticsearchOperations.swift - Plugins/ElasticsearchDriverPlugin/ElasticsearchQueryBuilder.swift - Plugins/ElasticsearchDriverPlugin/ElasticsearchStatementGenerator.swift - Plugins/EtcdDriverPlugin/EtcdCommandParser.swift