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
Binary file not shown.
42 changes: 21 additions & 21 deletions OpenTable/AppIcon.icon/icon.json
Original file line number Diff line number Diff line change
@@ -1,40 +1,40 @@
{
"fill" : {
"automatic-gradient" : "srgb:1.00000,0.57637,0.00000,1.00000"
"fill": {
"automatic-gradient": "srgb:1.00000,0.57637,0.00000,1.00000"
},
"groups" : [
"groups": [
{
"layers" : [
"layers": [
{
"fill" : {
"solid" : "display-p3:0.99736,1.00000,0.97886,1.00000"
"fill": {
"solid": "display-p3:0.99736,1.00000,0.97886,1.00000"
},
"image-name" : "cylinder.split.1x2.fill 1.svg",
"name" : "cylinder.split.1x2.fill 1"
"image-name": "cylinder.split.1x2.fill 1.svg",
"name": "cylinder.split.1x2.fill 1"
}
],
"position" : {
"scale" : 1.3,
"translation-in-points" : [
"position": {
"scale": 1.3,
"translation-in-points": [
0,
0
]
},
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
"shadow": {
"kind": "neutral",
"opacity": 0.5
},
"specular" : false,
"translucency" : {
"enabled" : true,
"value" : 0.5
"specular": false,
"translucency": {
"enabled": true,
"value": 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"supported-platforms": {
"circles": [
"watchOS"
],
"squares" : "shared"
"squares": "shared"
}
}
Comment on lines 1 to 40
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file appears to have been reformatted with spacing changes (spaces after colons added throughout) but no functional changes. This seems unintentional as it's not mentioned in the PR description and is unrelated to the inspector panel feature. Consider reverting these formatting changes to avoid unnecessary noise in the diff.

Copilot uses AI. Check for mistakes.
17 changes: 14 additions & 3 deletions OpenTable/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import SwiftUI
struct ContentView: View {
@StateObject private var dbManager = DatabaseManager.shared
@State private var connections: [DatabaseConnection] = []
@State private var columnVisibility: NavigationSplitViewVisibility = .detailOnly
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var showNewConnectionSheet = false
@State private var showEditConnectionSheet = false
@State private var connectionToEdit: DatabaseConnection?
Expand All @@ -20,6 +20,7 @@ struct ContentView: View {
@State private var pendingCloseSessionId: UUID?
@State private var hasLoaded = false
@State private var escapeKeyMonitor: Any?
@State private var isInspectorPresented = false // Right sidebar (inspector) visibility

@Environment(\.openWindow) private var openWindow
@Environment(\.dismissWindow) private var dismissWindow
Expand Down Expand Up @@ -91,10 +92,16 @@ struct ContentView: View {
guard currentSession != nil else { return }
Task { @MainActor in
withAnimation {
columnVisibility = columnVisibility == .all ? .detailOnly : .all
// Toggle left sidebar (2-column layout: sidebar + detail)
if columnVisibility == .all {
columnVisibility = .detailOnly
} else {
columnVisibility = .all
}
}
}
}
// Right sidebar toggle is handled by MainContentView (has the binding)
.onChange(of: dbManager.currentSessionId) { _, newSessionId in
Task { @MainActor in
withAnimation {
Expand All @@ -117,6 +124,7 @@ struct ContentView: View {
private var mainContent: some View {
if currentSession != nil {
NavigationSplitView(columnVisibility: $columnVisibility) {
// MARK: - Sidebar (Left) - Table Browser
VStack(spacing: 0) {
if !sessions.isEmpty {
ConnectionSidebarHeader(
Expand Down Expand Up @@ -152,13 +160,16 @@ struct ContentView: View {
pendingDeletes: sessionPendingDeletesBinding
)
}
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 350)
} detail: {
// MARK: - Detail (Main workspace with optional right sidebar)
MainContentView(
connection: currentSession!.connection,
tables: sessionTablesBinding,
selectedTables: sessionSelectedTablesBinding,
pendingTruncates: sessionPendingTruncatesBinding,
pendingDeletes: sessionPendingDeletesBinding
pendingDeletes: sessionPendingDeletesBinding,
isInspectorPresented: $isInspectorPresented
)
.id(currentSession!.id)
}
Expand Down
3 changes: 3 additions & 0 deletions OpenTable/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ protocol DatabaseDriver: AnyObject {

/// Fetch rows with LIMIT/OFFSET pagination
func fetchRows(query: String, offset: Int, limit: Int) async throws -> QueryResult

/// Fetch table metadata (size, comment, engine, etc.)
func fetchTableMetadata(tableName: String) async throws -> TableMetadata
}

/// Default implementation for common operations
Expand Down
78 changes: 78 additions & 0 deletions OpenTable/Core/Database/MySQLDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ final class MySQLDriver: DatabaseDriver {

/// The underlying MariaDB connection
private var mariadbConnection: MariaDBConnection?

/// Static date formatter for parsing MySQL dates (performance optimization)
private static let mysqlDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()

init(connection: DatabaseConnection) {
self.connection = connection
Expand Down Expand Up @@ -294,6 +301,77 @@ final class MySQLDriver: DatabaseDriver {

return ddl
}

func fetchTableMetadata(tableName: String) async throws -> TableMetadata {
let escapedTableName = tableName.replacingOccurrences(of: "'", with: "''")
Comment thread
datlechin marked this conversation as resolved.
// NOTE: `SHOW TABLE STATUS LIKE` expects a pattern string literal, not an
// identifier. For that reason we must use single-quoted string syntax here
// instead of the backtick identifier quoting used in other schema queries
// (e.g. `SHOW CREATE TABLE \`table\``). The table name is safely embedded
// by escaping single quotes above.
Comment on lines +307 to +311
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "SHOW TABLE STATUS LIKE expects a pattern string literal" but the query actually uses "WHERE Name =" which is an equality comparison, not a LIKE pattern match. The comment should be updated to reflect that this is a WHERE clause with string comparison, not a LIKE pattern. The escaping is correct for preventing SQL injection in string literals.

Suggested change
// NOTE: `SHOW TABLE STATUS LIKE` expects a pattern string literal, not an
// identifier. For that reason we must use single-quoted string syntax here
// instead of the backtick identifier quoting used in other schema queries
// (e.g. `SHOW CREATE TABLE \`table\``). The table name is safely embedded
// by escaping single quotes above.
// NOTE: This query filters `SHOW TABLE STATUS` results using a WHERE clause
// on the Name column. The table name is embedded as a single-quoted string
// literal (not as a backtick-quoted identifier), which is why we escape
// single quotes above before constructing the query string.

Copilot uses AI. Check for mistakes.
Comment on lines +305 to +311
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetchTableMetadata implementation builds a MySQL query by interpolating tableName into a single-quoted string and only escapes single quotes, which is not sufficient to prevent SQL injection in MySQL/MariaDB string literals. Because backslash escapes are enabled by default, a crafted table name or value such as abc\'; DROP TABLE important;-- can use \' to terminate the string despite the single-quote doubling and inject additional statements, executed with the full privileges of this connection. To fix this, avoid manual string concatenation here by using parameterized queries or the driver’s native literal-escaping API, or at minimum also correctly escape backslashes and other special characters when embedding identifiers as string literals.

Suggested change
func fetchTableMetadata(tableName: String) async throws -> TableMetadata {
let escapedTableName = tableName.replacingOccurrences(of: "'", with: "''")
// NOTE: `SHOW TABLE STATUS LIKE` expects a pattern string literal, not an
// identifier. For that reason we must use single-quoted string syntax here
// instead of the backtick identifier quoting used in other schema queries
// (e.g. `SHOW CREATE TABLE \`table\``). The table name is safely embedded
// by escaping single quotes above.
/// Escape a value for safe use as a single-quoted MySQL/MariaDB string literal.
/// This handles backslashes and single quotes according to MySQL string rules.
private func escapeMySQLStringLiteral(_ value: String) -> String {
// Order is important: escape backslashes first, then single quotes.
var escaped = value.replacingOccurrences(of: "\\", with: "\\\\")
escaped = escaped.replacingOccurrences(of: "'", with: "''")
return escaped
}
func fetchTableMetadata(tableName: String) async throws -> TableMetadata {
let escapedTableName = escapeMySQLStringLiteral(tableName)
// NOTE: `SHOW TABLE STATUS WHERE Name =` expects a pattern string literal,
// not an identifier. For that reason we must use single-quoted string syntax
// here instead of the backtick identifier quoting used in other schema
// queries (e.g. `SHOW CREATE TABLE \`table\``). The table name is safely
// embedded by escaping backslashes and single quotes above.

Copilot uses AI. Check for mistakes.
let query = "SHOW TABLE STATUS WHERE Name = '\(escapedTableName)'"
let result = try await execute(query: query)

guard let row = result.rows.first else {
return TableMetadata(
tableName: tableName,
dataSize: nil,
indexSize: nil,
totalSize: nil,
avgRowLength: nil,
rowCount: nil,
comment: nil,
engine: nil,
collation: nil,
createTime: nil,
updateTime: nil
)
}

// SHOW TABLE STATUS columns:
// 0: Name, 1: Engine, 2: Version, 3: Row_format, 4: Rows, 5: Avg_row_length,
// 6: Data_length, 7: Max_data_length, 8: Index_length, 9: Data_free,
// 10: Auto_increment, 11: Create_time, 12: Update_time, 13: Check_time,
// 14: Collation, 15: Checksum, 16: Create_options, 17: Comment

let engine = row.count > 1 ? row[1] : nil
let rowCount = row.count > 4 ? Int64(row[4] ?? "0") : nil
let avgRowLength = row.count > 5 ? Int64(row[5] ?? "0") : nil
let dataSize = row.count > 6 ? Int64(row[6] ?? "0") : nil
let indexSize = row.count > 8 ? Int64(row[8] ?? "0") : nil
let collation = row.count > 14 ? row[14] : nil
let comment = row.count > 17 ? row[17] : nil

// Parse dates using static formatter for performance
let createTime: Date? = {
guard row.count > 11, let dateStr = row[11] else { return nil }
return Self.mysqlDateFormatter.date(from: dateStr)
}()

let updateTime: Date? = {
guard row.count > 12, let dateStr = row[12] else { return nil }
return Self.mysqlDateFormatter.date(from: dateStr)
}()

let totalSize: Int64? = {
guard let data = dataSize, let index = indexSize else { return nil }
return data + index
}()

return TableMetadata(
tableName: tableName,
dataSize: dataSize,
indexSize: indexSize,
totalSize: totalSize,
avgRowLength: avgRowLength,
rowCount: rowCount,
comment: comment?.isEmpty == true ? nil : comment,
engine: engine,
collation: collation,
createTime: createTime,
updateTime: updateTime
)
}

// MARK: - Paginated Query Support

Expand Down
58 changes: 58 additions & 0 deletions OpenTable/Core/Database/PostgreSQLDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,64 @@ final class PostgreSQLDriver: DatabaseDriver {
let paginatedQuery = "\(baseQuery) LIMIT \(limit) OFFSET \(offset)"
return try await execute(query: paginatedQuery)
}

func fetchTableMetadata(tableName: String) async throws -> TableMetadata {
// Escape single quotes to prevent SQL injection (string literal context)
let safeTableName = tableName.replacingOccurrences(of: "'", with: "''")

let query = """
SELECT
pg_total_relation_size(c.oid) AS total_size,
pg_table_size(c.oid) AS data_size,
pg_indexes_size(c.oid) AS index_size,
c.reltuples::bigint AS row_count,
CASE WHEN c.reltuples > 0 THEN pg_table_size(c.oid) / GREATEST(c.reltuples, 1) ELSE 0 END AS avg_row_length,
obj_description(c.oid, 'pg_class') AS comment
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = '\(safeTableName)'
AND n.nspname = 'public'
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The query hardcodes the schema name as 'public', which may not work correctly for tables in other schemas. Users working with tables in custom schemas (e.g., 'myapp', 'staging') will not be able to view metadata for those tables. Consider either accepting the schema name as a parameter, using the search_path to determine the appropriate schema, or querying across all accessible schemas.

Suggested change
AND n.nspname = 'public'
AND pg_table_is_visible(c.oid)

Copilot uses AI. Check for mistakes.
"""

let result = try await execute(query: query)

guard let row = result.rows.first else {
return TableMetadata(
tableName: tableName,
dataSize: nil,
indexSize: nil,
totalSize: nil,
avgRowLength: nil,
rowCount: nil,
comment: nil,
engine: nil,
collation: nil,
createTime: nil,
updateTime: nil
)
}

let totalSize = row.count > 0 ? Int64(row[0] ?? "0") : nil
let dataSize = row.count > 1 ? Int64(row[1] ?? "0") : nil
let indexSize = row.count > 2 ? Int64(row[2] ?? "0") : nil
let rowCount = row.count > 3 ? Int64(row[3] ?? "0") : nil
let avgRowLength = row.count > 4 ? Int64(row[4] ?? "0") : nil
let comment = row.count > 5 ? row[5] : nil

return TableMetadata(
tableName: tableName,
dataSize: dataSize,
indexSize: indexSize,
totalSize: totalSize,
avgRowLength: avgRowLength,
rowCount: rowCount,
comment: comment?.isEmpty == true ? nil : comment,
engine: "PostgreSQL",
collation: nil,
createTime: nil,
updateTime: nil
)
}

private func stripLimitOffset(from query: String) -> String {
var result = query
Expand Down
33 changes: 33 additions & 0 deletions OpenTable/Core/Database/SQLiteDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,39 @@ final class SQLiteDriver: DatabaseDriver {
return try await execute(query: paginatedQuery)
}

func fetchTableMetadata(tableName: String) async throws -> TableMetadata {
guard status == .connected else {
throw DatabaseError.notConnected
}

// Escape table name to prevent SQL injection (escape double quotes for identifier quoting)
let safeTableName = tableName.replacingOccurrences(of: "\"", with: "\"\"")

// Get row count
let countQuery = "SELECT COUNT(*) FROM \"\(safeTableName)\""
let countResult = try await execute(query: countQuery)
let rowCount: Int64? = {
guard let row = countResult.rows.first, let countStr = row.first else { return nil }
return Int64(countStr ?? "0")
}()

// SQLite does not expose accurate per-table size information.
// To avoid reporting misleading values, we leave size-related fields as nil.
return TableMetadata(
tableName: tableName,
dataSize: nil,
indexSize: nil,
totalSize: nil,
avgRowLength: nil,
rowCount: rowCount,
comment: nil,
engine: "SQLite",
collation: nil,
createTime: nil,
updateTime: nil
)
}

private func stripLimitOffset(from query: String) -> String {
var result = query

Expand Down
39 changes: 39 additions & 0 deletions OpenTable/Models/TableMetadata.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//
// TableMetadata.swift
// OpenTable
//
// Model for table-level metadata
//

import Foundation

/// Represents table-level metadata fetched from database
struct TableMetadata {
let tableName: String
let dataSize: Int64?
let indexSize: Int64?
let totalSize: Int64?
let avgRowLength: Int64?
let rowCount: Int64?
let comment: String?
let engine: String? // MySQL/MariaDB only
let collation: String? // MySQL/MariaDB only
let createTime: Date?
let updateTime: Date?

/// Format a size in bytes to human readable format
static func formatSize(_ bytes: Int64?) -> String {
guard let bytes = bytes else { return "—" }
if bytes == 0 { return "0 B" }

let units = ["B", "KB", "MB", "GB", "TB"]
let exponent = min(Int(log(Double(bytes)) / log(1024)), units.count - 1)
let size = Double(bytes) / pow(1024, Double(exponent))

if exponent == 0 {
return "\(bytes) B"
} else {
return String(format: "%.1f %@", size, units[exponent])
}
}
}
Loading