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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 37 additions & 0 deletions TableProMobile/TableProMobile/Helpers/SQLBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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:
Expand Down
64 changes: 64 additions & 0 deletions TableProMobile/TableProMobile/Views/DataBrowserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -56,6 +57,32 @@ struct DataBrowserView: View {
filters.contains { $0.isEnabled && $0.isValid }
}

private var sortColumnBinding: Binding<String?> {
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<Bool> {
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading