-
-
Notifications
You must be signed in to change notification settings - Fork 285
feat: implement right sidebar inspector panel #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8ee98b9
a326d82
a9b61ee
9d7c043
e9e20dc
de8ab9c
f1169b1
7233278
354da94
a2d9ad3
0f9e72a
bf34e1e
d2e553e
6fd17c7
381ac0c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -294,6 +301,77 @@ final class MySQLDriver: DatabaseDriver { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ddl | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func fetchTableMetadata(tableName: String) async throws -> TableMetadata { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let escapedTableName = tableName.replacingOccurrences(of: "'", with: "''") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 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
AI
Dec 25, 2025
There was a problem hiding this comment.
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.
| 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. |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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' | ||||||
|
||||||
| AND n.nspname = 'public' | |
| AND pg_table_is_visible(c.oid) |
| 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]) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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.