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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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 == "_" }
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
48 changes: 48 additions & 0 deletions Plugins/ElasticsearchDriverPlugin/ElasticsearchOperations.swift
Original file line number Diff line number Diff line change
@@ -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 /<index>` 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 /<target>` 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: "/"))
}
14 changes: 14 additions & 0 deletions Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 4 additions & 4 deletions Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,15 @@ 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"]
}
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"
Expand Down Expand Up @@ -146,7 +146,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
// MARK: - Streaming

func streamRows(query: String) -> AsyncThrowingStream<PluginStreamElement, Error> {
return AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in
AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in
let streamTask = Task {
do {
try await self.performStreamRows(query: query, continuation: continuation)
Expand Down Expand Up @@ -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"
)
Expand Down
6 changes: 6 additions & 0 deletions Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 14 additions & 4 deletions Plugins/RedisDriverPlugin/RedisPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Plugins/TypesenseDriverPlugin/TypesenseOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 7 additions & 2 deletions Plugins/TypesenseDriverPlugin/TypesensePluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions Plugins/WeaviateDriverPlugin/WeaviatePluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Core/DataWrite/DataWriteError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
29 changes: 18 additions & 11 deletions TablePro/Core/Database/TableOperationSQLBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -25,7 +30,7 @@ struct TableOperationSQLBuilder {
deletes: Set<DatabaseTreeTableRef>,
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 }
Expand All @@ -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 {
Expand All @@ -68,17 +75,17 @@ 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
)
}

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),
Expand Down
Loading
Loading