diff --git a/CHANGELOG.md b/CHANGELOG.md index 038314ee8..7be62802f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - SSH tunnels using the None auth method now work on iPhone and iPad, not just the Mac. A connection synced from the Mac with None auth no longer fails to connect. (#1912) +- Browsing a Trino table no longer fails with a syntax error. Trino requires `OFFSET` before `LIMIT`, so paging now uses `OFFSET` and `FETCH FIRST`. (#1936) ## [0.59.0] - 2026-07-21 diff --git a/Plugins/TrinoDriverPlugin/TrinoPlugin.swift b/Plugins/TrinoDriverPlugin/TrinoPlugin.swift index deabbce87..79ce64d3d 100644 --- a/Plugins/TrinoDriverPlugin/TrinoPlugin.swift +++ b/Plugins/TrinoDriverPlugin/TrinoPlugin.swift @@ -133,7 +133,8 @@ final class TrinoPlugin: NSObject, TableProPlugin, DriverPlugin { regexSyntax: .regexpLike, booleanLiteralStyle: .truefalse, likeEscapeStyle: .explicit, - paginationStyle: .limit + paginationStyle: .offsetFetch, + offsetFetchOrderBy: "" ) static let explainVariants: [ExplainVariant] = [ diff --git a/TablePro/Core/Database/FilterSQLGenerator.swift b/TablePro/Core/Database/FilterSQLGenerator.swift index ff252b263..b5d6059f3 100644 --- a/TablePro/Core/Database/FilterSQLGenerator.swift +++ b/TablePro/Core/Database/FilterSQLGenerator.swift @@ -330,7 +330,8 @@ extension FilterSQLGenerator { if dialect.paginationStyle == .offsetFetch { let orderBy = dialect.offsetFetchOrderBy - sql += "\n\(orderBy) OFFSET 0 ROWS FETCH NEXT \(limit) ROWS ONLY" + let orderByPrefix = orderBy.isEmpty ? "" : "\(orderBy) " + sql += "\n\(orderByPrefix)OFFSET 0 ROWS FETCH NEXT \(limit) ROWS ONLY" } else { sql += "\nLIMIT \(limit)" } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 5cda9f147..520fb9d52 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -390,7 +390,8 @@ extension PluginMetadataRegistry { regexSyntax: .regexpLike, booleanLiteralStyle: .truefalse, likeEscapeStyle: .explicit, - paginationStyle: .limit + paginationStyle: .offsetFetch, + offsetFetchOrderBy: "" ), statementCompletions: [], columnTypesByCategory: [ diff --git a/TablePro/Core/Services/Query/TableQueryBuilder.swift b/TablePro/Core/Services/Query/TableQueryBuilder.swift index 15c2ed410..5b5441edf 100644 --- a/TablePro/Core/Services/Query/TableQueryBuilder.swift +++ b/TablePro/Core/Services/Query/TableQueryBuilder.swift @@ -215,7 +215,8 @@ struct TableQueryBuilder { return orderBy } guard dialect?.paginationStyle == .offsetFetch else { return nil } - return dialect?.offsetFetchOrderBy ?? "ORDER BY (SELECT NULL)" + let defaultOrderBy = dialect?.offsetFetchOrderBy ?? "ORDER BY (SELECT NULL)" + return defaultOrderBy.isEmpty ? nil : defaultOrderBy } private func buildOrderByClause(sortState: SortState?, columns: [String]) -> String? { diff --git a/TableProTests/Core/Database/FilterSQLGeneratorTests.swift b/TableProTests/Core/Database/FilterSQLGeneratorTests.swift index 4f28a5fa5..d841d93c2 100644 --- a/TableProTests/Core/Database/FilterSQLGeneratorTests.swift +++ b/TableProTests/Core/Database/FilterSQLGeneratorTests.swift @@ -50,6 +50,13 @@ struct FilterSQLGeneratorTests { offsetFetchOrderBy: "ORDER BY 1" ) + private static let trinoDialect = SQLDialectDescriptor( + identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], + regexSyntax: .regexpLike, booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, paginationStyle: .offsetFetch, + offsetFetchOrderBy: "" + ) + // MARK: - Per-Operator Tests (MySQL) @Test("Equal operator generates correct condition") @@ -826,6 +833,25 @@ struct FilterSQLGeneratorTests { #expect(result.contains("SELECT * FROM `users`")) } + @Test("Trino paging emits OFFSET before FETCH FIRST with no synthetic ORDER BY") + func testPreviewSQLTrinoOffsetFetch() { + let generator = FilterSQLGenerator(dialect: Self.trinoDialect) + let result = generator.generatePreviewSQL( + tableName: "pseudo_columns", schemaName: "jdbc", filters: [], limit: 1_000 + ) + #expect(result.contains("OFFSET 0 ROWS FETCH NEXT 1000 ROWS ONLY")) + #expect(!result.contains("LIMIT")) + #expect(!result.contains("ORDER BY")) + } + + @Test("Oracle paging keeps its default ORDER BY before OFFSET and FETCH FIRST") + func testPreviewSQLOracleOffsetFetch() { + let generator = FilterSQLGenerator(dialect: Self.oracleDialect) + let result = generator.generatePreviewSQL(tableName: "users", filters: [], limit: 1_000) + #expect(result.contains("ORDER BY 1 OFFSET 0 ROWS FETCH NEXT 1000 ROWS ONLY")) + #expect(!result.contains("LIMIT")) + } + // MARK: - Edge Cases @Test("Between with missing secondValue returns nil") diff --git a/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift b/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift index dc88dc177..771d12582 100644 --- a/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift +++ b/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift @@ -134,6 +134,75 @@ struct TableQueryBuilderFilteredCountTests { } } +@Suite("Table Query Builder - Pagination Clause") +struct TableQueryBuilderPaginationTests { + private static let trinoDialect = SQLDialectDescriptor( + identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], + regexSyntax: .regexpLike, booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, paginationStyle: .offsetFetch, offsetFetchOrderBy: "" + ) + + private static let mssqlDialect = SQLDialectDescriptor( + identifierQuote: "[", keywords: [], functions: [], dataTypes: [], + regexSyntax: .unsupported, booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, paginationStyle: .offsetFetch + ) + + private static let mysqlDialect = SQLDialectDescriptor( + identifierQuote: "`", keywords: [], functions: [], dataTypes: [], + regexSyntax: .regexp, booleanLiteralStyle: .numeric, + likeEscapeStyle: .implicit, paginationStyle: .limit + ) + + private func builder(_ dialect: SQLDialectDescriptor) -> TableQueryBuilder { + TableQueryBuilder(databaseType: .postgresql, dialect: dialect) + } + + private func enabledFilter(_ column: String, _ value: String) -> TableFilter { + var filter = TableFilter() + filter.columnName = column + filter.filterOperator = .equal + filter.value = value + filter.isEnabled = true + return filter + } + + @Test("Trino base query pages with OFFSET before FETCH FIRST and no ORDER BY") + func trinoBaseQueryOffsetFetch() { + let query = builder(Self.trinoDialect).buildBaseQuery( + tableName: "pseudo_columns", schemaName: "jdbc", limit: 1_000, offset: 0 + ) + #expect(query.contains("\"jdbc\".\"pseudo_columns\"")) + #expect(query.contains("OFFSET 0 ROWS FETCH NEXT 1000 ROWS ONLY")) + #expect(!query.contains("LIMIT")) + #expect(!query.contains("ORDER BY")) + } + + @Test("Trino filtered query keeps the WHERE and pages with OFFSET/FETCH FIRST") + func trinoFilteredQueryOffsetFetch() { + let query = builder(Self.trinoDialect).buildFilteredQuery( + tableName: "orders", filters: [enabledFilter("status", "open")], limit: 500, offset: 500 + ) + #expect(query.contains("WHERE")) + #expect(query.contains("OFFSET 500 ROWS FETCH NEXT 500 ROWS ONLY")) + #expect(!query.contains("LIMIT")) + } + + @Test("MSSQL base query injects its default ORDER BY before OFFSET/FETCH FIRST") + func mssqlBaseQueryKeepsDefaultOrderBy() { + let query = builder(Self.mssqlDialect).buildBaseQuery(tableName: "users", limit: 100, offset: 0) + #expect(query.contains("ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY")) + #expect(!query.contains("LIMIT")) + } + + @Test("LIMIT-style dialect pages with LIMIT then OFFSET") + func limitStyleBaseQuery() { + let query = builder(Self.mysqlDialect).buildBaseQuery(tableName: "users", limit: 200, offset: 0) + #expect(query.contains("LIMIT 200 OFFSET 0")) + #expect(!query.contains("FETCH NEXT")) + } +} + @Suite("Table Query Builder - NoSQL Nil Dialect Fallback") struct TableQueryBuilderNoSQLTests { // MongoDB has no SQL dialect — should produce bare SELECT without WHERE diff --git a/docs/databases/trino.mdx b/docs/databases/trino.mdx index 8606a0f76..bdd427138 100644 --- a/docs/databases/trino.mdx +++ b/docs/databases/trino.mdx @@ -32,7 +32,7 @@ Trino requires TLS for any authentication. If you pick Password or JWT, set an S A Trino connection is scoped to a catalog and schema, but every query can name objects in full as `catalog.schema.table`. TablePro shows catalogs from `SHOW CATALOGS`, schemas from `SHOW SCHEMAS`, and tables and columns from each catalog's `information_schema`. Materialized views appear alongside tables and views. Expand a catalog to see its schemas, then a schema to see its tables. Table row counts come from `SHOW STATS`, and table and column comments come from `information_schema` and the `system.metadata` tables. -Identifiers are quoted with double quotes. Results page with standard `LIMIT` and `OFFSET`. +Identifiers are quoted with double quotes. Results page with `OFFSET` and `FETCH FIRST`, the order Trino's grammar requires. ## Types