From 54a99028ebbae7312669370325d530df7f69e12f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 00:04:36 +0700 Subject: [PATCH 1/8] test(apple): probe a stock Presto coordinator for the facts the Presto type relies on --- .github/workflows/presto-probe.yml | 19 +++ apple/scripts/probes/check-presto-protocol.sh | 148 ++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 .github/workflows/presto-probe.yml create mode 100755 apple/scripts/probes/check-presto-protocol.sh diff --git a/.github/workflows/presto-probe.yml b/.github/workflows/presto-probe.yml new file mode 100644 index 0000000000..560926c69a --- /dev/null +++ b/.github/workflows/presto-probe.yml @@ -0,0 +1,19 @@ +name: Presto probe + +on: + push: + branches: [feat/3598-presto-connections] + +permissions: + contents: read + +jobs: + probe: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: docker run -d --name tp-presto -p 8080:8080 prestodb/presto:latest + - run: apple/scripts/probes/check-presto-protocol.sh 127.0.0.1 8080 + - if: always() + run: docker logs tp-presto 2>&1 | tail -40 diff --git a/apple/scripts/probes/check-presto-protocol.sh b/apple/scripts/probes/check-presto-protocol.sh new file mode 100755 index 0000000000..c7f2629567 --- /dev/null +++ b/apple/scripts/probes/check-presto-protocol.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# +# Checks the Presto facts the Trino plugin's Presto type hard-codes, against a stock coordinator. +# +# Usage: +# scripts/probes/check-presto-protocol.sh [host] [port] # default 127.0.0.1 8080 +# +# To bring a coordinator up first: +# docker run -d --name tp-presto -p 8080:8080 prestodb/presto:latest + +set -euo pipefail + +HOST="${1:-127.0.0.1}" +PORT="${2:-8080}" + +exec python3 -I - "$HOST" "$PORT" <<'PY' +import json +import sys +import time +import urllib.error +import urllib.request + +host, port = sys.argv[1], sys.argv[2] +base = f"http://{host}:{port}" +failures = [] + + +def request(method, url, headers=None, body=None): + req = urllib.request.Request(url, data=body, method=method, headers=headers or {}) + try: + with urllib.request.urlopen(req, timeout=60) as response: + return response.status, dict(response.headers), response.read() + except urllib.error.HTTPError as error: + return error.code, dict(error.headers), error.read() + + +def presto_headers(extra=None): + headers = {"X-Presto-User": "tablepro", "X-Presto-Source": "TablePro", "Content-Type": "text/plain"} + headers.update(extra or {}) + return headers + + +def run(sql, headers=None): + status, _, body = request("POST", f"{base}/v1/statement", headers or presto_headers(), sql.encode()) + if status != 200: + return {"httpStatus": status, "body": body.decode(errors="replace")} + results = json.loads(body) + rows, columns = [], None + while True: + columns = columns or results.get("columns") + rows += results.get("data") or [] + if results.get("error") or not results.get("nextUri"): + break + _, _, body = request("GET", results["nextUri"]) + results = json.loads(body) + return {"columns": columns, "rows": rows, "error": (results.get("error") or {}).get("message")} + + +def check(name, passed, detail): + print(("ok " if passed else "FAIL ") + name + ": " + json.dumps(detail)[:300]) + if not passed: + failures.append(name) + + +for attempt in range(60): + try: + status, _, body = request("GET", f"{base}/v1/info") + if status == 200 and not json.loads(body).get("starting"): + break + except OSError: + pass + time.sleep(5) + +trino_only = run("SELECT 1", {"X-Trino-User": "tablepro", "Content-Type": "text/plain"}) +check("X-Trino- headers carry no user", trino_only.get("httpStatus") == 400 or bool(trino_only.get("error")), trino_only) + +version = run("SELECT version()") +check("SELECT version()", not version.get("error") and version.get("rows"), version) + +catalogs = run("SHOW CATALOGS") +check("SHOW CATALOGS", "system" in [r[0] for r in catalogs.get("rows", [])], catalogs) + +check("SHOW SCHEMAS FROM a quoted catalog", not run('SHOW SCHEMAS FROM "system"').get("error"), "") + +tables = run( + 'SELECT table_name, table_type FROM "system".information_schema.tables ' + "WHERE table_schema = 'runtime' ORDER BY table_name" +) +check("information_schema.tables", not tables.get("error") and tables.get("rows"), tables) + +columns = run( + "SELECT column_name, data_type, is_nullable, column_default, comment, ordinal_position " + "FROM \"system\".information_schema.columns WHERE table_schema = 'runtime' AND table_name = 'nodes' " + "ORDER BY ordinal_position" +) +check("information_schema.columns has comment", not columns.get("error"), columns) + +comments = run("SELECT comment FROM system.metadata.table_comments") +check("system.metadata.table_comments is missing", bool(comments.get("error")), comments) + +old_views = run("SELECT name FROM system.metadata.materialized_views") +check("system.metadata.materialized_views is missing", bool(old_views.get("error")), old_views) + +views = run( + 'SELECT table_name FROM "system".information_schema.materialized_views WHERE table_schema = \'runtime\'' +) +check("information_schema.materialized_views", not views.get("error"), views) + +values = "SELECT x FROM (VALUES 1, 2, 3) AS t(x) ORDER BY x" +check("OFFSET FETCH NEXT is a syntax error", bool(run(values + " OFFSET 1 ROWS FETCH NEXT 1 ROWS ONLY").get("error")), "") +check("LIMIT then OFFSET is a syntax error", bool(run(values + " LIMIT 1 OFFSET 1").get("error")), "") +offset = run(values + " OFFSET 1 ROWS LIMIT 1") +check("OFFSET is off by default", "not enabled" in (offset.get("error") or ""), offset) +check("LIMIT alone runs", run(values + " LIMIT 2").get("rows") == [[1], [2]], "") +check("FETCH FIRST runs", run(values + " FETCH FIRST 2 ROWS ONLY").get("rows") == [[1], [2]], "") + +for prefix in ["EXPLAIN", "EXPLAIN (TYPE DISTRIBUTED)", "EXPLAIN (TYPE IO)", "EXPLAIN (TYPE VALIDATE)", "EXPLAIN ANALYZE"]: + check(prefix, not run(prefix + " " + values).get("error"), "") + +check("SHOW STATS FOR", not run("SHOW STATS FOR (SELECT 1 AS x)").get("error"), "") +check("COMMENT ON TABLE is a syntax error", bool(run("COMMENT ON TABLE system.runtime.nodes IS 'x'").get("error")), "") + +capability = run("SELECT 1", presto_headers({"X-Presto-Client-Capabilities": "PARAMETRIC_DATETIME"})) +check("an unknown capability header is ignored", capability.get("rows") == [[1]], capability) + +typed = run("SELECT TIMESTAMP '2024-01-02 03:04:05.123456', CAST('ab' AS varbinary), ARRAY[1, 2], JSON '{\"a\":1}'") +check("type names and values", not typed.get("error"), typed) + +session = run("SELECT 1", presto_headers({"X-Presto-Catalog": "system", "X-Presto-Schema": "runtime"})) +check("catalog and schema headers", session.get("rows") == [[1]], session) + +status, _, body = request( + "POST", f"{base}/v1/statement", presto_headers(), + b"SELECT count(*) FROM UNNEST(sequence(1, 1000000000)) CROSS JOIN UNNEST(sequence(1, 1000))", +) +results = json.loads(body) +query_id, next_uri = results["id"], results["nextUri"] +for _ in range(3): + _, _, body = request("GET", next_uri) + next_uri = json.loads(body).get("nextUri") or next_uri +cancel_status, _, _ = request("DELETE", next_uri) +time.sleep(3) +state = run(f"SELECT state, error_code FROM system.runtime.queries WHERE query_id = '{query_id}'") +check("DELETE on nextUri cancels", cancel_status == 204 and state.get("rows", [[None]])[0][0] == "FAILED", [cancel_status, state]) + +print("FAILED: " + ", ".join(failures) if failures else "all checks passed") +sys.exit(1 if failures else 0) +PY From 9e0b03438f37cf0e16864dbb2798d2b296ee56b5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 00:12:32 +0700 Subject: [PATCH 2/8] test(apple): probe Presto's version, stats, paging and cancel --- apple/scripts/probes/check-presto-protocol.sh | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/apple/scripts/probes/check-presto-protocol.sh b/apple/scripts/probes/check-presto-protocol.sh index c7f2629567..4153dc0163 100755 --- a/apple/scripts/probes/check-presto-protocol.sh +++ b/apple/scripts/probes/check-presto-protocol.sh @@ -57,7 +57,7 @@ def run(sql, headers=None): def check(name, passed, detail): - print(("ok " if passed else "FAIL ") + name + ": " + json.dumps(detail)[:300]) + print(("ok " if passed else "FAIL ") + name + ": " + json.dumps(detail, default=str)[:600]) if not passed: failures.append(name) @@ -74,8 +74,9 @@ for attempt in range(60): trino_only = run("SELECT 1", {"X-Trino-User": "tablepro", "Content-Type": "text/plain"}) check("X-Trino- headers carry no user", trino_only.get("httpStatus") == 400 or bool(trino_only.get("error")), trino_only) -version = run("SELECT version()") -check("SELECT version()", not version.get("error") and version.get("rows"), version) +check("version() does not exist", "not registered" in (run("SELECT version()").get("error") or ""), "") +node = run("SELECT node_version FROM system.runtime.nodes WHERE coordinator = true") +check("coordinator version from system.runtime.nodes", not node.get("error") and node.get("rows"), node) catalogs = run("SHOW CATALOGS") check("SHOW CATALOGS", "system" in [r[0] for r in catalogs.get("rows", [])], catalogs) @@ -117,21 +118,27 @@ check("FETCH FIRST runs", run(values + " FETCH FIRST 2 ROWS ONLY").get("rows") = for prefix in ["EXPLAIN", "EXPLAIN (TYPE DISTRIBUTED)", "EXPLAIN (TYPE IO)", "EXPLAIN (TYPE VALIDATE)", "EXPLAIN ANALYZE"]: check(prefix, not run(prefix + " " + values).get("error"), "") -check("SHOW STATS FOR", not run("SHOW STATS FOR (SELECT 1 AS x)").get("error"), "") +stats = run('SHOW STATS FOR "tpch"."tiny"."nation"') +summary = [r for r in stats.get("rows", []) if r[0] is None] +check("SHOW STATS row_count is the fifth column", bool(summary) and summary[0][4] == 25, stats) +browse = run('SELECT * FROM "tpch"."tiny"."nation" LIMIT 10') +check("browse with LIMIT", len(browse.get("rows", [])) == 10, browse.get("error")) +session_offset = run(values + " OFFSET 1 ROWS LIMIT 1", presto_headers({"X-Presto-Session": "offset_clause_enabled=true"})) +check("offset_clause_enabled turns OFFSET on", session_offset.get("rows") == [[2]], session_offset) check("COMMENT ON TABLE is a syntax error", bool(run("COMMENT ON TABLE system.runtime.nodes IS 'x'").get("error")), "") capability = run("SELECT 1", presto_headers({"X-Presto-Client-Capabilities": "PARAMETRIC_DATETIME"})) check("an unknown capability header is ignored", capability.get("rows") == [[1]], capability) typed = run("SELECT TIMESTAMP '2024-01-02 03:04:05.123456', CAST('ab' AS varbinary), ARRAY[1, 2], JSON '{\"a\":1}'") -check("type names and values", not typed.get("error"), typed) +check("type names and values", not typed.get("error"), [typed.get("rows"), [c["type"] for c in typed.get("columns") or []]]) session = run("SELECT 1", presto_headers({"X-Presto-Catalog": "system", "X-Presto-Schema": "runtime"})) check("catalog and schema headers", session.get("rows") == [[1]], session) status, _, body = request( "POST", f"{base}/v1/statement", presto_headers(), - b"SELECT count(*) FROM UNNEST(sequence(1, 1000000000)) CROSS JOIN UNNEST(sequence(1, 1000))", + b"SELECT count(*) FROM tpch.sf100000.lineitem", ) results = json.loads(body) query_id, next_uri = results["id"], results["nextUri"] @@ -139,9 +146,14 @@ for _ in range(3): _, _, body = request("GET", next_uri) next_uri = json.loads(body).get("nextUri") or next_uri cancel_status, _, _ = request("DELETE", next_uri) -time.sleep(3) -state = run(f"SELECT state, error_code FROM system.runtime.queries WHERE query_id = '{query_id}'") -check("DELETE on nextUri cancels", cancel_status == 204 and state.get("rows", [[None]])[0][0] == "FAILED", [cancel_status, state]) +state = {} +for _ in range(20): + state = run(f"SELECT state, error_code FROM system.runtime.queries WHERE query_id = '{query_id}'") + if state.get("rows") and state["rows"][0][0] in ("FAILED", "FINISHED"): + break + time.sleep(1) +final_state = (state.get("rows") or [[None]])[0][0] +check("DELETE on nextUri cancels", cancel_status == 204 and final_state == "FAILED", [cancel_status, state]) print("FAILED: " + ", ".join(failures) if failures else "all checks passed") sys.exit(1 if failures else 0) From 899855307018a8afe232b3cc3a3d44e79d55bb27 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 00:15:02 +0700 Subject: [PATCH 3/8] test(apple): probe Presto's structured values and cancel state --- apple/scripts/probes/check-presto-protocol.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apple/scripts/probes/check-presto-protocol.sh b/apple/scripts/probes/check-presto-protocol.sh index 4153dc0163..17444bd7d0 100755 --- a/apple/scripts/probes/check-presto-protocol.sh +++ b/apple/scripts/probes/check-presto-protocol.sh @@ -130,7 +130,10 @@ check("COMMENT ON TABLE is a syntax error", bool(run("COMMENT ON TABLE system.ru capability = run("SELECT 1", presto_headers({"X-Presto-Client-Capabilities": "PARAMETRIC_DATETIME"})) check("an unknown capability header is ignored", capability.get("rows") == [[1]], capability) -typed = run("SELECT TIMESTAMP '2024-01-02 03:04:05.123456', CAST('ab' AS varbinary), ARRAY[1, 2], JSON '{\"a\":1}'") +typed = run( + "SELECT TIMESTAMP '2024-01-02 03:04:05.123456', CAST('ab' AS varbinary), ARRAY[1, 2], JSON '{\"a\":1}', " + "MAP(ARRAY['k'], ARRAY[1]), CAST(ROW(1, 'x') AS ROW(a integer, b varchar)), ARRAY[ARRAY['y']]" +) check("type names and values", not typed.get("error"), [typed.get("rows"), [c["type"] for c in typed.get("columns") or []]]) session = run("SELECT 1", presto_headers({"X-Presto-Catalog": "system", "X-Presto-Schema": "runtime"})) @@ -148,7 +151,7 @@ for _ in range(3): cancel_status, _, _ = request("DELETE", next_uri) state = {} for _ in range(20): - state = run(f"SELECT state, error_code FROM system.runtime.queries WHERE query_id = '{query_id}'") + state = run(f"SELECT state FROM system.runtime.queries WHERE query_id = '{query_id}'") if state.get("rows") and state["rows"][0][0] in ("FAILED", "FINISHED"): break time.sleep(1) From e4296f7cd1c9716194babdafdfc3ec15a6f5acc4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 00:35:42 +0700 Subject: [PATCH 4/8] feat(plugins): connect to Presto through the Trino plugin --- .github/workflows/presto-probe.yml | 19 -- apple/Packages/TableProCore/Package.swift | 5 + .../SQLLexicalProfile.swift | 4 +- .../TableProTrinoCore/TrinoClientConfig.swift | 8 +- .../TableProTrinoCore/TrinoError.swift | 8 +- .../TrinoIntrospectionSQL.swift | 54 +++-- .../TableProTrinoCore/TrinoServerFlavor.swift | 42 ++++ .../TrinoStatementClient.swift | 12 +- .../TableProTrinoCore/TrinoTypeMapper.swift | 3 +- .../TableProTrinoCore/TrinoValue.swift | 16 +- .../SQLLexicalReadingsTests.swift | 2 +- .../TrinoServerFlavorTests.swift | 128 +++++++++++ apple/Plugins/TrinoDriverPlugin/Info.plist | 1 + .../TrinoConnectionFailure.swift | 8 +- .../TrinoDriverPlugin/TrinoPlugin.swift | 8 +- .../TrinoPluginDriver+DDL.swift | 4 +- .../TrinoPluginDriver+Schema.swift | 12 +- .../TrinoDriverPlugin/TrinoPluginDriver.swift | 24 +- .../presto-icon.imageset/Contents.json | 16 ++ .../presto-icon.imageset/presto.svg | 1 + .../PluginManager+DriverAvailability.swift | 17 +- .../Plugins/PluginManager+Registration.swift | 8 +- ...ginMetadataRegistry+RegistryDefaults.swift | 130 +---------- ...ginMetadataRegistry+SnapshotAdoption.swift | 61 +++-- ...PluginMetadataRegistry+TrinoDefaults.swift | 208 ++++++++++++++++++ .../Core/Plugins/PluginMetadataRegistry.swift | 1 + .../CellValues/CellExpressionVocabulary.swift | 4 +- .../Models/Connection/DatabaseType.swift | 1 + .../Database/StructureEditEligibility.swift | 5 + .../TablePro/Resources/Localizable.xcstrings | 8 + .../PluginManagerVariantAccessorTests.swift | 49 +++++ ...PluginMetadataRegistryTypeCountTests.swift | 3 +- .../PluginMetadataRegistryVariantTests.swift | 58 ++++- .../CellExpressionVocabularyTests.swift | 2 +- .../ConnectionURLParserJDBCTests.swift | 10 + 35 files changed, 716 insertions(+), 224 deletions(-) delete mode 100644 .github/workflows/presto-probe.yml create mode 100644 apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoServerFlavor.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoServerFlavorTests.swift create mode 100644 apple/TablePro/Assets.xcassets/presto-icon.imageset/Contents.json create mode 100644 apple/TablePro/Assets.xcassets/presto-icon.imageset/presto.svg create mode 100644 apple/TablePro/Core/Plugins/PluginMetadataRegistry+TrinoDefaults.swift diff --git a/.github/workflows/presto-probe.yml b/.github/workflows/presto-probe.yml deleted file mode 100644 index 560926c69a..0000000000 --- a/.github/workflows/presto-probe.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Presto probe - -on: - push: - branches: [feat/3598-presto-connections] - -permissions: - contents: read - -jobs: - probe: - runs-on: ubuntu-24.04 - timeout-minutes: 30 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - run: docker run -d --name tp-presto -p 8080:8080 prestodb/presto:latest - - run: apple/scripts/probes/check-presto-protocol.sh 127.0.0.1 8080 - - if: always() - run: docker logs tp-presto 2>&1 | tail -40 diff --git a/apple/Packages/TableProCore/Package.swift b/apple/Packages/TableProCore/Package.swift index 8c8e6c8c10..56366b4c22 100644 --- a/apple/Packages/TableProCore/Package.swift +++ b/apple/Packages/TableProCore/Package.swift @@ -262,6 +262,11 @@ let package = Package( dependencies: ["TableProSpannerCore"], path: "Tests/TableProSpannerCoreTests" ), + .testTarget( + name: "TableProTrinoCoreTests", + dependencies: ["TableProTrinoCore"], + path: "Tests/TableProTrinoCoreTests" + ), .testTarget( name: "TableProTabularIOTests", dependencies: ["TableProTabularIO", "TableProPluginKit"], diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift index 26c31c4877..49f7582a25 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift @@ -211,6 +211,7 @@ public struct SQLLexicalProfile: Sendable, Hashable { let sqliteFamily = SQLLexicalProfile(grammar: sqlite) let cqlFamily = SQLLexicalProfile(grammar: cql, undetermined: [.carriageReturnEndsLineComments]) let commandLineFamily = SQLLexicalProfile(grammar: commandLine) + let trinoFamily = SQLLexicalProfile(grammar: .ansi, undetermined: [.carriageReturnEndsLineComments]) return [ "PostgreSQL": postgreSQLFamily, "Greenplum": postgreSQLFamily, @@ -261,7 +262,8 @@ public struct SQLLexicalProfile: Sendable, Hashable { alternatives: [spannerPostgreSQL], undetermined: [.carriageReturnEndsLineComments] ), - "Trino": SQLLexicalProfile(grammar: .ansi, undetermined: [.carriageReturnEndsLineComments]), + "Trino": trinoFamily, + "Presto": trinoFamily, "Teradata": SQLLexicalProfile( grammar: .ansi, undetermined: [.nestedBlockComments, .carriageReturnEndsLineComments] diff --git a/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift b/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift index e6c9c318fb..c26f8a2a05 100644 --- a/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift +++ b/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoClientConfig.swift @@ -43,7 +43,7 @@ public struct TrinoClientConfig: Sendable { public var timeZone: String? public var auth: TrinoAuth public var clientTags: [String] - public var protocolHeaders: TrinoProtocolHeaders + public var flavor: TrinoServerFlavor public var requestTimeoutSeconds: Int public init( @@ -58,7 +58,7 @@ public struct TrinoClientConfig: Sendable { timeZone: String? = nil, auth: TrinoAuth = .none, clientTags: [String] = [], - protocolHeaders: TrinoProtocolHeaders = .trino, + flavor: TrinoServerFlavor = .trino, requestTimeoutSeconds: Int = 60 ) { self.host = host @@ -72,10 +72,12 @@ public struct TrinoClientConfig: Sendable { self.timeZone = timeZone self.auth = auth self.clientTags = clientTags - self.protocolHeaders = protocolHeaders + self.flavor = flavor self.requestTimeoutSeconds = requestTimeoutSeconds } + public var protocolHeaders: TrinoProtocolHeaders { flavor.protocolHeaders } + public var scheme: String { useTLS ? "https" : "http" } public var statementURL: URL? { diff --git a/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoError.swift b/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoError.swift index 700df914e5..3ef79c025d 100644 --- a/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoError.swift +++ b/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoError.swift @@ -55,7 +55,7 @@ public enum TrinoError: Error, LocalizedError, Equatable { case .invalidConfiguration(let detail): return detail case .notConnected: - return "Not connected to Trino" + return "Not connected to the coordinator" case .transport(let detail): return detail case .httpStatus(let code, let body): @@ -78,7 +78,7 @@ public enum TrinoError: Error, LocalizedError, Equatable { case .cancelled: return "Query was cancelled" case .timedOut: - return "Timed out waiting for Trino" + return "Timed out waiting for the coordinator" } } @@ -113,7 +113,7 @@ public enum TrinoError: Error, LocalizedError, Equatable { private static func describeRedirect(statusCode: Int, location: String?, advice: TrinoRedirectAdvice) -> String { guard let location else { return "The server answered with HTTP \(statusCode) and no address. " - + "Trino never redirects, so a proxy in front of it sent this. Check the host, port and SSL Mode." + + "The coordinator never redirects, so a proxy in front of it sent this. Check the host, port and SSL Mode." } switch advice { case .turnOnTLS(let port): @@ -121,7 +121,7 @@ public enum TrinoError: Error, LocalizedError, Equatable { + "Set Port to \(port) and SSL Mode to Verify Identity." case .checkAddress: return "The server redirected the request to \(location). TablePro does not follow redirects, " - + "and Trino never sends one, so a proxy in front of it did. Check the host, port and SSL Mode." + + "and the coordinator never sends one, so a proxy in front of it did. Check the host, port and SSL Mode." } } } diff --git a/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift b/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift index c2729732a5..b6da4755b9 100644 --- a/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift +++ b/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoIntrospectionSQL.swift @@ -53,23 +53,38 @@ public enum TrinoIntrospectionSQL { """ } - public static func tableComment(catalog: String, schema: String, table: String) -> String { - """ - SELECT comment FROM system.metadata.table_comments \ - WHERE catalog_name = \(quoteLiteral(catalog)) AND schema_name = \(quoteLiteral(schema)) \ - AND table_name = \(quoteLiteral(table)) - """ - } - - public static func listMaterializedViews(catalog: String, schema: String) -> String { - """ - SELECT name FROM system.metadata.materialized_views \ - WHERE catalog_name = \(quoteLiteral(catalog)) AND schema_name = \(quoteLiteral(schema)) - """ + // Presto has no `system.metadata.table_comments` and no other table that carries the comment. + public static func tableComment( + catalog: String, + schema: String, + table: String, + flavor: TrinoServerFlavor + ) -> String? { + guard flavor == .trino else { return nil } + return """ + SELECT comment FROM system.metadata.table_comments \ + WHERE catalog_name = \(quoteLiteral(catalog)) AND schema_name = \(quoteLiteral(schema)) \ + AND table_name = \(quoteLiteral(table)) + """ + } + + public static func listMaterializedViews(catalog: String, schema: String, flavor: TrinoServerFlavor) -> String { + switch flavor { + case .trino: + return """ + SELECT name FROM system.metadata.materialized_views \ + WHERE catalog_name = \(quoteLiteral(catalog)) AND schema_name = \(quoteLiteral(schema)) + """ + case .presto: + return """ + SELECT table_name FROM \(quoteIdentifier(catalog)).information_schema.materialized_views \ + WHERE table_schema = \(quoteLiteral(schema)) + """ + } } /// One relation's `table_type`, which says `VIEW` for a view. A materialized view is not told - /// apart here: ``materializedView(catalog:schema:name:)`` answers for it. + /// apart here: ``materializedView(catalog:schema:name:flavor:)`` answers for it. public static func relationType(catalog: String, schema: String, table: String) -> String { """ SELECT table_type FROM \(quoteIdentifier(catalog)).information_schema.tables \ @@ -77,8 +92,15 @@ public enum TrinoIntrospectionSQL { """ } - public static func materializedView(catalog: String, schema: String, name: String) -> String { - listMaterializedViews(catalog: catalog, schema: schema) + " AND name = \(quoteLiteral(name))" + public static func materializedView( + catalog: String, + schema: String, + name: String, + flavor: TrinoServerFlavor + ) -> String { + let nameColumn = flavor == .trino ? "name" : "table_name" + return listMaterializedViews(catalog: catalog, schema: schema, flavor: flavor) + + " AND \(nameColumn) = \(quoteLiteral(name))" } public static func approximateRowCount(catalog: String?, schema: String?, table: String) -> String { diff --git a/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoServerFlavor.swift b/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoServerFlavor.swift new file mode 100644 index 0000000000..05da03b1ab --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoServerFlavor.swift @@ -0,0 +1,42 @@ +import Foundation + +public enum TrinoServerFlavor: String, Sendable, Equatable, CaseIterable { + case trino = "Trino" + case presto = "Presto" + + public init(driverVariant: String?) { + self = driverVariant == TrinoServerFlavor.presto.rawValue ? .presto : .trino + } + + public var productName: String { rawValue } + + public var protocolHeaders: TrinoProtocolHeaders { + switch self { + case .trino: .trino + case .presto: .presto + } + } + + // Presto has no version() (measured on 0.299), so it connects on a statement any user can run. + public var connectStatement: String { + switch self { + case .trino: "SELECT version()" + case .presto: "SELECT 1" + } + } + + public var versionStatement: String { + switch self { + case .trino: "SELECT version()" + case .presto: "SELECT node_version FROM system.runtime.nodes WHERE coordinator = true" + } + } + + // Presto has no client capabilities header (PrestoHeaders, 0.299). + public var clientCapabilities: String? { + switch self { + case .trino: "PARAMETRIC_DATETIME" + case .presto: nil + } + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementClient.swift b/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementClient.swift index b55abf3f1f..9e502166ae 100644 --- a/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementClient.swift +++ b/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoStatementClient.swift @@ -97,7 +97,7 @@ public final class TrinoStatementClient: @unchecked Sendable { onPage: ([[TrinoValue]]) -> Void ) async throws -> StatementOutcome { guard let statementURL = config.statementURL else { - throw TrinoError.invalidConfiguration("Invalid Trino server URL") + throw TrinoError.invalidConfiguration("Invalid \(config.flavor.productName) server URL") } if let credential = config.plaintextCredential { throw TrinoError.credentialsRequireTLS(credential) @@ -263,7 +263,7 @@ public final class TrinoStatementClient: @unchecked Sendable { do { return try JSONDecoder().decode(TrinoQueryResults.self, from: response.body) } catch { - throw TrinoError.invalidResponse("Could not decode the Trino response") + throw TrinoError.invalidResponse("Could not decode the \(config.flavor.productName) response") } } @@ -322,7 +322,9 @@ public final class TrinoStatementClient: @unchecked Sendable { if !config.clientTags.isEmpty { headers[protocolHeaders.clientTags] = config.clientTags.joined(separator: ",") } - headers[protocolHeaders.clientCapabilities] = "PARAMETRIC_DATETIME" + if let capabilities = config.flavor.clientCapabilities { + headers[protocolHeaders.clientCapabilities] = capabilities + } headers["Content-Type"] = "text/plain; charset=utf-8" if let authorization = config.authorizationHeader { headers["Authorization"] = authorization @@ -340,11 +342,11 @@ public final class TrinoStatementClient: @unchecked Sendable { private func followURL(_ uri: String) throws -> URL { guard let url = URL(string: uri) else { - throw TrinoError.invalidResponse("Trino returned an invalid nextUri") + throw TrinoError.invalidResponse("\(config.flavor.productName) returned an invalid nextUri") } guard config.useTLS, url.scheme?.lowercased() != "https" else { return url } throw TrinoError.invalidResponse( - "Trino answered an HTTPS request with a plain http:// address, so it was not followed. " + "\(config.flavor.productName) answered an HTTPS request with a plain http:// address, so it was not followed. " + "A coordinator behind a TLS proxy needs http-server.process-forwarded=true." ) } diff --git a/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTypeMapper.swift b/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTypeMapper.swift index 215e9997f4..a140d960fa 100644 --- a/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTypeMapper.swift +++ b/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoTypeMapper.swift @@ -7,8 +7,9 @@ public enum TrinoTypeCategory: Sendable, Equatable { } public enum TrinoTypeMapper { + // Presto also sends `KHyperLogLog` and `kllsketch` as base64. static let binaryTypes: Set = [ - "varbinary", "hyperloglog", "p4hyperloglog", "qdigest", "tdigest", "setdigest" + "varbinary", "hyperloglog", "p4hyperloglog", "khyperloglog", "kllsketch", "qdigest", "tdigest", "setdigest" ] static let structuredTypes: Set = ["array", "map", "row"] diff --git a/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoValue.swift b/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoValue.swift index 1a534210b9..11d9101fdd 100644 --- a/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoValue.swift +++ b/apple/Packages/TableProCore/Sources/TableProTrinoCore/TrinoValue.swift @@ -20,7 +20,21 @@ public enum TrinoValueDecoder { } return .text(json.scalarText ?? "") case .structured: - return .text(json.jsonText()) + return .text(structuredText(json)) + } + } + + // Presto 0.299 sends array, map and row values as their JSON text rather than as JSON. + private static func structuredText(_ json: TrinoJSONValue) -> String { + guard case .string(let text) = json, + let parsed = try? JSONDecoder().decode(TrinoJSONValue.self, from: Data(text.utf8)) else { + return json.jsonText() + } + switch parsed { + case .array, .object: + return parsed.jsonText() + default: + return json.jsonText() } } } diff --git a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift index 8d194f955e..59794c57ca 100644 --- a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift @@ -9,7 +9,7 @@ struct SQLLexicalReadingsTests { let shipped = [ "MySQL", "MariaDB", "TiDB", "Databend", "OceanBase", "PostgreSQL", "Redshift", "CockroachDB", "PGlite", "SQLite", "libSQL", "Turso", "Cloudflare D1", "DuckDB", "Oracle", "Dameng", "SQL Server", "ClickHouse", - "Snowflake", "BigQuery", "Spanner", "Trino", "Teradata", "SAP HANA", "Cassandra", "ScyllaDB", "DynamoDB", + "Snowflake", "BigQuery", "Spanner", "Trino", "Presto", "Teradata", "SAP HANA", "Cassandra", "ScyllaDB", "DynamoDB", "SurrealDB", "Redis", "MongoDB", "etcd", "Elasticsearch", "Typesense", "Weaviate", "Kafka", "Firestore", "Firebase Realtime Database", "Bigtable", ] diff --git a/apple/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoServerFlavorTests.swift b/apple/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoServerFlavorTests.swift new file mode 100644 index 0000000000..ad4aec9e56 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoServerFlavorTests.swift @@ -0,0 +1,128 @@ +import Foundation +import TableProTrinoCore +import Testing + +private final class RecordingTransport: TrinoTransport, @unchecked Sendable { + private let lock = NSLock() + private var recorded: [TrinoHTTPRequest] = [] + private let responseHeaders: [String: String] + + init(responseHeaders: [String: String] = [:]) { + self.responseHeaders = responseHeaders + } + + var requests: [TrinoHTTPRequest] { + lock.withLock { recorded } + } + + func send(_ request: TrinoHTTPRequest) async throws -> TrinoHTTPResponse { + lock.withLock { recorded.append(request) } + let body = #"{"id":"q1","columns":[{"name":"x","type":"integer"}],"data":[[1]]}"# + return TrinoHTTPResponse(statusCode: 200, headers: TrinoHeaderFields(responseHeaders), body: Data(body.utf8)) + } + + func cancelAll() {} +} + +struct TrinoServerFlavorTests { + private func config(_ flavor: TrinoServerFlavor) -> TrinoClientConfig { + TrinoClientConfig(host: "localhost", user: "analyst", catalog: "hive", schema: "web", flavor: flavor) + } + + private func firstRequest(_ flavor: TrinoServerFlavor) async throws -> TrinoHTTPRequest { + let transport = RecordingTransport() + let session = TrinoSessionState(catalog: "hive", schema: "web") + let client = TrinoStatementClient(transport: transport, config: config(flavor), session: session) + _ = try await client.execute("SELECT 1") + return try #require(transport.requests.first) + } + + @Test("The driver variant picks the flavor, and anything but Presto stays Trino") + func flavorFromDriverVariant() { + #expect(TrinoServerFlavor(driverVariant: "Presto") == .presto) + #expect(TrinoServerFlavor(driverVariant: "Trino") == .trino) + #expect(TrinoServerFlavor(driverVariant: nil) == .trino) + #expect(TrinoServerFlavor(driverVariant: "presto") == .trino) + } + + @Test("A Presto statement names its user, catalog and schema under X-Presto-") + func prestoSendsPrestoHeaders() async throws { + let headers = try await firstRequest(.presto).headers + #expect(headers["X-Presto-User"] == "analyst") + #expect(headers["X-Presto-Source"] == "TablePro") + #expect(headers["X-Presto-Catalog"] == "hive") + #expect(headers["X-Presto-Schema"] == "web") + #expect(!headers.keys.contains { $0.hasPrefix("X-Trino-") }) + } + + @Test("Presto gets no client capabilities header, which it does not define") + func prestoSendsNoCapabilities() async throws { + let headers = try await firstRequest(.presto).headers + #expect(!headers.keys.contains { $0.hasSuffix("Client-Capabilities") }) + } + + @Test("Trino still asks for parametric datetimes") + func trinoSendsCapabilities() async throws { + let headers = try await firstRequest(.trino).headers + #expect(headers["X-Trino-User"] == "analyst") + #expect(headers["X-Trino-Client-Capabilities"] == "PARAMETRIC_DATETIME") + } + + @Test("A Presto client follows X-Presto-Set-Schema and ignores the Trino spelling") + func prestoReadsPrestoResponseHeaders() async throws { + let transport = RecordingTransport(responseHeaders: [ + "X-Presto-Set-Schema": "events", + "X-Trino-Set-Catalog": "other", + ]) + let session = TrinoSessionState(catalog: "hive", schema: "web") + let client = TrinoStatementClient(transport: transport, config: config(.presto), session: session) + _ = try await client.execute("USE events") + #expect(session.schema == "events") + #expect(session.catalog == "hive") + } + + @Test("Presto connects on SELECT 1 and reads its version from the coordinator node, having no version()") + func prestoConnectAndVersionStatements() { + #expect(TrinoServerFlavor.presto.connectStatement == "SELECT 1") + #expect(TrinoServerFlavor.presto.versionStatement.contains("system.runtime.nodes")) + #expect(TrinoServerFlavor.trino.connectStatement == "SELECT version()") + #expect(TrinoServerFlavor.trino.versionStatement == TrinoServerFlavor.trino.connectStatement) + } + + @Test("Presto reads materialized views from information_schema and has no table comment query") + func prestoIntrospection() { + let views = TrinoIntrospectionSQL.listMaterializedViews(catalog: "hive", schema: "web", flavor: .presto) + #expect(views.contains(#"FROM "hive".information_schema.materialized_views"#)) + #expect(views.contains("table_schema = 'web'")) + #expect(!views.contains("system.metadata")) + + let one = TrinoIntrospectionSQL.materializedView(catalog: "hive", schema: "web", name: "mv", flavor: .presto) + #expect(one.hasSuffix("AND table_name = 'mv'")) + + #expect(TrinoIntrospectionSQL.tableComment(catalog: "hive", schema: "web", table: "t", flavor: .presto) == nil) + } + + @Test("Trino keeps reading system.metadata") + func trinoIntrospection() { + let views = TrinoIntrospectionSQL.listMaterializedViews(catalog: "hive", schema: "web", flavor: .trino) + #expect(views.contains("system.metadata.materialized_views")) + let one = TrinoIntrospectionSQL.materializedView(catalog: "hive", schema: "web", name: "mv", flavor: .trino) + #expect(one.hasSuffix("AND name = 'mv'")) + let comment = TrinoIntrospectionSQL.tableComment(catalog: "hive", schema: "web", table: "t", flavor: .trino) + #expect(comment?.contains("system.metadata.table_comments") == true) + } + + @Test("An array, map or row sent as JSON text reads the same as one sent as JSON") + func structuredValueSentAsText() { + let asText = TrinoValueDecoder.decode(.string("[ 1, 2 ]"), category: .structured) + let asJSON = TrinoValueDecoder.decode(.array([.int(1), .int(2)]), category: .structured) + #expect(asText == .text("[1,2]")) + #expect(asText == asJSON) + #expect(TrinoValueDecoder.decode(.string(#"{ "k" : 1 }"#), category: .structured) == .text(#"{"k":1}"#)) + } + + @Test("Presto's sketch types arrive as base64 binary", arguments: ["KHyperLogLog", "kllsketch(bigint)"]) + func prestoSketchTypesAreBinary(type: String) { + #expect(TrinoTypeMapper.category(forRawType: type) == .binary) + } +} diff --git a/apple/Plugins/TrinoDriverPlugin/Info.plist b/apple/Plugins/TrinoDriverPlugin/Info.plist index 4b74e7ea00..79b7acc065 100644 --- a/apple/Plugins/TrinoDriverPlugin/Info.plist +++ b/apple/Plugins/TrinoDriverPlugin/Info.plist @@ -7,6 +7,7 @@ TableProProvidesDatabaseTypeIds Trino + Presto diff --git a/apple/Plugins/TrinoDriverPlugin/TrinoConnectionFailure.swift b/apple/Plugins/TrinoDriverPlugin/TrinoConnectionFailure.swift index e13450f5f2..b401949837 100644 --- a/apple/Plugins/TrinoDriverPlugin/TrinoConnectionFailure.swift +++ b/apple/Plugins/TrinoDriverPlugin/TrinoConnectionFailure.swift @@ -25,8 +25,8 @@ extension TrinoError { return String( format: String( localized: """ - The server answered with HTTP %lld and no address. Trino never redirects, so a proxy in \ - front of it sent this. Check the host, port and SSL Mode. + The server answered with HTTP %lld and no address. The coordinator never redirects, so a \ + proxy in front of it sent this. Check the host, port and SSL Mode. """ ), Int64(statusCode) @@ -45,8 +45,8 @@ extension TrinoError { return String( format: String( localized: """ - The server redirected the request to %@. TablePro does not follow redirects, and Trino never \ - sends one, so a proxy in front of it did. Check the host, port and SSL Mode. + The server redirected the request to %@. TablePro does not follow redirects, and the \ + coordinator never sends one, so a proxy in front of it did. Check the host, port and SSL Mode. """ ), location diff --git a/apple/Plugins/TrinoDriverPlugin/TrinoPlugin.swift b/apple/Plugins/TrinoDriverPlugin/TrinoPlugin.swift index 6bab44583d..ff59f1914f 100644 --- a/apple/Plugins/TrinoDriverPlugin/TrinoPlugin.swift +++ b/apple/Plugins/TrinoDriverPlugin/TrinoPlugin.swift @@ -1,13 +1,15 @@ import Foundation import TableProPluginKit +import TableProTrinoCore final class TrinoPlugin: NSObject, TableProPlugin, DriverPlugin { static let pluginName = "Trino Driver" static let pluginVersion = "1.0.0" - static let pluginDescription = "Trino distributed SQL engine support via the REST client protocol" + static let pluginDescription = "Trino and Presto support via the REST client protocol" static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "Trino" + static let additionalDatabaseTypeIds: [String] = ["Presto"] static let supportsRenameTable = true @@ -152,6 +154,10 @@ final class TrinoPlugin: NSObject, TableProPlugin, DriverPlugin { ExplainVariant(id: "analyze", label: "Explain Analyze", sqlPrefix: "EXPLAIN ANALYZE"), ] + static func driverVariant(for databaseTypeId: String) -> String? { + TrinoServerFlavor(rawValue: databaseTypeId)?.rawValue + } + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { TrinoPluginDriver(config: config) } diff --git a/apple/Plugins/TrinoDriverPlugin/TrinoPluginDriver+DDL.swift b/apple/Plugins/TrinoDriverPlugin/TrinoPluginDriver+DDL.swift index a93e4c5a79..b582725fc4 100644 --- a/apple/Plugins/TrinoDriverPlugin/TrinoPluginDriver+DDL.swift +++ b/apple/Plugins/TrinoDriverPlugin/TrinoPluginDriver+DDL.swift @@ -43,8 +43,10 @@ extension TrinoPluginDriver { ) } + // Presto has no COMMENT ON statement. func objectCommentStatement(name: String, objectType: String, schema: String?, comment: String?) -> String? { - TrinoDDLSQL.objectComment( + guard flavor == .trino else { return nil } + return TrinoDDLSQL.objectComment( qualifiedName: qualifiedName(table: name, schema: schema), objectType: objectType, comment: comment diff --git a/apple/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift b/apple/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift index e7f49225dd..201c296014 100644 --- a/apple/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift +++ b/apple/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Schema.swift @@ -140,7 +140,9 @@ extension TrinoPluginDriver { /// reads the same forgiving way. private func isMaterializedView(_ name: String, schema: String?) async -> Bool { guard let catalog = currentCatalog, let targetSchema = resolveSchema(schema) else { return false } - let sql = TrinoIntrospectionSQL.materializedView(catalog: catalog, schema: targetSchema, name: name) + let sql = TrinoIntrospectionSQL.materializedView( + catalog: catalog, schema: targetSchema, name: name, flavor: flavor + ) guard let result = try? await execute(query: sql) else { return false } return !result.rows.isEmpty } @@ -171,14 +173,16 @@ extension TrinoPluginDriver { } private func tableComment(table: String, schema: String?) async -> String? { - guard let catalog = currentCatalog, let targetSchema = resolveSchema(schema) else { return nil } - let sql = TrinoIntrospectionSQL.tableComment(catalog: catalog, schema: targetSchema, table: table) + guard let catalog = currentCatalog, let targetSchema = resolveSchema(schema), + let sql = TrinoIntrospectionSQL.tableComment( + catalog: catalog, schema: targetSchema, table: table, flavor: flavor + ) else { return nil } let result = try? await execute(query: sql) return Self.normalizedText(text(result?.rows.first?.first)) } private func materializedViewNames(catalog: String, schema: String) async -> Set { - let sql = TrinoIntrospectionSQL.listMaterializedViews(catalog: catalog, schema: schema) + let sql = TrinoIntrospectionSQL.listMaterializedViews(catalog: catalog, schema: schema, flavor: flavor) guard let result = try? await execute(query: sql) else { return [] } return Set(result.rows.compactMap { firstText($0) }) } diff --git a/apple/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift b/apple/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift index e5e203cb9e..5c8029b086 100644 --- a/apple/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift +++ b/apple/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift @@ -5,6 +5,7 @@ import TableProTrinoCore final class TrinoPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let config: DriverConnectionConfig + let flavor: TrinoServerFlavor let session: TrinoSessionState private let lock = NSLock() private var _client: TrinoStatementClient? @@ -15,6 +16,7 @@ final class TrinoPluginDriver: PluginDatabaseDriver, @unchecked Sendable { init(config: DriverConnectionConfig) { self.config = config + self.flavor = TrinoServerFlavor(driverVariant: config.additionalFields["driverVariant"]) self.session = TrinoSessionState( catalog: config.database.isEmpty ? nil : config.database, schema: Self.trimmedField(config.additionalFields["trinoSchema"]) @@ -43,7 +45,7 @@ final class TrinoPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func connect() async throws { - let clientConfig = try Self.makeClientConfig(config) + let clientConfig = try Self.makeClientConfig(config, flavor: flavor) let transport = URLSessionTrinoTransport(tls: clientConfig.tls) let connectTimeoutMilliseconds = PluginConnectTimeout.milliseconds( in: config.additionalFields, @@ -60,7 +62,7 @@ final class TrinoPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result: TrinoResultSet do { - result = try await client.execute("SELECT version()") + result = try await client.execute(flavor.connectStatement) } catch let error as TrinoError { lock.withLock { if _client === client { _client = nil } @@ -74,10 +76,14 @@ final class TrinoPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return true } guard adopted else { throw TrinoError.cancelled } - if case .text(let version)? = result.rows.first?.first { - lock.withLock { _serverVersion = "Trino \(version)" } + let versionResult = flavor.versionStatement == flavor.connectStatement + ? result + : try? await client.execute(flavor.versionStatement) + let product = flavor.productName + if case .text(let version)? = versionResult?.rows.first?.first { + lock.withLock { _serverVersion = "\(product) \(version)" } } else { - lock.withLock { _serverVersion = "Trino" } + lock.withLock { _serverVersion = product } } } @@ -194,7 +200,10 @@ final class TrinoPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return String(format: String(localized: "%1$@: %2$lld rows"), updateType, Int64(count)) } - private static func makeClientConfig(_ config: DriverConnectionConfig) throws -> TrinoClientConfig { + private static func makeClientConfig( + _ config: DriverConnectionConfig, + flavor: TrinoServerFlavor + ) throws -> TrinoClientConfig { let useTLS = config.ssl.isEnabled let port = config.port > 0 ? config.port : (useTLS ? 8_443 : 8_080) let clientConfig = TrinoClientConfig( @@ -206,7 +215,8 @@ final class TrinoPluginDriver: PluginDatabaseDriver, @unchecked Sendable { catalog: config.database.isEmpty ? nil : config.database, schema: trimmedField(config.additionalFields["trinoSchema"]), timeZone: trimmedField(config.additionalFields["trinoTimeZone"]), - auth: resolveAuth(config) + auth: resolveAuth(config), + flavor: flavor ) return clientConfig } diff --git a/apple/TablePro/Assets.xcassets/presto-icon.imageset/Contents.json b/apple/TablePro/Assets.xcassets/presto-icon.imageset/Contents.json new file mode 100644 index 0000000000..5e85400f36 --- /dev/null +++ b/apple/TablePro/Assets.xcassets/presto-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "presto.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/apple/TablePro/Assets.xcassets/presto-icon.imageset/presto.svg b/apple/TablePro/Assets.xcassets/presto-icon.imageset/presto.svg new file mode 100644 index 0000000000..c1e2c72330 --- /dev/null +++ b/apple/TablePro/Assets.xcassets/presto-icon.imageset/presto.svg @@ -0,0 +1 @@ +Presto diff --git a/apple/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift b/apple/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift index 9ae41c9e7a..9eceec9b11 100644 --- a/apple/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift +++ b/apple/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift @@ -15,9 +15,7 @@ internal enum DriverUnavailability: Equatable, Sendable { extension PluginManager { func installedEntry(servingTypeId typeId: String) -> PluginEntry? { - plugins.first { entry in - entry.databaseTypeId == typeId || entry.additionalTypeIds.contains(typeId) - } + plugins.first { $0.serves(typeId) } } func driverUnavailability( @@ -25,6 +23,13 @@ extension PluginManager { registryManifest: RegistryManifest? = RegistryClient.shared.manifest ) -> DriverUnavailability { let typeId = databaseType.pluginTypeId + if let entry = installedEntry(servingTypeId: typeId), !entry.serves(databaseType.rawValue) { + return .outdated(reason: String( + format: String(localized: "The installed %1$@ does not support %2$@. Update it in Settings > Plugins."), + entry.name, + Self.registryDisplayName(of: databaseType) + )) + } if let entry = installedEntry(servingTypeId: typeId) { let rejection = rejectedPlugins.first { $0.providedDatabaseTypeIds.contains(typeId) } return .failedToLoad(pluginId: entry.id, pluginName: entry.name, reason: rejection?.reason) @@ -61,3 +66,9 @@ extension PluginManager { } } } + +extension PluginEntry { + func serves(_ typeId: String) -> Bool { + databaseTypeId == typeId || additionalTypeIds.contains(typeId) + } +} diff --git a/apple/TablePro/Core/Plugins/PluginManager+Registration.swift b/apple/TablePro/Core/Plugins/PluginManager+Registration.swift index 92ba0ccbb5..85577a3316 100644 --- a/apple/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/apple/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -215,9 +215,11 @@ extension PluginManager { func driverPlugin(for databaseType: DatabaseType) -> (any DriverPlugin)? { let typeId = databaseType.pluginTypeId - if let driver = driverPlugins[typeId] { return driver } - activateDriver(databaseTypeId: typeId) - return driverPlugins[typeId] + if driverPlugins[typeId] == nil { + activateDriver(databaseTypeId: typeId) + } + // Keyed by the type itself: an installed plugin older than a variant serves only its primary. + return driverPlugins[databaseType.rawValue] } func exportPlugin(forFormat formatId: String) -> (any ExportFormatPlugin)? { diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index b73471a73a..42f862efe2 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -426,135 +426,6 @@ extension PluginMetadataRegistry { tagline: String(localized: "Teradata Vantage data warehouse") ) )), - ("Trino", PluginMetadataSnapshot( - displayName: "Trino", iconName: "trino-icon", defaultPort: 8_080, - requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: true, - isDownloadable: true, primaryUrlScheme: "trino", parameterStyle: .questionMark, - navigationModel: .standard, - explainVariants: [ - ExplainVariant(id: "logical", label: "Explain (Logical)", sqlPrefix: "EXPLAIN"), - ExplainVariant(id: "distributed", label: "Explain (Distributed)", sqlPrefix: "EXPLAIN (TYPE DISTRIBUTED)"), - ExplainVariant(id: "io", label: "Explain (IO)", sqlPrefix: "EXPLAIN (TYPE IO)"), - ExplainVariant(id: "validate", label: "Explain (Validate)", sqlPrefix: "EXPLAIN (TYPE VALIDATE)"), - ExplainVariant(id: "analyze", label: "Explain Analyze", sqlPrefix: "EXPLAIN ANALYZE"), - ], - pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: ["trino"], - postConnectActions: [.selectSchemaFromLastSession], - brandColorHex: "#DD5F3B", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: true, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: true, - supportsImport: false, - supportsExport: true, - supportsSSH: true, - supportsSSL: true, - supportsCascadeDrop: false, - supportsForeignKeyDisable: false, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: false, - supportsRenameColumn: false, - defaultSSLMode: .disabled, - supportsOpportunisticTLS: false, - tlsImpliedPorts: [443], - verifiesServerWithSystemTrust: true - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "", - defaultGroupName: "default", - tableEntityName: "Tables", - containerEntityName: "Catalog", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: [], - systemSchemaNames: ["information_schema"], - fileExtensions: [], - databaseGroupingStrategy: .hierarchicalSchema, - structureColumnFields: [.name, .type, .nullable, .comment] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: SQLDialectDescriptor( - identifierQuote: "\"", - keywords: [ - "SELECT", "FROM", "WHERE", "GROUP", "BY", "HAVING", "ORDER", "LIMIT", "OFFSET", - "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "ON", "USING", "AND", "OR", "NOT", - "IN", "LIKE", "BETWEEN", "AS", "DISTINCT", "UNION", "INTERSECT", "EXCEPT", "WITH", - "INSERT", "INTO", "UPDATE", "SET", "DELETE", "CREATE", "ALTER", "DROP", "TABLE", - "VIEW", "SCHEMA", "CATALOG", "CASE", "WHEN", "THEN", "ELSE", "END", "CAST", "TRY_CAST", - "SHOW", "CATALOGS", "SCHEMAS", "TABLES", "COLUMNS", "DESCRIBE", "EXPLAIN", "ANALYZE", - "USE", "UNNEST", "OVER", "PARTITION", "QUALIFY", - ], - functions: [ - "COUNT", "SUM", "AVG", "MIN", "MAX", "ARRAY_AGG", "APPROX_DISTINCT", "CARDINALITY", - "ELEMENT_AT", "JSON_EXTRACT", "JSON_EXTRACT_SCALAR", "REGEXP_LIKE", "REGEXP_REPLACE", - "SUBSTR", "LENGTH", "LOWER", "UPPER", "TRIM", "SPLIT", "CONCAT", "COALESCE", - "DATE_TRUNC", "DATE_DIFF", "DATE_FORMAT", "FROM_UNIXTIME", "TO_UNIXTIME", - "ROW_NUMBER", "RANK", "DENSE_RANK", "LAG", "LEAD", - ], - dataTypes: [ - "BOOLEAN", "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "REAL", "DOUBLE", "DECIMAL", - "VARCHAR", "CHAR", "VARBINARY", "JSON", "DATE", "TIME", "TIMESTAMP", "INTERVAL", - "ARRAY", "MAP", "ROW", "IPADDRESS", "UUID", - ], - regexSyntax: .regexpLike, - booleanLiteralStyle: .truefalse, - likeEscapeStyle: .explicit, - paginationStyle: .offsetFetch, - offsetFetchOrderBy: "", - caseSensitivityStyle: .regexFlag - ), - statementCompletions: [], - columnTypesByCategory: [ - "Boolean": ["boolean"], - "Integer": ["tinyint", "smallint", "integer", "bigint"], - "Floating": ["real", "double", "decimal"], - "String": ["varchar", "char"], - "Binary": ["varbinary"], - "Date/Time": ["date", "time", "timestamp"], - "Complex": ["array", "map", "row", "json"], - ] - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: [ - ConnectionField( - id: "trinoAuthMethod", - label: String(localized: "Auth Method"), - defaultValue: "password", - fieldType: .dropdown(options: [ - .init(value: "password", label: "Username & Password"), - .init(value: "jwt", label: "JWT Access Token"), - ]), - section: .authentication - ), - ConnectionField( - id: "trinoJwtToken", - label: String(localized: "Access Token"), - placeholder: "JWT bearer token", - fieldType: .secure, - section: .authentication, - hidesPassword: true, - visibleWhen: FieldVisibilityRule(fieldId: "trinoAuthMethod", values: ["jwt"]) - ), - ConnectionField( - id: "trinoSchema", - label: String(localized: "Schema"), - placeholder: "Default schema (optional)", - section: .connection - ), - ConnectionField( - id: "trinoTimeZone", - label: String(localized: "Time Zone"), - placeholder: "Optional (e.g. America/New_York)", - section: .advanced - ), - ], - category: .analytical, - tagline: String(localized: "Distributed SQL query engine for data lakes") - ) - )), ("Oracle", PluginMetadataSnapshot( displayName: "Oracle", iconName: "oracle-icon", defaultPort: 1_521, requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, @@ -1150,6 +1021,7 @@ extension PluginMetadataRegistry { + cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults() + kafkaPluginDefaults() + typesensePluginDefaults() + r2SQLPluginDefaults() + weaviatePluginDefaults() + hanaPluginDefaults() + firebasePluginDefaults() + bigtablePluginDefaults() + + trinoPluginDefaults() } // swiftlint:enable function_body_length } diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift index 194d242f14..aea01c2768 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift @@ -19,12 +19,13 @@ extension PluginMetadataRegistry { /// takes the plugin's editor config, and the curated entry keeps only the facts it states /// deliberately, meaning the ones where it differs from the curated primary. /// - /// Two facts qualify: case-insensitive matching, which is why Redshift is spelled - /// `postgresqlDialect.withCaseSensitivityStyle(.caseFoldFunction)`, and the column type list, - /// which TiDB narrows (no spatial types) and Databend replaces with its own. Each has its own - /// named adoption here rather than a value comparison: `SQLDialectDescriptor` is not - /// `Equatable`, and a whole-descriptor diff would report "differs" for Redshift and hand it - /// the stub back. + /// Three facts qualify: case-insensitive matching, which is why Redshift is spelled + /// `postgresqlDialect.withCaseSensitivityStyle(.caseFoldFunction)`, the column type list, + /// which TiDB narrows (no spatial types) and Databend replaces with its own, and pagination, + /// which Presto writes as a bare `LIMIT` where the Trino plugin pages with `OFFSET`/`FETCH`. + /// Each has its own named adoption here rather than a value comparison: `SQLDialectDescriptor` + /// is not `Equatable`, and a whole-descriptor diff would report "differs" for Redshift and hand + /// it the stub back. static func adoptPluginEditorConfig( _ snapshot: inout PluginMetadataSnapshot, pluginSnapshot: PluginMetadataSnapshot, @@ -44,15 +45,20 @@ extension PluginMetadataRegistry { ) } - guard let curatedDialect, - let primaryDialect = curatedPrimary?.editor.sqlDialect, - curatedDialect.caseSensitivityStyle != primaryDialect.caseSensitivityStyle - || curatedDialect.caseFoldFunction != primaryDialect.caseFoldFunction - else { return } - snapshot.editor.sqlDialect = pluginDialect.withCaseSensitivityStyle( - curatedDialect.caseSensitivityStyle, - caseFoldFunction: curatedDialect.caseFoldFunction - ) + guard let curatedDialect, let primaryDialect = curatedPrimary?.editor.sqlDialect else { return } + var dialect = pluginDialect + if curatedDialect.caseSensitivityStyle != primaryDialect.caseSensitivityStyle + || curatedDialect.caseFoldFunction != primaryDialect.caseFoldFunction { + dialect = dialect.withCaseSensitivityStyle( + curatedDialect.caseSensitivityStyle, + caseFoldFunction: curatedDialect.caseFoldFunction + ) + } + if curatedDialect.paginationStyle != primaryDialect.paginationStyle + || curatedDialect.offsetFetchOrderBy != primaryDialect.offsetFetchOrderBy { + dialect = dialect.withPagination(of: curatedDialect) + } + snapshot.editor.sqlDialect = dialect } /// A plugin built before case-insensitive matching existed reports `.unsupported`, @@ -119,3 +125,28 @@ extension PluginMetadataRegistry { && snapshot.schema.databaseGroupingStrategy == .bySchema } } + +private extension SQLDialectDescriptor { + func withPagination(of source: SQLDialectDescriptor) -> SQLDialectDescriptor { + SQLDialectDescriptor( + identifierQuote: identifierQuote, + keywords: keywords, + functions: functions, + dataTypes: dataTypes, + tableOptions: tableOptions, + regexSyntax: regexSyntax, + booleanLiteralStyle: booleanLiteralStyle, + likeEscapeStyle: likeEscapeStyle, + paginationStyle: source.paginationStyle, + offsetFetchOrderBy: source.offsetFetchOrderBy, + requiresBackslashEscaping: requiresBackslashEscaping, + autoLimitStyle: autoLimitStyle, + caseSensitivityStyle: caseSensitivityStyle, + caseFoldFunction: caseFoldFunction, + operators: operators, + textCastTypeName: textCastTypeName, + functionNamesAreCaseInsensitive: functionNamesAreCaseInsensitive, + lexicalFeatures: lexicalFeatures + ) + } +} diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+TrinoDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+TrinoDefaults.swift new file mode 100644 index 0000000000..4ced33b335 --- /dev/null +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+TrinoDefaults.swift @@ -0,0 +1,208 @@ +// +// PluginMetadataRegistry+TrinoDefaults.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension PluginMetadataRegistry { + func trinoPluginDefaults() -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + [ + ("Trino", PluginMetadataSnapshot( + displayName: "Trino", iconName: "trino-icon", defaultPort: 8_080, + requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: true, + isDownloadable: true, primaryUrlScheme: "trino", parameterStyle: .questionMark, + navigationModel: .standard, + explainVariants: Self.trinoExplainVariants, + pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["trino"], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#DD5F3B", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: false, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsRenameColumn: false, + defaultSSLMode: .disabled, + supportsOpportunisticTLS: false, + tlsImpliedPorts: [443], + verifiesServerWithSystemTrust: true + ), + schema: Self.trinoSchemaInfo, + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: Self.trinoDialect(paginationStyle: .offsetFetch), + statementCompletions: [], + columnTypesByCategory: Self.trinoColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: Self.trinoConnectionFields, + category: .analytical, + tagline: String(localized: "Distributed SQL query engine for data lakes") + ) + )), + ("Presto", PluginMetadataSnapshot( + displayName: "Presto", iconName: "presto-icon", defaultPort: 8_080, + requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: true, + isDownloadable: true, primaryUrlScheme: "presto", parameterStyle: .questionMark, + navigationModel: .standard, + explainVariants: Self.trinoExplainVariants, + pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["presto"], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#5890FF", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + structureEditing: SchemaEditingSupport(structureEdits: .presto), + // A variant takes no capability from its plugin, so the ones the Trino plugin declares are restated. + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: false, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsRenameTable: true, + supportsRenameView: true, + supportsRenameSchema: true, + supportsRenameColumn: false, + supportsAddIndex: false, + supportsDropIndex: false, + supportsModifyPrimaryKey: false, + defaultSSLMode: .disabled, + supportsOpportunisticTLS: false, + tlsImpliedPorts: [443], + verifiesServerWithSystemTrust: true, + // OFFSET is off by default (offset_clause_enabled), and the grammar takes neither + // FETCH NEXT nor LIMIT before OFFSET. + pagination: .leadingRowsOnly(maximumRows: nil) + ), + schema: Self.trinoSchemaInfo, + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: Self.trinoDialect(paginationStyle: .limit), + statementCompletions: [], + columnTypesByCategory: Self.trinoColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: Self.trinoConnectionFields, + category: .analytical, + tagline: String(localized: "PrestoDB distributed SQL query engine") + ) + )), + ] + } + + private static let trinoExplainVariants = [ + ExplainVariant(id: "logical", label: "Explain (Logical)", sqlPrefix: "EXPLAIN"), + ExplainVariant(id: "distributed", label: "Explain (Distributed)", sqlPrefix: "EXPLAIN (TYPE DISTRIBUTED)"), + ExplainVariant(id: "io", label: "Explain (IO)", sqlPrefix: "EXPLAIN (TYPE IO)"), + ExplainVariant(id: "validate", label: "Explain (Validate)", sqlPrefix: "EXPLAIN (TYPE VALIDATE)"), + ExplainVariant(id: "analyze", label: "Explain Analyze", sqlPrefix: "EXPLAIN ANALYZE"), + ] + + private static let trinoSchemaInfo = PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "", + defaultGroupName: "default", + tableEntityName: "Tables", + containerEntityName: "Catalog", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: [], + systemSchemaNames: ["information_schema"], + fileExtensions: [], + databaseGroupingStrategy: .hierarchicalSchema, + structureColumnFields: [.name, .type, .nullable, .comment] + ) + + private static let trinoColumnTypes: [String: [String]] = [ + "Boolean": ["boolean"], + "Integer": ["tinyint", "smallint", "integer", "bigint"], + "Floating": ["real", "double", "decimal"], + "String": ["varchar", "char"], + "Binary": ["varbinary"], + "Date/Time": ["date", "time", "timestamp"], + "Complex": ["array", "map", "row", "json"], + ] + + private static func trinoDialect(paginationStyle: SQLDialectDescriptor.PaginationStyle) -> SQLDialectDescriptor { + SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "GROUP", "BY", "HAVING", "ORDER", "LIMIT", "OFFSET", + "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "ON", "USING", "AND", "OR", "NOT", + "IN", "LIKE", "BETWEEN", "AS", "DISTINCT", "UNION", "INTERSECT", "EXCEPT", "WITH", + "INSERT", "INTO", "UPDATE", "SET", "DELETE", "CREATE", "ALTER", "DROP", "TABLE", + "VIEW", "SCHEMA", "CATALOG", "CASE", "WHEN", "THEN", "ELSE", "END", "CAST", "TRY_CAST", + "SHOW", "CATALOGS", "SCHEMAS", "TABLES", "COLUMNS", "DESCRIBE", "EXPLAIN", "ANALYZE", + "USE", "UNNEST", "OVER", "PARTITION", "QUALIFY", + ], + functions: [ + "COUNT", "SUM", "AVG", "MIN", "MAX", "ARRAY_AGG", "APPROX_DISTINCT", "CARDINALITY", + "ELEMENT_AT", "JSON_EXTRACT", "JSON_EXTRACT_SCALAR", "REGEXP_LIKE", "REGEXP_REPLACE", + "SUBSTR", "LENGTH", "LOWER", "UPPER", "TRIM", "SPLIT", "CONCAT", "COALESCE", + "DATE_TRUNC", "DATE_DIFF", "DATE_FORMAT", "FROM_UNIXTIME", "TO_UNIXTIME", + "ROW_NUMBER", "RANK", "DENSE_RANK", "LAG", "LEAD", + ], + dataTypes: [ + "BOOLEAN", "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "REAL", "DOUBLE", "DECIMAL", + "VARCHAR", "CHAR", "VARBINARY", "JSON", "DATE", "TIME", "TIMESTAMP", "INTERVAL", + "ARRAY", "MAP", "ROW", "IPADDRESS", "UUID", + ], + regexSyntax: .regexpLike, + booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, + paginationStyle: paginationStyle, + offsetFetchOrderBy: "", + caseSensitivityStyle: .regexFlag + ) + } + + private static let trinoConnectionFields = [ + ConnectionField( + id: "trinoAuthMethod", + label: String(localized: "Auth Method"), + defaultValue: "password", + fieldType: .dropdown(options: [ + .init(value: "password", label: "Username & Password"), + .init(value: "jwt", label: "JWT Access Token"), + ]), + section: .authentication + ), + ConnectionField( + id: "trinoJwtToken", + label: String(localized: "Access Token"), + placeholder: "JWT bearer token", + fieldType: .secure, + section: .authentication, + hidesPassword: true, + visibleWhen: FieldVisibilityRule(fieldId: "trinoAuthMethod", values: ["jwt"]) + ), + ConnectionField( + id: "trinoSchema", + label: String(localized: "Schema"), + placeholder: "Default schema (optional)", + section: .connection + ), + ConnectionField( + id: "trinoTimeZone", + label: String(localized: "Time Zone"), + placeholder: "Optional (e.g. America/New_York)", + section: .advanced + ), + ] +} diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift index fb6fa15ec1..0e1a62bf46 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -491,6 +491,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { reverseTypeIndex["PGlite"] = "PostgreSQL" reverseTypeIndex["ScyllaDB"] = "Cassandra" reverseTypeIndex["Turso"] = "libSQL" + reverseTypeIndex["Presto"] = "Trino" reverseTypeIndex["Firebase Realtime Database"] = "Firestore" } diff --git a/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift b/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift index e5a3898a0e..998e44d9f0 100644 --- a/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift +++ b/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift @@ -76,7 +76,7 @@ enum CellExpressionVocabulary { case .spanner: spanner case .bigQuery: bigQuery case .snowflake: snowflake - case .trino: trino + case .trino, .presto: trino default: [] } } @@ -100,7 +100,7 @@ enum CellExpressionVocabulary { /// INSERT leaves the column out. static func updateTakesDefault(_ databaseType: DatabaseType) -> Bool { switch databaseType { - case .sqlite, .libsql, .turso, .cloudflareD1, .clickhouse, .databend, .snowflake, .trino, + case .sqlite, .libsql, .turso, .cloudflareD1, .clickhouse, .databend, .snowflake, .trino, .presto, .cassandra, .scylladb: false default: diff --git a/apple/TablePro/Models/Connection/DatabaseType.swift b/apple/TablePro/Models/Connection/DatabaseType.swift index ce72173b7d..54359866a6 100644 --- a/apple/TablePro/Models/Connection/DatabaseType.swift +++ b/apple/TablePro/Models/Connection/DatabaseType.swift @@ -50,6 +50,7 @@ extension DatabaseType { static let typesense = DatabaseType(rawValue: "Typesense") static let teradata = DatabaseType(rawValue: "Teradata") static let trino = DatabaseType(rawValue: "Trino") + static let presto = DatabaseType(rawValue: "Presto") static let weaviate = DatabaseType(rawValue: "Weaviate") static let bigtable = DatabaseType(rawValue: "Bigtable") static let firestore = DatabaseType(rawValue: "Firestore") diff --git a/apple/TablePro/Models/Database/StructureEditEligibility.swift b/apple/TablePro/Models/Database/StructureEditEligibility.swift index 758b54be0e..693faa92d5 100644 --- a/apple/TablePro/Models/Database/StructureEditEligibility.swift +++ b/apple/TablePro/Models/Database/StructureEditEligibility.swift @@ -94,6 +94,11 @@ struct StructureObjectEditMatrix: Sendable, Equatable { /// Indexes are created and dropped in CQL, which the plugin does not write. static let cassandra = StructureObjectEditMatrix([.table: [.addColumn, .dropColumn]]) + /// What the Trino plugin writes for a column, minus `COMMENT ON`, which Presto's grammar does not have. + static let presto = StructureObjectEditMatrix([ + .table: [.addColumn, .dropColumn, .renameColumn, .changeColumnType] + ]) + /// Measured against PostgreSQL 17.11, one statement per cell. A table and a partitioned table /// take all seventeen. A view and a materialized view differ on `SET DEFAULT` and on /// `CREATE INDEX`, which is why one row per kind is the only shape that works. A foreign table diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index b34b1dc845..9cd50e0f0a 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -186898,6 +186898,10 @@ } } }, + "The server answered with HTTP %lld and no address. The coordinator never redirects, so a proxy in front of it sent this. Check the host, port and SSL Mode." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The server answered with HTTP %lld and no address. Trino never redirects, so a proxy in front of it sent this. Check the host, port and SSL Mode." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -187101,6 +187105,10 @@ } } }, + "The server redirected the request to %@. TablePro does not follow redirects, and the coordinator never sends one, so a proxy in front of it did. Check the host, port and SSL Mode." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The server redirected the request to %@. TablePro does not follow redirects, and Trino never sends one, so a proxy in front of it did. Check the host, port and SSL Mode." : { "extractionState" : "manual", "generatesSymbol" : false, diff --git a/apple/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift b/apple/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift index 5cb366a42d..11480ca444 100644 --- a/apple/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift @@ -21,6 +21,21 @@ import Foundation import TableProPluginKit import Testing +private final class TrinoPluginBeforePresto: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Trino Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "A Trino plugin built before it served Presto" + static let capabilities: [PluginCapability] = [.databaseDriver] + static let databaseTypeId = "Trino" + static let databaseDisplayName = "Trino" + static let iconName = "trino-icon" + static let defaultPort = 8_080 + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + fatalError("Not used in tests") + } +} + @MainActor struct PluginManagerVariantAccessorTests { private var manager: PluginManager { PluginManager.shared } @@ -34,6 +49,40 @@ struct PluginManagerVariantAccessorTests { #expect(manager.supportsTunnelCommand(for: .pglite) == false) } + @Test("A Trino plugin that predates Presto serves Trino, and a Presto connection asks for its update") + func pluginOlderThanItsVariantDoesNotServeIt() throws { + let pluginsDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: pluginsDir) } + let stale = PluginManager(builtInPluginsURL: nil, userPluginsDir: pluginsDir) + stale.plugins = [ + PluginEntry( + id: "com.TablePro.TrinoDriver", + bundle: Bundle.main, + url: pluginsDir.appendingPathComponent("TrinoDriver.tableplugin"), + source: .userInstalled, + name: "Trino Driver", + version: "1.0.0", + pluginDescription: "", + capabilities: [.databaseDriver], + databaseTypeId: "Trino", + additionalTypeIds: [], + pluginIconName: "trino-icon", + defaultPort: 8_080, + exportFormatId: nil, + importFormatId: nil + ) + ] + stale.driverPlugins["Trino"] = TrinoPluginBeforePresto() + + #expect(stale.driverPlugin(for: .trino) != nil) + #expect(stale.driverPlugin(for: .presto) == nil) + guard case .outdated(let reason) = stale.driverUnavailability(for: .presto, registryManifest: nil) else { + Issue.record("expected .outdated, got \(stale.driverUnavailability(for: .presto, registryManifest: nil))") + return + } + #expect(reason.contains("Presto")) + } + @Test("PostgreSQL keeps the transports PGlite declines, so the two are genuinely distinguished") func postgresKeepsItsOwnTransports() { #expect(manager.supportsSSH(for: .postgresql)) diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index a406054b63..dcbe133766 100644 --- a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -10,7 +10,7 @@ struct PluginMetadataRegistryTypeCountTests { "CockroachDB", "Dameng", "Databend", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", "libSQL", "MariaDB", "MongoDB", "MySQL", "Oracle", "PGlite", "PostgreSQL", "Redis", "Redshift", "SAP HANA", "ScyllaDB", "Snowflake", "Spanner", "SQL Server", "SQLite", "SurrealDB", "Teradata", "TiDB", - "Trino", "Turso", "Typesense", "Weaviate", "OceanBase", "Firestore", "Firebase Realtime Database", "Bigtable" + "Trino", "Presto", "Turso", "Typesense", "Weaviate", "OceanBase", "Firestore", "Firebase Realtime Database", "Bigtable" ] private static func builtInTypeIds() -> Set { @@ -39,6 +39,7 @@ struct PluginMetadataRegistryTypeCountTests { "PGlite": "PostgreSQL", "ScyllaDB": "Cassandra", "Turso": "libSQL", + "Presto": "Trino", "Firebase Realtime Database": "Firestore" ] diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift index 2314d8d2d4..2ff8d5e58c 100644 --- a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift @@ -96,6 +96,58 @@ struct PluginMetadataRegistryVariantTests { #expect(dialect.caseSensitivityStyle == .caseFoldFunction) } + @Test("Presto keeps a bare LIMIT when the Trino plugin's grammar pages with OFFSET and FETCH") + func prestoKeepsLimitPaging() throws { + let registry = PluginMetadataRegistry.shared + var pluginSnapshot = try #require(registry.snapshot(forRegisteredTypeId: "Trino")) + pluginSnapshot.editor.sqlDialect = Self.syntheticDialect( + caseSensitivityStyle: .regexFlag, + paginationStyle: .offsetFetch + ) + defer { registry.unregister(typeId: "Presto") } + + registry.registerVariant(pluginSnapshot: pluginSnapshot, forTypeId: "Presto", primaryTypeId: "Trino") + + let presto = try #require(registry.snapshot(for: .presto)) + let dialect = try #require(presto.editor.sqlDialect) + #expect(dialect.keywords.contains("SYNTHETIC_KEYWORD")) + #expect(dialect.paginationStyle == .limit) + #expect(presto.capabilities.pagination == .leadingRowsOnly(maximumRows: nil)) + #expect(presto.displayName == "Presto") + } + + @Test("Trino keeps paging with OFFSET and FETCH") + func trinoKeepsOffsetFetch() throws { + let trino = try #require(PluginMetadataRegistry.shared.snapshot(for: .trino)) + #expect(trino.editor.sqlDialect?.paginationStyle == .offsetFetch) + #expect(trino.capabilities.pagination == .offset) + } + + @Test("Presto is offered before its plugin loads, and is served by the Trino plugin") + func prestoIsATrinoVariant() throws { + let registry = PluginMetadataRegistry.shared + let presto = try #require(registry.snapshot(for: .presto)) + #expect(presto.isDownloadable) + #expect(presto.iconName == "presto-icon") + #expect(DatabaseType.presto.pluginTypeId == "Trino") + #expect(registry.databaseType(forUrlScheme: "presto") == .presto) + #expect(registry.databaseType(forUrlScheme: "trino") == .trino) + } + + @MainActor + @Test("A Presto browse reads the leading rows with LIMIT, never OFFSET or FETCH NEXT") + func prestoBrowseWritesLimitOnly() { + let builder = TableQueryBuilder( + databaseType: .presto, + dialect: PluginManager.shared.sqlDialect(for: .presto), + pagination: PluginManager.shared.paginationCapability(for: .presto) + ) + let query = builder.buildBaseQuery(tableName: "nation", schemaName: "tiny", limit: 1_000, offset: 1_000) + #expect(query.hasSuffix("LIMIT 1000")) + #expect(!query.contains("OFFSET")) + #expect(!query.contains("FETCH")) + } + @Test("a variant type resolves its own snapshot, not the plugin type's") func variantSnapshotResolvesByRawValue() throws { let registry = PluginMetadataRegistry.shared @@ -123,7 +175,8 @@ struct PluginMetadataRegistryVariantTests { } private static func syntheticDialect( - caseSensitivityStyle: SQLDialectDescriptor.CaseSensitivityStyle + caseSensitivityStyle: SQLDialectDescriptor.CaseSensitivityStyle, + paginationStyle: SQLDialectDescriptor.PaginationStyle = .limit ) -> SQLDialectDescriptor { SQLDialectDescriptor( identifierQuote: "\"", @@ -133,7 +186,8 @@ struct PluginMetadataRegistryVariantTests { regexSyntax: .tilde, booleanLiteralStyle: .truefalse, likeEscapeStyle: .explicit, - paginationStyle: .limit, + paginationStyle: paginationStyle, + offsetFetchOrderBy: "", caseSensitivityStyle: caseSensitivityStyle, operators: [SQLOperatorDescriptor(symbol: "@@", summary: "synthetic", category: .fullText)] ) diff --git a/apple/TableProTests/Core/Services/CellValues/CellExpressionVocabularyTests.swift b/apple/TableProTests/Core/Services/CellValues/CellExpressionVocabularyTests.swift index bfcf0bb9c7..e17903608b 100644 --- a/apple/TableProTests/Core/Services/CellValues/CellExpressionVocabularyTests.swift +++ b/apple/TableProTests/Core/Services/CellValues/CellExpressionVocabularyTests.swift @@ -134,7 +134,7 @@ struct CellExpressionVocabularyTests { @Test("UPDATE takes DEFAULT except where its grammar has none", arguments: [ (DatabaseType.sqlite, false), (.libsql, false), (.turso, false), (.cloudflareD1, false), - (.clickhouse, false), (.databend, false), (.snowflake, false), (.trino, false), + (.clickhouse, false), (.databend, false), (.snowflake, false), (.trino, false), (.presto, false), (.cassandra, false), (.scylladb, false), (.postgresql, true), (.mysql, true), (.mssql, true), (.oracle, true), (.duckdb, true), ]) diff --git a/apple/TableProTests/Core/Utilities/ConnectionURLParserJDBCTests.swift b/apple/TableProTests/Core/Utilities/ConnectionURLParserJDBCTests.swift index 0a17a4ed50..4075406574 100644 --- a/apple/TableProTests/Core/Utilities/ConnectionURLParserJDBCTests.swift +++ b/apple/TableProTests/Core/Utilities/ConnectionURLParserJDBCTests.swift @@ -28,6 +28,16 @@ struct ConnectionURLParserJDBCTests { #expect(parsed.sslModeResolution == SSLModeResolution(mode: .verifyIdentity, origin: .impliedByPort)) } + @Test("presto and jdbc:presto import as Presto, with Presto JDBC's port 443 rule") + func prestoImports() throws { + let parsed = try parse("jdbc:presto://presto.example.com:443/hive") + #expect(parsed.type == .presto) + #expect(parsed.host == "presto.example.com") + #expect(parsed.database == "hive") + #expect(parsed.sslModeResolution == SSLModeResolution(mode: .verifyIdentity, origin: .impliedByPort)) + #expect(try parse("presto://analyst@presto.example.com:8080/hive").type == .presto) + } + @Test("jdbc: in front of a registered scheme is dropped, whatever its case") func jdbcPrefixIsDropped() throws { #expect(try parse("jdbc:postgresql://localhost:5433/app").type == .postgresql) From 8fa99fa766e620d1bf6fb8c58f34ffb16afd6f79 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 00:49:54 +0700 Subject: [PATCH 5/8] fix(connections): read a Trino or Presto URL path as catalog and schema --- .../Connection/ConnectionURLFormatter.swift | 10 ++++++++ .../Connection/ConnectionURLParser.swift | 24 +++++++++++++++---- .../ConnectionURLFormatterTests.swift | 20 ++++++++++++++++ .../ConnectionURLParserJDBCTests.swift | 19 +++++++++++++++ 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/apple/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift b/apple/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift index 9f8445e22c..06f97fccac 100644 --- a/apple/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift +++ b/apple/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift @@ -126,6 +126,7 @@ struct ConnectionURLFormatter { if connection.type == .redis { sshPathComponent = redisPath(for: connection) } + sshPathComponent += catalogSchemaSuffix(for: connection, path: sshPathComponent) result += "/\(sshPathComponent)" let query = buildQueryString(connection, sshConfig: ssh) @@ -171,6 +172,7 @@ struct ConnectionURLFormatter { if connection.type == .redis { pathComponent = redisPath(for: connection) } + pathComponent += catalogSchemaSuffix(for: connection, path: pathComponent) // Unencoded, a `#` or `?` in the name would swallow the query, socket included. result += "/\(pathComponent.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? pathComponent)" @@ -199,6 +201,14 @@ struct ConnectionURLFormatter { path.addingPercentEncoding(withAllowedCharacters: socketPathAllowed) ?? path } + // Trino and Presto read the path as /catalog/schema, as ConnectionURLParser does. + private static func catalogSchemaSuffix(for connection: DatabaseConnection, path: String) -> String { + guard connection.type.pluginTypeId == DatabaseType.trino.rawValue, !path.isEmpty, + let schema = connection.additionalFields["trinoSchema"]?.trimmingCharacters(in: .whitespaces), + !schema.isEmpty else { return "" } + return "/\(schema)" + } + private static func redisPath(for connection: DatabaseConnection) -> String { let index = connection.redisDatabaseIndex return index == 0 ? "" : String(index) diff --git a/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift index 05814fe918..f1df4bf80f 100644 --- a/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift +++ b/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift @@ -198,10 +198,8 @@ struct ConnectionURLParser { $0.removingPercentEncoding } ?? ext.password ?? "" - var database = components.path - if database.hasPrefix("/") { - database = String(database.dropFirst()) - } + let catalogPath = databasePath(components.path, type: dbType) + var database = catalogPath.database // Redis-specific: parse database index from path and handle TLS scheme var redisDatabase: Int? if dbType == .redis { @@ -273,9 +271,23 @@ struct ConnectionURLParser { mongoQueryParams: ext.mongoQueryParams, multiHost: nil, additionalFields: tls.additionalFields.merging(endpoint.socketFields) { _, socket in socket } + .merging(catalogPath.fields) { current, _ in current } )) } + // Trino and Presto read the path as /catalog/schema, as their JDBC drivers do. + private static func databasePath( + _ rawPath: String, + type: DatabaseType + ) -> (database: String, fields: [String: String]) { + let path = rawPath.hasPrefix("/") ? String(rawPath.dropFirst()) : rawPath + guard type.pluginTypeId == DatabaseType.trino.rawValue, let slash = path.firstIndex(of: "/") else { + return (path, [:]) + } + let schema = String(path[path.index(after: slash)...]) + return (String(path[.. Date: Wed, 21 Oct 2026 00:59:29 +0700 Subject: [PATCH 6/8] fix(connections): decode the catalog and schema of an SSH Trino or Presto URL --- .../Utilities/Connection/ConnectionURLParser.swift | 13 ++++++++----- .../Utilities/ConnectionURLParserJDBCTests.swift | 7 +++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift index f1df4bf80f..3d298dfb32 100644 --- a/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift +++ b/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift @@ -275,17 +275,20 @@ struct ConnectionURLParser { )) } - // Trino and Presto read the path as /catalog/schema, as their JDBC drivers do. + // Trino and Presto read the path as /catalog/schema, as their JDBC drivers do. An SSH URL's + // path arrives still percent-encoded, so its two names are decoded after the split. private static func databasePath( _ rawPath: String, - type: DatabaseType + type: DatabaseType, + percentEncoded: Bool = false ) -> (database: String, fields: [String: String]) { let path = rawPath.hasPrefix("/") ? String(rawPath.dropFirst()) : rawPath guard type.pluginTypeId == DatabaseType.trino.rawValue, let slash = path.firstIndex(of: "/") else { return (path, [:]) } - let schema = String(path[path.index(after: slash)...]) - return (String(path[.. String = { percentEncoded ? ($0.removingPercentEncoding ?? $0) : $0 } + let schema = decode(String(path[path.index(after: slash)...])) + return (decode(String(path[.. Date: Wed, 21 Oct 2026 01:11:48 +0700 Subject: [PATCH 7/8] docs(changelog): add the Presto fragment --- apple/changelog.d/3712.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3712.added.md diff --git a/apple/changelog.d/3712.added.md b/apple/changelog.d/3712.added.md new file mode 100644 index 0000000000..82cfb2693c --- /dev/null +++ b/apple/changelog.d/3712.added.md @@ -0,0 +1 @@ +Presto connections through the Trino plugin, with their own entry in New Connection and `presto://` URLs. From b656078c9ab655ce0b12337dcf9ea27cb140079b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 02:29:14 +0700 Subject: [PATCH 8/8] test(plugins): classify Presto in the DDL fallback policy and the iOS import --- .../TableProMobile/Services/IOSConnectionImportService.swift | 2 +- .../TableProMobileTests/MacOnlyImportRowTests.swift | 2 +- .../Models/Database/SQLDDLFallbackPolicyTests.swift | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift b/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift index ce1fc5ea94..cacd981af9 100644 --- a/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift +++ b/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift @@ -9,7 +9,7 @@ enum IOSConnectionImportService { // Mac plugin type ids with no DatabaseType constant. static let recognizedTypeIds: Set = Set( (DatabaseType.allKnownTypes + [.cockroachdb, .scylladb, .turso]).map(\.rawValue) - ).union(["SAP HANA", "Elasticsearch", "Typesense", "PGlite"]) + ).union(["SAP HANA", "Elasticsearch", "Typesense", "PGlite", "Presto"]) static func environment(for appState: AppState) -> ImportEnvironment { ImportEnvironment( diff --git a/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift index 54d916c514..7df66d2a7f 100644 --- a/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift +++ b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift @@ -36,7 +36,7 @@ struct MacOnlyImportRowTests { ImportSelection.defaults(for: preview).resolution(for: row.ref) != nil } - @Test("A Mac plugin type is recognized, preselected and marked Mac only", arguments: ["Elasticsearch", "SAP HANA", "Typesense", "PGlite"]) + @Test("A Mac plugin type is recognized, preselected and marked Mac only", arguments: ["Elasticsearch", "SAP HANA", "Typesense", "PGlite", "Presto"]) func macPluginTypeIsMarked(_ typeId: String) throws { let preview = try analyzed(type: typeId) let row = try #require(preview.connections.first) diff --git a/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift b/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift index 1228d0f36d..ad08c61e2a 100644 --- a/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift +++ b/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift @@ -14,7 +14,7 @@ struct SQLDDLFallbackPolicyTests { func sqlEnginesFabricate() { for type in [ DatabaseType.mysql, .postgresql, .sqlite, .mssql, .oracle, - .duckdb, .cassandra, .trino, .teradata, .clickhouse, .bigQuery, .sapHana, + .duckdb, .cassandra, .trino, .presto, .teradata, .clickhouse, .bigQuery, .sapHana, ] { #expect(SQLDDLFallbackPolicy.allowsGeneratedDDL(for: type), "\(type.rawValue) should fabricate") } @@ -61,7 +61,7 @@ struct SQLDDLFallbackPolicyTests { .mysql, .mariadb, .tidb, .databend, .oceanbase, .postgresql, .sqlite, .redshift, .cockroachdb, .pglite, .mssql, .oracle, .snowflake, .dameng, .clickhouse, .duckdb, .cassandra, .scylladb, .cloudflareD1, .cloudflareR2SQL, .bigQuery, .spanner, - .libsql, .turso, .teradata, .trino, .sapHana, + .libsql, .turso, .teradata, .trino, .presto, .sapHana, ] /// The policy lives in the app and the statement lives in the plugin. An engine with a non-SQL