diff --git a/src/engines/DatabaseCore/providers/MySQLProvider.ts b/src/engines/DatabaseCore/providers/MySQLProvider.ts index dcbc6cb77e..671482d602 100644 --- a/src/engines/DatabaseCore/providers/MySQLProvider.ts +++ b/src/engines/DatabaseCore/providers/MySQLProvider.ts @@ -1,348 +1,11 @@ /** * MySQL Database Provider * - * Implements IDatabaseService for direct MySQL/MariaDB connections. - * Delegates to Tauri backend commands (sqlx) for TCP connection handling. + * Defines MySQL-specific connection and SQL syntax while the shared + * TauriSqlProvider owns the sqlx command lifecycle. */ -import { invoke } from "@tauri-apps/api/core"; - -import type { - ColumnInfo, - ConnectionStatus, - ExecuteResult, - IDatabaseService, - MySQLConnectionConfig, - QueryOptions, - QueryResult, - TableInfo, -} from "../types"; - -interface TauriQueryResult { - columns: string[]; - rows: unknown[][]; - row_count: number; -} - -interface TauriExecuteResult { - rows_affected: number; -} - -interface TauriTableInfo { - name: string; - table_type: string; - row_count: number | null; -} - -interface TauriColumnInfo { - name: string; - data_type: string; - nullable: boolean; - primary_key: boolean; - default_value: string | null; - auto_increment: boolean; -} - -function buildConnectionString(config: MySQLConnectionConfig): string { - const userPart = config.password - ? `${config.user}:${config.password}` - : config.user; - const sslMode = config.ssl ? "REQUIRED" : "PREFERRED"; - return `mysql://${userPart}@${config.host}:${config.port}/${config.database}?ssl-mode=${sslMode}`; -} - -export class MySQLProvider implements IDatabaseService { - readonly type = "mysql" as const; - readonly config: MySQLConnectionConfig; - - private _status: ConnectionStatus = { state: "disconnected" }; - private _connected = false; - - constructor(config: MySQLConnectionConfig) { - this.config = config; - } - - get status(): ConnectionStatus { - return this._status; - } - - async connect(): Promise { - if (this._connected) return; - - this._status = { state: "connecting" }; - - try { - await invoke("db_sql_connect", { - connectionId: this.config.id, - dbType: "mysql", - connectionString: buildConnectionString(this.config), - }); - this._connected = true; - this._status = { state: "connected", connectedAt: Date.now() }; - } catch (error) { - this._connected = false; - const message = error instanceof Error ? error.message : String(error); - this._status = { state: "error", error: message }; - throw new Error(message); - } - } - - async disconnect(): Promise { - if (this._connected) { - try { - await invoke("db_sql_disconnect", { - connectionId: this.config.id, - }); - } catch { - // Best-effort disconnect - } - } - this._connected = false; - this._status = { state: "disconnected" }; - } - - isConnected(): boolean { - return this._connected && this._status.state === "connected"; - } - - async getTables(): Promise { - this.ensureConnected(); - - const result = await invoke("db_sql_get_tables", { - connectionId: this.config.id, - }); - - return result.map((table) => ({ - name: table.name, - type: - table.table_type === "VIEW" ? ("view" as const) : ("table" as const), - rowCount: table.row_count ?? undefined, - })); - } - - async getTableSchema(tableName: string): Promise { - this.ensureConnected(); - - const result = await invoke("db_sql_get_table_schema", { - connectionId: this.config.id, - tableName, - }); - - return result.map((col) => ({ - name: col.name, - type: col.data_type, - nullable: col.nullable, - primaryKey: col.primary_key, - defaultValue: col.default_value, - autoIncrement: col.auto_increment, - })); - } - - async getTableData( - tableName: string, - options: QueryOptions = {} - ): Promise { - this.ensureConnected(); - - const { - page = 1, - pageSize = 100, - orderBy, - orderDirection = "asc", - } = options; - const offset = (page - 1) * pageSize; - const startTime = performance.now(); - - let sql = `SELECT * FROM \`${tableName}\``; - if (orderBy) { - sql += ` ORDER BY \`${orderBy}\` ${orderDirection.toUpperCase()}`; - } - sql += ` LIMIT ${pageSize} OFFSET ${offset}`; - - const result = await invoke("db_sql_query", { - connectionId: this.config.id, - sql, - }); - const duration = performance.now() - startTime; - - let totalCount: number | undefined; - try { - const countResult = await invoke("db_sql_query", { - connectionId: this.config.id, - sql: `SELECT COUNT(*) as count FROM \`${tableName}\``, - }); - if (countResult.rows.length > 0) { - totalCount = Number(countResult.rows[0][0]); - } - } catch { - // Ignore count errors - } - - return { - columns: result.columns, - values: result.rows, - rowCount: result.row_count, - totalCount, - duration, - }; - } - - async query(sql: string): Promise { - this.ensureConnected(); - - const startTime = performance.now(); - const result = await invoke("db_sql_query", { - connectionId: this.config.id, - sql, - }); - const duration = performance.now() - startTime; - - return { - columns: result.columns, - values: result.rows, - rowCount: result.row_count, - duration, - }; - } - - async execute(sql: string): Promise { - this.ensureConnected(); - - const startTime = performance.now(); - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async insert( - tableName: string, - data: Record - ): Promise { - this.ensureConnected(); - const startTime = performance.now(); - - const columns = Object.keys(data); - const values = columns.map((col) => formatMySqlValue(data[col])); - - const sql = ` - INSERT INTO \`${tableName}\` (${columns.map((col) => `\`${col}\``).join(", ")}) - VALUES (${values.join(", ")}) - `; - - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async update( - tableName: string, - data: Record, - where: Record - ): Promise { - this.ensureConnected(); - const startTime = performance.now(); - - const setClause = Object.entries(data) - .map(([col, val]) => `\`${col}\` = ${formatMySqlValue(val)}`) - .join(", "); - const whereClause = Object.entries(where) - .map(([col, val]) => `\`${col}\` = ${formatMySqlValue(val)}`) - .join(" AND "); - - const sql = `UPDATE \`${tableName}\` SET ${setClause} WHERE ${whereClause}`; - - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async delete( - tableName: string, - where: Record - ): Promise { - this.ensureConnected(); - const startTime = performance.now(); - - const whereClause = Object.entries(where) - .map(([col, val]) => `\`${col}\` = ${formatMySqlValue(val)}`) - .join(" AND "); - - const sql = `DELETE FROM \`${tableName}\` WHERE ${whereClause}`; - - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async save(): Promise { - // No-op for remote databases - } - - private ensureConnected(): void { - if (!this._connected) { - throw new Error("Database not connected. Call connect() first."); - } - } -} +import type { MySQLConnectionConfig } from "../types"; +import { type TauriSqlDialect, TauriSqlProvider } from "./TauriSqlProvider"; function formatMySqlValue(value: unknown): string { if (value === null || value === undefined) return "NULL"; @@ -355,4 +18,25 @@ function formatMySqlValue(value: unknown): string { return `'${String(value).replace(/'/g, "''")}'`; } +const MYSQL_DIALECT: TauriSqlDialect = { + type: "mysql", + buildConnectionString(config) { + const userPart = config.password + ? `${config.user}:${config.password}` + : config.user; + const sslMode = config.ssl ? "REQUIRED" : "PREFERRED"; + return `mysql://${userPart}@${config.host}:${config.port}/${config.database}?ssl-mode=${sslMode}`; + }, + quoteIdentifier(identifier) { + return `\`${identifier}\``; + }, + formatValue: formatMySqlValue, +}; + +export class MySQLProvider extends TauriSqlProvider { + constructor(config: MySQLConnectionConfig) { + super(config, MYSQL_DIALECT); + } +} + export default MySQLProvider; diff --git a/src/engines/DatabaseCore/providers/PostgresProvider.ts b/src/engines/DatabaseCore/providers/PostgresProvider.ts index 1087c12c94..e6f4cda718 100644 --- a/src/engines/DatabaseCore/providers/PostgresProvider.ts +++ b/src/engines/DatabaseCore/providers/PostgresProvider.ts @@ -1,350 +1,13 @@ /** * PostgreSQL Database Provider * - * Implements IDatabaseService for direct PostgreSQL connections. - * Delegates to Tauri backend commands (sqlx) for TCP connection handling. + * Defines PostgreSQL-specific connection and SQL syntax while the shared + * TauriSqlProvider owns the sqlx command lifecycle. */ -import { invoke } from "@tauri-apps/api/core"; +import type { PostgresConnectionConfig } from "../types"; +import { type TauriSqlDialect, TauriSqlProvider } from "./TauriSqlProvider"; -import type { - ColumnInfo, - ConnectionStatus, - ExecuteResult, - IDatabaseService, - PostgresConnectionConfig, - QueryOptions, - QueryResult, - TableInfo, -} from "../types"; - -interface TauriQueryResult { - columns: string[]; - rows: unknown[][]; - row_count: number; -} - -interface TauriExecuteResult { - rows_affected: number; -} - -interface TauriTableInfo { - name: string; - table_type: string; - row_count: number | null; -} - -interface TauriColumnInfo { - name: string; - data_type: string; - nullable: boolean; - primary_key: boolean; - default_value: string | null; - auto_increment: boolean; -} - -function buildConnectionString(config: PostgresConnectionConfig): string { - const userPart = config.password - ? `${config.user}:${config.password}` - : config.user; - const sslMode = config.ssl ? "require" : "prefer"; - return `postgres://${userPart}@${config.host}:${config.port}/${config.database}?sslmode=${sslMode}`; -} - -export class PostgresProvider implements IDatabaseService { - readonly type = "postgres" as const; - readonly config: PostgresConnectionConfig; - - private _status: ConnectionStatus = { state: "disconnected" }; - private _connected = false; - - constructor(config: PostgresConnectionConfig) { - this.config = config; - } - - get status(): ConnectionStatus { - return this._status; - } - - async connect(): Promise { - if (this._connected) return; - - this._status = { state: "connecting" }; - - try { - await invoke("db_sql_connect", { - connectionId: this.config.id, - dbType: "postgres", - connectionString: buildConnectionString(this.config), - }); - this._connected = true; - this._status = { state: "connected", connectedAt: Date.now() }; - } catch (error) { - this._connected = false; - const message = error instanceof Error ? error.message : String(error); - this._status = { state: "error", error: message }; - throw new Error(message); - } - } - - async disconnect(): Promise { - if (this._connected) { - try { - await invoke("db_sql_disconnect", { - connectionId: this.config.id, - }); - } catch { - // Best-effort disconnect - } - } - this._connected = false; - this._status = { state: "disconnected" }; - } - - isConnected(): boolean { - return this._connected && this._status.state === "connected"; - } - - async getTables(): Promise { - this.ensureConnected(); - - const result = await invoke("db_sql_get_tables", { - connectionId: this.config.id, - }); - - return result.map((table) => ({ - name: table.name, - type: - table.table_type === "VIEW" ? ("view" as const) : ("table" as const), - rowCount: table.row_count ?? undefined, - })); - } - - async getTableSchema(tableName: string): Promise { - this.ensureConnected(); - - const result = await invoke("db_sql_get_table_schema", { - connectionId: this.config.id, - tableName, - }); - - return result.map((col) => ({ - name: col.name, - type: col.data_type, - nullable: col.nullable, - primaryKey: col.primary_key, - defaultValue: col.default_value, - autoIncrement: col.auto_increment, - })); - } - - async getTableData( - tableName: string, - options: QueryOptions = {} - ): Promise { - this.ensureConnected(); - - const { - page = 1, - pageSize = 100, - orderBy, - orderDirection = "asc", - } = options; - const offset = (page - 1) * pageSize; - const startTime = performance.now(); - - let sql = `SELECT * FROM "${tableName}"`; - if (orderBy) { - sql += ` ORDER BY "${orderBy}" ${orderDirection.toUpperCase()}`; - } - sql += ` LIMIT ${pageSize} OFFSET ${offset}`; - - const result = await invoke("db_sql_query", { - connectionId: this.config.id, - sql, - }); - const duration = performance.now() - startTime; - - let totalCount: number | undefined; - try { - const countResult = await invoke("db_sql_query", { - connectionId: this.config.id, - sql: `SELECT COUNT(*) as count FROM "${tableName}"`, - }); - if (countResult.rows.length > 0) { - totalCount = Number(countResult.rows[0][0]); - } - } catch { - // Ignore count errors - } - - return { - columns: result.columns, - values: result.rows, - rowCount: result.row_count, - totalCount, - duration, - }; - } - - async query(sql: string): Promise { - this.ensureConnected(); - - const startTime = performance.now(); - const result = await invoke("db_sql_query", { - connectionId: this.config.id, - sql, - }); - const duration = performance.now() - startTime; - - return { - columns: result.columns, - values: result.rows, - rowCount: result.row_count, - duration, - }; - } - - async execute(sql: string): Promise { - this.ensureConnected(); - - const startTime = performance.now(); - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async insert( - tableName: string, - data: Record - ): Promise { - this.ensureConnected(); - const startTime = performance.now(); - - const columns = Object.keys(data); - const values = columns.map((col) => formatSqlValue(data[col])); - - const sql = ` - INSERT INTO "${tableName}" (${columns.map((col) => `"${col}"`).join(", ")}) - VALUES (${values.join(", ")}) - `; - - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async update( - tableName: string, - data: Record, - where: Record - ): Promise { - this.ensureConnected(); - const startTime = performance.now(); - - const setClause = Object.entries(data) - .map(([col, val]) => `"${col}" = ${formatSqlValue(val)}`) - .join(", "); - const whereClause = Object.entries(where) - .map(([col, val]) => `"${col}" = ${formatSqlValue(val)}`) - .join(" AND "); - - const sql = `UPDATE "${tableName}" SET ${setClause} WHERE ${whereClause}`; - - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async delete( - tableName: string, - where: Record - ): Promise { - this.ensureConnected(); - const startTime = performance.now(); - - const whereClause = Object.entries(where) - .map(([col, val]) => `"${col}" = ${formatSqlValue(val)}`) - .join(" AND "); - - const sql = `DELETE FROM "${tableName}" WHERE ${whereClause}`; - - try { - const result = await invoke("db_sql_execute", { - connectionId: this.config.id, - sql, - }); - return { - success: true, - rowsAffected: result.rows_affected, - duration: performance.now() - startTime, - }; - } catch (error) { - return { - success: false, - rowsAffected: 0, - duration: performance.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async save(): Promise { - // No-op for remote databases - } - - private ensureConnected(): void { - if (!this._connected) { - throw new Error("Database not connected. Call connect() first."); - } - } -} - -function formatSqlValue(value: unknown): string { +function formatPostgresValue(value: unknown): string { if (value === null || value === undefined) return "NULL"; if (typeof value === "number") return String(value); if (typeof value === "boolean") return value ? "TRUE" : "FALSE"; @@ -355,4 +18,25 @@ function formatSqlValue(value: unknown): string { return `'${String(value).replace(/'/g, "''")}'`; } +const POSTGRES_DIALECT: TauriSqlDialect = { + type: "postgres", + buildConnectionString(config) { + const userPart = config.password + ? `${config.user}:${config.password}` + : config.user; + const sslMode = config.ssl ? "require" : "prefer"; + return `postgres://${userPart}@${config.host}:${config.port}/${config.database}?sslmode=${sslMode}`; + }, + quoteIdentifier(identifier) { + return `"${identifier}"`; + }, + formatValue: formatPostgresValue, +}; + +export class PostgresProvider extends TauriSqlProvider { + constructor(config: PostgresConnectionConfig) { + super(config, POSTGRES_DIALECT); + } +} + export default PostgresProvider; diff --git a/src/engines/DatabaseCore/providers/TauriSqlProvider.ts b/src/engines/DatabaseCore/providers/TauriSqlProvider.ts new file mode 100644 index 0000000000..3522b92570 --- /dev/null +++ b/src/engines/DatabaseCore/providers/TauriSqlProvider.ts @@ -0,0 +1,321 @@ +import { invoke } from "@tauri-apps/api/core"; + +import type { + ColumnInfo, + ConnectionStatus, + ExecuteResult, + IDatabaseService, + MySQLConnectionConfig, + PostgresConnectionConfig, + QueryOptions, + QueryResult, + TableInfo, +} from "../types"; + +type TauriSqlConnectionConfig = + | PostgresConnectionConfig + | MySQLConnectionConfig; + +export interface TauriSqlDialect { + readonly type: Config["type"]; + buildConnectionString(config: Config): string; + quoteIdentifier(identifier: string): string; + formatValue(value: unknown): string; +} + +interface TauriQueryResult { + columns: string[]; + rows: unknown[][]; + row_count: number; +} + +interface TauriExecuteResult { + rows_affected: number; +} + +interface TauriTableInfo { + name: string; + table_type: string; + row_count: number | null; +} + +interface TauriColumnInfo { + name: string; + data_type: string; + nullable: boolean; + primary_key: boolean; + default_value: string | null; + auto_increment: boolean; +} + +/** + * Shared lifecycle and command adapter for the sqlx-backed database providers. + * Provider-specific connection strings and SQL syntax stay in a dialect object. + */ +export abstract class TauriSqlProvider< + Config extends TauriSqlConnectionConfig, +> implements IDatabaseService { + readonly type: Config["type"]; + readonly config: Config; + + private _status: ConnectionStatus = { state: "disconnected" }; + private _connected = false; + + protected constructor( + config: Config, + private readonly dialect: TauriSqlDialect + ) { + this.config = config; + this.type = dialect.type; + } + + get status(): ConnectionStatus { + return this._status; + } + + async connect(): Promise { + if (this._connected) return; + + this._status = { state: "connecting" }; + + try { + await invoke("db_sql_connect", { + connectionId: this.config.id, + dbType: this.type, + connectionString: this.dialect.buildConnectionString(this.config), + }); + this._connected = true; + this._status = { state: "connected", connectedAt: Date.now() }; + } catch (error) { + this._connected = false; + const message = error instanceof Error ? error.message : String(error); + this._status = { state: "error", error: message }; + throw new Error(message); + } + } + + async disconnect(): Promise { + if (this._connected) { + try { + await invoke("db_sql_disconnect", { + connectionId: this.config.id, + }); + } catch { + // Best-effort disconnect + } + } + this._connected = false; + this._status = { state: "disconnected" }; + } + + isConnected(): boolean { + return this._connected && this._status.state === "connected"; + } + + async getTables(): Promise { + this.ensureConnected(); + + const result = await invoke("db_sql_get_tables", { + connectionId: this.config.id, + }); + + return result.map((table) => ({ + name: table.name, + type: + table.table_type === "VIEW" ? ("view" as const) : ("table" as const), + rowCount: table.row_count ?? undefined, + })); + } + + async getTableSchema(tableName: string): Promise { + this.ensureConnected(); + + const result = await invoke("db_sql_get_table_schema", { + connectionId: this.config.id, + tableName, + }); + + return result.map((column) => ({ + name: column.name, + type: column.data_type, + nullable: column.nullable, + primaryKey: column.primary_key, + defaultValue: column.default_value, + autoIncrement: column.auto_increment, + })); + } + + async getTableData( + tableName: string, + options: QueryOptions = {} + ): Promise { + this.ensureConnected(); + + const { + page = 1, + pageSize = 100, + orderBy, + orderDirection = "asc", + } = options; + const offset = (page - 1) * pageSize; + const startTime = performance.now(); + const table = this.dialect.quoteIdentifier(tableName); + const orderColumn = orderBy + ? this.dialect.quoteIdentifier(orderBy) + : undefined; + + let sql = `SELECT * FROM ${table}`; + if (orderColumn) { + sql += ` ORDER BY ${orderColumn} ${orderDirection.toUpperCase()}`; + } + sql += ` LIMIT ${pageSize} OFFSET ${offset}`; + + const result = await invoke("db_sql_query", { + connectionId: this.config.id, + sql, + }); + const duration = performance.now() - startTime; + + let totalCount: number | undefined; + try { + const countResult = await invoke("db_sql_query", { + connectionId: this.config.id, + sql: `SELECT COUNT(*) as count FROM ${table}`, + }); + if (countResult.rows.length > 0) { + totalCount = Number(countResult.rows[0][0]); + } + } catch { + // Count metadata is optional; return the requested page when it fails. + } + + return { + columns: result.columns, + values: result.rows, + rowCount: result.row_count, + totalCount, + duration, + }; + } + + async query(sql: string): Promise { + this.ensureConnected(); + + const startTime = performance.now(); + const result = await invoke("db_sql_query", { + connectionId: this.config.id, + sql, + }); + + return { + columns: result.columns, + values: result.rows, + rowCount: result.row_count, + duration: performance.now() - startTime, + }; + } + + async execute(sql: string): Promise { + this.ensureConnected(); + return this.executeMutation(sql); + } + + async insert( + tableName: string, + data: Record + ): Promise { + this.ensureConnected(); + const startTime = performance.now(); + + const table = this.dialect.quoteIdentifier(tableName); + const columns = Object.keys(data); + const quotedColumns = columns.map((column) => + this.dialect.quoteIdentifier(column) + ); + const values = columns.map((column) => + this.dialect.formatValue(data[column]) + ); + const sql = ` + INSERT INTO ${table} (${quotedColumns.join(", ")}) + VALUES (${values.join(", ")}) + `; + + return this.executeMutation(sql, startTime); + } + + async update( + tableName: string, + data: Record, + where: Record + ): Promise { + this.ensureConnected(); + const startTime = performance.now(); + + const table = this.dialect.quoteIdentifier(tableName); + const setClause = this.formatAssignments(data, ", "); + const whereClause = this.formatAssignments(where, " AND "); + const sql = `UPDATE ${table} SET ${setClause} WHERE ${whereClause}`; + + return this.executeMutation(sql, startTime); + } + + async delete( + tableName: string, + where: Record + ): Promise { + this.ensureConnected(); + const startTime = performance.now(); + + const table = this.dialect.quoteIdentifier(tableName); + const whereClause = this.formatAssignments(where, " AND "); + const sql = `DELETE FROM ${table} WHERE ${whereClause}`; + + return this.executeMutation(sql, startTime); + } + + async save(): Promise { + // No-op for remote databases + } + + private formatAssignments( + values: Record, + separator: string + ): string { + return Object.entries(values) + .map( + ([column, value]) => + `${this.dialect.quoteIdentifier(column)} = ${this.dialect.formatValue(value)}` + ) + .join(separator); + } + + private async executeMutation( + sql: string, + startTime = performance.now() + ): Promise { + try { + const result = await invoke("db_sql_execute", { + connectionId: this.config.id, + sql, + }); + return { + success: true, + rowsAffected: result.rows_affected, + duration: performance.now() - startTime, + }; + } catch (error) { + return { + success: false, + rowsAffected: 0, + duration: performance.now() - startTime, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + private ensureConnected(): void { + if (!this._connected) { + throw new Error("Database not connected. Call connect() first."); + } + } +} diff --git a/src/engines/DatabaseCore/providers/__tests__/TauriSqlProvider.test.ts b/src/engines/DatabaseCore/providers/__tests__/TauriSqlProvider.test.ts new file mode 100644 index 0000000000..56a93d7efb --- /dev/null +++ b/src/engines/DatabaseCore/providers/__tests__/TauriSqlProvider.test.ts @@ -0,0 +1,226 @@ +import { invoke } from "@tauri-apps/api/core"; + +import type { + MySQLConnectionConfig, + PostgresConnectionConfig, +} from "../../types"; +import { MySQLProvider } from "../MySQLProvider"; +import { PostgresProvider } from "../PostgresProvider"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +const invokeMock = vi.mocked(invoke); + +const baseConfig = { + name: "Test database", + createdAt: 1, + updatedAt: 1, +} as const; + +const postgresConfig: PostgresConnectionConfig = { + ...baseConfig, + id: "postgres-1", + type: "postgres", + host: "postgres.example.com", + port: 5432, + database: "app", + user: "developer", + password: "secret", + ssl: true, +}; + +const mysqlConfig: MySQLConnectionConfig = { + ...baseConfig, + id: "mysql-1", + type: "mysql", + host: "mysql.example.com", + port: 3306, + database: "app", + user: "root", + ssl: false, +}; + +function sqlCalls(): string[] { + return invokeMock.mock.calls + .filter( + ([command]) => command === "db_sql_query" || command === "db_sql_execute" + ) + .map(([, args]) => (args as { sql: string }).sql); +} + +describe("TauriSqlProvider", () => { + beforeEach(() => { + invokeMock.mockReset().mockImplementation(async (command) => { + switch (command) { + case "db_sql_query": + return { columns: ["id"], rows: [[1]], row_count: 1 }; + case "db_sql_execute": + return { rows_affected: 2 }; + case "db_sql_get_tables": + return [ + { name: "users", table_type: "BASE TABLE", row_count: 4 }, + { name: "active_users", table_type: "VIEW", row_count: null }, + ]; + case "db_sql_get_table_schema": + return [ + { + name: "id", + data_type: "integer", + nullable: false, + primary_key: true, + default_value: null, + auto_increment: true, + }, + ]; + default: + return undefined; + } + }); + }); + + it("connects each provider with its unchanged database type and connection string", async () => { + const postgres = new PostgresProvider(postgresConfig); + const mysql = new MySQLProvider(mysqlConfig); + + await postgres.connect(); + await mysql.connect(); + + expect(invokeMock).toHaveBeenNthCalledWith(1, "db_sql_connect", { + connectionId: "postgres-1", + dbType: "postgres", + connectionString: + "postgres://developer:secret@postgres.example.com:5432/app?sslmode=require", + }); + expect(invokeMock).toHaveBeenNthCalledWith(2, "db_sql_connect", { + connectionId: "mysql-1", + dbType: "mysql", + connectionString: + "mysql://root@mysql.example.com:3306/app?ssl-mode=PREFERRED", + }); + expect(postgres.status.state).toBe("connected"); + expect(mysql.isConnected()).toBe(true); + }); + + it.each([ + { + name: "PostgreSQL", + provider: () => new PostgresProvider(postgresConfig), + expected: [ + 'SELECT * FROM "users" ORDER BY "created_at" DESC LIMIT 25 OFFSET 25', + 'SELECT COUNT(*) as count FROM "users"', + ], + }, + { + name: "MySQL", + provider: () => new MySQLProvider(mysqlConfig), + expected: [ + "SELECT * FROM `users` ORDER BY `created_at` DESC LIMIT 25 OFFSET 25", + "SELECT COUNT(*) as count FROM `users`", + ], + }, + ])( + "keeps $name pagination and identifier syntax", + async ({ provider, expected }) => { + const service = provider(); + await service.connect(); + + const result = await service.getTableData("users", { + page: 2, + pageSize: 25, + orderBy: "created_at", + orderDirection: "desc", + }); + + expect(sqlCalls()).toEqual(expected); + expect(result).toMatchObject({ + columns: ["id"], + values: [[1]], + rowCount: 1, + totalCount: 1, + }); + } + ); + + it("keeps PostgreSQL value formatting in shared CRUD commands", async () => { + const provider = new PostgresProvider(postgresConfig); + await provider.connect(); + + await provider.insert("events", { + enabled: true, + payload: { label: "it's ready" }, + }); + await provider.update("events", { enabled: false }, { id: 3 }); + await provider.delete("events", { id: 3 }); + + expect(sqlCalls()).toEqual([ + expect.stringContaining( + 'INSERT INTO "events" ("enabled", "payload")\n VALUES (TRUE, \'{"label":"it\'\'s ready"}\'::jsonb)' + ), + 'UPDATE "events" SET "enabled" = FALSE WHERE "id" = 3', + 'DELETE FROM "events" WHERE "id" = 3', + ]); + }); + + it("keeps MySQL value formatting in shared CRUD commands", async () => { + const provider = new MySQLProvider(mysqlConfig); + await provider.connect(); + + await provider.insert("events", { + enabled: true, + payload: { label: "it's ready" }, + }); + await provider.update("events", { enabled: false }, { id: 3 }); + await provider.delete("events", { id: 3 }); + + expect(sqlCalls()).toEqual([ + expect.stringContaining( + "INSERT INTO `events` (`enabled`, `payload`)\n VALUES (1, '{\"label\":\"it''s ready\"}')" + ), + "UPDATE `events` SET `enabled` = 0 WHERE `id` = 3", + "DELETE FROM `events` WHERE `id` = 3", + ]); + }); + + it("maps shared table metadata and resets state after a failed disconnect", async () => { + const provider = new PostgresProvider(postgresConfig); + await provider.connect(); + + await expect(provider.getTables()).resolves.toEqual([ + { name: "users", type: "table", rowCount: 4 }, + { name: "active_users", type: "view", rowCount: undefined }, + ]); + await expect(provider.getTableSchema("users")).resolves.toEqual([ + { + name: "id", + type: "integer", + nullable: false, + primaryKey: true, + defaultValue: null, + autoIncrement: true, + }, + ]); + + invokeMock.mockRejectedValueOnce(new Error("already closed")); + await provider.disconnect(); + + expect(provider.status).toEqual({ state: "disconnected" }); + expect(provider.isConnected()).toBe(false); + }); + + it("rejects commands before connection and records connect failures", async () => { + const provider = new MySQLProvider(mysqlConfig); + + await expect(provider.query("SELECT 1")).rejects.toThrow( + "Database not connected" + ); + invokeMock.mockRejectedValueOnce(new Error("connection refused")); + + await expect(provider.connect()).rejects.toThrow("connection refused"); + expect(provider.status).toEqual({ + state: "error", + error: "connection refused", + }); + }); +});