From 6a11eaa511d9f3209eeec52355e2b89491f56bff Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 22 Jun 2026 19:11:27 +0700 Subject: [PATCH 1/4] feat(plugin-elasticsearch): add Elasticsearch driver plugin (#1529) --- .github/workflows/build-plugin.yml | 5 + CHANGELOG.md | 5 + .../ElasticsearchConnection.swift | 319 +++++++++++++++++ .../ElasticsearchConsoleParser.swift | 45 +++ .../ElasticsearchMappingFlattener.swift | 163 +++++++++ .../ElasticsearchPlugin.swift | 113 ++++++ .../ElasticsearchPluginDriver+Execution.swift | 278 +++++++++++++++ .../ElasticsearchPluginDriver.swift | 246 +++++++++++++ .../ElasticsearchQueryBuilder.swift | 330 ++++++++++++++++++ .../ElasticsearchStatementGenerator.swift | 206 +++++++++++ Plugins/ElasticsearchDriverPlugin/Info.plist | 10 + TablePro.xcodeproj/project.pbxproj | 176 +++++++++- .../elasticsearch-icon.imageset/Contents.json | 16 + .../elasticsearch.svg | 1 + ...tadataRegistry+ElasticsearchDefaults.swift | 121 +++++++ ...ginMetadataRegistry+RegistryDefaults.swift | 2 +- .../Core/Plugins/PluginMetadataRegistry.swift | 3 +- .../Connection/DatabaseConnection.swift | 1 + TablePro/Models/Database/TableFilter.swift | 3 + TablePro/Resources/Localizable.xcstrings | 21 ++ TableProTests/Models/TableFilterTests.swift | 19 + .../ElasticsearchConsoleParser.swift | 1 + .../ElasticsearchMappingFlattener.swift | 1 + .../ElasticsearchQueryBuilder.swift | 1 + .../ElasticsearchStatementGenerator.swift | 1 + .../Plugins/ElasticsearchDriverTests.swift | 324 +++++++++++++++++ docs/databases/elasticsearch.mdx | 93 +++++ docs/docs.json | 3 +- docs/index.mdx | 1 + 29 files changed, 2493 insertions(+), 15 deletions(-) create mode 100644 Plugins/ElasticsearchDriverPlugin/ElasticsearchConnection.swift create mode 100644 Plugins/ElasticsearchDriverPlugin/ElasticsearchConsoleParser.swift create mode 100644 Plugins/ElasticsearchDriverPlugin/ElasticsearchMappingFlattener.swift create mode 100644 Plugins/ElasticsearchDriverPlugin/ElasticsearchPlugin.swift create mode 100644 Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver+Execution.swift create mode 100644 Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift create mode 100644 Plugins/ElasticsearchDriverPlugin/ElasticsearchQueryBuilder.swift create mode 100644 Plugins/ElasticsearchDriverPlugin/ElasticsearchStatementGenerator.swift create mode 100644 Plugins/ElasticsearchDriverPlugin/Info.plist create mode 100644 TablePro/Assets.xcassets/elasticsearch-icon.imageset/Contents.json create mode 100644 TablePro/Assets.xcassets/elasticsearch-icon.imageset/elasticsearch.svg create mode 100644 TablePro/Core/Plugins/PluginMetadataRegistry+ElasticsearchDefaults.swift create mode 120000 TableProTests/PluginTestSources/ElasticsearchConsoleParser.swift create mode 120000 TableProTests/PluginTestSources/ElasticsearchMappingFlattener.swift create mode 120000 TableProTests/PluginTestSources/ElasticsearchQueryBuilder.swift create mode 120000 TableProTests/PluginTestSources/ElasticsearchStatementGenerator.swift create mode 100644 TableProTests/Plugins/ElasticsearchDriverTests.swift create mode 100644 docs/databases/elasticsearch.mdx diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index ddd650abf..8bb2aeae8 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -198,6 +198,11 @@ jobs: DISPLAY_NAME="DynamoDB Driver"; SUMMARY="Amazon DynamoDB driver with PartiQL queries and AWS IAM/Profile/SSO authentication" DB_TYPE_IDS='["DynamoDB"]'; ICON="dynamodb-icon"; BUNDLE_NAME="DynamoDBDriverPlugin" CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/dynamodb" ;; + elasticsearch) + TARGET="ElasticsearchDriverPlugin"; BUNDLE_ID="com.TablePro.ElasticsearchDriverPlugin" + DISPLAY_NAME="Elasticsearch Driver"; SUMMARY="Elasticsearch driver via the REST API with a Query DSL console" + DB_TYPE_IDS='["Elasticsearch"]'; ICON="elasticsearch-icon"; BUNDLE_NAME="ElasticsearchDriverPlugin" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/elasticsearch" ;; bigquery) TARGET="BigQueryDriverPlugin"; BUNDLE_ID="com.TablePro.BigQueryDriverPlugin" DISPLAY_NAME="BigQuery Driver"; SUMMARY="Google BigQuery analytics database driver via REST API" diff --git a/CHANGELOG.md b/CHANGELOG.md index 27c75dfec..1af799f32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Elasticsearch support. Connect to Elasticsearch 7.x and 8.x, browse indices, run Query DSL requests in a console, and edit documents in the data grid. Install from Settings > Plugins. (#1529) + ### Fixed +- Raw filters in the data grid now apply on document and key-value databases; the typed text was being dropped before it reached the driver. (#1529) - Connecting to Oracle no longer crashes the app while reading certain server values during the handshake; a bad packet now fails the connection with an error instead. (#1746) ## [0.52.1] - 2026-06-22 diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchConnection.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchConnection.swift new file mode 100644 index 000000000..10d9fbfed --- /dev/null +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchConnection.swift @@ -0,0 +1,319 @@ +// +// ElasticsearchConnection.swift +// ElasticsearchDriverPlugin +// +// HTTP client for the Elasticsearch REST API. +// + +import Foundation +import os +import TableProPluginKit + +internal enum ElasticsearchError: Error, LocalizedError { + case notConnected + case connectionFailed(String) + case serverError(String) + case authFailed(String) + case requestCancelled + case invalidResponse(String) + + var errorDescription: String? { + switch self { + case .notConnected: + return String(localized: "Not connected to Elasticsearch") + case .connectionFailed(let detail): + return String(format: String(localized: "Connection failed: %@"), detail) + case .serverError(let detail): + return String(format: String(localized: "Elasticsearch error: %@"), detail) + case .authFailed(let detail): + return String(format: String(localized: "Authentication failed: %@"), detail) + case .requestCancelled: + return String(localized: "Request was cancelled") + case .invalidResponse(let detail): + return String(format: String(localized: "Invalid response: %@"), detail) + } + } +} + +internal struct ElasticsearchResponse { + let statusCode: Int + let json: Any? + let rawText: String +} + +internal struct ElasticsearchIndexInfo { + let name: String + let docsCount: Int? + let storeSize: String? +} + +internal final class ElasticsearchConnection: NSObject, @unchecked Sendable { + private let config: DriverConnectionConfig + private let lock = NSLock() + private var _session: URLSession? + private var _currentTask: URLSessionDataTask? + private var _serverVersion: String? + private let queryTimeout = HttpQueryTimeoutBox() + + private let baseURL: URL + private let authHeader: String? + private let skipTLSVerify: Bool + + private static let logger = Logger(subsystem: "com.TablePro", category: "ElasticsearchConnection") + + var serverVersion: String? { lock.withLock { _serverVersion } } + + init(config: DriverConnectionConfig) throws { + self.config = config + + let scheme = config.ssl.isEnabled ? "https" : "http" + let host = config.host.isEmpty ? "localhost" : config.host + let port = config.port > 0 ? config.port : 9200 + guard let url = URL(string: "\(scheme)://\(host):\(port)") else { + throw ElasticsearchError.connectionFailed("Invalid host: \(host):\(port)") + } + self.baseURL = url + self.authHeader = Self.resolveAuthHeader(config: config) + self.skipTLSVerify = (config.additionalFields["esSkipTLSVerify"] == "true") + || (config.ssl.isEnabled && !config.ssl.verifiesCertificate) + } + + func setQueryTimeout(_ seconds: Int) { + queryTimeout.set(serverTimeoutSeconds: seconds) + } + + // MARK: - Lifecycle + + func connect() async throws { + let sessionConfig = URLSessionConfiguration.default + sessionConfig.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout + sessionConfig.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout + let session = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil) + lock.withLock { _session = session } + + let info = try await request(method: "GET", path: "/") + guard info.statusCode == 200 else { + throw mapError(info, fallback: "Connection check failed") + } + if let json = info.json as? [String: Any], + let version = json["version"] as? [String: Any], + let number = version["number"] as? String { + lock.withLock { _serverVersion = number } + } + } + + func disconnect() { + lock.withLock { + _currentTask?.cancel() + _currentTask = nil + _session?.finishTasksAndInvalidate() + _session = nil + } + } + + func ping() async throws { + let response = try await request(method: "GET", path: "/_cluster/health") + guard response.statusCode == 200 else { + throw mapError(response, fallback: "Ping failed") + } + } + + func cancelCurrentRequest() { + lock.withLock { + _currentTask?.cancel() + _currentTask = nil + } + } + + // MARK: - API Operations + + func catIndices() async throws -> [ElasticsearchIndexInfo] { + let response = try await request( + method: "GET", + path: "/_cat/indices?format=json&h=index,docs.count,store.size&s=index" + ) + guard response.statusCode == 200 else { throw mapError(response, fallback: "Failed to list indices") } + guard let rows = response.json as? [[String: Any]] else { return [] } + return rows.compactMap { row in + guard let name = row["index"] as? String else { return nil } + let docs = (row["docs.count"] as? String).flatMap { Int($0) } + return ElasticsearchIndexInfo(name: name, docsCount: docs, storeSize: row["store.size"] as? String) + } + } + + func mappingProperties(index: String) async throws -> [ElasticsearchColumn] { + let response = try await request(method: "GET", path: "/\(encode(index))/_mapping") + guard response.statusCode == 200 else { throw mapError(response, fallback: "Failed to fetch mapping") } + guard let json = response.json as? [String: Any] else { + Self.logger.error("mappingProperties \(index, privacy: .public): response.json not a dictionary; raw=\(response.rawText.prefix(300), privacy: .public)") + return [] + } + let properties = ElasticsearchMappingFlattener.properties(fromMappingResponse: json, index: index) + let columns = ElasticsearchMappingFlattener.flattenMapping(properties: properties) + Self.logger.debug(""" + mappingProperties \(index, privacy: .public): topKeys=[\(json.keys.joined(separator: ","), privacy: .public)] \ + propertyCount=\(properties.count) columnCount=\(columns.count) \ + columns=[\(columns.map { "\($0.name):\($0.type)\($0.hasKeywordSubfield ? "+kw" : "")" }.joined(separator: ","), privacy: .public)] + """) + return columns + } + + func count(index: String, query: [String: Any]?) async throws -> Int { + let body: String? + if let query, JSONSerialization.isValidJSONObject(["query": query]) { + body = String(data: try JSONSerialization.data(withJSONObject: ["query": query]), encoding: .utf8) + } else { + body = nil + } + let response = try await request(method: "POST", path: "/\(encode(index))/_count", body: body) + guard response.statusCode == 200, let json = response.json as? [String: Any] else { + throw mapError(response, fallback: "Count failed") + } + return (json["count"] as? Int) ?? 0 + } + + func search(index: String?, body: [String: Any]) async throws -> ElasticsearchResponse { + let path = index.map { "/\(encode($0))/_search" } ?? "/_search" + let bodyString = try serialize(body) + let response = try await request(method: "POST", path: path, body: bodyString) + guard response.statusCode == 200 else { throw mapError(response, fallback: "Search failed") } + return response + } + + func openPointInTime(index: String, keepAlive: String) async throws -> String { + let response = try await request(method: "POST", path: "/\(encode(index))/_pit?keep_alive=\(keepAlive)") + guard response.statusCode == 200, + let json = response.json as? [String: Any], + let id = json["id"] as? String + else { throw mapError(response, fallback: "Failed to open point-in-time") } + return id + } + + func closePointInTime(id: String) async { + let body = try? serialize(["id": id]) + _ = try? await request(method: "DELETE", path: "/_pit", body: body) + } + + // MARK: - Raw Request + + @discardableResult + func request(method: String, path: String, body: String? = nil) async throws -> ElasticsearchResponse { + let session: URLSession = try lock.withLock { + guard let session = _session else { throw ElasticsearchError.notConnected } + return session + } + + guard let url = URL(string: path, relativeTo: baseURL) else { + throw ElasticsearchError.connectionFailed("Invalid path: \(path)") + } + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = method + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.setValue("application/json", forHTTPHeaderField: "Accept") + if let authHeader { + urlRequest.setValue(authHeader, forHTTPHeaderField: "Authorization") + } + if let body { + urlRequest.httpBody = Data(body.utf8) + } + urlRequest.timeoutInterval = queryTimeout.requestTimeoutInterval + + let (data, response) = try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation<(Data, URLResponse), Error>) in + let task = session.dataTask(with: urlRequest) { [weak self] data, response, error in + self?.lock.withLock { self?._currentTask = nil } + if let error { + if (error as? URLError)?.code == .cancelled { + continuation.resume(throwing: ElasticsearchError.requestCancelled) + } else { + continuation.resume(throwing: ElasticsearchError.connectionFailed(error.localizedDescription)) + } + return + } + guard let data, let response else { + continuation.resume(throwing: ElasticsearchError.invalidResponse("Empty response")) + return + } + continuation.resume(returning: (data, response)) + } + self.lock.withLock { self._currentTask = task } + task.resume() + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw ElasticsearchError.invalidResponse("Not an HTTP response") + } + + let json = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + let rawText = String(data: data, encoding: .utf8) ?? "" + return ElasticsearchResponse(statusCode: httpResponse.statusCode, json: json, rawText: rawText) + } + + // MARK: - Helpers + + private func serialize(_ object: [String: Any]) throws -> String { + guard JSONSerialization.isValidJSONObject(object) else { + throw ElasticsearchError.invalidResponse("Invalid request body") + } + let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + return String(data: data, encoding: .utf8) ?? "{}" + } + + private func encode(_ component: String) -> String { + component.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? component + } + + private func mapError(_ response: ElasticsearchResponse, fallback: String) -> ElasticsearchError { + if response.statusCode == 401 || response.statusCode == 403 { + return .authFailed(reason(from: response) ?? fallback) + } + return .serverError(reason(from: response) ?? "HTTP \(response.statusCode): \(fallback)") + } + + private func reason(from response: ElasticsearchResponse) -> String? { + guard let json = response.json as? [String: Any] else { + return response.rawText.isEmpty ? nil : response.rawText + } + if let error = json["error"] as? [String: Any] { + let type = error["type"] as? String + let reason = error["reason"] as? String + return [type, reason].compactMap { $0 }.joined(separator: ": ") + } + if let error = json["error"] as? String { + return error + } + return nil + } + + private static func resolveAuthHeader(config: DriverConnectionConfig) -> String? { + switch config.additionalFields["esAuthMethod"] { + case "apiKey": + let key = config.additionalFields["esApiKey"] ?? "" + return key.isEmpty ? nil : "ApiKey \(key)" + case "none": + return nil + default: + guard !config.username.isEmpty else { return nil } + let credentials = "\(config.username):\(config.password)" + return "Basic \(Data(credentials.utf8).base64EncodedString())" + } + } +} + +extension ElasticsearchConnection: URLSessionDelegate { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard skipTLSVerify, + challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust + else { + completionHandler(.performDefaultHandling, nil) + return + } + completionHandler(.useCredential, URLCredential(trust: trust)) + } +} diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchConsoleParser.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchConsoleParser.swift new file mode 100644 index 000000000..91fc09703 --- /dev/null +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchConsoleParser.swift @@ -0,0 +1,45 @@ +// +// ElasticsearchConsoleParser.swift +// ElasticsearchDriverPlugin +// +// Parses Kibana Dev Tools console input into a REST request. +// + +import Foundation + +struct ElasticsearchConsoleRequest: Equatable { + let method: String + let path: String + let body: String? +} + +enum ElasticsearchConsoleParser { + static let supportedMethods: Set = ["GET", "POST", "PUT", "DELETE", "HEAD"] + + static func parse(_ input: String) -> ElasticsearchConsoleRequest? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + var lines = trimmed.components(separatedBy: "\n") + let header = lines.removeFirst().trimmingCharacters(in: .whitespaces) + let parts = header.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) + guard let methodPart = parts.first else { return nil } + + let method = String(methodPart).uppercased() + guard supportedMethods.contains(method) else { return nil } + + let path = parts.count == 2 ? normalizePath(String(parts[1]).trimmingCharacters(in: .whitespaces)) : "/" + + let bodyText = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + return ElasticsearchConsoleRequest(method: method, path: path, body: bodyText.isEmpty ? nil : bodyText) + } + + static func normalizePath(_ path: String) -> String { + guard !path.isEmpty else { return "/" } + return path.hasPrefix("/") ? path : "/" + path + } + + static func looksLikeConsoleInput(_ input: String) -> Bool { + parse(input) != nil + } +} diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchMappingFlattener.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchMappingFlattener.swift new file mode 100644 index 000000000..b43d6467c --- /dev/null +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchMappingFlattener.swift @@ -0,0 +1,163 @@ +// +// ElasticsearchMappingFlattener.swift +// ElasticsearchDriverPlugin +// +// Flattens index mappings into columns and documents into tabular rows. +// + +import Foundation +import TableProPluginKit + +struct ElasticsearchColumn: Equatable { + let name: String + let type: String + let hasKeywordSubfield: Bool +} + +enum ElasticsearchMappingFlattener { + private static let maxNestedJsonLength = 10_000 + + static let idColumn = "_id" + static let indexColumn = "_index" + static let scoreColumn = "_score" + static let metaColumns = [idColumn, indexColumn, scoreColumn] + + // MARK: - Mapping + + static func flattenMapping(properties: [String: Any]) -> [ElasticsearchColumn] { + var columns: [ElasticsearchColumn] = [] + collect(properties: properties, prefix: "", into: &columns) + return columns.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + private static func collect(properties: [String: Any], prefix: String, into columns: inout [ElasticsearchColumn]) { + for (key, raw) in properties { + guard let field = raw as? [String: Any] else { continue } + let path = prefix.isEmpty ? key : "\(prefix).\(key)" + + if let nested = field["properties"] as? [String: Any] { + collect(properties: nested, prefix: path, into: &columns) + continue + } + + let type = field["type"] as? String ?? "object" + let hasKeyword = (field["fields"] as? [String: Any]).map { subfields in + subfields.values.contains { ($0 as? [String: Any])?["type"] as? String == "keyword" } + } ?? false + columns.append(ElasticsearchColumn(name: path, type: type, hasKeywordSubfield: hasKeyword)) + } + } + + static func properties(fromMappingResponse response: [String: Any], index: String) -> [String: Any] { + let indexMapping = (response[index] as? [String: Any]) ?? response.values.first as? [String: Any] + let mappings = indexMapping?["mappings"] as? [String: Any] + return mappings?["properties"] as? [String: Any] ?? [:] + } + + static func fieldInfo(from columns: [ElasticsearchColumn]) -> [String: ElasticsearchFieldInfo] { + var result: [String: ElasticsearchFieldInfo] = [:] + for column in columns { + result[column.name] = ElasticsearchFieldInfo(type: column.type, hasKeywordSubfield: column.hasKeywordSubfield) + } + return result + } + + // MARK: - Columns From Hits + + static func unionColumns(fromSources sources: [[String: Any]]) -> [String] { + var seen = Set() + var ordered: [String] = [] + for source in sources { + for key in flattenSource(source).keys where !seen.contains(key) { + seen.insert(key) + ordered.append(key) + } + } + return ordered.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + + static func columns(forHits hits: [[String: Any]], mappingColumns: [ElasticsearchColumn]) -> [String] { + let dataColumns = mappingColumns.isEmpty + ? unionColumns(fromSources: hits.compactMap { $0["_source"] as? [String: Any] }) + : mappingColumns.map(\.name) + return metaColumns + dataColumns + } + + // MARK: - Rows + + static func rows(forHits hits: [[String: Any]], columns: [String]) -> [[PluginCellValue]] { + hits.map { hit in + let source = hit["_source"] as? [String: Any] ?? [:] + let flat = flattenSource(source) + return columns.map { column in + switch column { + case idColumn: + return cell(hit["_id"]) + case indexColumn: + return cell(hit["_index"]) + case scoreColumn: + return cell(hit["_score"]) + default: + guard let value = flat[column] else { return .null } + return value + } + } + } + } + + static func flattenSource(_ source: [String: Any]) -> [String: PluginCellValue] { + var result: [String: PluginCellValue] = [:] + flatten(value: source, prefix: "", into: &result) + return result + } + + private static func flatten(value: Any, prefix: String, into result: inout [String: PluginCellValue]) { + if let dict = value as? [String: Any] { + for (key, nested) in dict { + let path = prefix.isEmpty ? key : "\(prefix).\(key)" + flatten(value: nested, prefix: path, into: &result) + } + return + } + result[prefix] = cell(value) + } + + // MARK: - Cell Conversion + + static func cell(_ value: Any?) -> PluginCellValue { + guard let value, !(value is NSNull) else { return .null } + + switch value { + case let string as String: + return .text(string) + case let number as NSNumber: + return .text(stringFromNumber(number)) + case let array as [Any]: + return .text(serializeJson(array)) + case let dict as [String: Any]: + return .text(serializeJson(dict)) + default: + return .text(String(describing: value)) + } + } + + private static func stringFromNumber(_ number: NSNumber) -> String { + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue ? "true" : "false" + } + return number.stringValue + } + + private static func serializeJson(_ value: Any) -> String { + guard JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]), + let json = String(data: data, encoding: .utf8) + else { + return String(describing: value) + } + if (json as NSString).length > maxNestedJsonLength { + return String(json.prefix(maxNestedJsonLength)) + "..." + } + return json + } +} diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchPlugin.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPlugin.swift new file mode 100644 index 000000000..cdb2bd070 --- /dev/null +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPlugin.swift @@ -0,0 +1,113 @@ +// +// ElasticsearchPlugin.swift +// ElasticsearchDriverPlugin +// +// Elasticsearch driver plugin via the REST API with a Query DSL console. +// + +import Foundation +import TableProPluginKit + +final class ElasticsearchPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Elasticsearch Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Elasticsearch support via the REST API with a Query DSL console" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "Elasticsearch" + static let databaseDisplayName = "Elasticsearch" + static let iconName = "elasticsearch-icon" + static let defaultPort = 9200 + static let isDownloadable = true + + static let navigationModel: NavigationModel = .standard + static let pathFieldRole: PathFieldRole = .database + static let requiresAuthentication = false + static let brandColorHex = "#FEC514" + static let queryLanguageName = "Query DSL" + static let editorLanguage: EditorLanguage = .javascript + static let supportsForeignKeys = false + static let supportsSchemaEditing = false + static let supportsDatabaseSwitching = false + static let supportsImport = false + static let supportsExport = true + static let supportsSSH = false + static let supportsSSL = true + static let supportsReadOnlyMode = true + static let supportsForeignKeyDisable = false + static let supportsAddColumn = false + static let supportsModifyColumn = false + static let supportsDropColumn = false + static let supportsAddIndex = false + static let supportsDropIndex = false + static let supportsModifyPrimaryKey = false + static let databaseGroupingStrategy: GroupingStrategy = .flat + static let defaultGroupName = "default" + static let tableEntityName = "Indices" + static let containerEntityName = "Cluster" + static let immutableColumns: [String] = ["_id", "_index", "_score"] + static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable] + + static let columnTypesByCategory: [String: [String]] = [ + "Text": ["text", "keyword", "match_only_text", "search_as_you_type"], + "Numeric": ["long", "integer", "short", "byte", "double", "float", "half_float", "scaled_float", "unsigned_long"], + "Boolean": ["boolean"], + "Date": ["date", "date_nanos"], + "Binary": ["binary"], + "Range": ["integer_range", "float_range", "long_range", "double_range", "date_range", "ip_range"], + "Geo": ["geo_point", "geo_shape", "point", "shape"], + "Structured": ["object", "nested", "flattened", "join"], + "Specialized": ["ip", "completion", "token_count", "dense_vector", "rank_feature", "rank_features"], + ] + + static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "esAuthMethod", + label: String(localized: "Auth Method"), + defaultValue: "basic", + fieldType: .dropdown(options: [ + .init(value: "basic", label: "Username & Password"), + .init(value: "apiKey", label: "API Key"), + .init(value: "none", label: "None"), + ]), + section: .authentication + ), + ConnectionField( + id: "esApiKey", + label: String(localized: "API Key"), + placeholder: "Base64-encoded API key", + fieldType: .secure, + section: .authentication, + hidesPassword: true, + visibleWhen: FieldVisibilityRule(fieldId: "esAuthMethod", values: ["apiKey"]) + ), + ConnectionField( + id: "esSkipTLSVerify", + label: String(localized: "Skip TLS Verification"), + defaultValue: "false", + fieldType: .toggle, + section: .advanced + ), + ] + + static var statementCompletions: [CompletionEntry] { + [ + CompletionEntry(label: "GET /_search", insertText: "GET /_search\n{\n \"query\": {\n \"match_all\": {}\n }\n}"), + CompletionEntry(label: "GET /_cat/indices", insertText: "GET /_cat/indices?format=json"), + CompletionEntry(label: "GET /_cluster/health", insertText: "GET /_cluster/health"), + CompletionEntry(label: "GET /_mapping", insertText: "GET /_mapping"), + CompletionEntry(label: "match", insertText: "\"match\": { \"field\": \"value\" }"), + CompletionEntry(label: "match_all", insertText: "\"match_all\": {}"), + CompletionEntry(label: "term", insertText: "\"term\": { \"field\": \"value\" }"), + CompletionEntry(label: "terms", insertText: "\"terms\": { \"field\": [\"a\", \"b\"] }"), + CompletionEntry(label: "range", insertText: "\"range\": { \"field\": { \"gte\": 0, \"lte\": 100 } }"), + CompletionEntry(label: "bool", insertText: "\"bool\": {\n \"must\": [],\n \"filter\": [],\n \"must_not\": [],\n \"should\": []\n}"), + CompletionEntry(label: "exists", insertText: "\"exists\": { \"field\": \"field\" }"), + CompletionEntry(label: "aggs", insertText: "\"aggs\": {\n \"name\": {\n \"terms\": { \"field\": \"field\" }\n }\n}"), + ] + } + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + ElasticsearchPluginDriver(config: config) + } +} diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver+Execution.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver+Execution.swift new file mode 100644 index 000000000..80ee9e477 --- /dev/null +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver+Execution.swift @@ -0,0 +1,278 @@ +// +// ElasticsearchPluginDriver+Execution.swift +// ElasticsearchDriverPlugin +// +// Query routing, search execution, and response rendering. +// + +import Foundation +import TableProPluginKit + +extension ElasticsearchPluginDriver { + func execute(query: String) async throws -> PluginQueryResult { + let startTime = Date() + guard let conn = connection else { throw ElasticsearchError.notConnected } + + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.lowercased() == "select 1" { + try await conn.ping() + return PluginQueryResult( + columns: ["ok"], + columnTypeNames: ["integer"], + rows: [[.text("1")]], + rowsAffected: 0, + executionTime: Date().timeIntervalSince(startTime) + ) + } + + if ElasticsearchQueryBuilder.isTaggedQuery(trimmed) { + return try await executeSearch(trimmed, conn: conn, startTime: startTime) + } + + if ElasticsearchStatementGenerator.isTaggedStatement(trimmed) { + return try await executeWrite(trimmed, conn: conn, startTime: startTime) + } + + return try await executeConsole(trimmed, conn: conn, startTime: startTime) + } + + // MARK: - Search + + private func executeSearch( + _ query: String, + conn: ElasticsearchConnection, + startTime: Date + ) async throws -> PluginQueryResult { + let (base, appendedSorts) = ElasticsearchQueryBuilder.extractOrderBy(query) + guard let parsedBase = ElasticsearchQueryBuilder.parseSearch(base) else { + throw ElasticsearchError.invalidResponse("Invalid search request") + } + let parsed = appendedSorts.isEmpty ? parsedBase : ElasticsearchParsedSearch( + index: parsedBase.index, from: parsedBase.from, size: parsedBase.size, + sorts: appendedSorts, filters: parsedBase.filters, logicMode: parsedBase.logicMode + ) + + let mappingColumns = try await cachedMappingColumns(parsed.index) + let fields = ElasticsearchMappingFlattener.fieldInfo(from: mappingColumns) + + Self.logger.debug(""" + executeSearch index=\(parsed.index, privacy: .public) from=\(parsed.from) size=\(parsed.size) \ + logic=\(parsed.logicMode, privacy: .public) \ + filters=\(parsed.filters.map { "\($0.column) \($0.op) \($0.value)" }.joined(separator: " | "), privacy: .public) \ + sorts=\(parsed.sorts.map { "\($0.column) \($0.ascending ? "asc" : "desc")" }.joined(separator: " | "), privacy: .public) \ + fieldInfoCount=\(fields.count) \ + fields=\(fields.map { "\($0.key):\($0.value.type)\($0.value.hasKeywordSubfield ? "+kw" : "")" }.sorted().joined(separator: ","), privacy: .public) + """) + + let hits = try await fetchHits(index: parsed.index, parsed: parsed, fields: fields, conn: conn) + + return renderHits(hits, mappingColumns: mappingColumns, fields: fields, startTime: startTime) + } + + private func fetchHits( + index: String, + parsed: ElasticsearchParsedSearch, + fields: [String: ElasticsearchFieldInfo], + conn: ElasticsearchConnection + ) async throws -> [[String: Any]] { + if parsed.from + parsed.size <= Self.maxResultWindow { + var body = ElasticsearchQueryBuilder.searchBody(for: parsed, fields: fields, size: parsed.size) + body["from"] = parsed.from + Self.logger.debug("POST /\(index, privacy: .public)/_search body=\(Self.jsonString(body), privacy: .public)") + let response = try await conn.search(index: index, body: body) + let hits = extractHits(response) + Self.logger.debug("_search returned \(hits.count) hit(s) for index=\(index, privacy: .public)") + return hits + } + return try await deepFetchHits(index: index, parsed: parsed, fields: fields, conn: conn) + } + + private func deepFetchHits( + index: String, + parsed: ElasticsearchParsedSearch, + fields: [String: ElasticsearchFieldInfo], + conn: ElasticsearchConnection + ) async throws -> [[String: Any]] { + let pit = try await conn.openPointInTime(index: index, keepAlive: Self.pitKeepAlive) + defer { Task { await conn.closePointInTime(id: pit) } } + + var searchAfter: [Any]? + var skipped = 0 + var collected: [[String: Any]] = [] + + while collected.count < parsed.size { + try Task.checkCancellation() + var body = ElasticsearchQueryBuilder.searchBody( + for: parsed, fields: fields, size: Self.deepPageBatchSize, + tiebreaker: true, searchAfter: searchAfter + ) + body["pit"] = ["id": pit, "keep_alive": Self.pitKeepAlive] + let response = try await conn.search(index: nil, body: body) + let hits = extractHits(response) + guard !hits.isEmpty else { break } + + for hit in hits { + if skipped < parsed.from { + skipped += 1 + } else if collected.count < parsed.size { + collected.append(hit) + } + searchAfter = hit["sort"] as? [Any] + } + + if hits.count < Self.deepPageBatchSize { break } + } + + return collected + } + + // MARK: - Write + + private func executeWrite( + _ statement: String, + conn: ElasticsearchConnection, + startTime: Date + ) async throws -> PluginQueryResult { + guard let request = ElasticsearchStatementGenerator.decode(statement) else { + throw ElasticsearchError.invalidResponse("Invalid write request") + } + + let response = try await conn.request(method: request.method, path: request.path, body: request.body) + guard (200..<300).contains(response.statusCode) else { + throw mapWriteError(response) + } + + let outcome = (response.json as? [String: Any])?["result"] as? String ?? "ok" + return PluginQueryResult( + columns: ["result"], + columnTypeNames: ["keyword"], + rows: [[.text(outcome)]], + rowsAffected: 1, + executionTime: Date().timeIntervalSince(startTime) + ) + } + + // MARK: - Console + + private func executeConsole( + _ input: String, + conn: ElasticsearchConnection, + startTime: Date + ) async throws -> PluginQueryResult { + guard let request = ElasticsearchConsoleParser.parse(input) else { + throw ElasticsearchError.invalidResponse( + String(localized: "Enter a request like: GET /my-index/_search") + ) + } + + let response = try await conn.request(method: request.method, path: request.path, body: request.body) + guard (200..<300).contains(response.statusCode) else { + throw mapWriteError(response) + } + + if let json = response.json as? [String: Any], json["hits"] is [String: Any] { + let hits = extractHits(response) + return renderHits(hits, mappingColumns: [], fields: [:], startTime: startTime) + } + if let rows = response.json as? [[String: Any]] { + return renderObjects(rows, startTime: startTime) + } + return renderRawJson(response, startTime: startTime) + } + + // MARK: - Rendering + + private func renderHits( + _ hits: [[String: Any]], + mappingColumns: [ElasticsearchColumn], + fields: [String: ElasticsearchFieldInfo], + startTime: Date + ) -> PluginQueryResult { + let columns = ElasticsearchMappingFlattener.columns(forHits: hits, mappingColumns: mappingColumns) + let rows = ElasticsearchMappingFlattener.rows(forHits: hits, columns: columns) + let typeNames = columns.map { column -> String in + switch column { + case ElasticsearchMappingFlattener.idColumn, ElasticsearchMappingFlattener.indexColumn: + return "keyword" + case ElasticsearchMappingFlattener.scoreColumn: + return "float" + default: + return fields[column]?.type ?? "" + } + } + return PluginQueryResult( + columns: columns, + columnTypeNames: typeNames, + rows: rows, + rowsAffected: 0, + executionTime: Date().timeIntervalSince(startTime) + ) + } + + private func renderObjects(_ objects: [[String: Any]], startTime: Date) -> PluginQueryResult { + let columns = ElasticsearchMappingFlattener.unionColumns(fromSources: objects) + let rows = objects.map { object -> [PluginCellValue] in + let flat = ElasticsearchMappingFlattener.flattenSource(object) + return columns.map { flat[$0] ?? .null } + } + return PluginQueryResult( + columns: columns.isEmpty ? ["response"] : columns, + columnTypeNames: columns.isEmpty ? ["json"] : columns.map { _ in "" }, + rows: columns.isEmpty ? [] : rows, + rowsAffected: 0, + executionTime: Date().timeIntervalSince(startTime) + ) + } + + private func renderRawJson(_ response: ElasticsearchResponse, startTime: Date) -> PluginQueryResult { + let pretty: String + if let json = response.json, + JSONSerialization.isValidJSONObject(json), + let data = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]), + let string = String(data: data, encoding: .utf8) { + pretty = string + } else { + pretty = response.rawText + } + return PluginQueryResult( + columns: ["response"], + columnTypeNames: ["json"], + rows: [[.text(pretty)]], + rowsAffected: 0, + executionTime: Date().timeIntervalSince(startTime) + ) + } + + // MARK: - Helpers + + static func jsonString(_ object: [String: Any]) -> String { + guard JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]), + let string = String(data: data, encoding: .utf8) + else { return "" } + return string + } + + private func extractHits(_ response: ElasticsearchResponse) -> [[String: Any]] { + guard let json = response.json as? [String: Any], + let hitsObject = json["hits"] as? [String: Any], + let hits = hitsObject["hits"] as? [[String: Any]] + else { return [] } + return hits + } + + private func mapWriteError(_ response: ElasticsearchResponse) -> ElasticsearchError { + let reason = (response.json as? [String: Any]).flatMap { json -> String? in + if let error = json["error"] as? [String: Any] { + return [error["type"] as? String, error["reason"] as? String] + .compactMap { $0 }.joined(separator: ": ") + } + return json["error"] as? String + } + if response.statusCode == 401 || response.statusCode == 403 { + return .authFailed(reason ?? "Access denied") + } + return .serverError(reason ?? "HTTP \(response.statusCode)") + } +} diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift new file mode 100644 index 000000000..03d4445d1 --- /dev/null +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift @@ -0,0 +1,246 @@ +// +// ElasticsearchPluginDriver.swift +// ElasticsearchDriverPlugin +// +// PluginDatabaseDriver implementation for Elasticsearch over the REST API. +// + +import Foundation +import os +import TableProPluginKit + +internal final class ElasticsearchPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + private let config: DriverConnectionConfig + private let lock = NSLock() + private var _connection: ElasticsearchConnection? + private var _mappingCache: [String: [ElasticsearchColumn]] = [:] + + static let logger = Logger(subsystem: "com.TablePro", category: "ElasticsearchPluginDriver") + static let maxResultWindow = 10_000 + static let deepPageBatchSize = 1_000 + static let pitKeepAlive = "1m" + + var connection: ElasticsearchConnection? { lock.withLock { _connection } } + + var serverVersion: String? { connection?.serverVersion } + + var supportsTransactions: Bool { false } + + var capabilities: PluginCapabilities { [.cancelQuery] } + + init(config: DriverConnectionConfig) { + self.config = config + } + + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} + + // MARK: - Lifecycle + + func connect() async throws { + let conn = try ElasticsearchConnection(config: config) + try await conn.connect() + lock.withLock { _connection = conn } + } + + func disconnect() { + lock.withLock { + _connection?.disconnect() + _connection = nil + _mappingCache.removeAll() + } + } + + func ping() async throws { + guard let conn = connection else { throw ElasticsearchError.notConnected } + try await conn.ping() + } + + func cancelQuery() throws { + connection?.cancelCurrentRequest() + } + + func applyQueryTimeout(_ seconds: Int) async throws { + connection?.setQueryTimeout(seconds) + } + + // MARK: - Schema + + func fetchDatabases() async throws -> [String] { + ["default"] + } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + guard let conn = connection else { throw ElasticsearchError.notConnected } + let indices = try await conn.catIndices() + return indices + .filter { !$0.name.hasPrefix(".") } + .map { PluginTableInfo(name: $0.name, type: "TABLE", rowCount: $0.docsCount) } + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + let columns = try await cachedMappingColumns(table) + + var result: [PluginColumnInfo] = [ + PluginColumnInfo(name: ElasticsearchMappingFlattener.idColumn, dataType: "keyword", isNullable: false, isPrimaryKey: true), + PluginColumnInfo(name: ElasticsearchMappingFlattener.indexColumn, dataType: "keyword"), + PluginColumnInfo(name: ElasticsearchMappingFlattener.scoreColumn, dataType: "float"), + ] + result += columns.map { column in + PluginColumnInfo( + name: column.name, + dataType: column.type, + isNullable: true, + isPrimaryKey: false, + extra: column.hasKeywordSubfield ? "keyword" : nil + ) + } + return result + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + [PluginIndexInfo(name: "_id", columns: ["_id"], isUnique: true, isPrimary: true, type: "PRIMARY KEY")] + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { + [] + } + + func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { + guard let conn = connection else { throw ElasticsearchError.notConnected } + return try await conn.count(index: table, query: nil) + } + + func fetchTableDDL(table: String, schema: String?) async throws -> String { + guard let conn = connection else { throw ElasticsearchError.notConnected } + let response = try await conn.request(method: "GET", path: "/\(table)/_mapping") + return prettyJson(response.rawText) + } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { + throw ElasticsearchError.serverError("Elasticsearch does not support views") + } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + guard let conn = connection else { throw ElasticsearchError.notConnected } + let info = try await conn.catIndices().first { $0.name == table } + return PluginTableMetadata( + tableName: table, + rowCount: info?.docsCount.map(Int64.init), + comment: info?.storeSize.map { String(format: String(localized: "Size: %@"), $0) }, + engine: "Elasticsearch" + ) + } + + // MARK: - Browse / Filter Hooks + + func buildBrowseQuery( + table: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + let sorts = sortSpecs(from: sortColumns, columns: columns) + Self.logger.debug(""" + buildBrowseQuery table=\(table, privacy: .public) limit=\(limit) offset=\(offset) \ + columns=[\(columns.joined(separator: ","), privacy: .public)] \ + sortColumns=\(sortColumns.map { "[\($0.columnIndex)]=\($0.ascending ? "asc" : "desc")" }.joined(separator: " "), privacy: .public) \ + resolvedSorts=\(sorts.map { "\($0.column) \($0.ascending ? "asc" : "desc")" }.joined(separator: " | "), privacy: .public) + """) + return ElasticsearchQueryBuilder().buildBrowseQuery(index: table, sorts: sorts, limit: limit, offset: offset) + } + + func buildFilteredQuery( + table: String, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + let sorts = sortSpecs(from: sortColumns, columns: columns) + Self.logger.debug(""" + buildFilteredQuery table=\(table, privacy: .public) logic=\(logicMode, privacy: .public) limit=\(limit) offset=\(offset) \ + columns=[\(columns.joined(separator: ","), privacy: .public)] \ + filters=\(filters.map { "\($0.column) \($0.op) '\($0.value)'" }.joined(separator: " | "), privacy: .public) \ + sortColumns=\(sortColumns.map { "[\($0.columnIndex)]=\($0.ascending ? "asc" : "desc")" }.joined(separator: " "), privacy: .public) \ + resolvedSorts=\(sorts.map { "\($0.column) \($0.ascending ? "asc" : "desc")" }.joined(separator: " | "), privacy: .public) + """) + return ElasticsearchQueryBuilder().buildFilteredQuery( + index: table, filters: filters, logicMode: logicMode, sorts: sorts, limit: limit, offset: offset + ) + } + + // MARK: - Statement Generation + + func generateStatements( + table: String, + columns: [String], + primaryKeyColumns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [PluginCellValue])]? { + let typeNames = columnTypeNames(for: columns, index: table) + let generator = ElasticsearchStatementGenerator(index: table, columns: columns, columnTypeNames: typeNames) + return generator.generateStatements( + from: changes, + insertedRowData: insertedRowData, + deletedRowIndices: deletedRowIndices, + insertedRowIndices: insertedRowIndices + ) + } + + // MARK: - Caching + + func cachedMappingColumns(_ index: String) async throws -> [ElasticsearchColumn] { + if let cached = lock.withLock({ _mappingCache[index] }) { + return cached + } + guard let conn = connection else { throw ElasticsearchError.notConnected } + let columns = try await conn.mappingProperties(index: index) + lock.withLock { _mappingCache[index] = columns } + return columns + } + + private func columnTypeNames(for columns: [String], index: String) -> [String] { + let cached = lock.withLock { _mappingCache[index] } ?? [] + var lookup: [String: String] = [:] + for column in cached { + lookup[column.name] = column.type + } + lookup[ElasticsearchMappingFlattener.idColumn] = "keyword" + lookup[ElasticsearchMappingFlattener.indexColumn] = "keyword" + lookup[ElasticsearchMappingFlattener.scoreColumn] = "float" + return columns.map { lookup[$0] ?? "" } + } + + private func sortSpecs( + from sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String] + ) -> [ElasticsearchSortSpec] { + sortColumns.compactMap { sort in + guard sort.columnIndex >= 0, sort.columnIndex < columns.count else { return nil } + let column = columns[sort.columnIndex] + guard column != ElasticsearchMappingFlattener.scoreColumn else { return nil } + return ElasticsearchSortSpec(column: column, ascending: sort.ascending) + } + } + + private func prettyJson(_ raw: String) -> String { + guard let data = raw.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let pretty = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]), + let string = String(data: pretty, encoding: .utf8) + else { return raw } + return string + } +} diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchQueryBuilder.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchQueryBuilder.swift new file mode 100644 index 000000000..0d5845055 --- /dev/null +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchQueryBuilder.swift @@ -0,0 +1,330 @@ +// +// ElasticsearchQueryBuilder.swift +// ElasticsearchDriverPlugin +// +// Encodes browse and filter requests as tagged strings and builds Query DSL bodies. +// + +import Foundation + +struct ElasticsearchFilterSpec: Codable, Equatable { + let column: String + let op: String + let value: String +} + +struct ElasticsearchSortSpec: Codable, Equatable { + let column: String + let ascending: Bool +} + +struct ElasticsearchFieldInfo: Equatable { + let type: String + let hasKeywordSubfield: Bool +} + +struct ElasticsearchParsedSearch: Equatable { + let index: String + let from: Int + let size: Int + let sorts: [ElasticsearchSortSpec] + let filters: [ElasticsearchFilterSpec] + let logicMode: String +} + +struct ElasticsearchQueryBuilder { + static let searchTag = "ELASTICSEARCH_SEARCH:" + + static let rawColumn = "__RAW__" + static let textTypes: Set = ["text", "match_only_text", "search_as_you_type"] + static let numericTypes: Set = [ + "long", "integer", "short", "byte", "double", "float", + "half_float", "scaled_float", "unsigned_long" + ] + static let metaColumns: Set = ["_id", "_index", "_score"] + + func buildBrowseQuery( + index: String, + sorts: [ElasticsearchSortSpec], + limit: Int, + offset: Int + ) -> String { + Self.encodeSearch(index: index, from: offset, size: limit, sorts: sorts, filters: [], logicMode: "AND") + } + + func buildFilteredQuery( + index: String, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sorts: [ElasticsearchSortSpec], + limit: Int, + offset: Int + ) -> String { + let specs = filters.map { ElasticsearchFilterSpec(column: $0.column, op: $0.op, value: $0.value) } + return Self.encodeSearch( + index: index, from: offset, size: limit, sorts: sorts, filters: specs, logicMode: logicMode + ) + } + + // MARK: - Encoding + + static func encodeSearch( + index: String, + from: Int, + size: Int, + sorts: [ElasticsearchSortSpec], + filters: [ElasticsearchFilterSpec], + logicMode: String + ) -> String { + let b64Index = Data(index.utf8).base64EncodedString() + let sortsJson = (try? JSONEncoder().encode(sorts)) ?? Data() + let filtersJson = (try? JSONEncoder().encode(filters)) ?? Data() + let b64Sorts = sortsJson.base64EncodedString() + let b64Filters = filtersJson.base64EncodedString() + let b64Logic = Data(logicMode.utf8).base64EncodedString() + return "\(searchTag)\(b64Index):\(from):\(size):\(b64Sorts):\(b64Filters):\(b64Logic)" + } + + static func parseSearch(_ query: String) -> ElasticsearchParsedSearch? { + guard query.hasPrefix(searchTag) else { return nil } + let body = String(query.dropFirst(searchTag.count)) + let parts = body.components(separatedBy: ":") + guard parts.count >= 6, + let indexData = Data(base64Encoded: parts[0]), + let index = String(data: indexData, encoding: .utf8), + let from = Int(parts[1]), + let size = Int(parts[2]) + else { return nil } + + let sorts: [ElasticsearchSortSpec] + if let data = Data(base64Encoded: parts[3]), + let decoded = try? JSONDecoder().decode([ElasticsearchSortSpec].self, from: data) { + sorts = decoded + } else { + sorts = [] + } + + let filters: [ElasticsearchFilterSpec] + if let data = Data(base64Encoded: parts[4]), + let decoded = try? JSONDecoder().decode([ElasticsearchFilterSpec].self, from: data) { + filters = decoded + } else { + filters = [] + } + + let logicMode = (Data(base64Encoded: parts[5]).flatMap { String(data: $0, encoding: .utf8) }) ?? "AND" + + return ElasticsearchParsedSearch( + index: index, from: from, size: size, sorts: sorts, filters: filters, logicMode: logicMode + ) + } + + static func isTaggedQuery(_ query: String) -> Bool { + query.hasPrefix(searchTag) + } + + /// The data grid appends a SQL `ORDER BY` clause to the opaque tagged query when the + /// user sorts a column. Split it off and parse it into sort specs. + static func extractOrderBy(_ query: String) -> (base: String, sorts: [ElasticsearchSortSpec]) { + guard let range = query.range(of: " ORDER BY ", options: .caseInsensitive) else { + return (query, []) + } + let base = String(query[.. [ElasticsearchSortSpec] { + clause.split(separator: ",").compactMap { rawPart in + let part = rawPart.trimmingCharacters(in: .whitespaces) + guard !part.isEmpty else { return nil } + + let column: String + var remainder: Substring + if part.hasPrefix("\"") { + let afterQuote = part.dropFirst() + guard let closing = afterQuote.firstIndex(of: "\"") else { return nil } + column = String(afterQuote[.. 1 ? tokens[1] : "" + } + + let ascending = !remainder.uppercased().contains("DESC") + return ElasticsearchSortSpec(column: column, ascending: ascending) + } + } + + // MARK: - Query DSL Construction + + static func searchBody( + for parsed: ElasticsearchParsedSearch, + fields: [String: ElasticsearchFieldInfo], + size: Int, + tiebreaker: Bool = false, + searchAfter: [Any]? = nil + ) -> [String: Any] { + var body: [String: Any] = ["size": size] + body["query"] = queryClause(filters: parsed.filters, logicMode: parsed.logicMode, fields: fields) + body["sort"] = sortClause(parsed.sorts, fields: fields, tiebreaker: tiebreaker) + if let searchAfter { + body["search_after"] = searchAfter + } + return body + } + + static func queryClause( + filters: [ElasticsearchFilterSpec], + logicMode: String, + fields: [String: ElasticsearchFieldInfo] + ) -> [String: Any] { + let active = filters.filter { !($0.column == rawColumn && $0.value.trimmingCharacters(in: .whitespaces).isEmpty) } + guard !active.isEmpty else { return ["match_all": [String: Any]()] } + + let clauses = active.map { clause(for: $0, fields: fields) } + let occur = logicMode.uppercased() == "OR" ? "should" : "must" + var bool: [String: Any] = [occur: clauses] + if occur == "should" { + bool["minimum_should_match"] = 1 + } + return ["bool": bool] + } + + static func sortClause( + _ sorts: [ElasticsearchSortSpec], + fields: [String: ElasticsearchFieldInfo], + tiebreaker: Bool + ) -> [[String: Any]] { + var result: [[String: Any]] = sorts.compactMap { sort in + guard let field = sortableField(sort.column, fields: fields) else { return nil } + return [field: ["order": sort.ascending ? "asc" : "desc"]] + } + if tiebreaker { + result.append(["_shard_doc": ["order": "asc"]]) + } + return result + } + + // MARK: - Field Resolution + + /// Returns the field to sort on, or nil when the column cannot be sorted (sorting it would + /// raise a fielddata error and fail the whole query). `_id` is not sortable in Elasticsearch; + /// a `text` field is sortable only through its `.keyword` sub-field. + static func sortableField(_ column: String, fields: [String: ElasticsearchFieldInfo]) -> String? { + if column == "_id" { return nil } + if column == "_score" || column == "_index" { return column } + guard let info = fields[column] else { return nil } + if textTypes.contains(info.type) { + return info.hasKeywordSubfield ? "\(column).keyword" : nil + } + return column + } + + private static func matchableKeywordField(_ column: String, fields: [String: ElasticsearchFieldInfo]) -> String? { + guard let info = fields[column] else { return column } + if textTypes.contains(info.type) { + return info.hasKeywordSubfield ? "\(column).keyword" : nil + } + return column + } + + private static func isTextField(_ column: String, fields: [String: ElasticsearchFieldInfo]) -> Bool { + guard let info = fields[column] else { return false } + return textTypes.contains(info.type) + } + + // MARK: - Per-Filter Clause + + static func clause(for filter: ElasticsearchFilterSpec, fields: [String: ElasticsearchFieldInfo]) -> [String: Any] { + let column = filter.column + let op = filter.op.uppercased() + let value = filter.value + + if column == rawColumn { + return ["query_string": ["query": value, "lenient": true]] + } + + switch op { + case "=": + return equalsClause(column: column, value: value, fields: fields) + case "!=", "<>": + return ["bool": ["must_not": [equalsClause(column: column, value: value, fields: fields)]]] + case ">": + return ["range": [column: ["gt": typedValue(value, column: column, fields: fields)]]] + case ">=": + return ["range": [column: ["gte": typedValue(value, column: column, fields: fields)]]] + case "<": + return ["range": [column: ["lt": typedValue(value, column: column, fields: fields)]]] + case "<=": + return ["range": [column: ["lte": typedValue(value, column: column, fields: fields)]]] + case "CONTAINS": + if isTextField(column, fields: fields) { + return ["match": [column: value]] + } + return ["wildcard": [keywordField(column, fields: fields): ["value": "*\(escapeWildcard(value))*", "case_insensitive": true]]] + case "STARTS WITH": + if isTextField(column, fields: fields) { + return ["match_phrase_prefix": [column: value]] + } + return ["prefix": [keywordField(column, fields: fields): ["value": value, "case_insensitive": true]]] + case "ENDS WITH": + return ["wildcard": [keywordField(column, fields: fields): ["value": "*\(escapeWildcard(value))", "case_insensitive": true]]] + case "IN": + let values = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + return ["terms": [keywordField(column, fields: fields): values]] + case "IS NULL", "IS_NULL": + return ["bool": ["must_not": [["exists": ["field": column]]]]] + case "IS NOT NULL", "IS_NOT_NULL": + return ["exists": ["field": column]] + default: + return equalsClause(column: column, value: value, fields: fields) + } + } + + private static func equalsClause( + column: String, + value: String, + fields: [String: ElasticsearchFieldInfo] + ) -> [String: Any] { + if isTextField(column, fields: fields) { + if let keyword = matchableKeywordField(column, fields: fields), keyword != column { + return ["term": [keyword: value]] + } + return ["match_phrase": [column: value]] + } + return ["term": [column: typedValue(value, column: column, fields: fields)]] + } + + private static func keywordField(_ column: String, fields: [String: ElasticsearchFieldInfo]) -> String { + matchableKeywordField(column, fields: fields) ?? column + } + + static func typedValue(_ value: String, column: String, fields: [String: ElasticsearchFieldInfo]) -> Any { + guard let info = fields[column] else { return value } + if numericTypes.contains(info.type) { + if let intVal = Int(value) { return intVal } + if let doubleVal = Double(value) { return doubleVal } + return value + } + if info.type == "boolean" { + let lower = value.lowercased() + if lower == "true" { return true } + if lower == "false" { return false } + } + return value + } + + private static func escapeWildcard(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "*", with: "\\*") + .replacingOccurrences(of: "?", with: "\\?") + } +} diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchStatementGenerator.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchStatementGenerator.swift new file mode 100644 index 000000000..0912a2283 --- /dev/null +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchStatementGenerator.swift @@ -0,0 +1,206 @@ +// +// ElasticsearchStatementGenerator.swift +// ElasticsearchDriverPlugin +// +// Converts tracked cell changes into tagged Elasticsearch REST mutations. +// + +import Foundation +import os +import TableProPluginKit + +struct ElasticsearchWriteRequest: Equatable { + let method: String + let path: String + let body: String? +} + +struct ElasticsearchStatementGenerator { + private static let logger = Logger(subsystem: "com.TablePro", category: "ElasticsearchStatementGenerator") + static let writeTag = "ELASTICSEARCH_WRITE:" + private static let refreshQuery = "?refresh=true" + + let index: String + let columns: [String] + let columnTypeNames: [String] + + private var metaColumns: Set { Set(ElasticsearchMappingFlattener.metaColumns) } + + func generateStatements( + from changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [PluginCellValue])] { + var statements: [(statement: String, parameters: [PluginCellValue])] = [] + + for change in changes { + switch change.type { + case .insert: + guard insertedRowIndices.contains(change.rowIndex) else { continue } + if let statement = generateInsert(for: change, insertedRowData: insertedRowData) { + statements.append(statement) + } + case .update: + if let statement = generateUpdate(for: change) { + statements.append(statement) + } + case .delete: + guard deletedRowIndices.contains(change.rowIndex) else { continue } + if let statement = generateDelete(for: change) { + statements.append(statement) + } + } + } + + return statements + } + + // MARK: - INSERT + + private func generateInsert( + for change: PluginRowChange, + insertedRowData: [Int: [PluginCellValue]] + ) -> (statement: String, parameters: [PluginCellValue])? { + var values: [String: PluginCellValue] = [:] + if let rowData = insertedRowData[change.rowIndex] { + for (columnIndex, column) in columns.enumerated() where columnIndex < rowData.count { + values[column] = rowData[columnIndex] + } + } else { + for cellChange in change.cellChanges { + values[cellChange.columnName] = cellChange.newValue + } + } + + var document: [String: Any] = [:] + for column in columns where !metaColumns.contains(column) { + guard let value = values[column], let text = value.asText, !text.isEmpty else { continue } + document[column] = jsonValue(text, for: column) + } + + guard let body = serialize(document) else { return nil } + + let explicitId = values[ElasticsearchMappingFlattener.idColumn]?.asText + if let id = explicitId, !id.isEmpty { + return encode(.init(method: "PUT", path: docPath(id: id), body: body)) + } + return encode(.init(method: "POST", path: "/\(encodedIndex)/_doc\(Self.refreshQuery)", body: body)) + } + + // MARK: - UPDATE + + private func generateUpdate(for change: PluginRowChange) -> (statement: String, parameters: [PluginCellValue])? { + guard let id = documentId(from: change) else { + Self.logger.warning("Skipping UPDATE - missing _id") + return nil + } + + var doc: [String: Any] = [:] + for cellChange in change.cellChanges where !metaColumns.contains(cellChange.columnName) { + if let text = cellChange.newValue.asText { + doc[cellChange.columnName] = jsonValue(text, for: cellChange.columnName) + } else { + doc[cellChange.columnName] = NSNull() + } + } + + guard !doc.isEmpty, let body = serialize(["doc": doc]) else { return nil } + return encode(.init(method: "POST", path: "/\(encodedIndex)/_update/\(encodePathComponent(id))\(Self.refreshQuery)", body: body)) + } + + // MARK: - DELETE + + private func generateDelete(for change: PluginRowChange) -> (statement: String, parameters: [PluginCellValue])? { + guard let id = documentId(from: change) else { + Self.logger.warning("Skipping DELETE - missing _id") + return nil + } + return encode(.init(method: "DELETE", path: docPath(id: id), body: nil)) + } + + // MARK: - Helpers + + private func documentId(from change: PluginRowChange) -> String? { + guard let originalRow = change.originalRow, + let idIndex = columns.firstIndex(of: ElasticsearchMappingFlattener.idColumn), + idIndex < originalRow.count, + let id = originalRow[idIndex].asText, + !id.isEmpty + else { return nil } + return id + } + + private func docPath(id: String) -> String { + "/\(encodedIndex)/_doc/\(encodePathComponent(id))\(Self.refreshQuery)" + } + + private var encodedIndex: String { + encodePathComponent(index) + } + + private func encodePathComponent(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value + } + + private func jsonValue(_ text: String, for column: String) -> Any { + let typeName = columns.firstIndex(of: column).flatMap { index in + index < columnTypeNames.count ? columnTypeNames[index] : nil + } ?? "" + + if ElasticsearchQueryBuilder.numericTypes.contains(typeName) { + if let intVal = Int(text) { return intVal } + if let doubleVal = Double(text) { return doubleVal } + } + if typeName == "boolean" { + let lower = text.lowercased() + if lower == "true" { return true } + if lower == "false" { return false } + } + + if let data = text.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data), + parsed is [Any] || parsed is [String: Any] { + return parsed + } + return text + } + + private func serialize(_ object: [String: Any]) -> String? { + guard JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + else { return nil } + return String(data: data, encoding: .utf8) + } + + private func encode(_ request: ElasticsearchWriteRequest) -> (statement: String, parameters: [PluginCellValue]) { + (statement: Self.encode(request), parameters: []) + } + + static func encode(_ request: ElasticsearchWriteRequest) -> String { + let b64Method = Data(request.method.utf8).base64EncodedString() + let b64Path = Data(request.path.utf8).base64EncodedString() + let b64Body = Data((request.body ?? "").utf8).base64EncodedString() + return "\(writeTag)\(b64Method):\(b64Path):\(b64Body)" + } + + static func decode(_ statement: String) -> ElasticsearchWriteRequest? { + guard statement.hasPrefix(writeTag) else { return nil } + let parts = String(statement.dropFirst(writeTag.count)).components(separatedBy: ":") + guard parts.count >= 3, + let method = decodeBase64(parts[0]), + let path = decodeBase64(parts[1]) + else { return nil } + let body = decodeBase64(parts[2]) + return ElasticsearchWriteRequest(method: method, path: path, body: (body?.isEmpty ?? true) ? nil : body) + } + + static func isTaggedStatement(_ statement: String) -> Bool { + statement.hasPrefix(writeTag) + } + + private static func decodeBase64(_ string: String) -> String? { + guard let data = Data(base64Encoded: string) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/Plugins/ElasticsearchDriverPlugin/Info.plist b/Plugins/ElasticsearchDriverPlugin/Info.plist new file mode 100644 index 000000000..d8d79de88 --- /dev/null +++ b/Plugins/ElasticsearchDriverPlugin/Info.plist @@ -0,0 +1,10 @@ + + + + + TableProMinAppVersion + 0.53.0 + TableProPluginKitVersion + 18 + + diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 7f7863231..bdd10eef1 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -7,13 +7,13 @@ objects = { /* Begin PBXBuildFile section */ - 5A1MPORT000000000000A011 /* TableProImport in Frameworks */ = {isa = PBXBuildFile; productRef = 5A1MPORT000000000000A010 /* TableProImport */; }; 16C74CC07CC30A38ADE1663E /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 267A5C6ECC62401598389396 /* SnowflakeDDLGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF2F4A15214F2AA1DE95CF /* SnowflakeDDLGenerator.swift */; }; 33E6FF2997594F41AA80EEC8 /* SnowflakeConnectionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8D74A27EE24B89A7DC79F9 /* SnowflakeConnectionRegistry.swift */; }; 3DD7311CA07CFCA8A996058F /* SnowflakeAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */; }; 4355C825A7554BBCB978E1E4 /* SnowflakeSchemaQueries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C399209C2D14499870FBD49 /* SnowflakeSchemaQueries.swift */; }; 4CA0E909166145AAB6B6CDD7 /* SnowflakeHTTPRetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F1B63541C24A10BF4AF873 /* SnowflakeHTTPRetry.swift */; }; + 5A1MPORT000000000000A011 /* TableProImport in Frameworks */ = {isa = PBXBuildFile; productRef = 5A1MPORT000000000000A010 /* TableProImport */; }; 5A32BBFB2F9D5EAB00BAEB5F /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = 5A32BBFA2F9D5EAB00BAEB5F /* X509 */; }; 5A32BC0B2F9D659100BAEB5F /* tablepro-mcp in Copy Files */ = {isa = PBXBuildFile; fileRef = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 5A3BE6FC2F97DB0000611C1F /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; @@ -66,7 +66,6 @@ 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; }; 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; }; 5ACE00012F4F00000000000A /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000009 /* Sparkle */; }; - 5ACE252A2FE4508500357377 /* DuckDBDriver.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A869000100000000 /* DuckDBDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5AD1D8C12FB5000000000001 /* TableProMSSQLCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */; }; 5ADDB00100000000000000A1 /* DynamoDBConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A1 /* DynamoDBConnection.swift */; }; 5ADDB00100000000000000A2 /* DynamoDBItemFlattener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A2 /* DynamoDBItemFlattener.swift */; }; @@ -85,6 +84,15 @@ 5AEA8B472F6808CA0040461A /* EtcdHttpClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3C2F6808CA0040461A /* EtcdHttpClient.swift */; }; 5AEA8B492F6808E90040461A /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5AEE5B362F5C9B7B00FA84D7 /* OracleNIO in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000F /* OracleNIO */; }; + 5E1A500100000000000000A1 /* ElasticsearchConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A1 /* ElasticsearchConnection.swift */; }; + 5E1A500100000000000000A2 /* ElasticsearchConsoleParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A2 /* ElasticsearchConsoleParser.swift */; }; + 5E1A500100000000000000A3 /* ElasticsearchMappingFlattener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A3 /* ElasticsearchMappingFlattener.swift */; }; + 5E1A500100000000000000A4 /* ElasticsearchPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A4 /* ElasticsearchPlugin.swift */; }; + 5E1A500100000000000000A5 /* ElasticsearchPluginDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A5 /* ElasticsearchPluginDriver.swift */; }; + 5E1A500100000000000000A6 /* ElasticsearchPluginDriver+Execution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A6 /* ElasticsearchPluginDriver+Execution.swift */; }; + 5E1A500100000000000000A7 /* ElasticsearchQueryBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A7 /* ElasticsearchQueryBuilder.swift */; }; + 5E1A500100000000000000A8 /* ElasticsearchStatementGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A8 /* ElasticsearchStatementGenerator.swift */; }; + 5E1A500100000000000000A9 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 690F1972044539AA3EC01FAD /* SnowflakePluginDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CAC14AE00C256911D2A9D2E /* SnowflakePluginDriver.swift */; }; 69F80E23278BD01CA254E291 /* SnowflakeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE1C881DFE85C2D2DBCC5B87 /* SnowflakeError.swift */; }; 703C8C18A7F43E262695F557 /* SnowflakePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73CFCC6EA4288F34EBED5F37 /* SnowflakePlugin.swift */; }; @@ -260,6 +268,13 @@ remoteGlobalIDString = 5A1091C62EF17EDC0055EA7C; remoteInfo = TablePro; }; + 5E1A500000000000000000C0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5E1A500600000000000000B0; + remoteInfo = ElasticsearchDriverPlugin; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -289,7 +304,6 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - 5ACE252A2FE4508500357377 /* DuckDBDriver.tableplugin in Copy Plug-Ins (12 items) */, 5A865000D00000000 /* MySQLDriver.tableplugin in Copy Plug-Ins (12 items) */, 5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins (12 items) */, 5A862000D00000000 /* SQLiteDriver.tableplugin in Copy Plug-Ins (12 items) */, @@ -343,7 +357,7 @@ 5A866000100000000 /* MongoDBDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MongoDBDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A867000100000000 /* RedisDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RedisDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A868000100000000 /* PostgreSQLDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PostgreSQLDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; - 5A869000100000000 /* DuckDBDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DuckDBDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; + 5A869000100000000 /* DuckDBDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = DuckDBDriver.tableplugin; path = /Users/ngoquocdat/Workspaces/TablePro/build/Debug/DuckDBDriver.tableplugin; sourceTree = ""; }; 5A86A000100000000 /* CSVExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CSVExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A86B000100000000 /* JSONExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JSONExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A86C000100000000 /* SQLExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SQLExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -383,6 +397,15 @@ 5AF00A102FB9000000000001 /* TableProUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5ASECRETS000000000000001 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = SOURCE_ROOT; }; 5E0809C2747F432C1F1C5A60 /* SnowflakeBrowserAuthServer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeBrowserAuthServer.swift; sourceTree = ""; }; + 5E1A500200000000000000A1 /* ElasticsearchConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElasticsearchConnection.swift; sourceTree = ""; }; + 5E1A500200000000000000A2 /* ElasticsearchConsoleParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElasticsearchConsoleParser.swift; sourceTree = ""; }; + 5E1A500200000000000000A3 /* ElasticsearchMappingFlattener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElasticsearchMappingFlattener.swift; sourceTree = ""; }; + 5E1A500200000000000000A4 /* ElasticsearchPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElasticsearchPlugin.swift; sourceTree = ""; }; + 5E1A500200000000000000A5 /* ElasticsearchPluginDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElasticsearchPluginDriver.swift; sourceTree = ""; }; + 5E1A500200000000000000A6 /* ElasticsearchPluginDriver+Execution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ElasticsearchPluginDriver+Execution.swift"; sourceTree = ""; }; + 5E1A500200000000000000A7 /* ElasticsearchQueryBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElasticsearchQueryBuilder.swift; sourceTree = ""; }; + 5E1A500200000000000000A8 /* ElasticsearchStatementGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElasticsearchStatementGenerator.swift; sourceTree = ""; }; + 5E1A500300000000000000A0 /* ElasticsearchDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = ElasticsearchDriverPlugin.tableplugin; path = /Users/ngoquocdat/Workspaces/TablePro/build/Debug/ElasticsearchDriverPlugin.tableplugin; sourceTree = ""; }; 73CFCC6EA4288F34EBED5F37 /* SnowflakePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakePlugin.swift; sourceTree = ""; }; 7D67B38E07DF4F42A9C86639 /* SnowflakeIdTokenStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeIdTokenStore.swift; sourceTree = ""; }; 84A5EE73D62C4576A9B9DFF2 /* SnowflakeStatementGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeStatementGenerator.swift; sourceTree = ""; }; @@ -1051,6 +1074,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5E1A500400000000000000B2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5E1A500100000000000000A9 /* TableProPluginKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; C49F7C0CBE979B96ACFB9ABC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1074,6 +1105,7 @@ isa = PBXGroup; children = ( 5ADDB00500000000000000B0 /* Plugins/DynamoDBDriverPlugin */, + 5E1A500500000000000000B0 /* Plugins/ElasticsearchDriverPlugin */, 5ABQR00500000000000000B0 /* Plugins/BigQueryDriverPlugin */, 5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */, 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */, @@ -1122,7 +1154,6 @@ 5A866000100000000 /* MongoDBDriver.tableplugin */, 5A867000100000000 /* RedisDriver.tableplugin */, 5A868000100000000 /* PostgreSQLDriver.tableplugin */, - 5A869000100000000 /* DuckDBDriver.tableplugin */, 5A87A000100000000 /* CassandraDriver.tableplugin */, 5A86A000100000000 /* CSVExport.tableplugin */, 5A86B000100000000 /* JSONExport.tableplugin */, @@ -1195,6 +1226,21 @@ name = Frameworks; sourceTree = ""; }; + 5E1A500500000000000000B0 /* Plugins/ElasticsearchDriverPlugin */ = { + isa = PBXGroup; + children = ( + 5E1A500200000000000000A1 /* ElasticsearchConnection.swift */, + 5E1A500200000000000000A2 /* ElasticsearchConsoleParser.swift */, + 5E1A500200000000000000A3 /* ElasticsearchMappingFlattener.swift */, + 5E1A500200000000000000A4 /* ElasticsearchPlugin.swift */, + 5E1A500200000000000000A5 /* ElasticsearchPluginDriver.swift */, + 5E1A500200000000000000A6 /* ElasticsearchPluginDriver+Execution.swift */, + 5E1A500200000000000000A7 /* ElasticsearchQueryBuilder.swift */, + 5E1A500200000000000000A8 /* ElasticsearchStatementGenerator.swift */, + ); + path = Plugins/ElasticsearchDriverPlugin; + sourceTree = ""; + }; 8FE5E1F9D0550A0E0AACD3EB /* SnowflakeDriverPlugin */ = { isa = PBXGroup; children = ( @@ -1267,6 +1313,7 @@ 5A86F003C00000000 /* PBXTargetDependency */, 5ABBED7F2FB55E1400A78382 /* PBXTargetDependency */, 5ADDB00000000000000000C1 /* PBXTargetDependency */, + 5E1A500000000000000000C1 /* PBXTargetDependency */, 5ABQR00000000000000000C1 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( @@ -1858,6 +1905,25 @@ productReference = 5AF00A102FB9000000000001 /* TableProUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + 5E1A500600000000000000B0 /* ElasticsearchDriverPlugin */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5E1A500800000000000000B0 /* Build configuration list for PBXNativeTarget "ElasticsearchDriverPlugin" */; + buildPhases = ( + 5E1A500400000000000000B1 /* Sources */, + 5E1A500400000000000000B2 /* Frameworks */, + 5E1A500400000000000000B3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ElasticsearchDriverPlugin; + packageProductDependencies = ( + ); + productName = ElasticsearchDriverPlugin; + productReference = 5E1A500300000000000000A0 /* ElasticsearchDriverPlugin.tableplugin */; + productType = "com.apple.product-type.bundle"; + }; FABFFD08BCD1EEE7219EAE75 /* SnowflakeDriverPlugin */ = { isa = PBXNativeTarget; buildConfigurationList = 2919EAF57187D4EFE7E47F98 /* Build configuration list for PBXNativeTarget "SnowflakeDriverPlugin" */; @@ -2008,6 +2074,7 @@ 5AEA8B292F6808270040461A /* EtcdDriverPlugin */, 5AE4F4732F6BC0640097AC5B /* CloudflareD1DriverPlugin */, 5ADDB00600000000000000B0 /* DynamoDBDriverPlugin */, + 5E1A500600000000000000B0 /* ElasticsearchDriverPlugin */, 5ABQR00600000000000000B0 /* BigQueryDriverPlugin */, 5A3BE6F72F97DA8100611C1F /* LibSQLDriverPlugin */, 5A32BBFF2F9D5F1300BAEB5F /* mcp-server */, @@ -2221,6 +2288,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5E1A500400000000000000B3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -2472,6 +2546,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5E1A500400000000000000B1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5E1A500100000000000000A1 /* ElasticsearchConnection.swift in Sources */, + 5E1A500100000000000000A2 /* ElasticsearchConsoleParser.swift in Sources */, + 5E1A500100000000000000A3 /* ElasticsearchMappingFlattener.swift in Sources */, + 5E1A500100000000000000A4 /* ElasticsearchPlugin.swift in Sources */, + 5E1A500100000000000000A5 /* ElasticsearchPluginDriver.swift in Sources */, + 5E1A500100000000000000A6 /* ElasticsearchPluginDriver+Execution.swift in Sources */, + 5E1A500100000000000000A7 /* ElasticsearchQueryBuilder.swift in Sources */, + 5E1A500100000000000000A8 /* ElasticsearchStatementGenerator.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -2590,6 +2679,11 @@ target = 5A1091C62EF17EDC0055EA7C /* TablePro */; targetProxy = 5AF00A112FB9000000000001 /* PBXContainerItemProxy */; }; + 5E1A500000000000000000C1 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5E1A500600000000000000B0 /* ElasticsearchDriverPlugin */; + targetProxy = 5E1A500000000000000000C0 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -2810,7 +2904,7 @@ ); LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libs/dylibs"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.52.1; + MARKETING_VERSION = 0.53.0; OTHER_LDFLAGS = ( "-Wl,-w", "-force_load", @@ -2888,7 +2982,7 @@ ); LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libs/dylibs"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.52.1; + MARKETING_VERSION = 0.53.0; OTHER_LDFLAGS = ( "-Wl,-w", "-force_load", @@ -4461,6 +4555,55 @@ }; name = Release; }; + 5E1A500700000000000000B1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/ElasticsearchDriverPlugin/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).ElasticsearchPlugin"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.ElasticsearchDriverPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; + 5E1A500700000000000000B2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/ElasticsearchDriverPlugin/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).ElasticsearchPlugin"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.ElasticsearchDriverPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -4743,6 +4886,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5E1A500800000000000000B0 /* Build configuration list for PBXNativeTarget "ElasticsearchDriverPlugin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5E1A500700000000000000B1 /* Debug */, + 5E1A500700000000000000B2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ @@ -4788,6 +4940,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 5A1MPORT000000000000A010 /* TableProImport */ = { + isa = XCSwiftPackageProductDependency; + package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; + productName = TableProImport; + }; 5A32BBFA2F9D5EAB00BAEB5F /* X509 */ = { isa = XCSwiftPackageProductDependency; package = 5A32BBF92F9D5EAB00BAEB5F /* XCRemoteSwiftPackageReference "swift-certificates" */; @@ -4824,11 +4981,6 @@ package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; productName = TableProMSSQLCore; }; - 5A1MPORT000000000000A010 /* TableProImport */ = { - isa = XCSwiftPackageProductDependency; - package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; - productName = TableProImport; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5A1091BF2EF17EDC0055EA7C /* Project object */; diff --git a/TablePro/Assets.xcassets/elasticsearch-icon.imageset/Contents.json b/TablePro/Assets.xcassets/elasticsearch-icon.imageset/Contents.json new file mode 100644 index 000000000..7a9021ede --- /dev/null +++ b/TablePro/Assets.xcassets/elasticsearch-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "elasticsearch.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/TablePro/Assets.xcassets/elasticsearch-icon.imageset/elasticsearch.svg b/TablePro/Assets.xcassets/elasticsearch-icon.imageset/elasticsearch.svg new file mode 100644 index 000000000..76708888e --- /dev/null +++ b/TablePro/Assets.xcassets/elasticsearch-icon.imageset/elasticsearch.svg @@ -0,0 +1 @@ +Elasticsearch diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+ElasticsearchDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+ElasticsearchDefaults.swift new file mode 100644 index 000000000..92c0392ed --- /dev/null +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+ElasticsearchDefaults.swift @@ -0,0 +1,121 @@ +// +// PluginMetadataRegistry+ElasticsearchDefaults.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension PluginMetadataRegistry { + func elasticsearchPluginDefaults() -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + [ + ("Elasticsearch", PluginMetadataSnapshot( + displayName: "Elasticsearch", iconName: "elasticsearch-icon", defaultPort: 9_200, + requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: true, primaryUrlScheme: "", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: [], postConnectActions: [], + brandColorHex: "#FEC514", + queryLanguageName: "Query DSL", editorLanguage: .javascript, + connectionMode: .network, supportsDatabaseSwitching: false, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: false, + supportsExport: true, + supportsSSH: false, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsOpportunisticTLS: false + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "", + defaultGroupName: "default", + tableEntityName: "Indices", + containerEntityName: "Cluster", + defaultPrimaryKeyColumn: "_id", + immutableColumns: ["_id", "_index", "_score"], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .flat, + structureColumnFields: [.name, .type, .nullable] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: nil, + statementCompletions: elasticsearchCompletions, + columnTypesByCategory: elasticsearchColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: elasticsearchConnectionFields(), + category: .document, + tagline: String(localized: "Search and analytics engine") + ) + )), + ] + } +} + +private let elasticsearchCompletions: [CompletionEntry] = [ + CompletionEntry(label: "GET /_search", insertText: "GET /_search\n{\n \"query\": {\n \"match_all\": {}\n }\n}"), + CompletionEntry(label: "GET /_cat/indices", insertText: "GET /_cat/indices?format=json"), + CompletionEntry(label: "GET /_cluster/health", insertText: "GET /_cluster/health"), + CompletionEntry(label: "GET /_mapping", insertText: "GET /_mapping"), + CompletionEntry(label: "match", insertText: "\"match\": { \"field\": \"value\" }"), + CompletionEntry(label: "match_all", insertText: "\"match_all\": {}"), + CompletionEntry(label: "term", insertText: "\"term\": { \"field\": \"value\" }"), + CompletionEntry(label: "terms", insertText: "\"terms\": { \"field\": [\"a\", \"b\"] }"), + CompletionEntry(label: "range", insertText: "\"range\": { \"field\": { \"gte\": 0, \"lte\": 100 } }"), + CompletionEntry(label: "bool", insertText: "\"bool\": {\n \"must\": [],\n \"filter\": [],\n \"must_not\": [],\n \"should\": []\n}"), + CompletionEntry(label: "exists", insertText: "\"exists\": { \"field\": \"field\" }"), + CompletionEntry(label: "aggs", insertText: "\"aggs\": {\n \"name\": {\n \"terms\": { \"field\": \"field\" }\n }\n}"), +] + +private let elasticsearchColumnTypes: [String: [String]] = [ + "Text": ["text", "keyword", "match_only_text", "search_as_you_type"], + "Numeric": ["long", "integer", "short", "byte", "double", "float", "half_float", "scaled_float", "unsigned_long"], + "Boolean": ["boolean"], + "Date": ["date", "date_nanos"], + "Binary": ["binary"], + "Range": ["integer_range", "float_range", "long_range", "double_range", "date_range", "ip_range"], + "Geo": ["geo_point", "geo_shape", "point", "shape"], + "Structured": ["object", "nested", "flattened", "join"], + "Specialized": ["ip", "completion", "token_count", "dense_vector", "rank_feature", "rank_features"], +] + +func elasticsearchConnectionFields() -> [ConnectionField] { + [ + ConnectionField( + id: "esAuthMethod", + label: String(localized: "Auth Method"), + defaultValue: "basic", + fieldType: .dropdown(options: [ + .init(value: "basic", label: "Username & Password"), + .init(value: "apiKey", label: "API Key"), + .init(value: "none", label: "None"), + ]), + section: .authentication + ), + ConnectionField( + id: "esApiKey", + label: String(localized: "API Key"), + placeholder: "Base64-encoded API key", + fieldType: .secure, + section: .authentication, + hidesPassword: true, + visibleWhen: FieldVisibilityRule(fieldId: "esAuthMethod", values: ["apiKey"]) + ), + ConnectionField( + id: "esSkipTLSVerify", + label: String(localized: "Skip TLS Verification"), + defaultValue: "false", + fieldType: .toggle, + section: .advanced + ), + ] +} diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 3023af336..35986e263 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -1220,7 +1220,7 @@ extension PluginMetadataRegistry { tagline: String(localized: "Distributed SQLite by Turso") ) )), - ] + cloudPluginDefaults() + ] + cloudPluginDefaults() + elasticsearchPluginDefaults() } // swiftlint:enable function_body_length } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index fb15b9cf0..ce8cdf048 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -949,7 +949,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { return .relational case "Redshift", "ClickHouse", "DuckDB", "BigQuery": return .analytical - case "MongoDB": + case "MongoDB", "Elasticsearch": return .document case "Redis": return .keyValue @@ -974,6 +974,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { case "MSSQL": return String(localized: "Microsoft's enterprise SQL database") case "Oracle": return String(localized: "Enterprise SQL with PL/SQL") case "MongoDB": return String(localized: "JSON-style document database") + case "Elasticsearch": return String(localized: "Search and analytics engine") case "Redis": return String(localized: "In-memory data store and cache") case "ClickHouse": return String(localized: "Column-oriented OLAP for big data") case "DuckDB": return String(localized: "Embedded analytical SQL") diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 3ed9c661c..ad93e640a 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -45,6 +45,7 @@ extension DatabaseType { static let bigQuery = DatabaseType(rawValue: "BigQuery") static let libsql = DatabaseType(rawValue: "libSQL") static let turso = DatabaseType(rawValue: "Turso") + static let elasticsearch = DatabaseType(rawValue: "Elasticsearch") } extension DatabaseType: Codable { diff --git a/TablePro/Models/Database/TableFilter.swift b/TablePro/Models/Database/TableFilter.swift index b3b906923..b132675ee 100644 --- a/TablePro/Models/Database/TableFilter.swift +++ b/TablePro/Models/Database/TableFilter.swift @@ -171,6 +171,9 @@ struct TableFilter: Identifiable, Equatable, Hashable, Codable { extension TableFilter { var asPluginFilterTuple: (column: String, op: String, value: String) { + if isRawSQL { + return (columnName, filterOperator.rawValue, rawSQL ?? "") + } let resolvedValue: String if filterOperator == .between, let second = secondValue { resolvedValue = "\(value),\(second)" diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 8ddbf50dc..307295088 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -12713,6 +12713,7 @@ } }, "CA certificate not found: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -15748,6 +15749,7 @@ } }, "Client certificate not found: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -15917,6 +15919,7 @@ } }, "Client key not found: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -23814,6 +23817,7 @@ } }, "Database type \"%@\" is not installed" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -24435,6 +24439,7 @@ } }, "Decryption failed: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -33109,6 +33114,7 @@ } }, "Failed to encode connection data" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -33908,6 +33914,7 @@ } }, "Failed to parse connection file: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -40070,6 +40077,7 @@ } }, "Incorrect passphrase" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -42158,6 +42166,7 @@ } }, "Jump host key not found: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -67654,6 +67663,9 @@ } } } + }, + "Search and analytics engine" : { + }, "Search columns..." : { "localizations" : { @@ -71905,6 +71917,9 @@ } } } + }, + "Skip TLS Verification" : { + }, "Skips foreign key constraint checks for this operation" : { "localizations" : { @@ -73551,6 +73566,7 @@ } }, "SSH private key not found: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -78137,6 +78153,7 @@ } }, "The encrypted file is corrupt or incomplete" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -79316,6 +79333,7 @@ } }, "This file is encrypted and requires a passphrase" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -79344,6 +79362,7 @@ } }, "This file is not a valid TablePro export" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -79372,6 +79391,7 @@ } }, "This file requires a newer version of TablePro (format version %d)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -83440,6 +83460,7 @@ } }, "Unsupported encryption version %d" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { diff --git a/TableProTests/Models/TableFilterTests.swift b/TableProTests/Models/TableFilterTests.swift index 5d84a44e5..7d5dea42b 100644 --- a/TableProTests/Models/TableFilterTests.swift +++ b/TableProTests/Models/TableFilterTests.swift @@ -183,4 +183,23 @@ struct TableFilterTests { #expect(filter.isValid == false) #expect(filter.validationError == String(localized: "Raw SQL cannot be empty")) } + + @Test("Plugin tuple forwards raw SQL content for a raw filter") + func pluginTupleForwardsRawSQL() { + let filter = TableFilter( + columnName: TableFilter.rawSQLColumn, + filterOperator: .equal, + value: "", + rawSQL: "name:Widget" + ) + let tuple = filter.asPluginFilterTuple + #expect(tuple.column == TableFilter.rawSQLColumn) + #expect(tuple.value == "name:Widget") + } + + @Test("Plugin tuple uses value for a column filter") + func pluginTupleUsesValueForColumn() { + let filter = TableFilter(columnName: "name", filterOperator: .equal, value: "Widget") + #expect(filter.asPluginFilterTuple.value == "Widget") + } } diff --git a/TableProTests/PluginTestSources/ElasticsearchConsoleParser.swift b/TableProTests/PluginTestSources/ElasticsearchConsoleParser.swift new file mode 120000 index 000000000..cc080e894 --- /dev/null +++ b/TableProTests/PluginTestSources/ElasticsearchConsoleParser.swift @@ -0,0 +1 @@ +../../Plugins/ElasticsearchDriverPlugin/ElasticsearchConsoleParser.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/ElasticsearchMappingFlattener.swift b/TableProTests/PluginTestSources/ElasticsearchMappingFlattener.swift new file mode 120000 index 000000000..3111983a8 --- /dev/null +++ b/TableProTests/PluginTestSources/ElasticsearchMappingFlattener.swift @@ -0,0 +1 @@ +../../Plugins/ElasticsearchDriverPlugin/ElasticsearchMappingFlattener.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/ElasticsearchQueryBuilder.swift b/TableProTests/PluginTestSources/ElasticsearchQueryBuilder.swift new file mode 120000 index 000000000..156fe253a --- /dev/null +++ b/TableProTests/PluginTestSources/ElasticsearchQueryBuilder.swift @@ -0,0 +1 @@ +../../Plugins/ElasticsearchDriverPlugin/ElasticsearchQueryBuilder.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/ElasticsearchStatementGenerator.swift b/TableProTests/PluginTestSources/ElasticsearchStatementGenerator.swift new file mode 120000 index 000000000..94055dba0 --- /dev/null +++ b/TableProTests/PluginTestSources/ElasticsearchStatementGenerator.swift @@ -0,0 +1 @@ +../../Plugins/ElasticsearchDriverPlugin/ElasticsearchStatementGenerator.swift \ No newline at end of file diff --git a/TableProTests/Plugins/ElasticsearchDriverTests.swift b/TableProTests/Plugins/ElasticsearchDriverTests.swift new file mode 100644 index 000000000..07d5eab7d --- /dev/null +++ b/TableProTests/Plugins/ElasticsearchDriverTests.swift @@ -0,0 +1,324 @@ +// +// ElasticsearchDriverTests.swift +// TableProTests +// +// Tests for Elasticsearch plugin pure logic (compiled via symlinks from ElasticsearchDriverPlugin). +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("Elasticsearch - Console Parser") +struct ElasticsearchConsoleParserTests { + @Test("Parses method, path, and JSON body") + func parsesFullRequest() { + let input = "POST /my-index/_search\n{\n \"query\": { \"match_all\": {} }\n}" + let request = ElasticsearchConsoleParser.parse(input) + #expect(request?.method == "POST") + #expect(request?.path == "/my-index/_search") + #expect(request?.body?.contains("match_all") == true) + } + + @Test("Normalizes a path without a leading slash") + func normalizesPath() { + let request = ElasticsearchConsoleParser.parse("GET _cat/indices?format=json") + #expect(request?.method == "GET") + #expect(request?.path == "/_cat/indices?format=json") + #expect(request?.body == nil) + } + + @Test("Lowercase method is normalized") + func uppercasesMethod() { + #expect(ElasticsearchConsoleParser.parse("get /")?.method == "GET") + } + + @Test("Rejects unsupported method") + func rejectsUnknownMethod() { + #expect(ElasticsearchConsoleParser.parse("FETCH /index") == nil) + #expect(ElasticsearchConsoleParser.parse("") == nil) + } +} + +@Suite("Elasticsearch - Query Builder Encoding") +struct ElasticsearchQueryBuilderEncodingTests { + private let builder = ElasticsearchQueryBuilder() + + @Test("Browse query round-trips") + func browseRoundTrip() { + let query = builder.buildBrowseQuery(index: "logs", sorts: [], limit: 50, offset: 10) + #expect(query.hasPrefix(ElasticsearchQueryBuilder.searchTag)) + let parsed = ElasticsearchQueryBuilder.parseSearch(query) + #expect(parsed?.index == "logs") + #expect(parsed?.from == 10) + #expect(parsed?.size == 50) + #expect(parsed?.filters.isEmpty == true) + } + + @Test("Filtered query preserves filters and sorts") + func filteredRoundTrip() { + let query = builder.buildFilteredQuery( + index: "users", + filters: [(column: "age", op: ">", value: "21")], + logicMode: "AND", + sorts: [ElasticsearchSortSpec(column: "name", ascending: true)], + limit: 100, + offset: 0 + ) + let parsed = ElasticsearchQueryBuilder.parseSearch(query) + #expect(parsed?.filters.first?.column == "age") + #expect(parsed?.filters.first?.op == ">") + #expect(parsed?.sorts.first?.column == "name") + #expect(parsed?.sorts.first?.ascending == true) + } +} + +@Suite("Elasticsearch - Query DSL") +struct ElasticsearchQueryDSLTests { + private let textField = ["title": ElasticsearchFieldInfo(type: "text", hasKeywordSubfield: true)] + private let keywordField = ["status": ElasticsearchFieldInfo(type: "keyword", hasKeywordSubfield: false)] + private let numericField = ["age": ElasticsearchFieldInfo(type: "long", hasKeywordSubfield: false)] + + @Test("Empty filters produce match_all") + func matchAll() { + let clause = ElasticsearchQueryBuilder.queryClause(filters: [], logicMode: "AND", fields: [:]) + #expect(clause["match_all"] != nil) + } + + @Test("Equality on keyword uses term") + func keywordTerm() { + let filter = ElasticsearchFilterSpec(column: "status", op: "=", value: "active") + let clause = ElasticsearchQueryBuilder.clause(for: filter, fields: keywordField) + let term = clause["term"] as? [String: Any] + #expect(term?["status"] as? String == "active") + } + + @Test("Numeric range uses typed value") + func numericRange() { + let filter = ElasticsearchFilterSpec(column: "age", op: ">", value: "21") + let clause = ElasticsearchQueryBuilder.clause(for: filter, fields: numericField) + let range = clause["range"] as? [String: Any] + let bounds = range?["age"] as? [String: Any] + #expect(bounds?["gt"] as? Int == 21) + } + + @Test("Sorting a text field targets its keyword subfield") + func sortUsesKeyword() { + let field = ElasticsearchQueryBuilder.sortableField("title", fields: textField) + #expect(field == "title.keyword") + } + + @Test("OR logic builds should with minimum_should_match") + func orLogic() { + let filters = [ + ElasticsearchFilterSpec(column: "status", op: "=", value: "a"), + ElasticsearchFilterSpec(column: "status", op: "=", value: "b"), + ] + let clause = ElasticsearchQueryBuilder.queryClause(filters: filters, logicMode: "OR", fields: keywordField) + let bool = clause["bool"] as? [String: Any] + #expect(bool?["should"] != nil) + #expect(bool?["minimum_should_match"] as? Int == 1) + } + + @Test("IS NULL becomes must_not exists") + func isNull() { + let filter = ElasticsearchFilterSpec(column: "status", op: "IS NULL", value: "") + let clause = ElasticsearchQueryBuilder.clause(for: filter, fields: keywordField) + let bool = clause["bool"] as? [String: Any] + #expect(bool?["must_not"] != nil) + } + + @Test("Deep pagination body includes _shard_doc tiebreaker") + func tiebreaker() { + let parsed = ElasticsearchParsedSearch(index: "i", from: 0, size: 10, sorts: [], filters: [], logicMode: "AND") + let body = ElasticsearchQueryBuilder.searchBody(for: parsed, fields: [:], size: 10, tiebreaker: true) + let sort = body["sort"] as? [[String: Any]] + #expect(sort?.contains { $0["_shard_doc"] != nil } == true) + } + + @Test("Raw filter sentinel builds a query_string") + func rawFilter() { + let filter = ElasticsearchFilterSpec(column: "__RAW__", op: "=", value: "Widget") + let clause = ElasticsearchQueryBuilder.clause(for: filter, fields: [:]) + let queryString = clause["query_string"] as? [String: Any] + #expect(queryString?["query"] as? String == "Widget") + } + + @Test("Empty raw filter is ignored (match_all)") + func emptyRawFilter() { + let filters = [ElasticsearchFilterSpec(column: "__RAW__", op: "=", value: "")] + let clause = ElasticsearchQueryBuilder.queryClause(filters: filters, logicMode: "AND", fields: [:]) + #expect(clause["match_all"] != nil) + } + + @Test("_id is not sortable (returns nil)") + func idNotSortable() { + #expect(ElasticsearchQueryBuilder.sortableField("_id", fields: [:]) == nil) + #expect(ElasticsearchQueryBuilder.sortableField("_score", fields: [:]) == "_score") + } +} + +@Suite("Elasticsearch - Appended ORDER BY") +struct ElasticsearchOrderByTests { + @Test("Extracts a single appended ORDER BY from the tagged query") + func singleOrderBy() { + let tagged = ElasticsearchQueryBuilder.encodeSearch( + index: "products", from: 0, size: 1_000, sorts: [], filters: [], logicMode: "AND" + ) + let (base, sorts) = ElasticsearchQueryBuilder.extractOrderBy(tagged + " ORDER BY \"name\" ASC") + #expect(base == tagged) + #expect(sorts == [ElasticsearchSortSpec(column: "name", ascending: true)]) + #expect(ElasticsearchQueryBuilder.parseSearch(base)?.index == "products") + } + + @Test("Parses multi-column ORDER BY with directions") + func multiOrderBy() { + let sorts = ElasticsearchQueryBuilder.parseOrderByClause("\"age\" DESC, \"country\" ASC") + #expect(sorts == [ + ElasticsearchSortSpec(column: "age", ascending: false), + ElasticsearchSortSpec(column: "country", ascending: true), + ]) + } + + @Test("No ORDER BY leaves the query unchanged") + func noOrderBy() { + let (base, sorts) = ElasticsearchQueryBuilder.extractOrderBy("ELASTICSEARCH_SEARCH:abc") + #expect(base == "ELASTICSEARCH_SEARCH:abc") + #expect(sorts.isEmpty) + } +} + +@Suite("Elasticsearch - Mapping Flattener") +struct ElasticsearchMappingFlattenerTests { + @Test("Flattens nested objects into dotted paths and records keyword subfields") + func flattenMapping() { + let properties: [String: Any] = [ + "name": ["type": "text", "fields": ["keyword": ["type": "keyword"]]], + "age": ["type": "long"], + "address": ["properties": ["city": ["type": "keyword"]]], + ] + let columns = ElasticsearchMappingFlattener.flattenMapping(properties: properties) + let byName = Dictionary(uniqueKeysWithValues: columns.map { ($0.name, $0) }) + #expect(byName["name"]?.type == "text") + #expect(byName["name"]?.hasKeywordSubfield == true) + #expect(byName["age"]?.type == "long") + #expect(byName["address.city"]?.type == "keyword") + } + + @Test("Columns include meta columns first") + func metaColumnsFirst() { + let mapping = [ElasticsearchColumn(name: "field", type: "keyword", hasKeywordSubfield: false)] + let columns = ElasticsearchMappingFlattener.columns(forHits: [], mappingColumns: mapping) + #expect(Array(columns.prefix(3)) == ["_id", "_index", "_score"]) + #expect(columns.contains("field")) + } + + @Test("Flattens a source document and renders nested values as JSON") + func flattenSource() { + let source: [String: Any] = [ + "name": "Alice", + "address": ["city": "NYC"], + "tags": ["a", "b"], + ] + let flat = ElasticsearchMappingFlattener.flattenSource(source) + #expect(flat["name"] == .text("Alice")) + #expect(flat["address.city"] == .text("NYC")) + #expect(flat["tags"]?.asText?.contains("a") == true) + } + + @Test("Rows pull meta fields from the hit envelope") + func rowsWithMeta() { + let hits: [[String: Any]] = [[ + "_id": "1", "_index": "logs", "_score": 1.5, + "_source": ["msg": "hello"], + ]] + let columns = ["_id", "_index", "_score", "msg"] + let rows = ElasticsearchMappingFlattener.rows(forHits: hits, columns: columns) + #expect(rows.first?[0] == .text("1")) + #expect(rows.first?[1] == .text("logs")) + #expect(rows.first?[3] == .text("hello")) + } + + @Test("Heterogeneous documents union their fields") + func heterogeneousUnion() { + let sources: [[String: Any]] = [["a": 1], ["b": 2]] + let columns = ElasticsearchMappingFlattener.unionColumns(fromSources: sources) + #expect(columns.contains("a")) + #expect(columns.contains("b")) + } +} + +@Suite("Elasticsearch - Statement Generator") +struct ElasticsearchStatementGeneratorTests { + private func generator() -> ElasticsearchStatementGenerator { + ElasticsearchStatementGenerator( + index: "users", + columns: ["_id", "_index", "_score", "name", "age"], + columnTypeNames: ["keyword", "keyword", "float", "text", "long"] + ) + } + + @Test("Update encodes a POST _update keyed by _id") + func updateRequest() { + let change = PluginRowChange( + rowIndex: 0, + type: .update, + cellChanges: [(columnIndex: 3, columnName: "name", oldValue: .text("Bob"), newValue: .text("Alice"))], + originalRow: [.text("doc1"), .text("users"), .text("1"), .text("Bob"), .text("30")] + ) + let statements = generator().generateStatements( + from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] + ) + #expect(statements.count == 1) + let decoded = ElasticsearchStatementGenerator.decode(statements[0].statement) + #expect(decoded?.method == "POST") + #expect(decoded?.path.contains("/users/_update/doc1") == true) + #expect(decoded?.body?.contains("Alice") == true) + } + + @Test("Delete encodes a DELETE _doc by _id") + func deleteRequest() { + let change = PluginRowChange( + rowIndex: 0, + type: .delete, + cellChanges: [], + originalRow: [.text("doc9"), .text("users"), .text("1"), .text("Bob"), .text("30")] + ) + let statements = generator().generateStatements( + from: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: [] + ) + let decoded = ElasticsearchStatementGenerator.decode(statements[0].statement) + #expect(decoded?.method == "DELETE") + #expect(decoded?.path.contains("/users/_doc/doc9") == true) + } + + @Test("Insert coerces numeric fields and omits meta columns") + func insertRequest() { + let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + let statements = generator().generateStatements( + from: [change], + insertedRowData: [0: [.null, .null, .null, .text("Eve"), .text("25")]], + deletedRowIndices: [], + insertedRowIndices: [0] + ) + let decoded = ElasticsearchStatementGenerator.decode(statements[0].statement) + #expect(decoded?.method == "POST") + #expect(decoded?.path.contains("/users/_doc") == true) + #expect(decoded?.body?.contains("\"age\":25") == true) + #expect(decoded?.body?.contains("_index") == false) + } + + @Test("Insert with explicit _id uses PUT") + func insertWithId() { + let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + let statements = generator().generateStatements( + from: [change], + insertedRowData: [0: [.text("custom"), .null, .null, .text("Eve"), .text("25")]], + deletedRowIndices: [], + insertedRowIndices: [0] + ) + let decoded = ElasticsearchStatementGenerator.decode(statements[0].statement) + #expect(decoded?.method == "PUT") + #expect(decoded?.path.contains("/users/_doc/custom") == true) + } +} diff --git a/docs/databases/elasticsearch.mdx b/docs/databases/elasticsearch.mdx new file mode 100644 index 000000000..41d1f9dc7 --- /dev/null +++ b/docs/databases/elasticsearch.mdx @@ -0,0 +1,93 @@ +--- +title: Elasticsearch +description: Connect to Elasticsearch with a Query DSL console, index browsing, and document editing +--- + +# Elasticsearch Connections + +TablePro connects to Elasticsearch 7.x and 8.x over the REST API. Browse indices as tables, run Query DSL requests in a Kibana-style console, page through documents in the data grid, and edit documents inline. Supports HTTP Basic auth, API keys, and TLS. + +## Quick Setup + +Click **New Connection**, select **Elasticsearch**, enter host and port (default 9200), pick an auth method, and connect. The plugin auto-installs, or use **Settings** > **Plugins** > **Browse** > **Elasticsearch Driver**. + +## Authentication Methods + +**Username & Password**: HTTP Basic auth. Enter the username and password in the standard connection fields. + +**API Key**: Paste a base64-encoded API key (the `id:api_key` pair encoded as base64, or the `encoded` value returned by the create API key request). Sent as the `Authorization: ApiKey` header. + +**None**: No authentication, for clusters with security disabled. + +## Connection Settings + +### Required Fields + +| Field | Description | +|-------|-------------| +| **Host** | Cluster host (e.g. `localhost`) | +| **Port** | REST port, default `9200` | +| **Auth Method** | Username & Password, API Key, or None | + +### Optional Fields + +| Field | Description | +|-------|-------------| +| **SSL** | Enable to connect over HTTPS | +| **Skip TLS Verification** | Trust self-signed certificates (Advanced section). Elasticsearch 8.x ships with TLS on by default | +| **API Key** | Base64-encoded key, shown when Auth Method is API Key | + +## Features + +**Index Browsing**: The sidebar lists indices as tables. System and hidden indices (names starting with `.`) are hidden. Each index shows its documents in the grid, its fields in the structure view, and its mapping as DDL. + +**Field Discovery**: Columns come from the index mapping. Nested `object` and `nested` fields flatten into dotted paths (`address.city`). Documents always include the `_id`, `_index`, and `_score` meta columns; `_id` is the primary key. Array and object values render as JSON in the cell. + +**Query DSL Console**: The editor is a Kibana Dev Tools-style console. Write a method, a path, and an optional JSON body: + +```json +GET /my-index/_search +{ + "query": { + "match": { "title": "search term" } + } +} +``` + +```json +GET /_cat/indices?format=json +``` + +```json +POST /my-index/_doc +{ + "title": "New document", + "views": 0 +} +``` + +Search responses render as a grid; `_cat` and other array responses tabularize; everything else shows as formatted JSON. + +**Filtering and Sorting**: Grid filters translate to Query DSL (`term`, `range`, `wildcard`, `terms`, `exists`). Sorting a `text` field automatically targets its `.keyword` subfield when one exists, since `text` fields are not directly sortable. + +**Data Editing**: Edit cells, insert documents, and delete documents. Edits map to REST operations keyed by `_id`: `POST /index/_update/{id}`, `PUT /index/_doc/{id}`, and `DELETE /index/_doc/{id}`. The `_id`, `_index`, and `_score` columns are read-only. + +**Pagination**: The grid pages with `from`/`size` up to the index `max_result_window` (default 10,000). Past that window, TablePro pages with `search_after` and a point-in-time (PIT) for deeper browsing. + +## Example Configurations + +**Local cluster**: Host `localhost`, port `9200`, Auth Method None (security disabled) or Username & Password. + +**Elastic 8.x with TLS**: Enable SSL, set Username & Password, and enable Skip TLS Verification if the cluster uses the default self-signed certificate. + +**API key**: Auth Method API Key, paste the base64-encoded key. + +## Troubleshooting + +**Authentication failed**: Verify the username and password, or the API key. On 8.x, security and TLS are on by default. + +**TLS errors**: Enable SSL, and enable Skip TLS Verification for self-signed certificates. + +**Sort not allowed on a text field**: Sort on the field's `.keyword` subfield, or add one to the mapping. + +**Limitations**: Read-only `_sql` is not exposed; use the Query DSL console. No schema editing, no transactions. Deep paging past `max_result_window` uses `search_after` and can drift under concurrent writes. Elasticsearch only (OpenSearch is not yet supported). diff --git a/docs/docs.json b/docs/docs.json index e9206eb74..e969a7348 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -51,7 +51,8 @@ "databases/mongodb", "databases/redis", "databases/cassandra", - "databases/etcd" + "databases/etcd", + "databases/elasticsearch" ] }, { diff --git a/docs/index.mdx b/docs/index.mdx index 3ad35ee40..79992468e 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -79,6 +79,7 @@ Native macOS client for every database. Built on SwiftUI and AppKit. Ships under | BigQuery | N/A (API-based) | Plugin | | Snowflake | N/A (API-based) | Plugin | | libSQL / Turso | N/A (API-based) | Plugin | +| Elasticsearch | 9200 | Plugin | ## System Requirements From 8d2f4891fb18b949d3b55e034c823d69612c07e3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 22 Jun 2026 19:11:28 +0700 Subject: [PATCH 2/4] chore(scripts): add install-plugin-dev.sh for local plugin testing --- scripts/install-plugin-dev.sh | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100755 scripts/install-plugin-dev.sh diff --git a/scripts/install-plugin-dev.sh b/scripts/install-plugin-dev.sh new file mode 100755 index 000000000..48d554b2d --- /dev/null +++ b/scripts/install-plugin-dev.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# install-plugin-dev.sh: install a locally-built registry plugin for testing. +# +# Registry-only plugins (Elasticsearch, DynamoDB, MongoDB, ...) are not bundled in the +# app; they install from the registry into the user plugins directory. This copies a +# freshly-built .tableplugin there so a local build can load it. +# +# Do NOT modify the copied bundle (e.g. patching Info.plist): any edit invalidates the +# code signature and dyld then refuses to load the executable. If the plugin's +# TableProMinAppVersion is newer than the running app, bump the app's MARKETING_VERSION +# to match instead of editing the plugin. +# +# A locally-built plugin is signed with the same team as your local app build, so the +# app's signature verifier accepts it. If you see a team mismatch, run a DEBUG build with +# TABLEPRO_ALLOW_UNSIGNED_PLUGINS=1 (skips the app verifier; the bundle must still be +# validly signed for dyld). +# +# Usage: scripts/install-plugin-dev.sh [TargetName] (default: ElasticsearchDriverPlugin) + +set -euo pipefail + +TARGET="${1:-ElasticsearchDriverPlugin}" +BUNDLE="${TARGET}.tableplugin" +DEST_DIR="${HOME}/Library/Application Support/TablePro/Plugins" + +SRC="$(find "${HOME}/Library/Developer/Xcode/DerivedData/TablePro-"*/Build/Products/Debug \ + -maxdepth 1 -name "${BUNDLE}" -print 2>/dev/null | head -n 1)" + +if [[ -z "${SRC}" ]]; then + echo "error: ${BUNDLE} not found in DerivedData. Build the app (or the ${TARGET} target) first." >&2 + exit 1 +fi + +PRODUCTS_DIR="$(dirname "${SRC}")" +APP_PLIST="${PRODUCTS_DIR}/TablePro.app/Contents/Info.plist" +APP_VERSION="$([[ -f "${APP_PLIST}" ]] && /usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "${APP_PLIST}" || echo "0.0.0")" +PLUGIN_MIN="$(/usr/libexec/PlistBuddy -c 'Print :TableProMinAppVersion' "${SRC}/Contents/Info.plist" 2>/dev/null || echo "0.0.0")" +BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "${SRC}/Contents/Info.plist")" + +mkdir -p "${DEST_DIR}" +rm -rf "${DEST_DIR:?}/${BUNDLE}" +cp -R "${SRC}" "${DEST_DIR}/" +printf '{"pluginId":"%s"}' "${BUNDLE_ID}" > "${DEST_DIR}/${BUNDLE}.metadata.json" + +echo "Installed ${BUNDLE} (${BUNDLE_ID}) into the user plugins directory." + +if [[ "$(printf '%s\n%s\n' "${PLUGIN_MIN}" "${APP_VERSION}" | sort -V | head -n1)" != "${PLUGIN_MIN}" ]]; then + echo + echo "warning: plugin TableProMinAppVersion (${PLUGIN_MIN}) is newer than the built app (${APP_VERSION})." + echo "The app will reject it as 'app version too old'. Bump MARKETING_VERSION to ${PLUGIN_MIN}" + echo "and rebuild the app. Do not edit the plugin Info.plist (it breaks code signing)." +else + echo "Relaunch TablePro to load it." +fi From a344b9e7a91d9685cff6ca1488c385ce031d0c3e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 22 Jun 2026 19:34:03 +0700 Subject: [PATCH 3/4] refactor(plugin-elasticsearch): address review feedback on requests, DDL, and counts --- .../ElasticsearchConnection.swift | 14 +++++++++++++- .../ElasticsearchPluginDriver+Execution.swift | 16 +++++++++++++++- .../ElasticsearchPluginDriver.swift | 15 +++++++++++++-- docs/databases/elasticsearch.mdx | 2 +- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchConnection.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchConnection.swift index 10d9fbfed..58f0df406 100644 --- a/Plugins/ElasticsearchDriverPlugin/ElasticsearchConnection.swift +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchConnection.swift @@ -158,6 +158,12 @@ internal final class ElasticsearchConnection: NSObject, @unchecked Sendable { return columns } + func mappingJSON(index: String) async throws -> String { + let response = try await request(method: "GET", path: "/\(encode(index))/_mapping") + guard response.statusCode == 200 else { throw mapError(response, fallback: "Failed to fetch mapping") } + return response.rawText + } + func count(index: String, query: [String: Any]?) async throws -> Int { let body: String? if let query, JSONSerialization.isValidJSONObject(["query": query]) { @@ -208,7 +214,7 @@ internal final class ElasticsearchConnection: NSObject, @unchecked Sendable { } var urlRequest = URLRequest(url: url) - urlRequest.httpMethod = method + urlRequest.httpMethod = Self.effectiveMethod(method, hasBody: body != nil) urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") urlRequest.setValue("application/json", forHTTPHeaderField: "Accept") if let authHeader { @@ -286,6 +292,12 @@ internal final class ElasticsearchConnection: NSObject, @unchecked Sendable { return nil } + static func effectiveMethod(_ method: String, hasBody: Bool) -> String { + guard hasBody else { return method } + let upper = method.uppercased() + return (upper == "GET" || upper == "HEAD") ? "POST" : method + } + private static func resolveAuthHeader(config: DriverConnectionConfig) -> String? { switch config.additionalFields["esAuthMethod"] { case "apiKey": diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver+Execution.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver+Execution.swift index 80ee9e477..63ec3fb9b 100644 --- a/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver+Execution.swift +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver+Execution.swift @@ -95,8 +95,22 @@ extension ElasticsearchPluginDriver { conn: ElasticsearchConnection ) async throws -> [[String: Any]] { let pit = try await conn.openPointInTime(index: index, keepAlive: Self.pitKeepAlive) - defer { Task { await conn.closePointInTime(id: pit) } } + do { + let hits = try await collectDeepHits(parsed: parsed, fields: fields, pit: pit, conn: conn) + await conn.closePointInTime(id: pit) + return hits + } catch { + await conn.closePointInTime(id: pit) + throw error + } + } + private func collectDeepHits( + parsed: ElasticsearchParsedSearch, + fields: [String: ElasticsearchFieldInfo], + pit: String, + conn: ElasticsearchConnection + ) async throws -> [[String: Any]] { var searchAfter: [Any]? var skipped = 0 var collected: [[String: Any]] = [] diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift index 03d4445d1..62750d482 100644 --- a/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift @@ -116,10 +116,21 @@ internal final class ElasticsearchPluginDriver: PluginDatabaseDriver, @unchecked return try await conn.count(index: table, query: nil) } + func fetchFilteredRowCount( + table: String, + filters: [(column: String, op: String, value: String)], + logicMode: String + ) async throws -> Int? { + guard let conn = connection else { throw ElasticsearchError.notConnected } + let fields = ElasticsearchMappingFlattener.fieldInfo(from: try await cachedMappingColumns(table)) + let specs = filters.map { ElasticsearchFilterSpec(column: $0.column, op: $0.op, value: $0.value) } + let query = ElasticsearchQueryBuilder.queryClause(filters: specs, logicMode: logicMode, fields: fields) + return try await conn.count(index: table, query: query) + } + func fetchTableDDL(table: String, schema: String?) async throws -> String { guard let conn = connection else { throw ElasticsearchError.notConnected } - let response = try await conn.request(method: "GET", path: "/\(table)/_mapping") - return prettyJson(response.rawText) + return prettyJson(try await conn.mappingJSON(index: table)) } func fetchViewDefinition(view: String, schema: String?) async throws -> String { diff --git a/docs/databases/elasticsearch.mdx b/docs/databases/elasticsearch.mdx index 41d1f9dc7..332394011 100644 --- a/docs/databases/elasticsearch.mdx +++ b/docs/databases/elasticsearch.mdx @@ -68,7 +68,7 @@ POST /my-index/_doc Search responses render as a grid; `_cat` and other array responses tabularize; everything else shows as formatted JSON. -**Filtering and Sorting**: Grid filters translate to Query DSL (`term`, `range`, `wildcard`, `terms`, `exists`). Sorting a `text` field automatically targets its `.keyword` subfield when one exists, since `text` fields are not directly sortable. +**Filtering and Sorting**: Column filters translate to Query DSL (`term`, `range`, `wildcard`, `terms`, `exists`). A raw filter (the default filter mode) is treated as an Elasticsearch `query_string`, so its text is Lucene syntax, for example `name:Widget` or `price:>10`, run across all fields. Sorting a `text` field automatically targets its `.keyword` subfield when one exists, since `text` fields are not directly sortable. **Data Editing**: Edit cells, insert documents, and delete documents. Edits map to REST operations keyed by `_id`: `POST /index/_update/{id}`, `PUT /index/_doc/{id}`, and `DELETE /index/_doc/{id}`. The `_id`, `_index`, and `_score` columns are read-only. From 6a20db4c0d6051a202e6d5632d62d0f10d83c37f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 22 Jun 2026 21:07:37 +0700 Subject: [PATCH 4/4] fix(plugin-elasticsearch): address Codex review (operators, id encoding, type coercion, aggs) --- .../ElasticsearchMappingFlattener.swift | 13 ++- .../ElasticsearchPluginDriver+Execution.swift | 11 ++- .../ElasticsearchPluginDriver.swift | 13 ++- .../ElasticsearchQueryBuilder.swift | 94 ++++++++++++++---- .../ElasticsearchStatementGenerator.swift | 14 ++- .../Plugins/ElasticsearchDriverTests.swift | 98 ++++++++++++++++++- 6 files changed, 213 insertions(+), 30 deletions(-) diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchMappingFlattener.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchMappingFlattener.swift index b43d6467c..7fd9634b7 100644 --- a/Plugins/ElasticsearchDriverPlugin/ElasticsearchMappingFlattener.swift +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchMappingFlattener.swift @@ -98,13 +98,22 @@ enum ElasticsearchMappingFlattener { case scoreColumn: return cell(hit["_score"]) default: - guard let value = flat[column] else { return .null } - return value + if let value = flat[column] { return value } + return cell(rawValue(in: source, atPath: column)) } } } } + static func rawValue(in source: [String: Any], atPath path: String) -> Any? { + var current: Any = source + for key in path.split(separator: ".") { + guard let dict = current as? [String: Any], let next = dict[String(key)] else { return nil } + current = next + } + return current + } + static func flattenSource(_ source: [String: Any]) -> [String: PluginCellValue] { var result: [String: PluginCellValue] = [:] flatten(value: source, prefix: "", into: &result) diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver+Execution.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver+Execution.swift index 63ec3fb9b..c12281661 100644 --- a/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver+Execution.swift +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver+Execution.swift @@ -77,7 +77,9 @@ extension ElasticsearchPluginDriver { conn: ElasticsearchConnection ) async throws -> [[String: Any]] { if parsed.from + parsed.size <= Self.maxResultWindow { - var body = ElasticsearchQueryBuilder.searchBody(for: parsed, fields: fields, size: parsed.size) + var body = ElasticsearchQueryBuilder.searchBody( + for: parsed, fields: fields, size: parsed.size, caseInsensitive: supportsCaseInsensitiveSearch + ) body["from"] = parsed.from Self.logger.debug("POST /\(index, privacy: .public)/_search body=\(Self.jsonString(body), privacy: .public)") let response = try await conn.search(index: index, body: body) @@ -119,7 +121,7 @@ extension ElasticsearchPluginDriver { try Task.checkCancellation() var body = ElasticsearchQueryBuilder.searchBody( for: parsed, fields: fields, size: Self.deepPageBatchSize, - tiebreaker: true, searchAfter: searchAfter + tiebreaker: true, searchAfter: searchAfter, caseInsensitive: supportsCaseInsensitiveSearch ) body["pit"] = ["id": pit, "keep_alive": Self.pitKeepAlive] let response = try await conn.search(index: nil, body: body) @@ -185,7 +187,10 @@ extension ElasticsearchPluginDriver { throw mapWriteError(response) } - if let json = response.json as? [String: Any], json["hits"] is [String: Any] { + if let json = response.json as? [String: Any], + json["hits"] is [String: Any], + json["aggregations"] == nil, + json["suggest"] == nil { let hits = extractHits(response) return renderHits(hits, mappingColumns: [], fields: [:], startTime: startTime) } diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift index 62750d482..9fafaf198 100644 --- a/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchPluginDriver.swift @@ -24,6 +24,15 @@ internal final class ElasticsearchPluginDriver: PluginDatabaseDriver, @unchecked var serverVersion: String? { connection?.serverVersion } + var supportsCaseInsensitiveSearch: Bool { + guard let version = serverVersion else { return true } + let parts = version.split(separator: ".").compactMap { Int($0) } + guard let major = parts.first else { return true } + if major >= 8 { return true } + if major == 7 { return (parts.count >= 2 ? parts[1] : 0) >= 10 } + return false + } + var supportsTransactions: Bool { false } var capabilities: PluginCapabilities { [.cancelQuery] } @@ -124,7 +133,9 @@ internal final class ElasticsearchPluginDriver: PluginDatabaseDriver, @unchecked guard let conn = connection else { throw ElasticsearchError.notConnected } let fields = ElasticsearchMappingFlattener.fieldInfo(from: try await cachedMappingColumns(table)) let specs = filters.map { ElasticsearchFilterSpec(column: $0.column, op: $0.op, value: $0.value) } - let query = ElasticsearchQueryBuilder.queryClause(filters: specs, logicMode: logicMode, fields: fields) + let query = ElasticsearchQueryBuilder.queryClause( + filters: specs, logicMode: logicMode, fields: fields, caseInsensitive: supportsCaseInsensitiveSearch + ) return try await conn.count(index: table, query: query) } diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchQueryBuilder.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchQueryBuilder.swift index 0d5845055..99e02a520 100644 --- a/Plugins/ElasticsearchDriverPlugin/ElasticsearchQueryBuilder.swift +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchQueryBuilder.swift @@ -169,10 +169,13 @@ struct ElasticsearchQueryBuilder { fields: [String: ElasticsearchFieldInfo], size: Int, tiebreaker: Bool = false, - searchAfter: [Any]? = nil + searchAfter: [Any]? = nil, + caseInsensitive: Bool = true ) -> [String: Any] { var body: [String: Any] = ["size": size] - body["query"] = queryClause(filters: parsed.filters, logicMode: parsed.logicMode, fields: fields) + body["query"] = queryClause( + filters: parsed.filters, logicMode: parsed.logicMode, fields: fields, caseInsensitive: caseInsensitive + ) body["sort"] = sortClause(parsed.sorts, fields: fields, tiebreaker: tiebreaker) if let searchAfter { body["search_after"] = searchAfter @@ -183,12 +186,13 @@ struct ElasticsearchQueryBuilder { static func queryClause( filters: [ElasticsearchFilterSpec], logicMode: String, - fields: [String: ElasticsearchFieldInfo] + fields: [String: ElasticsearchFieldInfo], + caseInsensitive: Bool = true ) -> [String: Any] { let active = filters.filter { !($0.column == rawColumn && $0.value.trimmingCharacters(in: .whitespaces).isEmpty) } guard !active.isEmpty else { return ["match_all": [String: Any]()] } - let clauses = active.map { clause(for: $0, fields: fields) } + let clauses = active.map { clause(for: $0, fields: fields, caseInsensitive: caseInsensitive) } let occur = logicMode.uppercased() == "OR" ? "should" : "must" var bool: [String: Any] = [occur: clauses] if occur == "should" { @@ -242,7 +246,11 @@ struct ElasticsearchQueryBuilder { // MARK: - Per-Filter Clause - static func clause(for filter: ElasticsearchFilterSpec, fields: [String: ElasticsearchFieldInfo]) -> [String: Any] { + static func clause( + for filter: ElasticsearchFilterSpec, + fields: [String: ElasticsearchFieldInfo], + caseInsensitive: Bool = true + ) -> [String: Any] { let column = filter.column let op = filter.op.uppercased() let value = filter.value @@ -255,7 +263,7 @@ struct ElasticsearchQueryBuilder { case "=": return equalsClause(column: column, value: value, fields: fields) case "!=", "<>": - return ["bool": ["must_not": [equalsClause(column: column, value: value, fields: fields)]]] + return mustNot(equalsClause(column: column, value: value, fields: fields)) case ">": return ["range": [column: ["gt": typedValue(value, column: column, fields: fields)]]] case ">=": @@ -264,30 +272,80 @@ struct ElasticsearchQueryBuilder { return ["range": [column: ["lt": typedValue(value, column: column, fields: fields)]]] case "<=": return ["range": [column: ["lte": typedValue(value, column: column, fields: fields)]]] + case "BETWEEN": + let bounds = value.split(separator: ",", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) } + guard bounds.count == 2 else { return equalsClause(column: column, value: value, fields: fields) } + return ["range": [column: [ + "gte": typedValue(bounds[0], column: column, fields: fields), + "lte": typedValue(bounds[1], column: column, fields: fields), + ]]] case "CONTAINS": - if isTextField(column, fields: fields) { - return ["match": [column: value]] - } - return ["wildcard": [keywordField(column, fields: fields): ["value": "*\(escapeWildcard(value))*", "case_insensitive": true]]] + return containsClause(column: column, value: value, fields: fields, caseInsensitive: caseInsensitive) + case "NOT CONTAINS": + return mustNot(containsClause(column: column, value: value, fields: fields, caseInsensitive: caseInsensitive)) case "STARTS WITH": if isTextField(column, fields: fields) { return ["match_phrase_prefix": [column: value]] } - return ["prefix": [keywordField(column, fields: fields): ["value": value, "case_insensitive": true]]] + return prefixClause(keywordField(column, fields: fields), value: value, caseInsensitive: caseInsensitive) case "ENDS WITH": - return ["wildcard": [keywordField(column, fields: fields): ["value": "*\(escapeWildcard(value))", "case_insensitive": true]]] + return wildcardClause(keywordField(column, fields: fields), pattern: "*\(escapeWildcard(value))", caseInsensitive: caseInsensitive) case "IN": - let values = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } - return ["terms": [keywordField(column, fields: fields): values]] - case "IS NULL", "IS_NULL": - return ["bool": ["must_not": [["exists": ["field": column]]]]] - case "IS NOT NULL", "IS_NOT_NULL": - return ["exists": ["field": column]] + return ["terms": [keywordField(column, fields: fields): splitList(value)]] + case "NOT IN": + return mustNot(["terms": [keywordField(column, fields: fields): splitList(value)]]) + case "REGEX": + return regexpClause(keywordField(column, fields: fields), value: value, caseInsensitive: caseInsensitive) + case "IS NULL", "IS_NULL", "IS EMPTY", "IS_EMPTY": + return ["bool": [ + "should": [mustNot(["exists": ["field": column]]), ["term": [keywordField(column, fields: fields): ""]]], + "minimum_should_match": 1, + ]] + case "IS NOT NULL", "IS_NOT_NULL", "IS NOT EMPTY", "IS_NOT_EMPTY": + return ["bool": [ + "must": [["exists": ["field": column]]], + "must_not": [["term": [keywordField(column, fields: fields): ""]]], + ]] default: return equalsClause(column: column, value: value, fields: fields) } } + private static func containsClause( + column: String, value: String, fields: [String: ElasticsearchFieldInfo], caseInsensitive: Bool + ) -> [String: Any] { + if isTextField(column, fields: fields) { + return ["match": [column: value]] + } + return wildcardClause(keywordField(column, fields: fields), pattern: "*\(escapeWildcard(value))*", caseInsensitive: caseInsensitive) + } + + private static func wildcardClause(_ field: String, pattern: String, caseInsensitive: Bool) -> [String: Any] { + var options: [String: Any] = ["value": pattern] + if caseInsensitive { options["case_insensitive"] = true } + return ["wildcard": [field: options]] + } + + private static func prefixClause(_ field: String, value: String, caseInsensitive: Bool) -> [String: Any] { + var options: [String: Any] = ["value": value] + if caseInsensitive { options["case_insensitive"] = true } + return ["prefix": [field: options]] + } + + private static func regexpClause(_ field: String, value: String, caseInsensitive: Bool) -> [String: Any] { + var options: [String: Any] = ["value": value] + if caseInsensitive { options["case_insensitive"] = true } + return ["regexp": [field: options]] + } + + private static func mustNot(_ clause: [String: Any]) -> [String: Any] { + ["bool": ["must_not": [clause]]] + } + + private static func splitList(_ value: String) -> [String] { + value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + } + private static func equalsClause( column: String, value: String, diff --git a/Plugins/ElasticsearchDriverPlugin/ElasticsearchStatementGenerator.swift b/Plugins/ElasticsearchDriverPlugin/ElasticsearchStatementGenerator.swift index 0912a2283..830a9f37e 100644 --- a/Plugins/ElasticsearchDriverPlugin/ElasticsearchStatementGenerator.swift +++ b/Plugins/ElasticsearchDriverPlugin/ElasticsearchStatementGenerator.swift @@ -75,7 +75,7 @@ struct ElasticsearchStatementGenerator { var document: [String: Any] = [:] for column in columns where !metaColumns.contains(column) { - guard let value = values[column], let text = value.asText, !text.isEmpty else { continue } + guard let value = values[column], let text = value.asText else { continue } document[column] = jsonValue(text, for: column) } @@ -139,8 +139,12 @@ struct ElasticsearchStatementGenerator { encodePathComponent(index) } + private static let pathComponentAllowed: CharacterSet = + .urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")) + private static let structuredTypes: Set = ["object", "nested", "flattened", "join"] + private func encodePathComponent(_ value: String) -> String { - value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value + value.addingPercentEncoding(withAllowedCharacters: Self.pathComponentAllowed) ?? value } private func jsonValue(_ text: String, for column: String) -> Any { @@ -159,9 +163,9 @@ struct ElasticsearchStatementGenerator { } if let data = text.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data), - parsed is [Any] || parsed is [String: Any] { - return parsed + let parsed = try? JSONSerialization.jsonObject(with: data) { + if parsed is [Any] { return parsed } + if parsed is [String: Any], Self.structuredTypes.contains(typeName) { return parsed } } return text } diff --git a/TableProTests/Plugins/ElasticsearchDriverTests.swift b/TableProTests/Plugins/ElasticsearchDriverTests.swift index 07d5eab7d..4495e3085 100644 --- a/TableProTests/Plugins/ElasticsearchDriverTests.swift +++ b/TableProTests/Plugins/ElasticsearchDriverTests.swift @@ -125,7 +125,8 @@ struct ElasticsearchQueryDSLTests { let filter = ElasticsearchFilterSpec(column: "status", op: "IS NULL", value: "") let clause = ElasticsearchQueryBuilder.clause(for: filter, fields: keywordField) let bool = clause["bool"] as? [String: Any] - #expect(bool?["must_not"] != nil) + #expect((bool?["should"] as? [[String: Any]])?.count == 2) + #expect(bool?["minimum_should_match"] as? Int == 1) } @Test("Deep pagination body includes _shard_doc tiebreaker") @@ -156,6 +157,53 @@ struct ElasticsearchQueryDSLTests { #expect(ElasticsearchQueryBuilder.sortableField("_id", fields: [:]) == nil) #expect(ElasticsearchQueryBuilder.sortableField("_score", fields: [:]) == "_score") } + + @Test("BETWEEN builds a gte/lte range") + func betweenRange() { + let clause = ElasticsearchQueryBuilder.clause( + for: ElasticsearchFilterSpec(column: "age", op: "BETWEEN", value: "10,20"), + fields: numericField + ) + let bounds = (clause["range"] as? [String: Any])?["age"] as? [String: Any] + #expect(bounds?["gte"] as? Int == 10) + #expect(bounds?["lte"] as? Int == 20) + } + + @Test("NOT CONTAINS and NOT IN wrap in must_not") + func negatedClauses() { + let notContains = ElasticsearchQueryBuilder.clause( + for: ElasticsearchFilterSpec(column: "status", op: "NOT CONTAINS", value: "x"), fields: keywordField + ) + #expect((notContains["bool"] as? [String: Any])?["must_not"] != nil) + let notIn = ElasticsearchQueryBuilder.clause( + for: ElasticsearchFilterSpec(column: "status", op: "NOT IN", value: "a,b"), fields: keywordField + ) + #expect((notIn["bool"] as? [String: Any])?["must_not"] != nil) + } + + @Test("REGEX builds a regexp query") + func regexClause() { + let clause = ElasticsearchQueryBuilder.clause( + for: ElasticsearchFilterSpec(column: "status", op: "REGEX", value: "a.*"), fields: keywordField + ) + let regexp = clause["regexp"] as? [String: Any] + #expect((regexp?["status"] as? [String: Any])?["value"] as? String == "a.*") + } + + @Test("case_insensitive is omitted when unsupported (pre-7.10)") + func caseInsensitiveGated() { + let on = ElasticsearchQueryBuilder.clause( + for: ElasticsearchFilterSpec(column: "status", op: "CONTAINS", value: "x"), + fields: keywordField, caseInsensitive: true + ) + #expect((on["wildcard"] as? [String: Any]).map { ($0["status"] as? [String: Any])?["case_insensitive"] as? Bool } == true) + let off = ElasticsearchQueryBuilder.clause( + for: ElasticsearchFilterSpec(column: "status", op: "CONTAINS", value: "x"), + fields: keywordField, caseInsensitive: false + ) + let offOptions = (off["wildcard"] as? [String: Any])?["status"] as? [String: Any] + #expect(offOptions?["case_insensitive"] == nil) + } } @Suite("Elasticsearch - Appended ORDER BY") @@ -246,6 +294,13 @@ struct ElasticsearchMappingFlattenerTests { #expect(columns.contains("a")) #expect(columns.contains("b")) } + + @Test("Object-valued parent column renders as JSON, not null") + func parentObjectColumnRendersJSON() { + let hits: [[String: Any]] = [["_source": ["labels": ["env": "prod", "tier": "1"]]]] + let rows = ElasticsearchMappingFlattener.rows(forHits: hits, columns: ["labels"]) + #expect(rows.first?[0].asText?.contains("env") == true) + } } @Suite("Elasticsearch - Statement Generator") @@ -321,4 +376,45 @@ struct ElasticsearchStatementGeneratorTests { #expect(decoded?.method == "PUT") #expect(decoded?.path.contains("/users/_doc/custom") == true) } + + @Test("Document id with a slash is percent-encoded into one path segment") + func slashInDocumentId() { + let change = PluginRowChange( + rowIndex: 0, + type: .delete, + cellChanges: [], + originalRow: [.text("tenant/123"), .text("users"), .text("1"), .text("Bob"), .text("30")] + ) + let statements = generator().generateStatements( + from: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: [] + ) + let decoded = ElasticsearchStatementGenerator.decode(statements[0].statement) + #expect(decoded?.path.contains("/users/_doc/tenant%2F123") == true) + } + + @Test("Insert preserves an intentional empty string") + func insertKeepsEmptyString() { + let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + let statements = generator().generateStatements( + from: [change], + insertedRowData: [0: [.null, .null, .null, .text(""), .text("25")]], + deletedRowIndices: [], + insertedRowIndices: [0] + ) + let decoded = ElasticsearchStatementGenerator.decode(statements[0].statement) + #expect(decoded?.body?.contains("\"name\":\"\"") == true) + } + + @Test("JSON object text is kept as a string on a scalar field") + func jsonObjectKeptAsStringOnScalarField() { + let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + let statements = generator().generateStatements( + from: [change], + insertedRowData: [0: [.null, .null, .null, .text("{\"a\":1}"), .text("25")]], + deletedRowIndices: [], + insertedRowIndices: [0] + ) + let decoded = ElasticsearchStatementGenerator.decode(statements[0].statement) + #expect(decoded?.body?.contains("\"name\":\"{\\\"a\\\":1}\"") == true) + } }