From a1ac808331b43cfca4c09d55c99981b313e6f727 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 09:22:06 +0700 Subject: [PATCH 1/2] feat: add startup commands for connections --- CHANGELOG.md | 1 + TablePro/Core/Database/DatabaseManager.swift | 49 +++++ TablePro/Core/Storage/ConnectionStorage.swift | 21 ++- .../Connection/DatabaseConnection.swift | 5 +- TablePro/Resources/Localizable.xcstrings | 6 + .../Views/Connection/ConnectionFormView.swift | 168 ++++++++++++++---- docs/databases/mysql.mdx | 17 ++ docs/databases/overview.mdx | 26 +++ docs/databases/postgresql.mdx | 20 +++ docs/vi/databases/mysql.mdx | 17 ++ docs/vi/databases/overview.mdx | 26 +++ docs/vi/databases/postgresql.mdx | 20 +++ 12 files changed, 343 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7b36a07..577a50ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Startup commands — run custom SQL after connecting (e.g., SET time_zone) in Connection > Advanced tab - Plugin system architecture — all 8 database drivers (MySQL, PostgreSQL, SQLite, ClickHouse, MSSQL, MongoDB, Redis, Oracle) extracted into `.tableplugin` bundles loaded at runtime - Settings > Plugins tab for plugin management — list installed plugins, enable/disable, install from file, uninstall user plugins, view plugin details - Plugin marketplace — browse, search, and install plugins from the GitHub-hosted registry with SHA-256 checksum verification, ETag caching, and offline fallback diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 279c2ddd..d2c660c2 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -138,6 +138,11 @@ final class DatabaseManager { try await driver.applyQueryTimeout(timeoutSeconds) } + // Run startup commands before schema init + await executeStartupCommands( + connection.startupCommands, on: driver, connectionName: connection.name + ) + // Initialize schema for drivers that support schema switching if let schemaDriver = driver as? SchemaSwitchable { activeSessions[connection.id]?.currentSchema = schemaDriver.currentSchema @@ -190,6 +195,9 @@ final class DatabaseManager { if metaTimeout > 0 { try? await metaDriver.applyQueryTimeout(metaTimeout) } + await self.executeStartupCommands( + connection.startupCommands, on: metaDriver, connectionName: connection.name + ) if let savedSchema = self.activeSessions[metaConnectionId]?.currentSchema, let schemaMetaDriver = metaDriver as? SchemaSwitchable { try? await schemaMetaDriver.switchSchema(to: savedSchema) @@ -547,6 +555,10 @@ final class DatabaseManager { try await driver.applyQueryTimeout(timeoutSeconds) } + await executeStartupCommands( + session.connection.startupCommands, on: driver, connectionName: session.connection.name + ) + if let savedSchema = session.currentSchema, let schemaDriver = driver as? SchemaSwitchable { try? await schemaDriver.switchSchema(to: savedSchema) @@ -617,6 +629,10 @@ final class DatabaseManager { try await driver.applyQueryTimeout(timeoutSeconds) } + await executeStartupCommands( + session.connection.startupCommands, on: driver, connectionName: session.connection.name + ) + if let savedSchema = activeSessions[sessionId]?.currentSchema, let schemaDriver = driver as? SchemaSwitchable { try? await schemaDriver.switchSchema(to: savedSchema) @@ -639,6 +655,8 @@ final class DatabaseManager { let metaConnection = effectiveConnection let metaConnectionId = sessionId let metaTimeout = AppSettingsManager.shared.general.queryTimeoutSeconds + let startupCmds = session.connection.startupCommands + let connName = session.connection.name Task { [weak self] in guard let self else { return } do { @@ -647,6 +665,9 @@ final class DatabaseManager { if metaTimeout > 0 { try? await metaDriver.applyQueryTimeout(metaTimeout) } + await self.executeStartupCommands( + startupCmds, on: metaDriver, connectionName: connName + ) if let savedSchema = self.activeSessions[metaConnectionId]?.currentSchema, let schemaMetaDriver = metaDriver as? SchemaSwitchable { try? await schemaMetaDriver.switchSchema(to: savedSchema) @@ -720,6 +741,34 @@ final class DatabaseManager { } } + // MARK: - Startup Commands + + nonisolated private func executeStartupCommands( + _ commands: String?, on driver: DatabaseDriver, connectionName: String + ) async { + guard let commands, !commands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return + } + + let statements = commands + .components(separatedBy: CharacterSet(charactersIn: ";\n")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + for statement in statements { + do { + _ = try await driver.execute(query: statement) + Self.logger.info( + "Startup command succeeded for '\(connectionName)': \(statement)" + ) + } catch { + Self.logger.warning( + "Startup command failed for '\(connectionName)': \(statement) — \(error.localizedDescription)" + ) + } + } + } + // MARK: - Schema Changes /// Execute schema changes (ALTER TABLE, CREATE INDEX, etc.) in a transaction diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 1761c233..e7bd9377 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -115,9 +115,18 @@ final class ConnectionStorage { username: connection.username, type: connection.type, sshConfig: connection.sshConfig, + sslConfig: connection.sslConfig, color: connection.color, tagId: connection.tagId, - groupId: connection.groupId + groupId: connection.groupId, + isReadOnly: connection.isReadOnly, + aiPolicy: connection.aiPolicy, + mongoReadPreference: connection.mongoReadPreference, + mongoWriteConcern: connection.mongoWriteConcern, + redisDatabase: connection.redisDatabase, + mssqlSchema: connection.mssqlSchema, + oracleServiceName: connection.oracleServiceName, + startupCommands: connection.startupCommands ) // Save the duplicate connection @@ -365,6 +374,9 @@ private struct StoredConnection: Codable { // Oracle service name let oracleServiceName: String? + // Startup commands + let startupCommands: String? + init(from connection: DatabaseConnection) { self.id = connection.id self.name = connection.name @@ -406,6 +418,9 @@ private struct StoredConnection: Codable { // Oracle service name self.oracleServiceName = connection.oracleServiceName + + // Startup commands + self.startupCommands = connection.startupCommands } // Custom decoder to handle migration from old format @@ -445,6 +460,7 @@ private struct StoredConnection: Codable { aiPolicy = try container.decodeIfPresent(String.self, forKey: .aiPolicy) mssqlSchema = try container.decodeIfPresent(String.self, forKey: .mssqlSchema) oracleServiceName = try container.decodeIfPresent(String.self, forKey: .oracleServiceName) + startupCommands = try container.decodeIfPresent(String.self, forKey: .startupCommands) } func toConnection() -> DatabaseConnection { @@ -487,7 +503,8 @@ private struct StoredConnection: Codable { isReadOnly: isReadOnly, aiPolicy: parsedAIPolicy, mssqlSchema: mssqlSchema, - oracleServiceName: oracleServiceName + oracleServiceName: oracleServiceName, + startupCommands: startupCommands ) } } diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 7a6d3a1e..5116b48d 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -408,6 +408,7 @@ struct DatabaseConnection: Identifiable, Hashable { var redisDatabase: Int? var mssqlSchema: String? var oracleServiceName: String? + var startupCommands: String? init( id: UUID = UUID(), @@ -428,7 +429,8 @@ struct DatabaseConnection: Identifiable, Hashable { mongoWriteConcern: String? = nil, redisDatabase: Int? = nil, mssqlSchema: String? = nil, - oracleServiceName: String? = nil + oracleServiceName: String? = nil, + startupCommands: String? = nil ) { self.id = id self.name = name @@ -449,6 +451,7 @@ struct DatabaseConnection: Identifiable, Hashable { self.redisDatabase = redisDatabase self.mssqlSchema = mssqlSchema self.oracleServiceName = oracleServiceName + self.startupCommands = startupCommands } /// Returns the display color (custom color or database type color) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 7569f2fb..887d4dca 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -8586,6 +8586,9 @@ } } } + }, + "SQL commands to run after connecting, e.g. SET time_zone = 'Asia/Ho_Chi_Minh'. One per line or separated by semicolons." : { + }, "SQL Dialect" : { @@ -8781,6 +8784,9 @@ } } } + }, + "Startup Commands" : { + }, "statement" : { "localizations" : { diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 267e9e42..9e193216 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -5,9 +5,9 @@ // Created by Ngo Quoc Dat on 16/12/25. // -import os import SwiftUI import UniformTypeIdentifiers +import os /// Form for creating or editing a database connection struct ConnectionFormView: View { @@ -77,6 +77,9 @@ struct ConnectionFormView: View { // Oracle-specific settings @State private var oracleServiceName: String = "" + // Startup commands + @State private var startupCommands: String = "" + @State private var isTesting: Bool = false @State private var testResult: TestResult? @@ -123,7 +126,9 @@ struct ConnectionFormView: View { footer } .frame(width: 480, height: 520) - .navigationTitle(isNew ? String(localized: "New Connection") : String(localized: "Edit Connection")) + .navigationTitle( + isNew ? String(localized: "New Connection") : String(localized: "Edit Connection") + ) .onAppear { loadConnectionData() loadSSHConfig() @@ -316,7 +321,8 @@ struct ConnectionFormView: View { if sshEnabled { Section(String(localized: "Server")) { if !sshConfigEntries.isEmpty { - Picker(String(localized: "Config Host"), selection: $selectedSSHConfigHost) { + Picker(String(localized: "Config Host"), selection: $selectedSSHConfigHost) + { Text(String(localized: "Manual")).tag("") ForEach(sshConfigEntries) { entry in Text(entry.displayName).tag(entry.host) @@ -371,7 +377,8 @@ struct ConnectionFormView: View { } else { LabeledContent(String(localized: "Key File")) { HStack { - TextField("", text: $sshPrivateKeyPath, prompt: Text("~/.ssh/id_rsa")) + TextField( + "", text: $sshPrivateKeyPath, prompt: Text("~/.ssh/id_rsa")) Button(String(localized: "Browse")) { browseForPrivateKey() } .controlSize(.small) } @@ -413,7 +420,9 @@ struct ConnectionFormView: View { if jumpHost.authMethod == .privateKey { LabeledContent(String(localized: "Key File")) { HStack { - TextField("", text: $jumpHost.privateKeyPath, prompt: Text("~/.ssh/id_rsa")) + TextField( + "", text: $jumpHost.privateKeyPath, + prompt: Text("~/.ssh/id_rsa")) Button(String(localized: "Browse")) { browseForJumpHostKey(jumpHost: $jumpHost) } @@ -423,8 +432,12 @@ struct ConnectionFormView: View { } } label: { HStack { - Text(jumpHost.host.isEmpty ? String(localized: "New Jump Host") : "\(jumpHost.username)@\(jumpHost.host)") - .foregroundStyle(jumpHost.host.isEmpty ? .secondary : .primary) + Text( + jumpHost.host.isEmpty + ? String(localized: "New Jump Host") + : "\(jumpHost.username)@\(jumpHost.host)" + ) + .foregroundStyle(jumpHost.host.isEmpty ? .secondary : .primary) Spacer() Button { let idToRemove = jumpHost.id @@ -449,9 +462,11 @@ struct ConnectionFormView: View { Label(String(localized: "Add Jump Host"), systemImage: "plus") } - Text("Jump hosts are connected in order before reaching the SSH server above. Only key and agent auth are supported for jumps.") - .font(.caption) - .foregroundStyle(.secondary) + Text( + "Jump hosts are connected in order before reaching the SSH server above. Only key and agent auth are supported for jumps." + ) + .font(.caption) + .foregroundStyle(.secondary) } } } @@ -482,7 +497,8 @@ struct ConnectionFormView: View { Section(String(localized: "CA Certificate")) { LabeledContent(String(localized: "CA Cert")) { HStack { - TextField("", text: $sslCaCertPath, prompt: Text("/path/to/ca-cert.pem")) + TextField( + "", text: $sslCaCertPath, prompt: Text("/path/to/ca-cert.pem")) Button(String(localized: "Browse")) { browseForCertificate(binding: $sslCaCertPath) } @@ -495,7 +511,9 @@ struct ConnectionFormView: View { Section(String(localized: "Client Certificates (Optional)")) { LabeledContent(String(localized: "Client Cert")) { HStack { - TextField("", text: $sslClientCertPath, prompt: Text(String(localized: "(optional)"))) + TextField( + "", text: $sslClientCertPath, + prompt: Text(String(localized: "(optional)"))) Button(String(localized: "Browse")) { browseForCertificate(binding: $sslClientCertPath) } @@ -504,7 +522,9 @@ struct ConnectionFormView: View { } LabeledContent(String(localized: "Client Key")) { HStack { - TextField("", text: $sslClientKeyPath, prompt: Text(String(localized: "(optional)"))) + TextField( + "", text: $sslClientKeyPath, + prompt: Text(String(localized: "(optional)"))) Button(String(localized: "Browse")) { browseForCertificate(binding: $sslClientKeyPath) } @@ -558,24 +578,46 @@ struct ConnectionFormView: View { if type == .mssql { Section("SQL Server") { - TextField(String(localized: "Schema"), text: Binding( - get: { mssqlSchema }, - set: { mssqlSchema = $0 } - )) + TextField( + String(localized: "Schema"), + text: Binding( + get: { mssqlSchema }, + set: { mssqlSchema = $0 } + ) + ) .textFieldStyle(.roundedBorder) } } if type == .oracle { Section(String(localized: "Oracle")) { - TextField(String(localized: "Service Name"), text: Binding( - get: { oracleServiceName }, - set: { oracleServiceName = $0 } - )) + TextField( + String(localized: "Service Name"), + text: Binding( + get: { oracleServiceName }, + set: { oracleServiceName = $0 } + ) + ) .textFieldStyle(.roundedBorder) } } + Section(String(localized: "Startup Commands")) { + StartupCommandsEditor(text: $startupCommands) + .frame(height: 80) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + Text( + "SQL commands to run after connecting, e.g. SET time_zone = 'Asia/Ho_Chi_Minh'. One per line or separated by semicolons." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + Section(String(localized: "AI")) { Picker(String(localized: "AI Policy"), selection: $aiPolicy) { Text(String(localized: "Use Default")) @@ -677,7 +719,9 @@ struct ConnectionFormView: View { let basicValid = !name.isEmpty && (type == .sqlite ? !database.isEmpty : true) if sshEnabled { let sshValid = !sshHost.isEmpty && !sshUsername.isEmpty - let authValid = sshAuthMethod == .password || sshAuthMethod == .sshAgent || !sshPrivateKeyPath.isEmpty + let authValid = + sshAuthMethod == .password || sshAuthMethod == .sshAgent + || !sshPrivateKeyPath.isEmpty let jumpValid = jumpHosts.allSatisfy(\.isValid) return basicValid && sshValid && authValid && jumpValid } @@ -703,7 +747,8 @@ struct ConnectionFormView: View { private func loadConnectionData() { // If editing, load from storage if let id = connectionId, - let existing = storage.loadConnections().first(where: { $0.id == id }) { + let existing = storage.loadConnections().first(where: { $0.id == id }) + { originalConnection = existing name = existing.name host = existing.host @@ -745,6 +790,9 @@ struct ConnectionFormView: View { // Load Oracle settings oracleServiceName = existing.oracleServiceName ?? "" + // Load startup commands + startupCommands = existing.startupCommands ?? "" + // Load passwords from Keychain if let savedSSHPassword = storage.loadSSHPassword(for: existing.id) { sshPassword = savedSSHPassword @@ -786,7 +834,8 @@ struct ConnectionFormView: View { let finalHost = host.trimmingCharacters(in: .whitespaces).isEmpty ? "localhost" : host let finalPort = Int(port) ?? type.defaultPort let trimmedUsername = username.trimmingCharacters(in: .whitespaces) - let finalUsername = trimmedUsername.isEmpty && type.requiresAuthentication ? "root" : trimmedUsername + let finalUsername = + trimmedUsername.isEmpty && type.requiresAuthentication ? "root" : trimmedUsername let connectionToSave = DatabaseConnection( id: connectionId ?? UUID(), @@ -806,7 +855,9 @@ struct ConnectionFormView: View { mongoReadPreference: mongoReadPreference.isEmpty ? nil : mongoReadPreference, mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern, mssqlSchema: mssqlSchema.isEmpty ? nil : mssqlSchema, - oracleServiceName: oracleServiceName.isEmpty ? nil : oracleServiceName + oracleServiceName: oracleServiceName.isEmpty ? nil : oracleServiceName, + startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil : startupCommands ) // Save passwords to Keychain @@ -855,7 +906,8 @@ struct ConnectionFormView: View { do { try await dbManager.connectToSession(connection) } catch { - Self.logger.error("Failed to connect: \(error.localizedDescription, privacy: .public)") + Self.logger.error( + "Failed to connect: \(error.localizedDescription, privacy: .public)") } } } @@ -889,7 +941,8 @@ struct ConnectionFormView: View { let finalHost = host.trimmingCharacters(in: .whitespaces).isEmpty ? "localhost" : host let finalPort = Int(port) ?? type.defaultPort let trimmedUsername = username.trimmingCharacters(in: .whitespaces) - let finalUsername = trimmedUsername.isEmpty && type.requiresAuthentication ? "root" : trimmedUsername + let finalUsername = + trimmedUsername.isEmpty && type.requiresAuthentication ? "root" : trimmedUsername // Build connection from form values let testConn = DatabaseConnection( @@ -927,7 +980,8 @@ struct ConnectionFormView: View { testConn, sshPassword: sshPassword) await MainActor.run { isTesting = false - testResult = success ? .success : .failure(String(localized: "Connection test failed")) + testResult = + success ? .success : .failure(String(localized: "Connection test failed")) } } catch { await MainActor.run { @@ -955,7 +1009,8 @@ struct ConnectionFormView: View { let panel = NSOpenPanel() panel.allowsMultipleSelection = false panel.canChooseDirectories = false - panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".ssh") + panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( + ".ssh") panel.showsHiddenFiles = true panel.begin { response in @@ -969,7 +1024,8 @@ struct ConnectionFormView: View { let panel = NSOpenPanel() panel.allowsMultipleSelection = false panel.canChooseDirectories = false - panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".ssh") + panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( + ".ssh") panel.showsHiddenFiles = true panel.begin { response in @@ -1073,6 +1129,58 @@ struct ConnectionFormView: View { } } +// MARK: - Startup Commands Editor + +private struct StartupCommandsEditor: NSViewRepresentable { + @Binding var text: String + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + guard let textView = scrollView.documentView as? NSTextView else { return scrollView } + + textView.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.isRichText = false + textView.string = text + textView.textContainerInset = NSSize(width: 2, height: 6) + textView.drawsBackground = false + textView.delegate = context.coordinator + + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = true + scrollView.drawsBackground = false + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { return } + if textView.string != text { + textView.string = text + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text) + } + + final class Coordinator: NSObject, NSTextViewDelegate { + private var text: Binding + + init(text: Binding) { + self.text = text + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + text.wrappedValue = textView.string + } + } +} + #Preview("New Connection") { ConnectionFormView(connectionId: nil) } diff --git a/docs/databases/mysql.mdx b/docs/databases/mysql.mdx index d69adc1c..9af159e8 100644 --- a/docs/databases/mysql.mdx +++ b/docs/databases/mysql.mdx @@ -306,6 +306,23 @@ For tables with millions of rows: 2. Monitor query execution time in the results panel +## Startup Commands + +Set session variables that apply automatically on every connection. Configure these in the **Advanced** tab of the connection form. + +Typical MySQL startup commands: + +```sql +SET time_zone = '+00:00'; +SET NAMES utf8mb4; +SET sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_DATE'; +SET group_concat_max_len = 1000000; +``` + +For example, setting `time_zone` ensures all `DATETIME` and `TIMESTAMP` queries return results in UTC regardless of the server's configured timezone. This is especially useful when your application servers and database server are in different timezones. + +See [Startup Commands](/databases/overview#startup-commands) for more details. + ## Character Sets TablePro defaults to UTF-8 (utf8mb4) for MySQL connections, which handles international characters, emoji, and special symbols correctly. diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index 465cc925..cea7c5fa 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -471,6 +471,32 @@ SQLite connections are file-based and don't require health monitoring or auto-re /> +## Startup Commands + +Startup commands are SQL statements that run automatically every time a connection is established. Use them to set session variables, configure timezone, change encoding, or set other session-level options. + +Configure startup commands in the **Advanced** tab of the connection form. Enter one SQL statement per line. + +### Common Examples + +```sql +SET time_zone = '+00:00'; +SET NAMES utf8mb4; +SET sql_mode = 'STRICT_TRANS_TABLES'; +SET search_path TO myschema, public; +SET statement_timeout = '30s'; +``` + +Startup commands execute in order, top to bottom. If a command fails, the connection still proceeds, but the failed command is skipped. + + +Startup commands are useful for enforcing consistent session settings across team members. For example, setting a specific timezone ensures all datetime queries return results in the same zone regardless of the server's default. + + + +Startup commands run on every connection, including auto-reconnects. They are database-specific: use MySQL syntax for MySQL connections, PostgreSQL syntax for PostgreSQL, and so on. + + ## Editing Connections To modify an existing connection: diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index 63b44460..d95ccde2 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -352,6 +352,26 @@ If you'd rather skip SSL certificate setup, [SSH tunneling](/databases/ssh-tunne /> +## Startup Commands + +Set session variables that apply automatically on every connection. Configure these in the **Advanced** tab of the connection form. + +Typical PostgreSQL startup commands: + +```sql +SET timezone = 'UTC'; +SET search_path TO myschema, public; +SET statement_timeout = '30s'; +SET work_mem = '256MB'; +SET client_encoding = 'UTF8'; +``` + +Setting `search_path` is useful when your tables live in a non-default schema. Instead of qualifying every table name (`myschema.users`), set the search path once and query tables by name directly. + +Setting `timezone` ensures all `timestamptz` queries return results in the same zone, regardless of the server or client OS timezone. + +See [Startup Commands](/databases/overview#startup-commands) for more details. + ## PostgreSQL Extensions TablePro works with databases using popular extensions: diff --git a/docs/vi/databases/mysql.mdx b/docs/vi/databases/mysql.mdx index c710dbd6..94d97564 100644 --- a/docs/vi/databases/mysql.mdx +++ b/docs/vi/databases/mysql.mdx @@ -306,6 +306,23 @@ Cho bảng hàng triệu dòng: 2. Theo dõi thời gian thực thi trong bảng kết quả +## Lệnh Khởi động + +Đặt biến phiên tự động áp dụng mỗi khi kết nối. Cấu hình trong tab **Advanced** của form kết nối. + +Lệnh khởi động MySQL thường dùng: + +```sql +SET time_zone = '+00:00'; +SET NAMES utf8mb4; +SET sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_DATE'; +SET group_concat_max_len = 1000000; +``` + +Ví dụ: đặt `time_zone` giúp mọi truy vấn `DATETIME` và `TIMESTAMP` trả kết quả theo UTC, bất kể timezone cấu hình trên server. Đặc biệt hữu ích khi application server và database server ở các múi giờ khác nhau. + +Xem [Lệnh Khởi động](/vi/databases/overview#lệnh-khởi-động) để biết thêm chi tiết. + ## Bộ ký tự TablePro mặc định UTF-8 (utf8mb4) cho kết nối MySQL, xử lý đúng ký tự quốc tế, emoji và ký hiệu đặc biệt. diff --git a/docs/vi/databases/overview.mdx b/docs/vi/databases/overview.mdx index 78d5d945..a91785f3 100644 --- a/docs/vi/databases/overview.mdx +++ b/docs/vi/databases/overview.mdx @@ -471,6 +471,32 @@ SQLite dạng file, không cần giám sát sức khỏe hay tự động kết /> +## Lệnh Khởi động + +Lệnh khởi động là các câu lệnh SQL tự động chạy mỗi khi kết nối được thiết lập. Dùng để đặt biến phiên, cấu hình timezone, thay đổi encoding hoặc thiết lập các tùy chọn cấp phiên khác. + +Cấu hình lệnh khởi động trong tab **Advanced** của form kết nối. Nhập mỗi câu lệnh SQL trên một dòng. + +### Ví dụ Phổ biến + +```sql +SET time_zone = '+00:00'; +SET NAMES utf8mb4; +SET sql_mode = 'STRICT_TRANS_TABLES'; +SET search_path TO myschema, public; +SET statement_timeout = '30s'; +``` + +Lệnh khởi động thực thi theo thứ tự từ trên xuống dưới. Nếu một lệnh thất bại, kết nối vẫn tiếp tục nhưng lệnh lỗi sẽ bị bỏ qua. + + +Lệnh khởi động hữu ích để đảm bảo cài đặt phiên thống nhất giữa các thành viên trong nhóm. Ví dụ: đặt timezone cụ thể giúp mọi truy vấn datetime trả kết quả cùng múi giờ, bất kể cài đặt mặc định của server. + + + +Lệnh khởi động chạy mỗi lần kết nối, kể cả khi tự động kết nối lại. Lệnh phải đúng cú pháp của loại database: dùng cú pháp MySQL cho kết nối MySQL, PostgreSQL cho PostgreSQL, v.v. + + ## Chỉnh sửa Kết nối Sửa kết nối hiện có: diff --git a/docs/vi/databases/postgresql.mdx b/docs/vi/databases/postgresql.mdx index d4451056..468461e6 100644 --- a/docs/vi/databases/postgresql.mdx +++ b/docs/vi/databases/postgresql.mdx @@ -352,6 +352,26 @@ Nếu không muốn cấu hình chứng chỉ SSL, [SSH tunneling](/vi/databases /> +## Lệnh Khởi động + +Đặt biến phiên tự động áp dụng mỗi khi kết nối. Cấu hình trong tab **Advanced** của form kết nối. + +Lệnh khởi động PostgreSQL thường dùng: + +```sql +SET timezone = 'UTC'; +SET search_path TO myschema, public; +SET statement_timeout = '30s'; +SET work_mem = '256MB'; +SET client_encoding = 'UTF8'; +``` + +Đặt `search_path` hữu ích khi bảng nằm trong schema không mặc định. Thay vì phải ghi đầy đủ tên bảng (`myschema.users`), đặt search path một lần rồi truy vấn trực tiếp theo tên bảng. + +Đặt `timezone` giúp mọi truy vấn `timestamptz` trả kết quả cùng múi giờ, bất kể timezone của server hay hệ điều hành client. + +Xem [Lệnh Khởi động](/vi/databases/overview#lệnh-khởi-động) để biết thêm chi tiết. + ## PostgreSQL Extensions TablePro hoạt động với database dùng các extension phổ biến: From 253f51c526461bb172feee02a1934f80c80cc7be Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 09:26:54 +0700 Subject: [PATCH 2/2] fix: add startupCommands to test connection and fix import order --- TablePro/Views/Connection/ConnectionFormView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 9e193216..88fe37e9 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -5,9 +5,9 @@ // Created by Ngo Quoc Dat on 16/12/25. // +import os import SwiftUI import UniformTypeIdentifiers -import os /// Form for creating or editing a database connection struct ConnectionFormView: View { @@ -960,7 +960,9 @@ struct ConnectionFormView: View { mongoReadPreference: mongoReadPreference.isEmpty ? nil : mongoReadPreference, mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern, mssqlSchema: mssqlSchema.isEmpty ? nil : mssqlSchema, - oracleServiceName: oracleServiceName.isEmpty ? nil : oracleServiceName + oracleServiceName: oracleServiceName.isEmpty ? nil : oracleServiceName, + startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil : startupCommands ) Task {