From 4921fb440ca1781ec4a06ecdcbe76fbc908668f2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 6 Apr 2026 12:38:09 +0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20sort=20columns=20in=20iOS=20data=20?= =?UTF-8?q?browser=20=E2=80=94=20native=20Picker=20menu,=20ORDER=20BY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TableProMobile/Helpers/SQLBuilder.swift | 37 +++++++++++ .../Views/DataBrowserView.swift | 64 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index 1ad24d93b..3b75eee27 100644 --- a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -81,6 +81,17 @@ enum SQLBuilder { return "INSERT INTO \(quotedTable) (\(cols)) VALUES (\(vals))" } + static func buildSelect( + table: String, type: DatabaseType, + sortState: SortState, + limit: Int, offset: Int + ) -> String { + let quoted = quoteIdentifier(table, for: type) + let orderBy = buildOrderByClause(sortState, for: type) + return "SELECT * FROM \(quoted) \(orderBy) LIMIT \(limit) OFFSET \(offset)" + .replacingOccurrences(of: " ", with: " ") + } + static func buildFilteredSelect( table: String, type: DatabaseType, filters: [TableFilter], logicMode: FilterLogicMode, @@ -96,6 +107,24 @@ enum SQLBuilder { return "SELECT * FROM \(quoted) \(whereClause) LIMIT \(limit) OFFSET \(offset)" } + static func buildFilteredSelect( + table: String, type: DatabaseType, + filters: [TableFilter], logicMode: FilterLogicMode, + sortState: SortState, + limit: Int, offset: Int + ) -> String { + let dialect = dialectDescriptor(for: type) + let generator = FilterSQLGenerator(dialect: dialect) + let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) + let orderBy = buildOrderByClause(sortState, for: type) + let quoted = quoteIdentifier(table, for: type) + var sql = "SELECT * FROM \(quoted)" + if !whereClause.isEmpty { sql += " \(whereClause)" } + if !orderBy.isEmpty { sql += " \(orderBy)" } + sql += " LIMIT \(limit) OFFSET \(offset)" + return sql + } + static func buildFilteredCount( table: String, type: DatabaseType, filters: [TableFilter], logicMode: FilterLogicMode @@ -110,6 +139,14 @@ enum SQLBuilder { return "SELECT COUNT(*) FROM \(quoted) \(whereClause)" } + private static func buildOrderByClause(_ sortState: SortState, for type: DatabaseType) -> String { + guard sortState.isSorting else { return "" } + let clauses = sortState.columns.map { col in + "\(quoteIdentifier(col.name, for: type)) \(col.ascending ? "ASC" : "DESC")" + } + return "ORDER BY " + clauses.joined(separator: ", ") + } + private static func dialectDescriptor(for type: DatabaseType) -> SQLDialectDescriptor { switch type { case .mysql, .mariadb: diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 4589725c6..9064229d1 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -33,6 +33,7 @@ struct DataBrowserView: View { @State private var filters: [TableFilter] = [] @State private var filterLogicMode: FilterLogicMode = .and @State private var showFilterSheet = false + @State private var sortState = SortState() private var isView: Bool { table.type == .view || table.type == .materializedView @@ -56,6 +57,32 @@ struct DataBrowserView: View { filters.contains { $0.isEnabled && $0.isValid } } + private var sortColumnBinding: Binding { + Binding( + get: { sortState.columns.first?.name }, + set: { newColumn in + if let column = newColumn { + sortState.columns = [SortColumn(name: column, ascending: true)] + } else { + sortState.clear() + } + applySort() + } + ) + } + + private var sortDirectionBinding: Binding { + Binding( + get: { sortState.columns.first?.ascending ?? true }, + set: { ascending in + if let current = sortState.columns.first { + sortState.columns = [SortColumn(name: current.name, ascending: ascending)] + } + applySort() + } + ) + } + var body: some View { content .navigationTitle(table.name) @@ -208,6 +235,30 @@ struct DataBrowserView: View { } .disabled(rows.isEmpty) } + ToolbarItem(placement: .topBarTrailing) { + Menu { + Picker("Sort By", selection: sortColumnBinding) { + Text("Default").tag(String?.none) + ForEach(columns, id: \.name) { col in + Text(col.name).tag(Optional(col.name)) + } + } + .pickerStyle(.inline) + + if sortState.isSorting { + Picker("Order", selection: sortDirectionBinding) { + Label("Ascending", systemImage: "chevron.up").tag(true) + Label("Descending", systemImage: "chevron.down").tag(false) + } + .pickerStyle(.inline) + } + } label: { + Image(systemName: sortState.isSorting + ? "arrow.up.arrow.down.circle.fill" + : "arrow.up.arrow.down.circle") + } + .disabled(columns.isEmpty) + } ToolbarItem(placement: .topBarTrailing) { Button { showFilterSheet = true } label: { Image(systemName: hasActiveFilters @@ -316,6 +367,13 @@ struct DataBrowserView: View { query = SQLBuilder.buildFilteredSelect( table: table.name, type: connection.type, filters: filters, logicMode: filterLogicMode, + sortState: sortState, + limit: pagination.pageSize, offset: pagination.currentOffset + ) + } else if sortState.isSorting { + query = SQLBuilder.buildSelect( + table: table.name, type: connection.type, + sortState: sortState, limit: pagination.pageSize, offset: pagination.currentOffset ) } else { @@ -427,6 +485,12 @@ struct DataBrowserView: View { } } + private func applySort() { + pagination.currentPage = 0 + pagination.totalRows = nil + Task { await loadData() } + } + private func applyFilters() { pagination.currentPage = 0 pagination.totalRows = nil From 803b720296b7bfc1fc2ee9a0185db5f8fc548c0b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 6 Apr 2026 12:40:47 +0700 Subject: [PATCH 2/2] docs: add CHANGELOG entry for sort columns --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a728c248..968bdd7e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS: filter bar with 16 operators, AND/OR logic - iOS: persistent query history with timestamps - iOS: export to clipboard (JSON, CSV, SQL INSERT) +- iOS: sort columns with native Picker menu ## [0.27.4] - 2026-04-05