mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
refactor(sql): move builders to Rust
This commit is contained in:
Generated
+1
@@ -1793,6 +1793,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"portpicker",
|
||||
"redis",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"russh",
|
||||
"rust_decimal",
|
||||
|
||||
@@ -31,7 +31,7 @@ import * as api from "@/lib/api";
|
||||
import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
|
||||
import { findTreeNodeById, resolveNewQueryTarget } from "@/lib/newQueryContext";
|
||||
import { buildExecutableObjectSourceStatements, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor";
|
||||
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
|
||||
import { resolveExecutableSql, resolveExecutableSqlWithBackend } from "@/lib/sqlExecutionTarget";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import { sqlFileTitleFromPath } from "@/lib/sqlFileOpen";
|
||||
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connectionDeepLink";
|
||||
@@ -167,6 +167,16 @@ const executableSql = computed(() => {
|
||||
: "";
|
||||
});
|
||||
|
||||
async function resolveActiveExecutableSql() {
|
||||
const tab = activeTab.value;
|
||||
return tab
|
||||
? await resolveExecutableSqlWithBackend(tab.sql, selectedSql.value, {
|
||||
mode: settingsStore.editorSettings.executeMode,
|
||||
cursorPos: cursorPos.value,
|
||||
})
|
||||
: "";
|
||||
}
|
||||
|
||||
const {
|
||||
dangerSql,
|
||||
pendingDangerSql,
|
||||
@@ -176,7 +186,13 @@ const {
|
||||
cancelActiveExecution,
|
||||
tryExplain,
|
||||
onDangerConfirm,
|
||||
} = useSqlExecution({ activeTab, activeConnection, executableSql, activeOutputView });
|
||||
} = useSqlExecution({
|
||||
activeTab,
|
||||
activeConnection,
|
||||
executableSql,
|
||||
resolveExecutableSql: resolveActiveExecutableSql,
|
||||
activeOutputView,
|
||||
});
|
||||
|
||||
const dialogs = useDialogSources();
|
||||
const { getDatabaseOptions } = useDatabaseOptions();
|
||||
@@ -310,7 +326,7 @@ async function saveActiveObjectSource(tab: NonNullable<typeof activeTab.value>)
|
||||
if (!connection || !source) return;
|
||||
|
||||
try {
|
||||
const statements = buildExecutableObjectSourceStatements({
|
||||
const statements = await buildExecutableObjectSourceStatements({
|
||||
databaseType: connection.db_type,
|
||||
objectType: source.objectType,
|
||||
schema: source.schema || tab.schema || tab.database,
|
||||
|
||||
@@ -73,12 +73,7 @@ import ImagePreviewDialog from "@/components/grid/ImagePreviewDialog.vue";
|
||||
import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
|
||||
import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
import {
|
||||
buildTableSelectSql,
|
||||
qualifiedTableName,
|
||||
normalizeWhereInput,
|
||||
quoteTableIdentifier,
|
||||
} from "@/lib/tableSelectSql";
|
||||
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
|
||||
import { uuid } from "@/lib/utils";
|
||||
import {
|
||||
canEditExistingTableRows,
|
||||
@@ -87,7 +82,12 @@ import {
|
||||
isTdengineExistingRowReadonlyColumn,
|
||||
usesSyntheticRowIdKey,
|
||||
} from "@/lib/tableEditing";
|
||||
import { formatGridSqlLiteral } from "@/lib/dataGridSql";
|
||||
import {
|
||||
buildDataGridContextFilterCondition,
|
||||
buildDataGridCountSql,
|
||||
buildHiveTablePropertiesSql,
|
||||
type DataGridContextFilterMode,
|
||||
} from "@/lib/dataGridSql";
|
||||
import {
|
||||
buildVisibleTransposeRows,
|
||||
nextAppendedTransposeState,
|
||||
@@ -718,7 +718,7 @@ async function applyTypedLocalFilterValue() {
|
||||
if (!draft) return;
|
||||
const columnName = props.result.columns[draft.columnIndex];
|
||||
if (!columnName) return;
|
||||
const condition = buildColumnValueFilterCondition({
|
||||
const condition = await buildColumnValueFilterCondition({
|
||||
databaseType: props.databaseType,
|
||||
columnName,
|
||||
columnInfo: props.tableMeta?.columns.find((column) => column.name === columnName),
|
||||
@@ -1306,18 +1306,13 @@ watch(
|
||||
hiveTableTransactional.value = undefined;
|
||||
return;
|
||||
}
|
||||
const table = qualifiedTableName({
|
||||
databaseType: "hive",
|
||||
schema: props.tableMeta.schema,
|
||||
tableName: props.tableMeta.tableName,
|
||||
});
|
||||
try {
|
||||
const result = await api.executeQuery(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
`SHOW TBLPROPERTIES ${table} ('transactional')`,
|
||||
props.tableMeta.schema,
|
||||
);
|
||||
const sql = await buildHiveTablePropertiesSql({
|
||||
schema: props.tableMeta.schema,
|
||||
tableName: props.tableMeta.tableName,
|
||||
propertyName: "transactional",
|
||||
});
|
||||
const result = await api.executeQuery(props.connectionId, props.database, sql, props.tableMeta.schema);
|
||||
hiveTableTransactional.value = hiveTablePropertiesIndicateTransactional(result);
|
||||
} catch {
|
||||
hiveTableTransactional.value = false;
|
||||
@@ -1385,14 +1380,12 @@ async function lastPage() {
|
||||
let sql = props.countSql;
|
||||
let schema = props.schema;
|
||||
if (props.tableMeta) {
|
||||
const table = qualifiedTableName({
|
||||
sql = await buildDataGridCountSql({
|
||||
databaseType: props.databaseType,
|
||||
schema: props.tableMeta.schema,
|
||||
tableName: props.tableMeta.tableName,
|
||||
whereInput: currentWhereInput(),
|
||||
});
|
||||
const predicate = normalizeWhereInput(currentWhereInput());
|
||||
const where = predicate ? ` WHERE (${predicate})` : "";
|
||||
sql = `SELECT COUNT(*) AS cnt FROM ${table}${where}`;
|
||||
schema = props.tableMeta.schema;
|
||||
}
|
||||
if (!sql) return;
|
||||
@@ -2122,34 +2115,24 @@ function applyContextSort(direction: "asc" | "desc" | null) {
|
||||
emit("sort", column, columnIndex, direction, currentWhereInput());
|
||||
}
|
||||
|
||||
type FilterMode =
|
||||
| "equals"
|
||||
| "not-equals"
|
||||
| "is-null"
|
||||
| "is-not-null"
|
||||
| "like"
|
||||
| "not-like"
|
||||
| "less-than"
|
||||
| "greater-than";
|
||||
type FilterMode = DataGridContextFilterMode;
|
||||
|
||||
function contextFilterCondition(mode: FilterMode): string | null {
|
||||
async function contextFilterCondition(mode: FilterMode): Promise<string | null> {
|
||||
if (!contextColumn.value) return null;
|
||||
const column = queryColumnRef(contextColumn.value);
|
||||
const value = contextCellValue.value;
|
||||
|
||||
if (mode === "is-null") return `${column} IS NULL`;
|
||||
if (mode === "is-not-null") return `${column} IS NOT NULL`;
|
||||
if (value === null) return mode === "equals" ? `${column} IS NULL` : `${column} IS NOT NULL`;
|
||||
if (mode === "like") return `${column} LIKE ${escapeVal(`%${value}%`)}`;
|
||||
if (mode === "not-like") return `${column} NOT LIKE ${escapeVal(`%${value}%`)}`;
|
||||
if (mode === "less-than") return `${column} < ${escapeVal(value)}`;
|
||||
if (mode === "greater-than") return `${column} > ${escapeVal(value)}`;
|
||||
return mode === "equals" ? `${column} = ${escapeVal(value)}` : `${column} <> ${escapeVal(value)}`;
|
||||
return (
|
||||
(await buildDataGridContextFilterCondition({
|
||||
databaseType: props.databaseType,
|
||||
columnName: contextColumn.value,
|
||||
columnInfo: props.tableMeta?.columns.find((column) => column.name === contextColumn.value),
|
||||
mode,
|
||||
value: contextCellValue.value,
|
||||
})) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async function applyContextFilter(mode: FilterMode) {
|
||||
if (!canUseWhereSearch.value) return;
|
||||
const condition = contextFilterCondition(mode);
|
||||
const condition = await contextFilterCondition(mode);
|
||||
if (!condition) return;
|
||||
const existing = whereFilterInput.value.trim();
|
||||
whereFilterInput.value = existing ? `(${existing}) AND (${condition})` : condition;
|
||||
@@ -2172,7 +2155,7 @@ async function applyOrderBySearch() {
|
||||
sortColIndex.value = null;
|
||||
sortDir.value = "asc";
|
||||
try {
|
||||
const sql = buildTableSelectSql({
|
||||
const sql = await buildTableSelectSql({
|
||||
databaseType: props.databaseType,
|
||||
schema: props.tableMeta.schema,
|
||||
tableName: props.tableMeta.tableName,
|
||||
@@ -2197,7 +2180,7 @@ async function applyWhereFilter() {
|
||||
saveError.value = "";
|
||||
currentPage.value = 1;
|
||||
try {
|
||||
const sql = buildTableSelectSql({
|
||||
const sql = await buildTableSelectSql({
|
||||
databaseType: props.databaseType,
|
||||
schema: props.tableMeta.schema,
|
||||
tableName: props.tableMeta.tableName,
|
||||
@@ -2235,10 +2218,6 @@ function queryColumnRef(name: string): string {
|
||||
return props.databaseType === "neo4j" ? `n.${quoted}` : quoted;
|
||||
}
|
||||
|
||||
function escapeVal(value: CellValue): string {
|
||||
return formatGridSqlLiteral(value);
|
||||
}
|
||||
|
||||
function isNull(value: unknown): boolean {
|
||||
return value === null;
|
||||
}
|
||||
@@ -2520,12 +2499,17 @@ function copyDetailColumnName() {
|
||||
copyText(activeCellDetail.value.column);
|
||||
}
|
||||
|
||||
function copyDetailSqlCondition() {
|
||||
async function copyDetailSqlCondition() {
|
||||
const detail = activeCellDetail.value;
|
||||
if (!detail) return;
|
||||
const column = quoteIdent(detail.column);
|
||||
const condition = detail.value === null ? `${column} IS NULL` : `${column} = ${escapeVal(detail.value)}`;
|
||||
copyText(condition);
|
||||
const condition = await buildDataGridContextFilterCondition({
|
||||
databaseType: props.databaseType,
|
||||
columnName: detail.column,
|
||||
columnInfo: props.tableMeta?.columns.find((column) => column.name === detail.column),
|
||||
mode: "equals",
|
||||
value: detail.value,
|
||||
});
|
||||
if (condition) copyText(condition);
|
||||
}
|
||||
|
||||
const TRANSPOSE_RECORD_DEFAULT_WIDTH = 168;
|
||||
|
||||
@@ -51,10 +51,15 @@ import {
|
||||
supportsTableImport,
|
||||
supportsTableStructureEditing,
|
||||
supportsTableTruncate,
|
||||
usesPostgresLikeStructureCopy,
|
||||
usesFetchFirst,
|
||||
} from "@/lib/databaseFeatureSupport";
|
||||
import { buildTableSelectSql, qualifiedTableName, quoteTableIdentifier } from "@/lib/tableSelectSql";
|
||||
import { buildTableSelectSql } from "@/lib/tableSelectSql";
|
||||
import {
|
||||
buildDropObjectSql,
|
||||
buildDuplicateTableStructureSql,
|
||||
buildEmptyTableSql,
|
||||
buildTruncateTableSql,
|
||||
type TableAdminSqlOptions,
|
||||
} from "@/lib/dbAdminSql";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import {
|
||||
buildExecutableObjectSourceStatements,
|
||||
@@ -128,10 +133,13 @@ const showRenameDialog = ref(false);
|
||||
const renameTarget = ref<ObjectBrowserRow | null>(null);
|
||||
const renameInput = ref("");
|
||||
const renameError = ref("");
|
||||
const renamePreviewSqlText = ref("");
|
||||
const showTruncateConfirm = ref(false);
|
||||
const truncateTarget = ref<ObjectBrowserRow | null>(null);
|
||||
const truncatePreviewSql = ref("");
|
||||
const showEmptyConfirm = ref(false);
|
||||
const emptyTarget = ref<ObjectBrowserRow | null>(null);
|
||||
const emptyPreviewSql = ref("");
|
||||
const showDuplicateDialog = ref(false);
|
||||
const duplicateTarget = ref<ObjectBrowserRow | null>(null);
|
||||
const duplicateTableName = ref("");
|
||||
@@ -295,7 +303,7 @@ async function openViewDdl(row: ObjectBrowserRow) {
|
||||
row.name,
|
||||
"VIEW",
|
||||
);
|
||||
const ddl = buildViewDdl({
|
||||
const ddl = await buildViewDdl({
|
||||
databaseType: props.connection.db_type,
|
||||
schema: row.schema || selectedSchema.value || props.database,
|
||||
name: row.name,
|
||||
@@ -308,11 +316,11 @@ async function openViewDdl(row: ObjectBrowserRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function openNewQuery(row: ObjectBrowserRow) {
|
||||
async function openNewQuery(row: ObjectBrowserRow) {
|
||||
const tabId = queryStore.createTab(props.connection.id, props.database, row.name);
|
||||
queryStore.updateSql(
|
||||
tabId,
|
||||
buildTableSelectSql({
|
||||
await buildTableSelectSql({
|
||||
databaseType: props.connection.db_type,
|
||||
schema: row.schema || selectedSchema.value,
|
||||
tableName: row.name,
|
||||
@@ -321,14 +329,6 @@ function openNewQuery(row: ObjectBrowserRow) {
|
||||
);
|
||||
}
|
||||
|
||||
function qualifiedName(row: ObjectBrowserRow): string {
|
||||
return qualifiedTableName({
|
||||
databaseType: props.connection.db_type,
|
||||
schema: row.schema || selectedSchema.value,
|
||||
tableName: row.name,
|
||||
});
|
||||
}
|
||||
|
||||
function requestDrop(row: ObjectBrowserRow) {
|
||||
dropTarget.value = row;
|
||||
showDropConfirm.value = true;
|
||||
@@ -338,29 +338,42 @@ function requestRename(row: ObjectBrowserRow) {
|
||||
renameTarget.value = row;
|
||||
renameInput.value = row.name;
|
||||
renameError.value = "";
|
||||
renamePreviewSqlText.value = "";
|
||||
showRenameDialog.value = true;
|
||||
}
|
||||
|
||||
function renamePreviewSql() {
|
||||
let renamePreviewRequestId = 0;
|
||||
|
||||
async function refreshRenamePreviewSql() {
|
||||
const requestId = ++renamePreviewRequestId;
|
||||
const row = renameTarget.value;
|
||||
const newName = renameInput.value.trim();
|
||||
if (!row || !newName || newName === row.name) return "";
|
||||
if (!showRenameDialog.value || !row || !newName || newName === row.name) {
|
||||
renamePreviewSqlText.value = "";
|
||||
return;
|
||||
}
|
||||
if (supportsSourceBackedRoutineRename(props.connection.db_type, row.type as ObjectSourceKind)) {
|
||||
return `-- Recreate ${row.type} from source, then drop the original object.`;
|
||||
renamePreviewSqlText.value = `-- Recreate ${row.type} from source, then drop the original object.`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
return buildRenameObjectSql({
|
||||
const sql = await buildRenameObjectSql({
|
||||
databaseType: props.connection.db_type,
|
||||
objectType: row.type,
|
||||
schema: row.schema || selectedSchema.value,
|
||||
oldName: row.name,
|
||||
newName,
|
||||
});
|
||||
if (requestId === renamePreviewRequestId) renamePreviewSqlText.value = sql;
|
||||
} catch {
|
||||
return "";
|
||||
if (requestId === renamePreviewRequestId) renamePreviewSqlText.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
watch([showRenameDialog, renameTarget, renameInput, selectedSchema], () => {
|
||||
void refreshRenamePreviewSql();
|
||||
});
|
||||
|
||||
async function confirmRename() {
|
||||
const row = renameTarget.value;
|
||||
const newName = renameInput.value.trim();
|
||||
@@ -376,7 +389,7 @@ async function confirmRename() {
|
||||
row.name,
|
||||
row.type as ObjectSourceKind,
|
||||
);
|
||||
const statements = buildRoutineRenameObjectSourceStatements({
|
||||
const statements = await buildRoutineRenameObjectSourceStatements({
|
||||
databaseType: props.connection.db_type,
|
||||
objectType: row.type as ObjectSourceKind,
|
||||
schema,
|
||||
@@ -388,7 +401,7 @@ async function confirmRename() {
|
||||
await api.executeQuery(props.connection.id, props.database, sql, schema);
|
||||
}
|
||||
} else {
|
||||
const sql = buildRenameObjectSql({
|
||||
const sql = await buildRenameObjectSql({
|
||||
databaseType: props.connection.db_type,
|
||||
objectType: row.type,
|
||||
schema,
|
||||
@@ -414,17 +427,14 @@ async function confirmRename() {
|
||||
async function confirmDrop() {
|
||||
if (!dropTarget.value) return;
|
||||
const row = dropTarget.value;
|
||||
const typeSql =
|
||||
row.type === "VIEW"
|
||||
? "VIEW"
|
||||
: row.type === "PROCEDURE"
|
||||
? "PROCEDURE"
|
||||
: row.type === "FUNCTION"
|
||||
? "FUNCTION"
|
||||
: "TABLE";
|
||||
const name = qualifiedName(row);
|
||||
try {
|
||||
await api.executeQuery(props.connection.id, props.database, `DROP ${typeSql} ${name}`);
|
||||
const sql = await buildDropObjectSql({
|
||||
databaseType: props.connection.db_type,
|
||||
objectType: row.type,
|
||||
schema: row.schema || selectedSchema.value,
|
||||
name: row.name,
|
||||
});
|
||||
await api.executeQuery(props.connection.id, props.database, sql);
|
||||
const successKey =
|
||||
row.type === "VIEW"
|
||||
? "contextMenu.dropViewSuccess"
|
||||
@@ -474,10 +484,6 @@ function closeSource() {
|
||||
sourceSaveError.value = "";
|
||||
}
|
||||
|
||||
function quoteIdent(name: string): string {
|
||||
return quoteTableIdentifier(props.connection.db_type, name);
|
||||
}
|
||||
|
||||
async function saveFileContent(content: string, defaultFileName: string, filterName: string, filterExt: string) {
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
@@ -562,10 +568,6 @@ async function exportStructure(row: ObjectBrowserRow) {
|
||||
async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") {
|
||||
try {
|
||||
const schema = row.schema || selectedSchema.value;
|
||||
const qualName =
|
||||
isSchemaAware(props.connection.db_type) && schema
|
||||
? `${quoteIdent(schema)}.${quoteIdent(row.name)}`
|
||||
: quoteIdent(row.name);
|
||||
const queryColumns =
|
||||
props.connection.db_type === "neo4j"
|
||||
? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map(
|
||||
@@ -612,7 +614,13 @@ async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql")
|
||||
return;
|
||||
}
|
||||
|
||||
const content = formatSqlInsert(qualName, result.columns, result.rows, quoteIdent);
|
||||
const content = await formatSqlInsert({
|
||||
databaseType: props.connection.db_type,
|
||||
schema,
|
||||
tableName: row.name,
|
||||
columns: result.columns,
|
||||
rows: result.rows,
|
||||
});
|
||||
await saveFileContent(content, `${row.name}.sql`, "SQL", "sql");
|
||||
toast(t("grid.exported"));
|
||||
} catch (e: any) {
|
||||
@@ -667,25 +675,12 @@ async function confirmDuplicateStructure() {
|
||||
showDuplicateDialog.value = false;
|
||||
try {
|
||||
const schema = row.schema || selectedSchema.value;
|
||||
const source = qualifiedName(row);
|
||||
const target = qualifiedTableName({
|
||||
const sql = await buildDuplicateTableStructureSql({
|
||||
databaseType: props.connection.db_type,
|
||||
schema,
|
||||
tableName: newName,
|
||||
sourceName: row.name,
|
||||
targetName: newName,
|
||||
});
|
||||
let sql: string;
|
||||
const dbType = props.connection.db_type;
|
||||
if (dbType === "mysql") {
|
||||
sql = `CREATE TABLE ${target} LIKE ${source};`;
|
||||
} else if (usesPostgresLikeStructureCopy(dbType)) {
|
||||
sql = `CREATE TABLE ${target} (LIKE ${source} INCLUDING ALL);`;
|
||||
} else if (dbType === "sqlserver") {
|
||||
sql = `SELECT TOP 0 * INTO ${target} FROM ${source};`;
|
||||
} else if (usesFetchFirst(dbType)) {
|
||||
sql = `CREATE TABLE ${target} AS SELECT * FROM ${source} WHERE 1=0`;
|
||||
} else {
|
||||
sql = `CREATE TABLE ${target} AS SELECT * FROM ${source} WHERE 0;`;
|
||||
}
|
||||
await api.executeQuery(props.connection.id, props.database, sql, schema);
|
||||
toast(t("contextMenu.duplicateStructureSuccess", { name: newName }));
|
||||
await reload();
|
||||
@@ -695,15 +690,22 @@ async function confirmDuplicateStructure() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildTruncateTableSql(row: ObjectBrowserRow): string {
|
||||
const name = qualifiedName(row);
|
||||
const dbType = props.connection.db_type;
|
||||
if (dbType === "sqlite" || dbType === "duckdb") return `DELETE FROM ${name};`;
|
||||
return `TRUNCATE TABLE ${name};`;
|
||||
function tableAdminSqlOptions(row: ObjectBrowserRow): TableAdminSqlOptions {
|
||||
return {
|
||||
databaseType: props.connection.db_type,
|
||||
schema: row.schema || selectedSchema.value,
|
||||
tableName: row.name,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshTruncatePreviewSql(row: ObjectBrowserRow) {
|
||||
truncatePreviewSql.value = "";
|
||||
truncatePreviewSql.value = await buildTruncateTableSql(tableAdminSqlOptions(row)).catch(() => "");
|
||||
}
|
||||
|
||||
function requestTruncateTable(row: ObjectBrowserRow) {
|
||||
truncateTarget.value = row;
|
||||
void refreshTruncatePreviewSql(row);
|
||||
showTruncateConfirm.value = true;
|
||||
}
|
||||
|
||||
@@ -711,7 +713,8 @@ async function confirmTruncateTable() {
|
||||
const row = truncateTarget.value;
|
||||
if (!row) return;
|
||||
try {
|
||||
await api.executeQuery(props.connection.id, props.database, buildTruncateTableSql(row));
|
||||
const sql = truncatePreviewSql.value || (await buildTruncateTableSql(tableAdminSqlOptions(row)));
|
||||
await api.executeQuery(props.connection.id, props.database, sql);
|
||||
toast(t("contextMenu.truncateTableSuccess", { name: row.name }));
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
@@ -719,12 +722,14 @@ async function confirmTruncateTable() {
|
||||
truncateTarget.value = null;
|
||||
}
|
||||
|
||||
function buildEmptyTableSql(row: ObjectBrowserRow): string {
|
||||
return `DELETE FROM ${qualifiedName(row)};`;
|
||||
async function refreshEmptyPreviewSql(row: ObjectBrowserRow) {
|
||||
emptyPreviewSql.value = "";
|
||||
emptyPreviewSql.value = await buildEmptyTableSql(tableAdminSqlOptions(row)).catch(() => "");
|
||||
}
|
||||
|
||||
function requestEmptyTable(row: ObjectBrowserRow) {
|
||||
emptyTarget.value = row;
|
||||
void refreshEmptyPreviewSql(row);
|
||||
showEmptyConfirm.value = true;
|
||||
}
|
||||
|
||||
@@ -732,7 +737,8 @@ async function confirmEmptyTable() {
|
||||
const row = emptyTarget.value;
|
||||
if (!row) return;
|
||||
try {
|
||||
await api.executeQuery(props.connection.id, props.database, buildEmptyTableSql(row));
|
||||
const sql = emptyPreviewSql.value || (await buildEmptyTableSql(tableAdminSqlOptions(row)));
|
||||
await api.executeQuery(props.connection.id, props.database, sql);
|
||||
toast(t("contextMenu.emptyTableSuccess", { name: row.name }));
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
@@ -771,7 +777,7 @@ async function saveSource() {
|
||||
sourceSaving.value = true;
|
||||
sourceSaveError.value = "";
|
||||
try {
|
||||
const statements = buildExecutableObjectSourceStatements({
|
||||
const statements = await buildExecutableObjectSourceStatements({
|
||||
databaseType: props.connection.db_type,
|
||||
objectType: row.type as ObjectSourceKind,
|
||||
schema,
|
||||
@@ -1300,9 +1306,9 @@ watch(
|
||||
@keydown.enter.prevent="confirmRename"
|
||||
/>
|
||||
<pre
|
||||
v-if="renamePreviewSql()"
|
||||
v-if="renamePreviewSqlText"
|
||||
class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap"
|
||||
>{{ renamePreviewSql() }}</pre
|
||||
>{{ renamePreviewSqlText }}</pre
|
||||
>
|
||||
<p v-if="renameError" class="text-sm text-destructive">{{ renameError }}</p>
|
||||
</div>
|
||||
@@ -1319,7 +1325,7 @@ watch(
|
||||
v-model:open="showTruncateConfirm"
|
||||
:title="t('contextMenu.confirmTruncateTableTitle')"
|
||||
:message="t('contextMenu.confirmTruncateTableMessage', { name: truncateTarget?.name ?? '' })"
|
||||
:sql="truncateTarget ? buildTruncateTableSql(truncateTarget) : ''"
|
||||
:sql="truncatePreviewSql"
|
||||
:confirm-label="t('contextMenu.truncateTable')"
|
||||
@confirm="confirmTruncateTable"
|
||||
/>
|
||||
@@ -1328,7 +1334,7 @@ watch(
|
||||
v-model:open="showEmptyConfirm"
|
||||
:title="t('contextMenu.confirmEmptyTableTitle')"
|
||||
:message="t('contextMenu.confirmEmptyTableMessage', { name: emptyTarget?.name ?? '' })"
|
||||
:sql="emptyTarget ? buildEmptyTableSql(emptyTarget) : ''"
|
||||
:sql="emptyPreviewSql"
|
||||
:confirm-label="t('contextMenu.emptyTable')"
|
||||
@confirm="confirmEmptyTable"
|
||||
/>
|
||||
|
||||
@@ -216,7 +216,7 @@ async function searchTable(task: SearchTableTask, databaseType: DatabaseType, cu
|
||||
try {
|
||||
const schema = task.schema || props.prefillDatabase;
|
||||
const columns = await api.getColumns(props.prefillConnectionId, props.prefillDatabase, schema, task.table.name);
|
||||
const query = buildDatabaseSearchSql({
|
||||
const query = await buildDatabaseSearchSql({
|
||||
databaseType,
|
||||
schema: task.schema,
|
||||
tableName: task.table.name,
|
||||
@@ -228,13 +228,19 @@ async function searchTable(task: SearchTableTask, databaseType: DatabaseType, cu
|
||||
|
||||
const executionId = makeExecutionId();
|
||||
currentExecutionId.value = executionId;
|
||||
const result = await api.executeQuery(props.prefillConnectionId, props.prefillDatabase, query.sql, executionId);
|
||||
const result = await api.executeQuery(
|
||||
props.prefillConnectionId,
|
||||
props.prefillDatabase,
|
||||
query.sql,
|
||||
undefined,
|
||||
executionId,
|
||||
);
|
||||
if (currentExecutionId.value === executionId) currentExecutionId.value = "";
|
||||
if (currentRun !== runId || cancelled.value) return;
|
||||
|
||||
result.rows.forEach((row, rowIndex) => {
|
||||
for (const [rowIndex, row] of result.rows.entries()) {
|
||||
const matchedColumns = findMatchedSearchColumns(result.columns, row, columns, keyword.value);
|
||||
const whereInput = buildSearchResultWhere({
|
||||
const whereInput = await buildSearchResultWhere({
|
||||
databaseType,
|
||||
columns,
|
||||
resultColumns: result.columns,
|
||||
@@ -249,7 +255,7 @@ async function searchTable(task: SearchTableTask, databaseType: DatabaseType, cu
|
||||
preview: rowPreview(result.columns, row, matchedColumns),
|
||||
whereInput,
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!cancelled.value) {
|
||||
tableErrors.value.push({ tableName: tableLabel, message: error?.message || String(error) });
|
||||
|
||||
@@ -65,14 +65,9 @@ import * as api from "@/lib/api";
|
||||
import { uuid } from "@/lib/utils";
|
||||
import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
|
||||
import { canTreeNodeShowExpander, treeItemPaddingLeft } from "@/lib/sidebarTreeItemLayout";
|
||||
import {
|
||||
buildTableSelectSql,
|
||||
qualifiedTableName as buildQualifiedTableName,
|
||||
quoteTableIdentifier,
|
||||
} from "@/lib/tableSelectSql";
|
||||
import { buildTableSelectSql } from "@/lib/tableSelectSql";
|
||||
import { editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/tableEditing";
|
||||
import {
|
||||
isSchemaAware,
|
||||
supportsDatabaseCreation,
|
||||
supportsDatabaseSearch,
|
||||
supportsFieldLineage,
|
||||
@@ -82,9 +77,7 @@ import {
|
||||
supportsTableImport,
|
||||
supportsTableTruncate,
|
||||
supportsTableStructureEditing,
|
||||
usesPostgresLikeStructureCopy,
|
||||
usesTreeSchemaMode,
|
||||
usesFetchFirst,
|
||||
} from "@/lib/databaseCapabilities";
|
||||
import {
|
||||
objectSourceKindForTreeNode,
|
||||
@@ -101,6 +94,18 @@ import {
|
||||
supportsCreateDatabaseCharset,
|
||||
uniqueDuckDbAttachedDatabaseName,
|
||||
} from "@/lib/createDatabaseSql";
|
||||
import {
|
||||
buildCreateSchemaSql,
|
||||
buildDropDatabaseSql,
|
||||
buildDropObjectSql,
|
||||
buildDropSchemaSql,
|
||||
buildDropTableSql,
|
||||
buildDuplicateTableStructureSql,
|
||||
buildEmptyTableSql,
|
||||
buildTruncateTableSql,
|
||||
type DropObjectSqlOptions,
|
||||
type TableAdminSqlOptions,
|
||||
} from "@/lib/dbAdminSql";
|
||||
import { buildRenameObjectSql, supportsObjectRename, type RenameableObjectType } from "@/lib/objectRenameSql";
|
||||
import { buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/objectSourceEditor";
|
||||
import { buildViewDdl } from "@/lib/viewDdl";
|
||||
@@ -151,18 +156,6 @@ function currentDatabaseType(): DatabaseType | undefined {
|
||||
return props.node.connectionId ? connectionStore.getConfig(props.node.connectionId)?.db_type : undefined;
|
||||
}
|
||||
|
||||
function quoteIdent(name: string): string {
|
||||
return quoteTableIdentifier(currentDatabaseType(), name);
|
||||
}
|
||||
|
||||
function qualifiedTableName(tableName: string, schema?: string): string {
|
||||
return buildQualifiedTableName({
|
||||
databaseType: currentDatabaseType(),
|
||||
schema,
|
||||
tableName,
|
||||
});
|
||||
}
|
||||
|
||||
function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
||||
switch (node.type) {
|
||||
case "connection":
|
||||
@@ -527,7 +520,7 @@ async function openData() {
|
||||
});
|
||||
const pks = editablePrimaryKeys(config.db_type, columns);
|
||||
const limit = settingsStore.editorSettings.pageSize;
|
||||
const sql = buildTableSelectSql({
|
||||
const sql = await buildTableSelectSql({
|
||||
databaseType: config.db_type,
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
@@ -660,6 +653,13 @@ const showTruncateTableConfirm = ref(false);
|
||||
const showRenameObjectDialog = ref(false);
|
||||
const renameObjectName = ref("");
|
||||
const renameObjectError = ref("");
|
||||
const renameObjectPreviewSql = ref("");
|
||||
const dropTablePreviewSql = ref("");
|
||||
const emptyTablePreviewSql = ref("");
|
||||
const truncateTablePreviewSql = ref("");
|
||||
const dropObjectPreviewSql = ref("");
|
||||
const dropDatabasePreviewSql = ref("");
|
||||
const dropSchemaPreviewSql = ref("");
|
||||
const showDuplicateDialog = ref(false);
|
||||
const duplicateTableName = ref("");
|
||||
|
||||
@@ -676,11 +676,21 @@ const showDropSchemaConfirm = ref(false);
|
||||
// --- Procedure / Function Management ---
|
||||
const showDropObjectConfirm = ref(false);
|
||||
|
||||
function buildDropObjectSql(): string {
|
||||
function dropObjectSqlOptions(): DropObjectSqlOptions | null {
|
||||
const node = props.node;
|
||||
const keyword = node.type === "procedure" ? "PROCEDURE" : "FUNCTION";
|
||||
const name = qualifiedTableName(node.label, node.schema);
|
||||
return `DROP ${keyword} ${name};`;
|
||||
if (node.type !== "procedure" && node.type !== "function") return null;
|
||||
return {
|
||||
databaseType: currentDatabaseType(),
|
||||
objectType: node.type === "procedure" ? "PROCEDURE" : "FUNCTION",
|
||||
schema: node.schema,
|
||||
name: node.label,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshDropObjectPreviewSql() {
|
||||
const options = dropObjectSqlOptions();
|
||||
dropObjectPreviewSql.value = "";
|
||||
dropObjectPreviewSql.value = options ? await buildDropObjectSql(options).catch(() => "") : "";
|
||||
}
|
||||
|
||||
function viewObjectSource() {
|
||||
@@ -695,7 +705,7 @@ function viewObjectSource() {
|
||||
connectionStore.activeConnectionId = node.connectionId!;
|
||||
return api.getObjectSource(node.connectionId!, node.database!, schema, node.label, objectType as any);
|
||||
})
|
||||
.then((result) => {
|
||||
.then(async (result) => {
|
||||
const tabId = queryStore.createTab(node.connectionId!, node.database!, node.label);
|
||||
queryStore.updateSql(tabId, result.source);
|
||||
queryStore.setObjectSource(tabId, {
|
||||
@@ -719,9 +729,9 @@ function viewObjectDdl() {
|
||||
connectionStore.activeConnectionId = node.connectionId!;
|
||||
return api.getObjectSource(node.connectionId!, node.database!, schema, node.label, "VIEW");
|
||||
})
|
||||
.then((result) => {
|
||||
.then(async (result) => {
|
||||
const connection = connectionStore.getConfig(node.connectionId!);
|
||||
const ddl = buildViewDdl({
|
||||
const ddl = await buildViewDdl({
|
||||
databaseType: connection?.db_type,
|
||||
schema,
|
||||
name: node.label,
|
||||
@@ -736,6 +746,7 @@ function viewObjectDdl() {
|
||||
}
|
||||
|
||||
function requestDropObject() {
|
||||
void refreshDropObjectPreviewSql();
|
||||
showDropObjectConfirm.value = true;
|
||||
}
|
||||
|
||||
@@ -759,29 +770,52 @@ const canRenameObject = computed(() => {
|
||||
function openRenameObjectDialog() {
|
||||
renameObjectName.value = props.node.label;
|
||||
renameObjectError.value = "";
|
||||
renameObjectPreviewSql.value = "";
|
||||
showRenameObjectDialog.value = true;
|
||||
}
|
||||
|
||||
function buildRenameObjectPreviewSql(): string {
|
||||
let renameObjectPreviewRequestId = 0;
|
||||
|
||||
async function refreshRenameObjectPreviewSql() {
|
||||
const requestId = ++renameObjectPreviewRequestId;
|
||||
const objectType = nodeRenameObjectType();
|
||||
const newName = renameObjectName.value.trim();
|
||||
if (!objectType || !newName || newName === props.node.label) return "";
|
||||
if (!showRenameObjectDialog.value || !objectType || !newName || newName === props.node.label) {
|
||||
renameObjectPreviewSql.value = "";
|
||||
return;
|
||||
}
|
||||
if (supportsSourceBackedRoutineRename(currentDatabaseType(), objectType as any)) {
|
||||
return `-- Recreate ${objectType} from source, then drop the original object.`;
|
||||
renameObjectPreviewSql.value = `-- Recreate ${objectType} from source, then drop the original object.`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
return buildRenameObjectSql({
|
||||
const sql = await buildRenameObjectSql({
|
||||
databaseType: currentDatabaseType(),
|
||||
objectType,
|
||||
schema: props.node.schema,
|
||||
oldName: props.node.label,
|
||||
newName,
|
||||
});
|
||||
if (requestId === renameObjectPreviewRequestId) renameObjectPreviewSql.value = sql;
|
||||
} catch {
|
||||
return "";
|
||||
if (requestId === renameObjectPreviewRequestId) renameObjectPreviewSql.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
showRenameObjectDialog,
|
||||
renameObjectName,
|
||||
() => props.node.label,
|
||||
() => props.node.schema,
|
||||
() => props.node.type,
|
||||
() => currentDatabaseType(),
|
||||
],
|
||||
() => {
|
||||
void refreshRenameObjectPreviewSql();
|
||||
},
|
||||
);
|
||||
|
||||
async function confirmRenameObject() {
|
||||
const node = props.node;
|
||||
const objectType = nodeRenameObjectType();
|
||||
@@ -794,7 +828,7 @@ async function confirmRenameObject() {
|
||||
if (supportsSourceBackedRoutineRename(dbType, objectType as any)) {
|
||||
const schema = node.schema || node.database;
|
||||
const source = await api.getObjectSource(node.connectionId, node.database, schema, node.label, objectType as any);
|
||||
const statements = buildRoutineRenameObjectSourceStatements({
|
||||
const statements = await buildRoutineRenameObjectSourceStatements({
|
||||
databaseType: dbType!,
|
||||
objectType: objectType as any,
|
||||
schema,
|
||||
@@ -806,7 +840,7 @@ async function confirmRenameObject() {
|
||||
await api.executeQuery(node.connectionId, node.database, sql, schema);
|
||||
}
|
||||
} else {
|
||||
const sql = buildRenameObjectSql({
|
||||
const sql = await buildRenameObjectSql({
|
||||
databaseType: dbType,
|
||||
objectType,
|
||||
schema: node.schema,
|
||||
@@ -826,9 +860,12 @@ async function confirmRenameObject() {
|
||||
async function confirmDropObject() {
|
||||
const node = props.node;
|
||||
if (!node.connectionId || !node.database) return;
|
||||
const options = dropObjectSqlOptions();
|
||||
if (!options) return;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
await api.executeQuery(node.connectionId, node.database, buildDropObjectSql(), node.schema);
|
||||
const sql = dropObjectPreviewSql.value || (await buildDropObjectSql(options));
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
const msgKey = node.type === "procedure" ? "contextMenu.dropProcedureSuccess" : "contextMenu.dropFunctionSuccess";
|
||||
toast(t(msgKey, { name: node.label }), 3000);
|
||||
await refreshTableList(node);
|
||||
@@ -884,22 +921,31 @@ const canDropSchema = computed(() => {
|
||||
return props.node.type === "schema" && usesTreeSchemaMode(config?.db_type);
|
||||
});
|
||||
|
||||
function buildDropTableSql(): string {
|
||||
return `DROP TABLE ${qualifiedTableName(props.node.label, props.node.schema)};`;
|
||||
function tableAdminSqlOptions(): TableAdminSqlOptions {
|
||||
return {
|
||||
databaseType: currentDatabaseType(),
|
||||
schema: props.node.schema,
|
||||
tableName: props.node.label,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmptyTableSql(): string {
|
||||
return `DELETE FROM ${qualifiedTableName(props.node.label, props.node.schema)};`;
|
||||
async function refreshDropTablePreviewSql() {
|
||||
dropTablePreviewSql.value = "";
|
||||
dropTablePreviewSql.value = await buildDropTableSql(tableAdminSqlOptions()).catch(() => "");
|
||||
}
|
||||
|
||||
function buildTruncateTableSql(): string {
|
||||
const dbType = currentDatabaseType();
|
||||
const name = qualifiedTableName(props.node.label, props.node.schema);
|
||||
if (dbType === "sqlite" || dbType === "duckdb") return `DELETE FROM ${name};`;
|
||||
return `TRUNCATE TABLE ${name};`;
|
||||
async function refreshEmptyTablePreviewSql() {
|
||||
emptyTablePreviewSql.value = "";
|
||||
emptyTablePreviewSql.value = await buildEmptyTableSql(tableAdminSqlOptions()).catch(() => "");
|
||||
}
|
||||
|
||||
async function refreshTruncateTablePreviewSql() {
|
||||
truncateTablePreviewSql.value = "";
|
||||
truncateTablePreviewSql.value = await buildTruncateTableSql(tableAdminSqlOptions()).catch(() => "");
|
||||
}
|
||||
|
||||
function dropTable() {
|
||||
void refreshDropTablePreviewSql();
|
||||
showDropTableConfirm.value = true;
|
||||
}
|
||||
|
||||
@@ -913,7 +959,8 @@ async function confirmDropTable() {
|
||||
if (!node.connectionId || !node.database) return;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
await api.executeQuery(node.connectionId, node.database, buildDropTableSql(), node.schema);
|
||||
const sql = dropTablePreviewSql.value || (await buildDropTableSql(tableAdminSqlOptions()));
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
toast(t("contextMenu.dropTableSuccess", { name: node.label }), 3000);
|
||||
connectionStore.removeTreeNode(node.id);
|
||||
} catch (e: any) {
|
||||
@@ -922,6 +969,7 @@ async function confirmDropTable() {
|
||||
}
|
||||
|
||||
function emptyTable() {
|
||||
void refreshEmptyTablePreviewSql();
|
||||
showEmptyTableConfirm.value = true;
|
||||
}
|
||||
|
||||
@@ -930,7 +978,8 @@ async function confirmEmptyTable() {
|
||||
if (!node.connectionId || !node.database) return;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
await api.executeQuery(node.connectionId, node.database, buildEmptyTableSql(), node.schema);
|
||||
const sql = emptyTablePreviewSql.value || (await buildEmptyTableSql(tableAdminSqlOptions()));
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
toast(t("contextMenu.emptyTableSuccess", { name: node.label }), 3000);
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
@@ -938,6 +987,7 @@ async function confirmEmptyTable() {
|
||||
}
|
||||
|
||||
function truncateTable() {
|
||||
void refreshTruncateTablePreviewSql();
|
||||
showTruncateTableConfirm.value = true;
|
||||
}
|
||||
|
||||
@@ -946,22 +996,28 @@ async function confirmTruncateTable() {
|
||||
if (!node.connectionId || !node.database) return;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
await api.executeQuery(node.connectionId, node.database, buildTruncateTableSql(), node.schema);
|
||||
const sql = truncateTablePreviewSql.value || (await buildTruncateTableSql(tableAdminSqlOptions()));
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
toast(t("contextMenu.truncateTableSuccess", { name: node.label }), 3000);
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function buildDropDatabaseSql(): string {
|
||||
return `DROP DATABASE ${quoteIdent(props.node.label)};`;
|
||||
async function refreshDropDatabasePreviewSql() {
|
||||
dropDatabasePreviewSql.value = "";
|
||||
dropDatabasePreviewSql.value = await buildDropDatabaseSql({
|
||||
databaseType: currentDatabaseType(),
|
||||
name: props.node.label,
|
||||
}).catch(() => "");
|
||||
}
|
||||
|
||||
function buildDropSchemaSql(): string {
|
||||
const dbType = currentDatabaseType();
|
||||
const name = quoteIdent(props.node.label);
|
||||
if (dbType === "postgres" || dbType === "gaussdb") return `DROP SCHEMA ${name} CASCADE;`;
|
||||
return `DROP SCHEMA ${name};`;
|
||||
async function refreshDropSchemaPreviewSql() {
|
||||
dropSchemaPreviewSql.value = "";
|
||||
dropSchemaPreviewSql.value = await buildDropSchemaSql({
|
||||
databaseType: currentDatabaseType(),
|
||||
name: props.node.label,
|
||||
}).catch(() => "");
|
||||
}
|
||||
|
||||
async function openCreateDatabase() {
|
||||
@@ -1006,7 +1062,7 @@ async function createDuckDbAttachedDatabaseFile() {
|
||||
duckDbAttachedDatabaseNameFromPath(path),
|
||||
existingDatabases.map((database) => database.name),
|
||||
);
|
||||
await api.executeQuery(node.connectionId, "", buildDuckDbAttachDatabaseSql(path, name));
|
||||
await api.executeQuery(node.connectionId, "", await buildDuckDbAttachDatabaseSql(path, name));
|
||||
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
if (config) {
|
||||
@@ -1031,7 +1087,7 @@ async function confirmCreateDatabase() {
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
const sql = buildCreateDatabaseSql({
|
||||
const sql = await buildCreateDatabaseSql({
|
||||
databaseType: config?.db_type,
|
||||
driverProfile: config?.driver_profile,
|
||||
name,
|
||||
@@ -1047,6 +1103,7 @@ async function confirmCreateDatabase() {
|
||||
}
|
||||
|
||||
function dropDatabase() {
|
||||
void refreshDropDatabasePreviewSql();
|
||||
showDropDatabaseConfirm.value = true;
|
||||
}
|
||||
|
||||
@@ -1077,7 +1134,13 @@ async function confirmDropDatabase() {
|
||||
if (!node.connectionId) return;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
await api.executeQuery(node.connectionId, "", buildDropDatabaseSql());
|
||||
const sql =
|
||||
dropDatabasePreviewSql.value ||
|
||||
(await buildDropDatabaseSql({
|
||||
databaseType: currentDatabaseType(),
|
||||
name: node.label,
|
||||
}));
|
||||
await api.executeQuery(node.connectionId, "", sql);
|
||||
toast(t("contextMenu.dropDatabaseSuccess", { name: node.label }), 3000);
|
||||
await connectionStore.loadDatabases(node.connectionId, { force: true });
|
||||
} catch (e: any) {
|
||||
@@ -1097,7 +1160,10 @@ async function confirmCreateSchema() {
|
||||
showCreateSchemaDialog.value = false;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const sql = `CREATE SCHEMA ${quoteIdent(name)};`;
|
||||
const sql = await buildCreateSchemaSql({
|
||||
databaseType: currentDatabaseType(),
|
||||
name,
|
||||
});
|
||||
await api.executeQuery(node.connectionId, node.database, sql);
|
||||
toast(t("contextMenu.createSchemaSuccess", { name }), 3000);
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
@@ -1112,6 +1178,7 @@ async function confirmCreateSchema() {
|
||||
}
|
||||
|
||||
function dropSchema() {
|
||||
void refreshDropSchemaPreviewSql();
|
||||
showDropSchemaConfirm.value = true;
|
||||
}
|
||||
|
||||
@@ -1120,7 +1187,13 @@ async function confirmDropSchema() {
|
||||
if (!node.connectionId || !node.database) return;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
await api.executeQuery(node.connectionId, node.database, buildDropSchemaSql());
|
||||
const sql =
|
||||
dropSchemaPreviewSql.value ||
|
||||
(await buildDropSchemaSql({
|
||||
databaseType: currentDatabaseType(),
|
||||
name: node.label,
|
||||
}));
|
||||
await api.executeQuery(node.connectionId, node.database, sql);
|
||||
toast(t("contextMenu.dropSchemaSuccess", { name: node.label }), 3000);
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
if (config?.db_type === "sqlserver") {
|
||||
@@ -1145,21 +1218,12 @@ async function confirmDuplicateStructure() {
|
||||
showDuplicateDialog.value = false;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const dbType = currentDatabaseType();
|
||||
const source = qualifiedTableName(node.label, node.schema);
|
||||
const target = qualifiedTableName(newName, node.schema);
|
||||
let sql: string;
|
||||
if (dbType === "mysql") {
|
||||
sql = `CREATE TABLE ${target} LIKE ${source};`;
|
||||
} else if (usesPostgresLikeStructureCopy(dbType)) {
|
||||
sql = `CREATE TABLE ${target} (LIKE ${source} INCLUDING ALL);`;
|
||||
} else if (dbType === "sqlserver") {
|
||||
sql = `SELECT TOP 0 * INTO ${target} FROM ${source};`;
|
||||
} else if (usesFetchFirst(dbType)) {
|
||||
sql = `CREATE TABLE ${target} AS SELECT * FROM ${source} WHERE 1=0`;
|
||||
} else {
|
||||
sql = `CREATE TABLE ${target} AS SELECT * FROM ${source} WHERE 0;`;
|
||||
}
|
||||
const sql = await buildDuplicateTableStructureSql({
|
||||
databaseType: currentDatabaseType(),
|
||||
schema: node.schema,
|
||||
sourceName: node.label,
|
||||
targetName: newName,
|
||||
});
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
toast(t("contextMenu.duplicateStructureSuccess", { name: newName }), 3000);
|
||||
await refreshTableList(node);
|
||||
@@ -1221,10 +1285,6 @@ async function exportData(format: "csv" | "json" | "sql") {
|
||||
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
const qualifiedName =
|
||||
isSchemaAware(config.db_type) && node.schema
|
||||
? `${quoteIdent(node.schema)}.${quoteIdent(node.label)}`
|
||||
: quoteIdent(node.label);
|
||||
const queryColumns =
|
||||
config.db_type === "neo4j"
|
||||
? (await api.getColumns(connectionId, database, node.schema || database, node.label)).map(
|
||||
@@ -1271,7 +1331,13 @@ async function exportData(format: "csv" | "json" | "sql") {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = formatSqlInsert(qualifiedName, result.columns, result.rows, quoteIdent);
|
||||
const content = await formatSqlInsert({
|
||||
databaseType: config.db_type,
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns: result.columns,
|
||||
rows: result.rows,
|
||||
});
|
||||
await saveFileContent(content, `${node.label}.sql`, "SQL", "sql");
|
||||
toast(t("grid.exported"));
|
||||
} catch (e: any) {
|
||||
@@ -2236,9 +2302,9 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
|
||||
@keydown.enter.prevent="confirmRenameObject"
|
||||
/>
|
||||
<pre
|
||||
v-if="buildRenameObjectPreviewSql()"
|
||||
v-if="renameObjectPreviewSql"
|
||||
class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap"
|
||||
>{{ buildRenameObjectPreviewSql() }}</pre
|
||||
>{{ renameObjectPreviewSql }}</pre
|
||||
>
|
||||
<p v-if="renameObjectError" class="text-sm text-destructive">{{ renameObjectError }}</p>
|
||||
</div>
|
||||
@@ -2290,7 +2356,7 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
|
||||
v-model:open="showDropTableConfirm"
|
||||
:title="t('contextMenu.confirmDropTableTitle')"
|
||||
:message="t('contextMenu.confirmDropTableMessage', { name: node.label })"
|
||||
:sql="buildDropTableSql()"
|
||||
:sql="dropTablePreviewSql"
|
||||
:confirm-label="t('contextMenu.dropTable')"
|
||||
@confirm="confirmDropTable"
|
||||
/>
|
||||
@@ -2299,7 +2365,7 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
|
||||
v-model:open="showEmptyTableConfirm"
|
||||
:title="t('contextMenu.confirmEmptyTableTitle')"
|
||||
:message="t('contextMenu.confirmEmptyTableMessage', { name: node.label })"
|
||||
:sql="buildEmptyTableSql()"
|
||||
:sql="emptyTablePreviewSql"
|
||||
:confirm-label="t('contextMenu.emptyTable')"
|
||||
@confirm="confirmEmptyTable"
|
||||
/>
|
||||
@@ -2308,7 +2374,7 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
|
||||
v-model:open="showTruncateTableConfirm"
|
||||
:title="t('contextMenu.confirmTruncateTableTitle')"
|
||||
:message="t('contextMenu.confirmTruncateTableMessage', { name: node.label })"
|
||||
:sql="buildTruncateTableSql()"
|
||||
:sql="truncateTablePreviewSql"
|
||||
:confirm-label="t('contextMenu.truncateTable')"
|
||||
@confirm="confirmTruncateTable"
|
||||
/>
|
||||
@@ -2323,7 +2389,7 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
|
||||
? t('contextMenu.confirmDropProcedureMessage', { name: node.label })
|
||||
: t('contextMenu.confirmDropFunctionMessage', { name: node.label })
|
||||
"
|
||||
:sql="buildDropObjectSql()"
|
||||
:sql="dropObjectPreviewSql"
|
||||
:confirm-label="node.type === 'procedure' ? t('contextMenu.dropProcedure') : t('contextMenu.dropFunction')"
|
||||
@confirm="confirmDropObject"
|
||||
/>
|
||||
@@ -2390,7 +2456,7 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
|
||||
v-model:open="showDropDatabaseConfirm"
|
||||
:title="t('contextMenu.confirmDropDatabaseTitle')"
|
||||
:message="t('contextMenu.confirmDropDatabaseMessage', { name: node.label })"
|
||||
:sql="buildDropDatabaseSql()"
|
||||
:sql="dropDatabasePreviewSql"
|
||||
:confirm-label="t('contextMenu.dropDatabase')"
|
||||
@confirm="confirmDropDatabase"
|
||||
/>
|
||||
@@ -2427,7 +2493,7 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
|
||||
v-model:open="showDropSchemaConfirm"
|
||||
:title="t('contextMenu.confirmDropSchemaTitle')"
|
||||
:message="t('contextMenu.confirmDropSchemaMessage', { name: node.label })"
|
||||
:sql="buildDropSchemaSql()"
|
||||
:sql="dropSchemaPreviewSql"
|
||||
:confirm-label="t('contextMenu.dropSchema')"
|
||||
@confirm="confirmDropSchema"
|
||||
/>
|
||||
|
||||
@@ -33,12 +33,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import {
|
||||
buildTableStructureChangeSql,
|
||||
buildCreateTableSql,
|
||||
type EditableStructureColumn,
|
||||
type EditableStructureIndex,
|
||||
} from "@/lib/tableStructureEditorSql";
|
||||
import { type EditableStructureColumn, type EditableStructureIndex } from "@/lib/tableStructureEditorSql";
|
||||
import { getTableStructureCapabilities } from "@/lib/tableStructureCapabilities";
|
||||
import {
|
||||
buildStructureTargetLabel,
|
||||
@@ -68,9 +63,12 @@ const emit = defineEmits<{
|
||||
const activeTab = ref("columns");
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const sqlPreviewLoading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const columns = ref<EditableStructureColumn[]>([]);
|
||||
const indexes = ref<EditableStructureIndex[]>([]);
|
||||
const pendingStatements = ref<string[]>([]);
|
||||
const warnings = ref<string[]>([]);
|
||||
const foreignKeys = ref<ForeignKeyInfo[]>([]);
|
||||
const triggers = ref<TriggerInfo[]>([]);
|
||||
|
||||
@@ -131,30 +129,44 @@ const targetLabel = computed(() =>
|
||||
),
|
||||
);
|
||||
|
||||
const changeSql = computed(() => {
|
||||
if (isCreateMode.value) {
|
||||
return buildCreateTableSql({
|
||||
databaseType: databaseType.value,
|
||||
schema: props.prefillSchema,
|
||||
tableName: newTableName.value,
|
||||
columns: columns.value,
|
||||
indexes: indexes.value,
|
||||
});
|
||||
let sqlPreviewRequestId = 0;
|
||||
|
||||
async function refreshSqlPreview() {
|
||||
const requestId = ++sqlPreviewRequestId;
|
||||
if (!open.value) {
|
||||
pendingStatements.value = [];
|
||||
warnings.value = [];
|
||||
return;
|
||||
}
|
||||
return buildTableStructureChangeSql({
|
||||
sqlPreviewLoading.value = true;
|
||||
const options = {
|
||||
databaseType: databaseType.value,
|
||||
schema: props.prefillSchema,
|
||||
tableName: props.prefillTable || "",
|
||||
tableName: isCreateMode.value ? newTableName.value : props.prefillTable || "",
|
||||
columns: columns.value,
|
||||
indexes: indexes.value,
|
||||
});
|
||||
});
|
||||
const pendingStatements = computed(() => changeSql.value.statements);
|
||||
const warnings = computed(() => changeSql.value.warnings);
|
||||
};
|
||||
try {
|
||||
const result = isCreateMode.value
|
||||
? await api.buildCreateTableSql(options)
|
||||
: await api.buildTableStructureChangeSql(options);
|
||||
if (requestId !== sqlPreviewRequestId) return;
|
||||
pendingStatements.value = result.statements;
|
||||
warnings.value = result.warnings;
|
||||
} catch (e: any) {
|
||||
if (requestId !== sqlPreviewRequestId) return;
|
||||
pendingStatements.value = [];
|
||||
warnings.value = [e?.message || String(e)];
|
||||
} finally {
|
||||
if (requestId === sqlPreviewRequestId) sqlPreviewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const canApply = computed(
|
||||
() =>
|
||||
!loading.value &&
|
||||
!saving.value &&
|
||||
!sqlPreviewLoading.value &&
|
||||
pendingStatements.value.length > 0 &&
|
||||
warnings.value.length === 0 &&
|
||||
!!props.prefillConnectionId &&
|
||||
@@ -165,9 +177,12 @@ function resetState() {
|
||||
activeTab.value = "columns";
|
||||
loading.value = false;
|
||||
saving.value = false;
|
||||
sqlPreviewLoading.value = false;
|
||||
errorMessage.value = "";
|
||||
columns.value = [];
|
||||
indexes.value = [];
|
||||
pendingStatements.value = [];
|
||||
warnings.value = [];
|
||||
foreignKeys.value = [];
|
||||
triggers.value = [];
|
||||
newTableName.value = "";
|
||||
@@ -375,6 +390,23 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
[
|
||||
open,
|
||||
isCreateMode,
|
||||
databaseType,
|
||||
() => props.prefillSchema,
|
||||
() => props.prefillTable,
|
||||
newTableName,
|
||||
columns,
|
||||
indexes,
|
||||
],
|
||||
() => {
|
||||
void refreshSqlPreview();
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -843,7 +875,10 @@ watch(
|
||||
<div class="flex min-w-0 flex-col rounded-md border">
|
||||
<div class="flex items-center justify-between border-b px-2 py-1.5 text-[11px] font-medium">
|
||||
<span>{{ t("structureEditor.sqlPreview") }}</span>
|
||||
<Badge variant="secondary">{{ pendingStatements.length }}</Badge>
|
||||
<Badge variant="secondary">
|
||||
<Loader2 v-if="sqlPreviewLoading" class="h-3 w-3 animate-spin" />
|
||||
<span v-else>{{ pendingStatements.length }}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-2">
|
||||
<div v-if="warnings.length" class="mb-2 space-y-1">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
|
||||
import { editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/tableEditing";
|
||||
import { buildSortedQuerySql } from "@/lib/queryResultSort";
|
||||
import * as api from "@/lib/api";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
|
||||
@@ -24,7 +24,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
function buildTableSql(
|
||||
tab: QueryTab,
|
||||
options: { orderBy?: string; limit?: number; offset?: number; whereInput?: string } = {},
|
||||
): string {
|
||||
): Promise<string> {
|
||||
const config = connectionStore.getConfig(tab.connectionId);
|
||||
const primaryKeys = tab.tableMeta ? editablePrimaryKeys(config?.db_type, tab.tableMeta.columns) : [];
|
||||
if (tab.tableMeta && primaryKeys.join("\0") !== tab.tableMeta.primaryKeys.join("\0")) {
|
||||
@@ -67,7 +67,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
if (!tab) return;
|
||||
if (tab.mode === "data" && tab.tableMeta) {
|
||||
tab.whereInput = whereInput ?? "";
|
||||
queryStore.updateSql(tab.id, buildTableSql(tab, { whereInput, orderBy, limit, offset }));
|
||||
queryStore.updateSql(tab.id, await buildTableSql(tab, { whereInput, orderBy, limit, offset }));
|
||||
await queryStore.executeCurrentTab();
|
||||
return;
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
|
||||
if (!tab.tableMeta) return;
|
||||
tab.whereInput = whereInput ?? "";
|
||||
const sql = buildTableSql(tab, { limit, offset, whereInput, orderBy });
|
||||
const sql = await buildTableSql(tab, { limit, offset, whereInput, orderBy });
|
||||
queryStore.updateSql(tab.id, sql);
|
||||
await queryStore.executeCurrentTab();
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
const orderBy = direction
|
||||
? `${config?.db_type === "neo4j" ? `n.${quotedColumn}` : quotedColumn} ${direction.toUpperCase()}`
|
||||
: undefined;
|
||||
const sql = buildTableSql(tab, { orderBy, whereInput });
|
||||
const sql = await buildTableSql(tab, { orderBy, whereInput });
|
||||
queryStore.updateSql(tab.id, sql);
|
||||
await queryStore.executeCurrentTab();
|
||||
return;
|
||||
@@ -144,15 +144,15 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
}
|
||||
|
||||
const config = connectionStore.getConfig(tab.connectionId);
|
||||
const built = buildSortedQuerySql(
|
||||
baseSql,
|
||||
config?.db_type,
|
||||
tab.result?.columns ?? [],
|
||||
const built = await api.buildSortedQuerySql({
|
||||
originalSql: baseSql,
|
||||
databaseType: config?.db_type,
|
||||
resultColumns: tab.result?.columns ?? [],
|
||||
columnIndex,
|
||||
column,
|
||||
direction,
|
||||
);
|
||||
if (!built.ok) {
|
||||
});
|
||||
if (!built.ok || !built.sql) {
|
||||
toast(t("grid.sortUnsupported"), 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
}
|
||||
|
||||
async function copyRowAsInsertStatement(excludePrimaryKeys: boolean) {
|
||||
const statement = buildDataGridCopyInsertStatement({
|
||||
const statement = await buildDataGridCopyInsertStatement({
|
||||
databaseType: databaseType.value,
|
||||
tableMeta: tableMeta.value,
|
||||
columns: columns.value,
|
||||
@@ -191,7 +191,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
|
||||
async function copyRowAsUpdate() {
|
||||
if (!tableMeta.value?.primaryKeys.length) return;
|
||||
const statements = buildDataGridCopyUpdateStatements({
|
||||
const statements = await buildDataGridCopyUpdateStatements({
|
||||
databaseType: databaseType.value,
|
||||
tableMeta: tableMeta.value,
|
||||
columns: columns.value,
|
||||
@@ -206,29 +206,25 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
if (!tableMeta.value?.primaryKeys.length) return false;
|
||||
const rows = updateEligibleRows();
|
||||
if (!rows.length) return false;
|
||||
return (
|
||||
buildDataGridCopyUpdateStatements({
|
||||
databaseType: databaseType.value,
|
||||
tableMeta: tableMeta.value,
|
||||
columns: columns.value,
|
||||
sourceColumns: sourceColumns.value,
|
||||
rows: [rows[0].data],
|
||||
}).length > 0
|
||||
);
|
||||
if (databaseType.value === "neo4j" || databaseType.value === "tdengine") return false;
|
||||
const saveColumns = effectiveColumns(sourceColumns.value, columns.value);
|
||||
const primaryKeys = tableMeta.value.primaryKeys;
|
||||
if (primaryKeys.some((primaryKey) => findColumnIndex(saveColumns, primaryKey) === -1)) return false;
|
||||
const primaryKeySet = new Set(primaryKeys.map(normalizeColumnName));
|
||||
return saveColumns.some((column) => column && !primaryKeySet.has(normalizeColumnName(column)));
|
||||
});
|
||||
|
||||
const canCopyRowAsInsertWithoutPrimaryKeys = computed(() => {
|
||||
if (!tableMeta.value?.primaryKeys.length) return false;
|
||||
const rows = insertEligibleRows();
|
||||
if (!rows.length) return false;
|
||||
return !!buildDataGridCopyInsertStatement({
|
||||
databaseType: databaseType.value,
|
||||
tableMeta: tableMeta.value,
|
||||
columns: columns.value,
|
||||
sourceColumns: sourceColumns.value,
|
||||
rows: [rows[0].data],
|
||||
excludePrimaryKeys: true,
|
||||
});
|
||||
const saveColumns = effectiveColumns(sourceColumns.value, columns.value);
|
||||
const primaryKeySet = new Set(tableMeta.value.primaryKeys.map(normalizeColumnName));
|
||||
const insertableCount = saveColumns.filter(Boolean).length;
|
||||
const insertColumnsCount = saveColumns.filter(
|
||||
(column) => column && !primaryKeySet.has(normalizeColumnName(column)),
|
||||
).length;
|
||||
return insertColumnsCount > 0 && insertColumnsCount < insertableCount;
|
||||
});
|
||||
|
||||
async function copyAll() {
|
||||
@@ -372,3 +368,20 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
copySql,
|
||||
};
|
||||
}
|
||||
|
||||
function effectiveColumns(
|
||||
sourceColumns: Array<string | undefined> | undefined,
|
||||
columns: string[],
|
||||
): Array<string | undefined> {
|
||||
if (!sourceColumns || sourceColumns.length !== columns.length) return columns;
|
||||
return sourceColumns;
|
||||
}
|
||||
|
||||
function findColumnIndex(columns: Array<string | undefined>, target: string): number {
|
||||
const normalizedTarget = normalizeColumnName(target);
|
||||
return columns.findIndex((column) => (column ? normalizeColumnName(column) : "") === normalizedTarget);
|
||||
}
|
||||
|
||||
function normalizeColumnName(name: string): string {
|
||||
return name.toUpperCase();
|
||||
}
|
||||
|
||||
@@ -15,14 +15,8 @@ function getDbType(path: string): "sqlite" | "duckdb" | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDataFileQuery(path: string): string | null {
|
||||
const lower = path.toLowerCase();
|
||||
const escaped = path.replace(/'/g, "''");
|
||||
if (lower.endsWith(".parquet")) return `SELECT * FROM read_parquet('${escaped}') LIMIT 1000`;
|
||||
if (lower.endsWith(".csv")) return `SELECT * FROM read_csv('${escaped}') LIMIT 1000`;
|
||||
if (lower.endsWith(".tsv")) return `SELECT * FROM read_csv('${escaped}', delim='\\t') LIMIT 1000`;
|
||||
if (lower.endsWith(".json")) return `SELECT * FROM read_json('${escaped}') LIMIT 1000`;
|
||||
return null;
|
||||
function getDataFileQuery(path: string): Promise<string | undefined> {
|
||||
return api.buildDroppedFilePreviewSql({ path });
|
||||
}
|
||||
|
||||
export function useFileDrop() {
|
||||
@@ -39,7 +33,7 @@ export function useFileDrop() {
|
||||
for (const path of event.payload.paths) {
|
||||
const name = path.split("/").pop()?.split("\\").pop() || path;
|
||||
|
||||
const dataQuery = getDataFileQuery(path);
|
||||
const dataQuery = await getDataFileQuery(path);
|
||||
if (dataQuery) {
|
||||
const config: ConnectionConfig = {
|
||||
id: uuid(),
|
||||
|
||||
@@ -33,7 +33,7 @@ async function openTableTarget(target: NavigationTarget) {
|
||||
if (config.db_type === "neo4j") {
|
||||
const columns = await api.getColumns(target.connectionId, target.database, querySchema, target.tableName);
|
||||
const primaryKeys = editablePrimaryKeys(config.db_type, columns);
|
||||
const sql = buildTableSelectSql({
|
||||
const sql = await buildTableSelectSql({
|
||||
databaseType: config.db_type,
|
||||
schema: target.schema,
|
||||
tableName: target.tableName,
|
||||
@@ -52,7 +52,7 @@ async function openTableTarget(target: NavigationTarget) {
|
||||
await queryStore.executeTabSql(tabId, sql);
|
||||
return;
|
||||
}
|
||||
const sql = buildTableSelectSql({
|
||||
const sql = await buildTableSelectSql({
|
||||
databaseType: config.db_type,
|
||||
schema: target.schema,
|
||||
tableName: target.tableName,
|
||||
@@ -80,7 +80,7 @@ async function openTableTarget(target: NavigationTarget) {
|
||||
primaryKeys,
|
||||
});
|
||||
if (useRowId || config.db_type === "tdengine") {
|
||||
const newSql = buildTableSelectSql({
|
||||
const newSql = await buildTableSelectSql({
|
||||
databaseType: config.db_type,
|
||||
schema: target.schema,
|
||||
tableName: target.tableName,
|
||||
|
||||
@@ -35,6 +35,7 @@ export function useSqlExecution(deps: {
|
||||
activeTab: ComputedRef<QueryTab | undefined>;
|
||||
activeConnection: ComputedRef<ConnectionConfig | undefined>;
|
||||
executableSql: ComputedRef<string>;
|
||||
resolveExecutableSql?: () => Promise<string>;
|
||||
activeOutputView: Ref<"result" | "explain" | "chart">;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -47,9 +48,13 @@ export function useSqlExecution(deps: {
|
||||
const pendingDangerSql = ref("");
|
||||
const showDangerDialog = ref(false);
|
||||
|
||||
function tryExecute(sqlOverride?: string) {
|
||||
async function resolvedExecutableSql(): Promise<string> {
|
||||
return deps.resolveExecutableSql ? await deps.resolveExecutableSql() : deps.executableSql.value;
|
||||
}
|
||||
|
||||
async function tryExecute(sqlOverride?: string) {
|
||||
const tab = deps.activeTab.value;
|
||||
const sql = sqlOverride ?? deps.executableSql.value;
|
||||
const sql = sqlOverride ?? (await resolvedExecutableSql());
|
||||
if (!tab || !sql.trim()) return;
|
||||
if (isDangerousSql(sql)) {
|
||||
dangerSql.value = sql;
|
||||
@@ -60,7 +65,8 @@ export function useSqlExecution(deps: {
|
||||
}
|
||||
}
|
||||
|
||||
async function doExecute(sql = deps.executableSql.value) {
|
||||
async function doExecute(sql?: string) {
|
||||
sql ??= await resolvedExecutableSql();
|
||||
const tab = deps.activeTab.value;
|
||||
if (!tab || !sql.trim()) return;
|
||||
deps.activeOutputView.value = "result";
|
||||
@@ -106,7 +112,7 @@ export function useSqlExecution(deps: {
|
||||
|
||||
async function tryExplain(sqlOverride?: string) {
|
||||
const tab = deps.activeTab.value;
|
||||
const sql = sqlOverride ?? deps.executableSql.value;
|
||||
const sql = sqlOverride ?? (await resolvedExecutableSql());
|
||||
if (!tab || !sql.trim()) {
|
||||
toast(t("explain.emptySql"));
|
||||
return;
|
||||
@@ -123,10 +129,10 @@ export function useSqlExecution(deps: {
|
||||
if (current?.explainError) toast(current.explainError, 5000);
|
||||
}
|
||||
|
||||
function onDangerConfirm() {
|
||||
const sql = pendingDangerSql.value || deps.executableSql.value;
|
||||
async function onDangerConfirm() {
|
||||
const sql = pendingDangerSql.value || (await resolvedExecutableSql());
|
||||
pendingDangerSql.value = "";
|
||||
doExecute(sql);
|
||||
await doExecute(sql);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -90,8 +90,42 @@ export const executeInTransaction = forward("executeInTransaction");
|
||||
export const cancelQuery = forward("cancelQuery");
|
||||
export const closeQuerySession = forward("closeQuerySession");
|
||||
export const analyzeSqlReferences = forward("analyzeSqlReferences");
|
||||
export const findStatementAtCursor = forward("findStatementAtCursor");
|
||||
export const prepareQueryPaginationExecutionPlan = forward("prepareQueryPaginationExecutionPlan");
|
||||
export const buildSortedQuerySql = forward("buildSortedQuerySql");
|
||||
export const buildExplainSql = forward("buildExplainSql");
|
||||
export const buildDroppedFilePreviewSql = forward("buildDroppedFilePreviewSql");
|
||||
export const buildTableSelectSql = forward("buildTableSelectSql");
|
||||
export const buildDatabaseSearchSql = forward("buildDatabaseSearchSql");
|
||||
export const buildSearchResultWhere = forward("buildSearchResultWhere");
|
||||
export const buildRenameObjectSql = forward("buildRenameObjectSql");
|
||||
export const buildCreateDatabaseSql = forward("buildCreateDatabaseSql");
|
||||
export const buildDuckDbAttachDatabaseSql = forward("buildDuckDbAttachDatabaseSql");
|
||||
export const buildDropObjectSql = forward("buildDropObjectSql");
|
||||
export const buildDropTableSql = forward("buildDropTableSql");
|
||||
export const buildEmptyTableSql = forward("buildEmptyTableSql");
|
||||
export const buildTruncateTableSql = forward("buildTruncateTableSql");
|
||||
export const buildDropDatabaseSql = forward("buildDropDatabaseSql");
|
||||
export const buildCreateSchemaSql = forward("buildCreateSchemaSql");
|
||||
export const buildDropSchemaSql = forward("buildDropSchemaSql");
|
||||
export const buildDuplicateTableStructureSql = forward("buildDuplicateTableStructureSql");
|
||||
export const buildExecutableObjectSourceStatements = forward("buildExecutableObjectSourceStatements");
|
||||
export const buildExecutableObjectSourceSql = forward("buildExecutableObjectSourceSql");
|
||||
export const buildRoutineRenameObjectSourceStatements = forward("buildRoutineRenameObjectSourceStatements");
|
||||
export const buildViewDdlSql = forward("buildViewDdlSql");
|
||||
export const buildTableStructureChangeSql = forward("buildTableStructureChangeSql");
|
||||
export const buildCreateTableSql = forward("buildCreateTableSql");
|
||||
export const analyzeEditableQueryEditability = forward("analyzeEditableQueryEditability");
|
||||
export const prepareDataGridSave = forward("prepareDataGridSave");
|
||||
export const buildDataGridCopyUpdateStatements = forward("buildDataGridCopyUpdateStatements");
|
||||
export const buildDataGridCopyInsertStatement = forward("buildDataGridCopyInsertStatement");
|
||||
export const buildDataGridContextFilterCondition = forward("buildDataGridContextFilterCondition");
|
||||
export const buildDataGridColumnValueFilterCondition = forward("buildDataGridColumnValueFilterCondition");
|
||||
export const buildDataGridCountSql = forward("buildDataGridCountSql");
|
||||
export const buildHiveTablePropertiesSql = forward("buildHiveTablePropertiesSql");
|
||||
export const buildExportInsertStatements = forward("buildExportInsertStatements");
|
||||
export const buildExportSqlInsert = forward("buildExportSqlInsert");
|
||||
export const buildDatabaseSqlExport = forward("buildDatabaseSqlExport");
|
||||
export const prepareDataCompare = forward("prepareDataCompare");
|
||||
export const prepareDataCompareFromTables = forward("prepareDataCompareFromTables");
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { quoteTableIdentifier } from "./tableSelectSql";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
const MYSQL_COMPATIBLE_PROFILES = new Set([
|
||||
"mysql",
|
||||
@@ -27,19 +27,12 @@ export function supportsCreateDatabaseCharset(databaseType?: DatabaseType, drive
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCreateDatabaseSql(options: CreateDatabaseSqlOptions): string {
|
||||
const name = quoteTableIdentifier(options.databaseType, options.name);
|
||||
const charset = cleanSqlOption(options.charset);
|
||||
const collation = cleanSqlOption(options.collation);
|
||||
if (!supportsCreateDatabaseCharset(options.databaseType, options.driverProfile) || !charset) {
|
||||
return `CREATE DATABASE ${name};`;
|
||||
}
|
||||
const collateClause = collation ? ` COLLATE ${collation}` : "";
|
||||
return `CREATE DATABASE ${name} CHARACTER SET ${charset}${collateClause};`;
|
||||
export function buildCreateDatabaseSql(options: CreateDatabaseSqlOptions): Promise<string> {
|
||||
return api.buildCreateDatabaseSql(options);
|
||||
}
|
||||
|
||||
export function buildDuckDbAttachDatabaseSql(path: string, name: string): string {
|
||||
return `ATTACH ${quoteSqlString(path)} AS ${quoteTableIdentifier("duckdb", name)};`;
|
||||
export function buildDuckDbAttachDatabaseSql(path: string, name: string): Promise<string> {
|
||||
return api.buildDuckDbAttachDatabaseSql(path, name);
|
||||
}
|
||||
|
||||
export function duckDbAttachedDatabaseNameFromPath(path: string): string {
|
||||
@@ -61,11 +54,3 @@ export function uniqueDuckDbAttachedDatabaseName(baseName: string, existingNames
|
||||
}
|
||||
return `${baseName}_${Date.now()}`;
|
||||
}
|
||||
|
||||
function cleanSqlOption(value: string | undefined): string {
|
||||
return value?.trim().replace(/[;\s]+/g, "") ?? "";
|
||||
}
|
||||
|
||||
function quoteSqlString(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import type { ColumnInfo, DatabaseType } from "@/types/database";
|
||||
import { formatGridSqlLiteral } from "@/lib/dataGridSql";
|
||||
import { quoteTableIdentifier, normalizeWhereInput } from "@/lib/tableSelectSql";
|
||||
import { buildDataGridColumnValueFilterCondition } from "@/lib/dataGridSql";
|
||||
import { normalizeWhereInput } from "@/lib/tableSelectSql";
|
||||
|
||||
export function buildColumnValueFilterCondition(options: {
|
||||
databaseType?: DatabaseType;
|
||||
columnName: string;
|
||||
columnInfo?: Pick<ColumnInfo, "data_type">;
|
||||
rawValue: string;
|
||||
}): string | undefined {
|
||||
const text = options.rawValue.trim();
|
||||
if (!text) return undefined;
|
||||
|
||||
const column = columnFilterRef(options.databaseType, options.columnName);
|
||||
if (/^null$/i.test(text)) return `${column} IS NULL`;
|
||||
return `${column} = ${formatGridSqlLiteral(parseTypedFilterValue(text, options.columnInfo), options.databaseType, options.columnInfo)}`;
|
||||
}): Promise<string | undefined> {
|
||||
return buildDataGridColumnValueFilterCondition({
|
||||
databaseType: options.databaseType,
|
||||
columnName: options.columnName,
|
||||
columnInfo: options.columnInfo
|
||||
? {
|
||||
name: options.columnName,
|
||||
data_type: options.columnInfo.data_type,
|
||||
is_nullable: true,
|
||||
}
|
||||
: undefined,
|
||||
rawValue: options.rawValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function appendColumnValueFilterCondition(
|
||||
@@ -24,38 +30,3 @@ export function appendColumnValueFilterCondition(
|
||||
const existing = normalizeWhereInput(whereInput);
|
||||
return existing ? `(${existing}) AND (${condition})` : condition;
|
||||
}
|
||||
|
||||
function columnFilterRef(databaseType: DatabaseType | undefined, columnName: string): string {
|
||||
const quoted = quoteTableIdentifier(databaseType, columnName);
|
||||
return databaseType === "neo4j" ? `n.${quoted}` : quoted;
|
||||
}
|
||||
|
||||
function parseTypedFilterValue(text: string, columnInfo: Pick<ColumnInfo, "data_type"> | undefined) {
|
||||
const unquoted = unwrapMatchingQuotes(text);
|
||||
const dataType = columnInfo?.data_type.toLowerCase() ?? "";
|
||||
if (isBooleanType(dataType) && /^(true|false)$/i.test(unquoted)) return /^true$/i.test(unquoted);
|
||||
if ((isNumericType(dataType) || !dataType) && isNumericLiteral(unquoted)) return Number(unquoted);
|
||||
return unquoted;
|
||||
}
|
||||
|
||||
function unwrapMatchingQuotes(text: string): string {
|
||||
if (text.length < 2) return text;
|
||||
const first = text[0];
|
||||
const last = text[text.length - 1];
|
||||
if ((first === "'" && last === "'") || (first === '"' && last === '"')) return text.slice(1, -1);
|
||||
return text;
|
||||
}
|
||||
|
||||
function isNumericType(dataType: string): boolean {
|
||||
return /\b(int|integer|bigint|smallint|tinyint|mediumint|serial|number|numeric|decimal|float|double|real|money)\b/i.test(
|
||||
dataType,
|
||||
);
|
||||
}
|
||||
|
||||
function isBooleanType(dataType: string): boolean {
|
||||
return /\b(bool|boolean|bit)\b/i.test(dataType);
|
||||
}
|
||||
|
||||
function isNumericLiteral(text: string): boolean {
|
||||
return /^[+-]?(?:\d+|\d*\.\d+)(?:e[+-]?\d+)?$/i.test(text) && Number.isFinite(Number(text));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { DBX_ROWID_COLUMN } from "./tableEditing.ts";
|
||||
import { qualifiedTableName, quoteTableIdentifier } from "./tableSelectSql.ts";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
export type GridCellValue = string | number | boolean | null;
|
||||
|
||||
@@ -15,12 +14,11 @@ export interface DataGridColumnInfo {
|
||||
name: string;
|
||||
data_type: string;
|
||||
is_nullable: boolean;
|
||||
is_primary_key?: boolean;
|
||||
column_default?: string | null;
|
||||
extra?: string | null;
|
||||
}
|
||||
|
||||
export type GridSqlLiteralColumnInfo = Pick<DataGridColumnInfo, "data_type">;
|
||||
|
||||
export interface DataGridSaveStatementOptions {
|
||||
databaseType?: DatabaseType;
|
||||
tableMeta: DataGridTableMeta;
|
||||
@@ -49,108 +47,72 @@ export interface DataGridCopyInsertStatementOptions {
|
||||
excludePrimaryKeys?: boolean;
|
||||
}
|
||||
|
||||
export function buildDataGridCopyUpdateStatements(options: DataGridCopyUpdateStatementOptions): string[] {
|
||||
if (options.databaseType === "neo4j" || options.databaseType === "tdengine") return [];
|
||||
const primaryKeys = options.tableMeta.primaryKeys;
|
||||
if (primaryKeys.length === 0) return [];
|
||||
export type DataGridContextFilterMode =
|
||||
| "equals"
|
||||
| "not-equals"
|
||||
| "is-null"
|
||||
| "is-not-null"
|
||||
| "like"
|
||||
| "not-like"
|
||||
| "less-than"
|
||||
| "greater-than";
|
||||
|
||||
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
|
||||
const columnInfo = columnInfoByName(options.tableMeta.columns);
|
||||
const primaryKeyIndexes = primaryKeys.map((primaryKey) => findColumnIndex(saveColumns, primaryKey));
|
||||
if (primaryKeyIndexes.some((index) => index === -1)) return [];
|
||||
|
||||
const primaryKeySet = new Set(primaryKeys.map((primaryKey) => normalizeColumnName(primaryKey)));
|
||||
const writableIndexes = saveColumns
|
||||
.map((column, index) => ({ column, index }))
|
||||
.filter((entry): entry is { column: string; index: number } => !!entry.column)
|
||||
.filter((entry) => !primaryKeySet.has(normalizeColumnName(entry.column)))
|
||||
.filter((entry) => !isOracleRowId(options.databaseType, entry.column));
|
||||
|
||||
if (writableIndexes.length === 0) return [];
|
||||
|
||||
const table = qualifiedTableName({
|
||||
databaseType: options.databaseType,
|
||||
schema: options.tableMeta.schema,
|
||||
tableName: options.tableMeta.tableName,
|
||||
});
|
||||
|
||||
const statements: string[] = [];
|
||||
for (const row of options.rows) {
|
||||
if (primaryKeyIndexes.some((index) => row[index] === null || row[index] === undefined)) continue;
|
||||
const sets = writableIndexes
|
||||
.map(
|
||||
({ column, index }) =>
|
||||
`${quoteIdent(options.databaseType, column)} = ${formatGridSqlLiteral(
|
||||
row[index],
|
||||
options.databaseType,
|
||||
columnInfo.get(normalizeColumnName(column)),
|
||||
)}`,
|
||||
)
|
||||
.join(", ");
|
||||
if (!sets) continue;
|
||||
const where = primaryKeys
|
||||
.map((primaryKey, index) =>
|
||||
buildColumnPredicate(
|
||||
options.databaseType,
|
||||
primaryKey,
|
||||
row[primaryKeyIndexes[index]],
|
||||
columnInfo.get(normalizeColumnName(primaryKey)),
|
||||
),
|
||||
)
|
||||
.join(" AND ");
|
||||
statements.push(`UPDATE ${table} SET ${sets} WHERE ${where};`);
|
||||
}
|
||||
|
||||
return statements;
|
||||
export interface DataGridContextFilterConditionOptions {
|
||||
databaseType?: DatabaseType;
|
||||
columnName: string;
|
||||
mode: DataGridContextFilterMode;
|
||||
value: GridCellValue;
|
||||
columnInfo?: DataGridColumnInfo;
|
||||
}
|
||||
|
||||
export function buildDataGridCopyInsertStatement(options: DataGridCopyInsertStatementOptions): string | undefined {
|
||||
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
|
||||
const columnInfo = columnInfoByName(options.tableMeta?.columns);
|
||||
const primaryKeySet = new Set(
|
||||
(options.tableMeta?.primaryKeys ?? []).map((primaryKey) => normalizeColumnName(primaryKey)),
|
||||
);
|
||||
const insertableColumns = saveColumns
|
||||
.map((column, index) => ({ column, index }))
|
||||
.filter((entry): entry is { column: string; index: number } => !!entry.column)
|
||||
.filter((entry) => !isOracleRowId(options.databaseType, entry.column));
|
||||
const insertColumns = insertableColumns.filter(
|
||||
(entry) => !options.excludePrimaryKeys || !primaryKeySet.has(normalizeColumnName(entry.column)),
|
||||
);
|
||||
|
||||
if (options.excludePrimaryKeys && insertColumns.length === insertableColumns.length) return undefined;
|
||||
if (insertColumns.length === 0 || options.rows.length === 0) return undefined;
|
||||
|
||||
const table = options.tableMeta
|
||||
? qualifiedTableName({
|
||||
databaseType: options.databaseType,
|
||||
schema: options.tableMeta.schema,
|
||||
tableName: options.tableMeta.tableName,
|
||||
})
|
||||
: "table_name";
|
||||
const columns = insertColumns.map((entry) => quoteIdent(options.databaseType, entry.column)).join(", ");
|
||||
const valueRows = options.rows.map(
|
||||
(row) =>
|
||||
`(${insertColumns
|
||||
.map(({ column, index }) =>
|
||||
formatGridSqlLiteral(row[index], options.databaseType, columnInfo.get(normalizeColumnName(column))),
|
||||
)
|
||||
.join(", ")})`,
|
||||
);
|
||||
|
||||
return `INSERT INTO ${table} (${columns}) VALUES${valueRows.length === 1 ? " " : "\n"}${valueRows.join(",\n")};`;
|
||||
export interface DataGridColumnValueFilterConditionOptions {
|
||||
databaseType?: DatabaseType;
|
||||
columnName: string;
|
||||
columnInfo?: DataGridColumnInfo;
|
||||
rawValue: string;
|
||||
}
|
||||
|
||||
function effectiveColumns(
|
||||
sourceColumns: Array<string | undefined> | undefined,
|
||||
columns: string[],
|
||||
): Array<string | undefined> {
|
||||
if (!sourceColumns || sourceColumns.length !== columns.length) return columns;
|
||||
return sourceColumns;
|
||||
export interface DataGridCountSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
whereInput?: string;
|
||||
}
|
||||
|
||||
function columnInfoByName(columns: DataGridColumnInfo[] | undefined): Map<string, DataGridColumnInfo> {
|
||||
return new Map((columns ?? []).map((column) => [normalizeColumnName(column.name), column]));
|
||||
export interface HiveTablePropertiesSqlOptions {
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
propertyName: string;
|
||||
}
|
||||
|
||||
export function buildDataGridCopyUpdateStatements(options: DataGridCopyUpdateStatementOptions): Promise<string[]> {
|
||||
return api.buildDataGridCopyUpdateStatements(options);
|
||||
}
|
||||
|
||||
export function buildDataGridCopyInsertStatement(
|
||||
options: DataGridCopyInsertStatementOptions,
|
||||
): Promise<string | undefined> {
|
||||
return api.buildDataGridCopyInsertStatement(options);
|
||||
}
|
||||
|
||||
export function buildDataGridContextFilterCondition(
|
||||
options: DataGridContextFilterConditionOptions,
|
||||
): Promise<string | undefined> {
|
||||
return api.buildDataGridContextFilterCondition(options);
|
||||
}
|
||||
|
||||
export function buildDataGridColumnValueFilterCondition(
|
||||
options: DataGridColumnValueFilterConditionOptions,
|
||||
): Promise<string | undefined> {
|
||||
return api.buildDataGridColumnValueFilterCondition(options);
|
||||
}
|
||||
|
||||
export function buildDataGridCountSql(options: DataGridCountSqlOptions): Promise<string> {
|
||||
return api.buildDataGridCountSql(options);
|
||||
}
|
||||
|
||||
export function buildHiveTablePropertiesSql(options: HiveTablePropertiesSqlOptions): Promise<string> {
|
||||
return api.buildHiveTablePropertiesSql(options);
|
||||
}
|
||||
|
||||
export function normalizeDataGridSaveError(databaseType: DatabaseType | undefined, error: unknown): string {
|
||||
@@ -160,115 +122,3 @@ export function normalizeDataGridSaveError(databaseType: DatabaseType | undefine
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
export function formatGridSqlLiteral(
|
||||
value: GridCellValue,
|
||||
databaseType?: DatabaseType,
|
||||
columnInfo?: GridSqlLiteralColumnInfo,
|
||||
): string {
|
||||
if (value === null || value === undefined) return "NULL";
|
||||
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
const text = String(value);
|
||||
if (text === "") return databaseType === "sqlserver" ? "N''" : "''";
|
||||
const literalText =
|
||||
databaseType === "tdengine"
|
||||
? formatTdengineTimestampLiteralText(text)
|
||||
: isMysqlDatetimeLiteralDatabase(databaseType) && (!columnInfo || isTemporalColumnType(columnInfo.data_type))
|
||||
? formatMysqlTemporalLiteralText(text, columnInfo?.data_type)
|
||||
: text;
|
||||
const escaped = `'${literalText.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`;
|
||||
return databaseType === "sqlserver" ? `N${escaped}` : escaped;
|
||||
}
|
||||
|
||||
function isMysqlDatetimeLiteralDatabase(databaseType: DatabaseType | undefined): boolean {
|
||||
return (
|
||||
databaseType === "mysql" ||
|
||||
databaseType === "doris" ||
|
||||
databaseType === "starrocks" ||
|
||||
databaseType === "goldendb" ||
|
||||
databaseType === "sundb"
|
||||
);
|
||||
}
|
||||
|
||||
function formatMysqlTemporalLiteralText(text: string, dataType: string | undefined): string {
|
||||
const match = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/i.exec(text);
|
||||
if (!match) return text;
|
||||
const kind = temporalColumnKind(dataType);
|
||||
if (kind === "date") return match[1];
|
||||
if (kind === "time") return `${match[2]}${normalizeMysqlFractionalSeconds(match[3])}`;
|
||||
return `${match[1]} ${match[2]}${normalizeMysqlFractionalSeconds(match[3])}`;
|
||||
}
|
||||
|
||||
function normalizeMysqlFractionalSeconds(fraction: string | undefined): string {
|
||||
if (!fraction) return "";
|
||||
return fraction.length > 7 ? fraction.slice(0, 7) : fraction;
|
||||
}
|
||||
|
||||
function isTemporalColumnType(dataType: string | undefined): boolean {
|
||||
return temporalColumnKind(dataType) !== undefined;
|
||||
}
|
||||
|
||||
function temporalColumnKind(dataType: string | undefined): "date" | "time" | "datetime" | undefined {
|
||||
const base = (dataType ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/[(:\s]/)[0];
|
||||
if (base === "date") return "date";
|
||||
if (base === "time") return "time";
|
||||
if (base === "datetime" || base === "timestamp") return "datetime";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatTdengineTimestampLiteralText(text: string): string {
|
||||
const match = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d{1,9})?$/.exec(text);
|
||||
if (!match) return text;
|
||||
return `${match[1]}T${match[2]}${normalizeFractionalSeconds(match[3])}${localTimezoneOffsetSuffix(text)}`;
|
||||
}
|
||||
|
||||
function normalizeFractionalSeconds(fraction: string | undefined): string {
|
||||
if (!fraction) return ".000";
|
||||
return fraction.length >= 4 ? fraction.slice(0, 4) : fraction.padEnd(4, "0");
|
||||
}
|
||||
|
||||
function localTimezoneOffsetSuffix(text: string): string {
|
||||
const date = new Date(text.replace(" ", "T"));
|
||||
const offsetMinutes = Number.isNaN(date.getTime()) ? new Date().getTimezoneOffset() : date.getTimezoneOffset();
|
||||
const sign = offsetMinutes <= 0 ? "+" : "-";
|
||||
const abs = Math.abs(offsetMinutes);
|
||||
const hours = String(Math.floor(abs / 60)).padStart(2, "0");
|
||||
const minutes = String(abs % 60).padStart(2, "0");
|
||||
return `${sign}${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function buildColumnPredicate(
|
||||
databaseType: DatabaseType | undefined,
|
||||
column: string,
|
||||
value: GridCellValue,
|
||||
columnInfo?: DataGridColumnInfo,
|
||||
): string {
|
||||
const ident = predicateIdent(databaseType, column);
|
||||
if (value === null || value === undefined) return `${ident} IS NULL`;
|
||||
return `${ident} = ${formatGridSqlLiteral(value, databaseType, columnInfo)}`;
|
||||
}
|
||||
|
||||
function isOracleRowId(databaseType: DatabaseType | undefined, name: string | undefined): boolean {
|
||||
return databaseType === "oracle" && name?.toUpperCase() === DBX_ROWID_COLUMN;
|
||||
}
|
||||
|
||||
function findColumnIndex(columns: Array<string | undefined>, target: string): number {
|
||||
const normalizedTarget = normalizeColumnName(target);
|
||||
return columns.findIndex((column) => (column ? normalizeColumnName(column) : "") === normalizedTarget);
|
||||
}
|
||||
|
||||
function normalizeColumnName(name: string): string {
|
||||
return name.toUpperCase();
|
||||
}
|
||||
|
||||
function predicateIdent(databaseType: DatabaseType | undefined, name: string): string {
|
||||
return isOracleRowId(databaseType, name) ? "ROWIDTOCHAR(ROWID)" : quoteIdent(databaseType, name);
|
||||
}
|
||||
|
||||
function quoteIdent(databaseType: DatabaseType | undefined, name: string): string {
|
||||
return quoteTableIdentifier(databaseType, name);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { DatabaseType, QueryResult } from "../types/database.ts";
|
||||
import * as api from "./api.ts";
|
||||
import { buildTableSelectSql } from "./tableSelectSql.ts";
|
||||
import { uuid } from "./utils.ts";
|
||||
|
||||
type SqlValue = QueryResult["rows"][number][number];
|
||||
|
||||
export const DATABASE_EXPORT_ROW_LIMIT = 10_000;
|
||||
export const DATABASE_EXPORT_PAGE_SIZE = 500;
|
||||
export const DATABASE_EXPORT_INSERT_BATCH_SIZE = 100;
|
||||
|
||||
export interface ExportedTableSql {
|
||||
displayName: string;
|
||||
qualifiedTableName: string;
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
tableName?: string;
|
||||
qualifiedTableName?: string;
|
||||
ddl?: string;
|
||||
columns: string[];
|
||||
rows: QueryResult["rows"];
|
||||
@@ -19,13 +21,22 @@ export interface ExportedTableSql {
|
||||
|
||||
export interface BuildDatabaseSqlExportOptions {
|
||||
databaseName: string;
|
||||
exportedAt?: Date;
|
||||
exportedAt?: Date | string;
|
||||
tables: ExportedTableSql[];
|
||||
quoteIdentifier: (name: string) => string;
|
||||
rowLimitPerTable?: number;
|
||||
insertBatchSize?: number;
|
||||
}
|
||||
|
||||
export interface BuildExportInsertStatementsOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
tableName?: string;
|
||||
qualifiedTableName?: string;
|
||||
columns: string[];
|
||||
rows: QueryResult["rows"];
|
||||
batchSize?: number;
|
||||
}
|
||||
|
||||
export interface BuildExportPageSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
@@ -34,36 +45,11 @@ export interface BuildExportPageSqlOptions {
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export function formatSqlLiteral(value: SqlValue): string {
|
||||
if (value === null) return "NULL";
|
||||
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL";
|
||||
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
export function buildInsertStatements(options: BuildExportInsertStatementsOptions): Promise<string[]> {
|
||||
return api.buildExportInsertStatements(options);
|
||||
}
|
||||
|
||||
export function buildInsertStatements(
|
||||
table: Pick<ExportedTableSql, "qualifiedTableName" | "columns" | "rows"> & {
|
||||
quoteIdentifier: (name: string) => string;
|
||||
batchSize?: number;
|
||||
},
|
||||
): string[] {
|
||||
if (table.columns.length === 0 || table.rows.length === 0) return [];
|
||||
const batchSize = Math.max(1, table.batchSize ?? DATABASE_EXPORT_INSERT_BATCH_SIZE);
|
||||
const columns = table.columns.map((column) => table.quoteIdentifier(column)).join(", ");
|
||||
const statements: string[] = [];
|
||||
|
||||
for (let start = 0; start < table.rows.length; start += batchSize) {
|
||||
const values = table.rows
|
||||
.slice(start, start + batchSize)
|
||||
.map((row) => `(${row.map(formatSqlLiteral).join(", ")})`)
|
||||
.join(", ");
|
||||
statements.push(`INSERT INTO ${table.qualifiedTableName} (${columns}) VALUES ${values};`);
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
export function buildExportPageSql(options: BuildExportPageSqlOptions): string {
|
||||
export async function buildExportPageSql(options: BuildExportPageSqlOptions): Promise<string> {
|
||||
return buildTableSelectSql({
|
||||
databaseType: options.databaseType,
|
||||
schema: options.schema,
|
||||
@@ -77,43 +63,9 @@ export function generateDatabaseExportId(): string {
|
||||
return uuid();
|
||||
}
|
||||
|
||||
export function buildDatabaseSqlExport(options: BuildDatabaseSqlExportOptions): string {
|
||||
const exportedAt = options.exportedAt ?? new Date();
|
||||
const rowLimit = options.rowLimitPerTable ?? DATABASE_EXPORT_ROW_LIMIT;
|
||||
const insertBatchSize = options.insertBatchSize ?? DATABASE_EXPORT_INSERT_BATCH_SIZE;
|
||||
const lines: string[] = [
|
||||
"-- DBX database export",
|
||||
`-- Database: ${options.databaseName}`,
|
||||
`-- Exported at: ${exportedAt.toISOString()}`,
|
||||
`-- Row limit per table: ${rowLimit}`,
|
||||
"",
|
||||
];
|
||||
|
||||
for (const table of options.tables) {
|
||||
if (table.ddl?.trim()) {
|
||||
lines.push(`-- Structure for ${table.displayName}`);
|
||||
lines.push(table.ddl.trim().replace(/;*$/, ";"));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push(`-- Data for ${table.displayName}`);
|
||||
lines.push(
|
||||
table.truncated
|
||||
? `-- Exported rows: ${table.rows.length} (truncated at ${rowLimit})`
|
||||
: `-- Exported rows: ${table.rows.length}`,
|
||||
);
|
||||
const inserts = buildInsertStatements({
|
||||
...table,
|
||||
quoteIdentifier: options.quoteIdentifier,
|
||||
batchSize: insertBatchSize,
|
||||
});
|
||||
if (inserts.length > 0) {
|
||||
lines.push(...inserts);
|
||||
} else {
|
||||
lines.push("-- No rows");
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
export function buildDatabaseSqlExport(options: BuildDatabaseSqlExportOptions): Promise<string> {
|
||||
return api.buildDatabaseSqlExport({
|
||||
...options,
|
||||
exportedAt: options.exportedAt instanceof Date ? options.exportedAt.toISOString() : options.exportedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ColumnInfo, DatabaseType } from "../types/database.ts";
|
||||
import { qualifiedTableName, quoteTableIdentifier } from "./tableSelectSql.ts";
|
||||
import { usesFetchFirst } from "@/lib/databaseCapabilities";
|
||||
import * as api from "./api.ts";
|
||||
|
||||
export interface DatabaseSearchSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
@@ -50,101 +49,14 @@ function cleanTerm(term: string): string {
|
||||
return term.trim();
|
||||
}
|
||||
|
||||
function clampLimit(limit?: number): number {
|
||||
if (!Number.isFinite(limit)) return 20;
|
||||
return Math.min(100, Math.max(1, Math.trunc(limit ?? 20)));
|
||||
}
|
||||
|
||||
function parseNumericTerm(term: string): string | null {
|
||||
const trimmed = cleanTerm(term);
|
||||
if (!/^[+-]?(?:\d+|\d+\.\d+|\.\d+)$/.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function sqlStringLiteral(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function likePattern(term: string): string {
|
||||
return `%${cleanTerm(term)
|
||||
.toLowerCase()
|
||||
.replace(/[~%_]/g, (match) => `~${match}`)}%`;
|
||||
}
|
||||
|
||||
function textCastExpression(databaseType: DatabaseType | undefined, identifier: string): string {
|
||||
if (databaseType === "mysql") return `LOWER(CAST(${identifier} AS CHAR))`;
|
||||
if (databaseType === "sqlserver") return `LOWER(CAST(${identifier} AS NVARCHAR(MAX)))`;
|
||||
if (databaseType === "oracle") return `LOWER(CAST(${identifier} AS VARCHAR2(4000)))`;
|
||||
if (databaseType === "clickhouse") return `lower(toString(${identifier}))`;
|
||||
return `LOWER(CAST(${identifier} AS TEXT))`;
|
||||
}
|
||||
|
||||
function sqlValueLiteral(
|
||||
databaseType: DatabaseType | undefined,
|
||||
column: ColumnInfo | undefined,
|
||||
value: unknown,
|
||||
): string {
|
||||
if (value === null || value === undefined) return "NULL";
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
||||
const stringValue = String(value);
|
||||
if (column && isNumericSearchColumn(column) && parseNumericTerm(stringValue)) {
|
||||
return stringValue.trim();
|
||||
}
|
||||
if (databaseType === "sqlserver") return `N${sqlStringLiteral(stringValue)}`;
|
||||
return sqlStringLiteral(stringValue);
|
||||
}
|
||||
|
||||
export function buildDatabaseSearchSql(options: DatabaseSearchSqlOptions): DatabaseSearchSql | null {
|
||||
const term = cleanTerm(options.term);
|
||||
if (!term) return null;
|
||||
|
||||
const textColumns = options.columns.filter(isTextSearchColumn);
|
||||
const numericTerm = parseNumericTerm(term);
|
||||
const numericColumns = numericTerm ? options.columns.filter(isNumericSearchColumn) : [];
|
||||
const conditions: string[] = [];
|
||||
|
||||
for (const column of textColumns) {
|
||||
const identifier = quoteTableIdentifier(options.databaseType, column.name);
|
||||
conditions.push(
|
||||
`${textCastExpression(options.databaseType, identifier)} LIKE ${sqlStringLiteral(likePattern(term))} ESCAPE '~'`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const column of numericColumns) {
|
||||
const identifier = quoteTableIdentifier(options.databaseType, column.name);
|
||||
conditions.push(`${identifier} = ${numericTerm}`);
|
||||
}
|
||||
|
||||
if (!conditions.length) return null;
|
||||
|
||||
const table = qualifiedTableName({
|
||||
databaseType: options.databaseType,
|
||||
schema: options.schema,
|
||||
tableName: options.tableName,
|
||||
});
|
||||
const where = conditions.join(" OR ");
|
||||
const limit = clampLimit(options.limit);
|
||||
const searchableColumns = [...textColumns, ...numericColumns].map((column) => column.name);
|
||||
|
||||
if (options.databaseType === "sqlserver") {
|
||||
return {
|
||||
sql: `SELECT TOP ${limit} * FROM ${table} WHERE (${where})`,
|
||||
searchableColumns,
|
||||
};
|
||||
}
|
||||
|
||||
if (usesFetchFirst(options.databaseType)) {
|
||||
return {
|
||||
sql: `SELECT * FROM ${table} WHERE (${where}) FETCH FIRST ${limit} ROWS ONLY`,
|
||||
searchableColumns,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sql: `SELECT * FROM ${table} WHERE (${where}) LIMIT ${limit};`,
|
||||
searchableColumns,
|
||||
};
|
||||
export async function buildDatabaseSearchSql(options: DatabaseSearchSqlOptions): Promise<DatabaseSearchSql | null> {
|
||||
return api.buildDatabaseSearchSql(options);
|
||||
}
|
||||
|
||||
function columnByName(columns: ColumnInfo[], name: string): ColumnInfo | undefined {
|
||||
@@ -180,28 +92,6 @@ export function findMatchedSearchColumns(
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function buildSearchResultWhere(options: SearchResultWhereOptions): string {
|
||||
const valueByColumn = new Map<string, unknown>();
|
||||
options.resultColumns.forEach((column, index) => {
|
||||
valueByColumn.set(column.toLowerCase(), options.row[index]);
|
||||
});
|
||||
|
||||
const primaryColumns = options.columns.filter(
|
||||
(column) => column.is_primary_key && valueByColumn.get(column.name.toLowerCase()) !== undefined,
|
||||
);
|
||||
const fallbackColumns = options.columns.filter(
|
||||
(column) =>
|
||||
(options.matchedColumns ?? []).some((name) => name.toLowerCase() === column.name.toLowerCase()) &&
|
||||
valueByColumn.get(column.name.toLowerCase()) !== undefined,
|
||||
);
|
||||
const selectedColumns = (primaryColumns.length ? primaryColumns : fallbackColumns).slice(0, 3);
|
||||
|
||||
return selectedColumns
|
||||
.map((column) => {
|
||||
const identifier = quoteTableIdentifier(options.databaseType, column.name);
|
||||
const value = valueByColumn.get(column.name.toLowerCase());
|
||||
if (value === null || value === undefined) return `${identifier} IS NULL`;
|
||||
return `${identifier} = ${sqlValueLiteral(options.databaseType, column, value)}`;
|
||||
})
|
||||
.join(" AND ");
|
||||
export async function buildSearchResultWhere(options: SearchResultWhereOptions): Promise<string> {
|
||||
return api.buildSearchResultWhere(options);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { DatabaseObjectType, DatabaseType } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
export interface DropObjectSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
objectType: DatabaseObjectType;
|
||||
schema?: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TableAdminSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string | null;
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
export interface DatabaseNameSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SchemaNameSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DuplicateTableStructureSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string | null;
|
||||
sourceName: string;
|
||||
targetName: string;
|
||||
}
|
||||
|
||||
export function buildDropObjectSql(options: DropObjectSqlOptions): Promise<string> {
|
||||
return api.buildDropObjectSql(options);
|
||||
}
|
||||
|
||||
export function buildDropTableSql(options: TableAdminSqlOptions): Promise<string> {
|
||||
return api.buildDropTableSql(options);
|
||||
}
|
||||
|
||||
export function buildEmptyTableSql(options: TableAdminSqlOptions): Promise<string> {
|
||||
return api.buildEmptyTableSql(options);
|
||||
}
|
||||
|
||||
export function buildTruncateTableSql(options: TableAdminSqlOptions): Promise<string> {
|
||||
return api.buildTruncateTableSql(options);
|
||||
}
|
||||
|
||||
export function buildDropDatabaseSql(options: DatabaseNameSqlOptions): Promise<string> {
|
||||
return api.buildDropDatabaseSql(options);
|
||||
}
|
||||
|
||||
export function buildCreateSchemaSql(options: SchemaNameSqlOptions): Promise<string> {
|
||||
return api.buildCreateSchemaSql(options);
|
||||
}
|
||||
|
||||
export function buildDropSchemaSql(options: SchemaNameSqlOptions): Promise<string> {
|
||||
return api.buildDropSchemaSql(options);
|
||||
}
|
||||
|
||||
export function buildDuplicateTableStructureSql(options: DuplicateTableStructureSqlOptions): Promise<string> {
|
||||
return api.buildDuplicateTableStructureSql(options);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
export interface ExplainPlanNode {
|
||||
id: string;
|
||||
@@ -24,24 +25,12 @@ export type BuildExplainSqlResult =
|
||||
| { ok: false; reason: "unsupported" | "empty" | "unsafe" };
|
||||
|
||||
const SUPPORTED_EXPLAIN_TYPES = new Set<DatabaseType>(["mysql", "postgres"]);
|
||||
const SAFE_EXPLAIN_RE = /^(select|with|table|values)\b/i;
|
||||
|
||||
export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is "mysql" | "postgres" {
|
||||
return !!databaseType && SUPPORTED_EXPLAIN_TYPES.has(databaseType);
|
||||
}
|
||||
|
||||
export function buildExplainSql(databaseType: DatabaseType | undefined, sql: string): BuildExplainSqlResult {
|
||||
if (!supportsExplainPlan(databaseType)) return { ok: false, reason: "unsupported" };
|
||||
|
||||
const source = stripTrailingSemicolons(sql.trim());
|
||||
if (!source) return { ok: false, reason: "empty" };
|
||||
if (!SAFE_EXPLAIN_RE.test(stripSqlComments(source).trim())) return { ok: false, reason: "unsafe" };
|
||||
|
||||
if (databaseType === "postgres") {
|
||||
return { ok: true, sql: `EXPLAIN (FORMAT JSON) ${source}` };
|
||||
}
|
||||
|
||||
return { ok: true, sql: `EXPLAIN FORMAT=JSON ${source}` };
|
||||
export function buildExplainSql(databaseType: DatabaseType | undefined, sql: string): Promise<BuildExplainSqlResult> {
|
||||
return api.buildExplainSql({ databaseType, sql }) as Promise<BuildExplainSqlResult>;
|
||||
}
|
||||
|
||||
export function parseExplainResult(databaseType: "mysql" | "postgres", result: QueryResult): ParsedExplainPlan {
|
||||
@@ -61,17 +50,6 @@ export function flattenExplainPlanNodes(nodes: ExplainPlanNode[]): ExplainPlanNo
|
||||
return rows;
|
||||
}
|
||||
|
||||
function stripTrailingSemicolons(sql: string): string {
|
||||
return sql.replace(/;\s*$/g, "");
|
||||
}
|
||||
|
||||
function stripSqlComments(sql: string): string {
|
||||
return sql
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
.replace(/--.*$/gm, " ")
|
||||
.replace(/#.*$/gm, " ");
|
||||
}
|
||||
|
||||
function parseExplainCell(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
export type ExportCellValue = string | number | boolean | null;
|
||||
|
||||
export function formatCsv(columns: string[], rows: ExportCellValue[][]): string {
|
||||
@@ -7,22 +10,18 @@ export function formatCsv(columns: string[], rows: ExportCellValue[][]): string
|
||||
return `${header}\n${body}`;
|
||||
}
|
||||
|
||||
export function formatSqlInsert(
|
||||
qualifiedName: string,
|
||||
columns: string[],
|
||||
rows: ExportCellValue[][],
|
||||
quoteIdent: (name: string) => string,
|
||||
): string {
|
||||
const cols = columns.map((c) => quoteIdent(c)).join(", ");
|
||||
const lines = rows.map((row) => {
|
||||
const vals = row
|
||||
.map((v) => {
|
||||
if (v === null) return "NULL";
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
return `'${String(v).replace(/'/g, "''")}'`;
|
||||
})
|
||||
.join(", ");
|
||||
return `INSERT INTO ${qualifiedName} (${cols}) VALUES (${vals});`;
|
||||
export interface FormatSqlInsertOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
tableName?: string;
|
||||
qualifiedTableName?: string;
|
||||
columns: string[];
|
||||
rows: ExportCellValue[][];
|
||||
}
|
||||
|
||||
export function formatSqlInsert(options: FormatSqlInsertOptions): Promise<string> {
|
||||
return api.buildExportSqlInsert({
|
||||
...options,
|
||||
batchSize: 1,
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -50,9 +50,39 @@ import type {
|
||||
DatabaseExportRequest,
|
||||
ExportProgress,
|
||||
XlsxCellValue,
|
||||
QueryPaginationExecutionPlanOptions,
|
||||
QueryPaginationExecutionPlan,
|
||||
SortedQuerySqlOptions,
|
||||
QuerySqlBuildResult,
|
||||
BuildExplainSqlOptions,
|
||||
ExplainSqlBuildResult,
|
||||
DroppedFilePreviewSqlOptions,
|
||||
} from "./tauri";
|
||||
import type { QueryEditability } from "@/lib/sqlAnalysis";
|
||||
import type { DataGridSaveStatementOptions } from "@/lib/dataGridSql";
|
||||
import type {
|
||||
DataGridColumnValueFilterConditionOptions,
|
||||
DataGridContextFilterConditionOptions,
|
||||
DataGridCountSqlOptions,
|
||||
DataGridCopyInsertStatementOptions,
|
||||
DataGridCopyUpdateStatementOptions,
|
||||
DataGridSaveStatementOptions,
|
||||
HiveTablePropertiesSqlOptions,
|
||||
} from "@/lib/dataGridSql";
|
||||
import type { BuildTableStructureChangeSqlOptions, TableStructureChangeSql } from "@/lib/tableStructureEditorSql";
|
||||
import type { BuildTableSelectSqlOptions } from "@/lib/tableSelectSql";
|
||||
import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/databaseSearch";
|
||||
import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceInput } from "@/lib/objectSourceEditor";
|
||||
import type { BuildViewDdlInput } from "@/lib/viewDdl";
|
||||
import type { BuildRenameObjectSqlOptions } from "@/lib/objectRenameSql";
|
||||
import type { CreateDatabaseSqlOptions } from "@/lib/createDatabaseSql";
|
||||
import type {
|
||||
DatabaseNameSqlOptions,
|
||||
DropObjectSqlOptions,
|
||||
DuplicateTableStructureSqlOptions,
|
||||
SchemaNameSqlOptions,
|
||||
TableAdminSqlOptions,
|
||||
} from "@/lib/dbAdminSql";
|
||||
import type { BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions } from "@/lib/databaseExport";
|
||||
import type {
|
||||
DataCompareFromTablesOptions,
|
||||
DataCompareFromTablesPreparation,
|
||||
@@ -432,6 +462,117 @@ export async function analyzeSqlReferences(sql: string, dialect?: string): Promi
|
||||
return post("/api/query/analyze-sql-references", { sql, dialect });
|
||||
}
|
||||
|
||||
export async function findStatementAtCursor(sql: string, cursorPos: number): Promise<string> {
|
||||
return post("/api/query/find-statement-at-cursor", { sql, cursorPos });
|
||||
}
|
||||
|
||||
export async function prepareQueryPaginationExecutionPlan(
|
||||
options: QueryPaginationExecutionPlanOptions,
|
||||
): Promise<QueryPaginationExecutionPlan> {
|
||||
return post("/api/query/prepare-pagination-plan", { options });
|
||||
}
|
||||
|
||||
export async function buildSortedQuerySql(options: SortedQuerySqlOptions): Promise<QuerySqlBuildResult> {
|
||||
return post("/api/query/build-sorted-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildExplainSql(options: BuildExplainSqlOptions): Promise<ExplainSqlBuildResult> {
|
||||
return post("/api/query/build-explain-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlOptions): Promise<string | undefined> {
|
||||
const result = await post<string | null>("/api/query/build-dropped-file-preview-sql", { options });
|
||||
return result ?? undefined;
|
||||
}
|
||||
|
||||
export async function buildTableSelectSql(options: BuildTableSelectSqlOptions): Promise<string> {
|
||||
return post("/api/query/build-table-select-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDatabaseSearchSql(options: DatabaseSearchSqlOptions): Promise<DatabaseSearchSql | null> {
|
||||
return post("/api/query/build-database-search-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildSearchResultWhere(options: SearchResultWhereOptions): Promise<string> {
|
||||
return post("/api/query/build-search-result-where", { options });
|
||||
}
|
||||
|
||||
export async function buildRenameObjectSql(options: BuildRenameObjectSqlOptions): Promise<string> {
|
||||
return post("/api/query/build-rename-object-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildCreateDatabaseSql(options: CreateDatabaseSqlOptions): Promise<string> {
|
||||
return post("/api/query/build-create-database-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDuckDbAttachDatabaseSql(path: string, name: string): Promise<string> {
|
||||
return post("/api/query/build-duckdb-attach-database-sql", { options: { path, name } });
|
||||
}
|
||||
|
||||
export async function buildDropObjectSql(options: DropObjectSqlOptions): Promise<string> {
|
||||
return post("/api/query/build-drop-object-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDropTableSql(options: TableAdminSqlOptions): Promise<string> {
|
||||
return post("/api/query/build-drop-table-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildEmptyTableSql(options: TableAdminSqlOptions): Promise<string> {
|
||||
return post("/api/query/build-empty-table-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildTruncateTableSql(options: TableAdminSqlOptions): Promise<string> {
|
||||
return post("/api/query/build-truncate-table-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDropDatabaseSql(options: DatabaseNameSqlOptions): Promise<string> {
|
||||
return post("/api/query/build-drop-database-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildCreateSchemaSql(options: SchemaNameSqlOptions): Promise<string> {
|
||||
return post("/api/query/build-create-schema-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDropSchemaSql(options: SchemaNameSqlOptions): Promise<string> {
|
||||
return post("/api/query/build-drop-schema-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDuplicateTableStructureSql(options: DuplicateTableStructureSqlOptions): Promise<string> {
|
||||
return post("/api/query/build-duplicate-table-structure-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildExecutableObjectSourceStatements(
|
||||
input: BuildEditableObjectSourceSqlInput,
|
||||
): Promise<string[]> {
|
||||
return post("/api/query/build-executable-object-source-statements", { input });
|
||||
}
|
||||
|
||||
export async function buildExecutableObjectSourceSql(input: BuildEditableObjectSourceSqlInput): Promise<string> {
|
||||
return post("/api/query/build-executable-object-source-sql", { input });
|
||||
}
|
||||
|
||||
export async function buildRoutineRenameObjectSourceStatements(
|
||||
input: BuildRoutineRenameObjectSourceInput,
|
||||
): Promise<string[]> {
|
||||
return post("/api/query/build-routine-rename-object-source-statements", { input });
|
||||
}
|
||||
|
||||
export async function buildViewDdlSql(input: BuildViewDdlInput): Promise<string> {
|
||||
return post("/api/query/build-view-ddl-sql", { input });
|
||||
}
|
||||
|
||||
export async function buildTableStructureChangeSql(
|
||||
options: BuildTableStructureChangeSqlOptions,
|
||||
): Promise<TableStructureChangeSql> {
|
||||
return post("/api/query/build-table-structure-change-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildCreateTableSql(
|
||||
options: BuildTableStructureChangeSqlOptions,
|
||||
): Promise<TableStructureChangeSql> {
|
||||
return post("/api/query/build-create-table-sql", { options });
|
||||
}
|
||||
|
||||
export async function analyzeEditableQueryEditability(sql: string): Promise<QueryEditability> {
|
||||
return post("/api/query/analyze-editability", { sql });
|
||||
}
|
||||
@@ -440,6 +581,53 @@ export async function prepareDataGridSave(options: DataGridSaveStatementOptions)
|
||||
return post("/api/query/prepare-data-grid-save", { options });
|
||||
}
|
||||
|
||||
export async function buildDataGridCopyUpdateStatements(
|
||||
options: DataGridCopyUpdateStatementOptions,
|
||||
): Promise<string[]> {
|
||||
return post("/api/query/build-data-grid-copy-update-statements", { options });
|
||||
}
|
||||
|
||||
export async function buildDataGridCopyInsertStatement(
|
||||
options: DataGridCopyInsertStatementOptions,
|
||||
): Promise<string | undefined> {
|
||||
const result = await post<string | null>("/api/query/build-data-grid-copy-insert-statement", { options });
|
||||
return result ?? undefined;
|
||||
}
|
||||
|
||||
export async function buildDataGridContextFilterCondition(
|
||||
options: DataGridContextFilterConditionOptions,
|
||||
): Promise<string | undefined> {
|
||||
const result = await post<string | null>("/api/query/build-data-grid-context-filter-condition", { options });
|
||||
return result ?? undefined;
|
||||
}
|
||||
|
||||
export async function buildDataGridColumnValueFilterCondition(
|
||||
options: DataGridColumnValueFilterConditionOptions,
|
||||
): Promise<string | undefined> {
|
||||
const result = await post<string | null>("/api/query/build-data-grid-column-value-filter-condition", { options });
|
||||
return result ?? undefined;
|
||||
}
|
||||
|
||||
export async function buildDataGridCountSql(options: DataGridCountSqlOptions): Promise<string> {
|
||||
return post("/api/query/build-data-grid-count-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildHiveTablePropertiesSql(options: HiveTablePropertiesSqlOptions): Promise<string> {
|
||||
return post("/api/query/build-hive-table-properties-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildExportInsertStatements(options: BuildExportInsertStatementsOptions): Promise<string[]> {
|
||||
return post("/api/query/build-export-insert-statements", { options });
|
||||
}
|
||||
|
||||
export async function buildExportSqlInsert(options: BuildExportInsertStatementsOptions): Promise<string> {
|
||||
return post("/api/query/build-export-sql-insert", { options });
|
||||
}
|
||||
|
||||
export async function buildDatabaseSqlExport(options: BuildDatabaseSqlExportOptions): Promise<string> {
|
||||
return post("/api/query/build-database-sql-export", { options });
|
||||
}
|
||||
|
||||
export async function prepareDataCompare(options: DataComparePreparationOptions): Promise<DataComparePreparation> {
|
||||
return post("/api/data-compare/prepare", options);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { DatabaseObjectType, DatabaseType } from "@/types/database";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
import { quoteTableIdentifier } from "@/lib/tableSelectSql";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
export type RenameableObjectType = DatabaseObjectType;
|
||||
|
||||
@@ -23,26 +22,6 @@ const postgresLikeRenameTypes = new Set<DatabaseType>([
|
||||
|
||||
const oracleLikeRenameTypes = new Set<DatabaseType>(["oracle", "dameng"]);
|
||||
|
||||
function sqlServerString(value: string): string {
|
||||
return `N'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
function quoteRenameIdentifier(databaseType: DatabaseType | undefined, name: string): string {
|
||||
if (databaseType === "mysql" || databaseType === "goldendb") return `\`${name.replaceAll("`", "``")}\``;
|
||||
return quoteTableIdentifier(databaseType, name);
|
||||
}
|
||||
|
||||
function qualifiedName(databaseType: DatabaseType | undefined, schema: string | null | undefined, name: string) {
|
||||
if (isSchemaAware(databaseType) && schema) {
|
||||
return `${quoteRenameIdentifier(databaseType, schema)}.${quoteRenameIdentifier(databaseType, name)}`;
|
||||
}
|
||||
return quoteRenameIdentifier(databaseType, name);
|
||||
}
|
||||
|
||||
function sqlServerObjectName(schema: string | null | undefined, name: string) {
|
||||
return schema ? `${schema}.${name}` : name;
|
||||
}
|
||||
|
||||
export function supportsObjectRename(
|
||||
databaseType: DatabaseType | undefined,
|
||||
objectType: RenameableObjectType,
|
||||
@@ -59,31 +38,6 @@ export function supportsObjectRename(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildRenameObjectSql(options: BuildRenameObjectSqlOptions): string {
|
||||
const { databaseType, objectType, schema, oldName, newName } = options;
|
||||
if (!supportsObjectRename(databaseType, objectType)) {
|
||||
throw new Error(`Renaming ${objectType} is not supported for ${databaseType ?? "this database"}.`);
|
||||
}
|
||||
|
||||
if (databaseType === "sqlserver") {
|
||||
return `EXEC sp_rename ${sqlServerString(sqlServerObjectName(schema, oldName))}, ${sqlServerString(newName)}, N'OBJECT';`;
|
||||
}
|
||||
|
||||
if (databaseType === "mysql" || databaseType === "goldendb") {
|
||||
return `RENAME TABLE ${qualifiedName(databaseType, schema, oldName)} TO ${qualifiedName(databaseType, schema, newName)};`;
|
||||
}
|
||||
|
||||
if (databaseType === "sqlite") {
|
||||
return `ALTER TABLE ${qualifiedName(databaseType, schema, oldName)} RENAME TO ${quoteRenameIdentifier(databaseType, newName)};`;
|
||||
}
|
||||
|
||||
if (
|
||||
postgresLikeRenameTypes.has(databaseType as DatabaseType) ||
|
||||
oracleLikeRenameTypes.has(databaseType as DatabaseType)
|
||||
) {
|
||||
const keyword = objectType;
|
||||
return `ALTER ${keyword} ${qualifiedName(databaseType, schema, oldName)} RENAME TO ${quoteRenameIdentifier(databaseType, newName)};`;
|
||||
}
|
||||
|
||||
throw new Error(`Renaming ${objectType} is not supported for ${databaseType ?? "this database"}.`);
|
||||
export function buildRenameObjectSql(options: BuildRenameObjectSqlOptions): Promise<string> {
|
||||
return api.buildRenameObjectSql(options);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DatabaseType, ObjectSourceKind } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
type BuildEditableObjectSourceSqlInput = {
|
||||
export type BuildEditableObjectSourceSqlInput = {
|
||||
databaseType: DatabaseType;
|
||||
objectType: ObjectSourceKind;
|
||||
schema?: string | null;
|
||||
@@ -8,7 +9,7 @@ type BuildEditableObjectSourceSqlInput = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
type BuildRoutineRenameObjectSourceInput = BuildEditableObjectSourceSqlInput & {
|
||||
export type BuildRoutineRenameObjectSourceInput = BuildEditableObjectSourceSqlInput & {
|
||||
newName: string;
|
||||
};
|
||||
|
||||
@@ -25,128 +26,6 @@ const postgresLikeRoutineRenameTypes = new Set<DatabaseType>([
|
||||
const mysqlLikeRoutineRenameTypes = new Set<DatabaseType>(["mysql", "goldendb"]);
|
||||
const oracleLikeRoutineRenameTypes = new Set<DatabaseType>(["oracle", "dameng"]);
|
||||
|
||||
function quotePostgresIdentifier(value: string) {
|
||||
return `"${value.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function quoteMysqlIdentifier(value: string) {
|
||||
return `\`${value.replaceAll("`", "``")}\``;
|
||||
}
|
||||
|
||||
function ensureSemicolon(sql: string) {
|
||||
const trimmed = sql.trim();
|
||||
return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
|
||||
}
|
||||
|
||||
function postgresQualifiedName(schema: string | null | undefined, name: string) {
|
||||
return [schema, name]
|
||||
.filter(Boolean)
|
||||
.map((part) => quotePostgresIdentifier(part as string))
|
||||
.join(".");
|
||||
}
|
||||
|
||||
function mysqlQualifiedName(schema: string | null | undefined, name: string) {
|
||||
return [schema, name]
|
||||
.filter(Boolean)
|
||||
.map((part) => quoteMysqlIdentifier(part as string))
|
||||
.join(".");
|
||||
}
|
||||
|
||||
function unquotePostgresIdentifier(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed.slice(1, -1).replaceAll('""', '"');
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function splitQualifiedRoutineName(value: string) {
|
||||
const parts = value.match(/"(?:""|[^"])+"|[A-Za-z_][\w$]*/g) ?? [];
|
||||
return parts.map(unquotePostgresIdentifier);
|
||||
}
|
||||
|
||||
function unquoteMysqlIdentifier(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith("`") && trimmed.endsWith("`")) return trimmed.slice(1, -1).replaceAll("``", "`");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function splitMysqlQualifiedRoutineName(value: string) {
|
||||
const parts = value.match(/`(?:``|[^`])+`|[A-Za-z_][\w$]*/g) ?? [];
|
||||
return parts.map(unquoteMysqlIdentifier);
|
||||
}
|
||||
|
||||
function routineDeclaration(source: string) {
|
||||
const match = source.match(
|
||||
/^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:NON)?EDITIONABLE\s+)?(FUNCTION|PROCEDURE)\s+((?:"(?:""|[^"])+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"(?:""|[^"])+"|[A-Za-z_][\w$]*))?)\s*(\([^]*?\))?/i,
|
||||
);
|
||||
if (!match) return null;
|
||||
const nameParts = splitQualifiedRoutineName(match[2]);
|
||||
const name = nameParts[nameParts.length - 1];
|
||||
if (!name) return null;
|
||||
return {
|
||||
kind: match[1].toUpperCase() as "FUNCTION" | "PROCEDURE",
|
||||
name,
|
||||
signature: match[3]?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function replaceSqlRoutineDeclarationName(source: string, schema: string | null | undefined, newName: string) {
|
||||
const match = source.match(
|
||||
/^(\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:NON)?EDITIONABLE\s+)?(?:FUNCTION|PROCEDURE)\s+)((?:"(?:""|[^"])+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"(?:""|[^"])+"|[A-Za-z_][\w$]*))?)/i,
|
||||
);
|
||||
if (!match) return null;
|
||||
const existingParts = splitQualifiedRoutineName(match[2]);
|
||||
const schemaName = schema || (existingParts.length > 1 ? existingParts[0] : null);
|
||||
const replacement = schemaName
|
||||
? `${quotePostgresIdentifier(schemaName)}.${quotePostgresIdentifier(newName)}`
|
||||
: quotePostgresIdentifier(newName);
|
||||
return `${source.slice(0, match.index)}${match[1]}${replacement}${source.slice((match.index ?? 0) + match[0].length)}`;
|
||||
}
|
||||
|
||||
function mysqlRoutineDeclaration(source: string) {
|
||||
const match = source.match(
|
||||
/^\s*CREATE\s+(?:DEFINER\s*=\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s*@\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s+)?(FUNCTION|PROCEDURE)\s+((?:`(?:``|[^`])+`|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:`(?:``|[^`])+`|[A-Za-z_][\w$]*))?)/i,
|
||||
);
|
||||
if (!match) return null;
|
||||
const nameParts = splitMysqlQualifiedRoutineName(match[2]);
|
||||
const name = nameParts[nameParts.length - 1];
|
||||
if (!name) return null;
|
||||
return {
|
||||
kind: match[1].toUpperCase() as "FUNCTION" | "PROCEDURE",
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
function replaceMysqlRoutineDeclarationName(source: string, newName: string) {
|
||||
const match = source.match(
|
||||
/^(\s*CREATE\s+(?:DEFINER\s*=\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s*@\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s+)?(?:FUNCTION|PROCEDURE)\s+)((?:`(?:``|[^`])+`|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:`(?:``|[^`])+`|[A-Za-z_][\w$]*))?)/i,
|
||||
);
|
||||
if (!match) return null;
|
||||
return `${source.slice(0, match.index)}${match[1]}${quoteMysqlIdentifier(newName)}${source.slice((match.index ?? 0) + match[0].length)}`;
|
||||
}
|
||||
|
||||
function routineNameChanged(sourceName: string, savedName: string) {
|
||||
return sourceName.toLowerCase() !== savedName.toLowerCase();
|
||||
}
|
||||
|
||||
function buildRoutineRenameCleanup(input: BuildEditableObjectSourceSqlInput, source: string) {
|
||||
if (input.objectType !== "FUNCTION" && input.objectType !== "PROCEDURE") return null;
|
||||
|
||||
if (mysqlLikeRoutineRenameTypes.has(input.databaseType)) {
|
||||
const declaration = mysqlRoutineDeclaration(source);
|
||||
if (!declaration || declaration.kind !== input.objectType) return null;
|
||||
if (!routineNameChanged(declaration.name, input.name)) return null;
|
||||
return `DROP ${input.objectType} IF EXISTS ${mysqlQualifiedName(input.schema, input.name)};`;
|
||||
}
|
||||
|
||||
if (!postgresLikeRoutineRenameTypes.has(input.databaseType)) return null;
|
||||
|
||||
const declaration = routineDeclaration(source);
|
||||
if (!declaration || declaration.kind !== input.objectType) return null;
|
||||
if (!routineNameChanged(declaration.name, input.name)) return null;
|
||||
|
||||
return `DROP ${input.objectType} IF EXISTS ${postgresQualifiedName(input.schema, input.name)}${declaration.signature};`;
|
||||
}
|
||||
|
||||
export function supportsSourceBackedRoutineRename(
|
||||
databaseType: DatabaseType | undefined,
|
||||
objectType: ObjectSourceKind,
|
||||
@@ -160,59 +39,18 @@ export function supportsSourceBackedRoutineRename(
|
||||
);
|
||||
}
|
||||
|
||||
export function buildRoutineRenameObjectSourceStatements(input: BuildRoutineRenameObjectSourceInput) {
|
||||
if (!supportsSourceBackedRoutineRename(input.databaseType, input.objectType)) {
|
||||
throw new Error(`Renaming ${input.objectType} from source is not supported for ${input.databaseType}.`);
|
||||
}
|
||||
|
||||
const source = input.source.trim();
|
||||
const declaration = mysqlLikeRoutineRenameTypes.has(input.databaseType)
|
||||
? mysqlRoutineDeclaration(source)
|
||||
: routineDeclaration(source);
|
||||
if (!declaration || declaration.kind !== input.objectType) {
|
||||
throw new Error(`Cannot find a CREATE ${input.objectType} declaration in the object source.`);
|
||||
}
|
||||
|
||||
const renamedSource = mysqlLikeRoutineRenameTypes.has(input.databaseType)
|
||||
? replaceMysqlRoutineDeclarationName(source, input.newName)
|
||||
: replaceSqlRoutineDeclarationName(source, input.schema, input.newName);
|
||||
if (!renamedSource) {
|
||||
throw new Error(`Cannot rewrite the ${input.objectType} name in the object source.`);
|
||||
}
|
||||
|
||||
if (oracleLikeRoutineRenameTypes.has(input.databaseType)) {
|
||||
return [
|
||||
ensureSemicolon(renamedSource),
|
||||
`DROP ${input.objectType} ${postgresQualifiedName(input.schema, input.name)};`,
|
||||
];
|
||||
}
|
||||
|
||||
return buildExecutableObjectSourceStatements({
|
||||
databaseType: input.databaseType,
|
||||
objectType: input.objectType,
|
||||
schema: input.schema,
|
||||
name: input.name,
|
||||
source: renamedSource,
|
||||
});
|
||||
export function buildRoutineRenameObjectSourceStatements(
|
||||
input: BuildRoutineRenameObjectSourceInput,
|
||||
): Promise<string[]> {
|
||||
return api.buildRoutineRenameObjectSourceStatements(input);
|
||||
}
|
||||
|
||||
export function buildExecutableObjectSourceStatements(input: BuildEditableObjectSourceSqlInput) {
|
||||
const source = input.source.trim();
|
||||
if (input.databaseType === "sqlserver") {
|
||||
return [source.replace(/^CREATE\s+(?:OR\s+ALTER\s+)?/i, "ALTER ")];
|
||||
}
|
||||
|
||||
if ((input.databaseType === "postgres" || input.databaseType === "gaussdb") && input.objectType === "VIEW") {
|
||||
return [`CREATE OR REPLACE VIEW ${postgresQualifiedName(input.schema, input.name)} AS\n${ensureSemicolon(source)}`];
|
||||
}
|
||||
|
||||
const createStatement = ensureSemicolon(source);
|
||||
const cleanup = buildRoutineRenameCleanup(input, source);
|
||||
return cleanup ? [createStatement, cleanup] : [createStatement];
|
||||
export function buildExecutableObjectSourceStatements(input: BuildEditableObjectSourceSqlInput): Promise<string[]> {
|
||||
return api.buildExecutableObjectSourceStatements(input);
|
||||
}
|
||||
|
||||
export function buildExecutableObjectSourceSql(input: BuildEditableObjectSourceSqlInput) {
|
||||
return buildExecutableObjectSourceStatements(input).join("\n");
|
||||
export async function buildExecutableObjectSourceSql(input: BuildEditableObjectSourceSqlInput): Promise<string> {
|
||||
return api.buildExecutableObjectSourceSql(input);
|
||||
}
|
||||
|
||||
export function objectSourceSaveExecutionMode(_databaseType: DatabaseType): ObjectSourceSaveExecutionMode {
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import type { DatabaseType } from "../types/database.ts";
|
||||
import { usesFetchFirst } from "./databaseCapabilities.ts";
|
||||
import { quoteTableIdentifier } from "./tableSelectSql.ts";
|
||||
import { findStatementAtCursor } from "./sqlStatementSplit.ts";
|
||||
import { sqlServerStatementForDerivedTable } from "./sqlServerQueryWrapping.ts";
|
||||
|
||||
export interface PaginatedQuerySqlResult {
|
||||
ok: true;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
export interface PaginatedQuerySqlError {
|
||||
ok: false;
|
||||
reason: "empty" | "multi" | "not_select" | "unsupported";
|
||||
}
|
||||
|
||||
export interface QueryPaginationExecutionPlan {
|
||||
sqlToExecute: string;
|
||||
pageSql?: string;
|
||||
pageLimit?: number;
|
||||
pageOffset?: number;
|
||||
countSql?: string;
|
||||
useAgentResultSession: boolean;
|
||||
}
|
||||
|
||||
const unsupportedPaginationTypes = new Set<DatabaseType | undefined>(["neo4j", "mongodb", "redis", "elasticsearch"]);
|
||||
|
||||
export function buildQueryPaginationExecutionPlan({
|
||||
sql,
|
||||
queryBaseSql,
|
||||
databaseType,
|
||||
pagination,
|
||||
useAgentCursor,
|
||||
}: {
|
||||
sql: string;
|
||||
queryBaseSql: string;
|
||||
databaseType: DatabaseType | undefined;
|
||||
pagination: { limit: number; offset: number; sessionId?: string };
|
||||
useAgentCursor: boolean;
|
||||
}): QueryPaginationExecutionPlan {
|
||||
const plan: QueryPaginationExecutionPlan = {
|
||||
sqlToExecute: sql,
|
||||
useAgentResultSession: false,
|
||||
};
|
||||
const counted = buildCountQuerySql(queryBaseSql, databaseType);
|
||||
if (counted.ok) {
|
||||
plan.countSql = counted.sql;
|
||||
}
|
||||
|
||||
if (pagination.sessionId) {
|
||||
plan.pageLimit = pagination.limit;
|
||||
plan.pageOffset = pagination.offset;
|
||||
plan.useAgentResultSession = true;
|
||||
return plan;
|
||||
}
|
||||
|
||||
if (useAgentCursor && pagination.offset === 0) {
|
||||
plan.sqlToExecute = queryBaseSql;
|
||||
plan.pageLimit = pagination.limit;
|
||||
plan.pageOffset = pagination.offset;
|
||||
plan.useAgentResultSession = true;
|
||||
return plan;
|
||||
}
|
||||
|
||||
const paginated = buildPaginatedQuerySql(sql, databaseType, pagination.limit, pagination.offset);
|
||||
if (paginated.ok) {
|
||||
plan.sqlToExecute = paginated.sql;
|
||||
plan.pageSql = paginated.sql;
|
||||
plan.pageLimit = pagination.limit;
|
||||
plan.pageOffset = pagination.offset;
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
export function buildPaginatedQuerySql(
|
||||
originalSql: string,
|
||||
databaseType: DatabaseType | undefined,
|
||||
limit: number,
|
||||
offset: number,
|
||||
): PaginatedQuerySqlResult | PaginatedQuerySqlError {
|
||||
const statement = singleSelectableStatement(originalSql);
|
||||
if (!statement.ok) return statement;
|
||||
if (unsupportedPaginationTypes.has(databaseType)) return { ok: false, reason: "unsupported" };
|
||||
|
||||
const safeLimit = Math.max(1, Math.floor(limit));
|
||||
const safeOffset = Math.max(0, Math.floor(offset));
|
||||
const alias = quoteTableIdentifier(databaseType, "dbx_page");
|
||||
const wrappedSql = databaseType === "sqlserver" ? sqlServerStatementForDerivedTable(statement.sql) : statement.sql;
|
||||
const base = `SELECT * FROM (${wrappedSql}) ${alias}`;
|
||||
|
||||
if (databaseType === "sqlserver") {
|
||||
if (safeOffset > 0) return { ok: false, reason: "unsupported" };
|
||||
return {
|
||||
ok: true,
|
||||
sql: `SELECT TOP (${safeLimit}) * FROM (${wrappedSql}) ${alias}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (usesFetchFirst(databaseType)) {
|
||||
const offsetSql = safeOffset ? ` OFFSET ${safeOffset} ROWS` : "";
|
||||
return { ok: true, sql: `${base}${offsetSql} FETCH FIRST ${safeLimit} ROWS ONLY` };
|
||||
}
|
||||
|
||||
const offsetSql = safeOffset ? ` OFFSET ${safeOffset}` : "";
|
||||
return { ok: true, sql: `${base} LIMIT ${safeLimit}${offsetSql};` };
|
||||
}
|
||||
|
||||
export function buildCountQuerySql(
|
||||
originalSql: string,
|
||||
databaseType: DatabaseType | undefined,
|
||||
): PaginatedQuerySqlResult | PaginatedQuerySqlError {
|
||||
const statement = singleSelectableStatement(originalSql);
|
||||
if (!statement.ok) return statement;
|
||||
if (unsupportedPaginationTypes.has(databaseType)) return { ok: false, reason: "unsupported" };
|
||||
|
||||
const alias = quoteTableIdentifier(databaseType, "dbx_count");
|
||||
const wrappedSql = databaseType === "sqlserver" ? sqlServerStatementForDerivedTable(statement.sql) : statement.sql;
|
||||
return { ok: true, sql: `SELECT COUNT(*) AS dbx_total_rows FROM (${wrappedSql}) ${alias};` };
|
||||
}
|
||||
|
||||
function singleSelectableStatement(
|
||||
originalSql: string,
|
||||
): { ok: true; sql: string } | Pick<PaginatedQuerySqlError, "ok" | "reason"> {
|
||||
const baseSql = originalSql.trim();
|
||||
if (!baseSql) return { ok: false, reason: "empty" };
|
||||
|
||||
const statement = findStatementAtCursor(baseSql, 0)
|
||||
.trim()
|
||||
.replace(/;+\s*$/, "")
|
||||
.trim();
|
||||
if (!statement) return { ok: false, reason: "empty" };
|
||||
if (statement.length !== baseSql.replace(/;+\s*$/, "").trim().length) {
|
||||
return { ok: false, reason: "multi" };
|
||||
}
|
||||
if (!/^\s*(SELECT|WITH)\b/i.test(statement)) {
|
||||
return { ok: false, reason: "not_select" };
|
||||
}
|
||||
if (hasTopLevelSelectInto(statement)) {
|
||||
return { ok: false, reason: "not_select" };
|
||||
}
|
||||
|
||||
return { ok: true, sql: statement };
|
||||
}
|
||||
|
||||
function hasTopLevelSelectInto(sql: string): boolean {
|
||||
let sawSelect = false;
|
||||
for (const token of topLevelSqlTokens(sql)) {
|
||||
if (!sawSelect) {
|
||||
sawSelect = token === "SELECT";
|
||||
continue;
|
||||
}
|
||||
if (token === "INTO") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function topLevelSqlTokens(sql: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let i = 0;
|
||||
let depth = 0;
|
||||
|
||||
while (i < sql.length) {
|
||||
const ch = sql[i];
|
||||
const next = sql[i + 1];
|
||||
|
||||
if (ch === "-" && next === "-") {
|
||||
i += 2;
|
||||
while (i < sql.length && sql[i] !== "\n") i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "/" && next === "*") {
|
||||
i += 2;
|
||||
while (i < sql.length - 1 && !(sql[i] === "*" && sql[i + 1] === "/")) i++;
|
||||
i = Math.min(i + 2, sql.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" || ch === '"' || ch === "`") {
|
||||
i = skipSqlQuoted(sql, i, ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "[") {
|
||||
i = skipSqlBracketIdentifier(sql, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "(") {
|
||||
depth++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ")") {
|
||||
depth = Math.max(0, depth - 1);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (depth === 0 && isSqlTokenStart(ch)) {
|
||||
const start = i;
|
||||
i++;
|
||||
while (i < sql.length && isSqlTokenPart(sql[i])) i++;
|
||||
tokens.push(sql.slice(start, i).toUpperCase());
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function skipSqlQuoted(sql: string, pos: number, quote: string): number {
|
||||
let i = pos + 1;
|
||||
while (i < sql.length) {
|
||||
if (sql[i] === quote) {
|
||||
if (sql[i + 1] === quote) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
return i + 1;
|
||||
}
|
||||
if (quote === "'" && sql[i] === "\\") {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return sql.length;
|
||||
}
|
||||
|
||||
function skipSqlBracketIdentifier(sql: string, pos: number): number {
|
||||
let i = pos + 1;
|
||||
while (i < sql.length) {
|
||||
if (sql[i] === "]") {
|
||||
if (sql[i + 1] === "]") {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
return i + 1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return sql.length;
|
||||
}
|
||||
|
||||
function isSqlTokenStart(ch: string): boolean {
|
||||
return /[A-Za-z_]/.test(ch);
|
||||
}
|
||||
|
||||
function isSqlTokenPart(ch: string): boolean {
|
||||
return /[A-Za-z0-9_$#]/.test(ch);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { DatabaseType } from "../types/database.ts";
|
||||
import { quoteTableIdentifier } from "./tableSelectSql.ts";
|
||||
import { findStatementAtCursor } from "./sqlStatementSplit.ts";
|
||||
import { sqlServerStatementForDerivedTable } from "./sqlServerQueryWrapping.ts";
|
||||
|
||||
export type QuerySortDirection = "asc" | "desc";
|
||||
|
||||
export interface SortedQuerySqlResult {
|
||||
ok: true;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
export interface SortedQuerySqlError {
|
||||
ok: false;
|
||||
reason: "empty" | "multi" | "not_select" | "with";
|
||||
}
|
||||
|
||||
export function buildSortedQuerySql(
|
||||
originalSql: string,
|
||||
databaseType: DatabaseType | undefined,
|
||||
resultColumns: string[],
|
||||
columnIndex: number,
|
||||
column: string,
|
||||
direction: QuerySortDirection,
|
||||
): SortedQuerySqlResult | SortedQuerySqlError {
|
||||
const baseSql = originalSql.trim();
|
||||
if (!baseSql) return { ok: false, reason: "empty" };
|
||||
|
||||
const statement = findStatementAtCursor(baseSql, 0)
|
||||
.trim()
|
||||
.replace(/;+\s*$/, "")
|
||||
.trim();
|
||||
if (!statement) return { ok: false, reason: "empty" };
|
||||
if (statement.length !== baseSql.replace(/;+\s*$/, "").trim().length) {
|
||||
return { ok: false, reason: "multi" };
|
||||
}
|
||||
if (/^\s*WITH\b/i.test(statement)) {
|
||||
return { ok: false, reason: "with" };
|
||||
}
|
||||
if (!/^\s*SELECT\b/i.test(statement)) {
|
||||
return { ok: false, reason: "not_select" };
|
||||
}
|
||||
|
||||
const aliases = buildDerivedColumnAliases(resultColumns);
|
||||
const useDerivedColumnAliases = supportsDerivedColumnAliasList(databaseType);
|
||||
const sortAlias = useDerivedColumnAliases
|
||||
? (aliases[columnIndex] ?? aliases[resultColumns.indexOf(column)] ?? fallbackAlias(columnIndex))
|
||||
: (resultColumns[columnIndex] ?? column);
|
||||
const quotedColumn = quoteTableIdentifier(databaseType, sortAlias);
|
||||
const aliasList = aliases.map((alias) => quoteTableIdentifier(databaseType, alias)).join(", ");
|
||||
const wrappedStatement = databaseType === "sqlserver" ? sqlServerStatementForDerivedTable(statement) : statement;
|
||||
return {
|
||||
ok: true,
|
||||
sql: useDerivedColumnAliases
|
||||
? `SELECT * FROM (${wrappedStatement}) t(${aliasList}) ORDER BY ${quotedColumn} ${direction.toUpperCase()};`
|
||||
: `SELECT * FROM (${wrappedStatement}) t ORDER BY ${quotedColumn} ${direction.toUpperCase()};`,
|
||||
};
|
||||
}
|
||||
|
||||
function supportsDerivedColumnAliasList(databaseType: DatabaseType | undefined): boolean {
|
||||
return databaseType !== "mysql";
|
||||
}
|
||||
|
||||
function buildDerivedColumnAliases(resultColumns: string[]): string[] {
|
||||
const seen = new Map<string, number>();
|
||||
return resultColumns.map((column, index) => {
|
||||
const base = normalizeAliasBase(column, index);
|
||||
const count = (seen.get(base) ?? 0) + 1;
|
||||
seen.set(base, count);
|
||||
return count === 1 ? base : `${base}_${count}`;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAliasBase(column: string, index: number): string {
|
||||
const compact = column.trim().replace(/\s+/g, "_");
|
||||
const safe = compact.replace(/[^\p{L}\p{N}_$]/gu, "_").replace(/^_+|_+$/g, "");
|
||||
return safe || fallbackAlias(index);
|
||||
}
|
||||
|
||||
function fallbackAlias(index: number): string {
|
||||
return `column_${index + 1}`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { findStatementAtCursor } from "./sqlStatementSplit";
|
||||
import * as api from "./api";
|
||||
|
||||
export type ExecuteMode = "all" | "current";
|
||||
|
||||
@@ -11,7 +11,22 @@ export function resolveExecutableSql(
|
||||
if (trimmedSelection) return trimmedSelection;
|
||||
|
||||
if (options?.mode === "current" && options.cursorPos !== undefined) {
|
||||
return findStatementAtCursor(fullSql, options.cursorPos);
|
||||
return fullSql;
|
||||
}
|
||||
|
||||
return fullSql;
|
||||
}
|
||||
|
||||
export async function resolveExecutableSqlWithBackend(
|
||||
fullSql: string,
|
||||
selectedSql: string,
|
||||
options?: { mode?: ExecuteMode; cursorPos?: number },
|
||||
): Promise<string> {
|
||||
const trimmedSelection = selectedSql.trim();
|
||||
if (trimmedSelection) return trimmedSelection;
|
||||
|
||||
if (options?.mode === "current" && options.cursorPos !== undefined) {
|
||||
return await api.findStatementAtCursor(fullSql, options.cursorPos);
|
||||
}
|
||||
|
||||
return fullSql;
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
export function findStatementAtCursor(sql: string, cursorPos: number): string {
|
||||
const statements = splitStatements(sql);
|
||||
for (const stmt of statements) {
|
||||
if (cursorPos >= stmt.start && cursorPos <= stmt.end) {
|
||||
return stmt.text;
|
||||
}
|
||||
}
|
||||
if (statements.length > 0) {
|
||||
const last = statements[statements.length - 1];
|
||||
if (cursorPos >= last.end) return last.text;
|
||||
}
|
||||
return sql.trim();
|
||||
}
|
||||
|
||||
interface StatementRange {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function splitStatements(sql: string): StatementRange[] {
|
||||
const results: StatementRange[] = [];
|
||||
let i = 0;
|
||||
let stmtStart = 0;
|
||||
|
||||
while (i < sql.length) {
|
||||
const ch = sql[i];
|
||||
|
||||
if (ch === "-" && sql[i + 1] === "-") {
|
||||
i = skipLineComment(sql, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "/" && sql[i + 1] === "*") {
|
||||
i = skipBlockComment(sql, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" || ch === '"') {
|
||||
i = skipQuoted(sql, i, ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "`") {
|
||||
i = skipQuoted(sql, i, "`");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ";") {
|
||||
const text = sql.slice(stmtStart, i).trim();
|
||||
if (text) {
|
||||
results.push({ text, start: stmtStart, end: i + 1 });
|
||||
}
|
||||
stmtStart = i + 1;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
const trailing = sql.slice(stmtStart).trim();
|
||||
if (trailing) {
|
||||
results.push({ text: trailing, start: stmtStart, end: sql.length });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function skipLineComment(sql: string, pos: number): number {
|
||||
let i = pos + 2;
|
||||
while (i < sql.length && sql[i] !== "\n") i++;
|
||||
return i + 1;
|
||||
}
|
||||
|
||||
function skipBlockComment(sql: string, pos: number): number {
|
||||
let i = pos + 2;
|
||||
while (i < sql.length - 1) {
|
||||
if (sql[i] === "*" && sql[i + 1] === "/") return i + 2;
|
||||
i++;
|
||||
}
|
||||
return sql.length;
|
||||
}
|
||||
|
||||
function skipQuoted(sql: string, pos: number, quote: string): number {
|
||||
let i = pos + 1;
|
||||
while (i < sql.length) {
|
||||
if (sql[i] === quote) {
|
||||
if (sql[i + 1] === quote) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
return i + 1;
|
||||
}
|
||||
if (sql[i] === "\\" && quote === "'") {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return sql.length;
|
||||
}
|
||||
@@ -9,6 +9,14 @@ export interface FetchTableDataForExportOptions {
|
||||
tableName: string;
|
||||
columns?: string[];
|
||||
pageSize?: number;
|
||||
buildPageSql?: (options: {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
columns?: string[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
}) => Promise<string> | string;
|
||||
executePage: (sql: string) => Promise<QueryResult>;
|
||||
}
|
||||
|
||||
@@ -20,7 +28,7 @@ export async function fetchTableDataForExport(options: FetchTableDataForExportOp
|
||||
let executionTimeMs = 0;
|
||||
|
||||
while (true) {
|
||||
const sql = buildTableSelectSql({
|
||||
const sql = await (options.buildPageSql ?? buildTableSelectSql)({
|
||||
databaseType: options.databaseType,
|
||||
schema: options.schema,
|
||||
tableName: options.tableName,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DatabaseType } from "../types/database.ts";
|
||||
import { isSchemaAware, usesFetchFirst } from "./databaseCapabilities.ts";
|
||||
import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN, DBX_TDENGINE_TBNAME_COLUMN } from "./tableEditing.ts";
|
||||
import { isSchemaAware } from "./databaseCapabilities.ts";
|
||||
import * as api from "./api.ts";
|
||||
|
||||
export interface BuildTableSelectSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
@@ -29,17 +29,6 @@ function quoteCypherIdentifier(name: string): string {
|
||||
return `\`${name.replace(/`/g, "``")}\``;
|
||||
}
|
||||
|
||||
function isOracleRowId(databaseType: DatabaseType | undefined, name: string): boolean {
|
||||
return databaseType === "oracle" && name.toUpperCase() === DBX_ROWID_COLUMN;
|
||||
}
|
||||
|
||||
function quoteOrderIdentifier(databaseType: DatabaseType | undefined, name: string, tableAlias?: string): string {
|
||||
if (isOracleRowId(databaseType, name)) return tableAlias ? `${tableAlias}.ROWID` : "ROWID";
|
||||
if (isTdengineTbname(databaseType, name)) return DBX_TDENGINE_TBNAME_COLUMN;
|
||||
const quoted = quoteTableIdentifier(databaseType, name);
|
||||
return tableAlias ? `${tableAlias}.${quoted}` : quoted;
|
||||
}
|
||||
|
||||
export function qualifiedTableName(
|
||||
options: Pick<BuildTableSelectSqlOptions, "databaseType" | "schema" | "tableName">,
|
||||
): string {
|
||||
@@ -55,125 +44,6 @@ export function normalizeWhereInput(whereInput?: string): string {
|
||||
return withoutSemicolon.replace(/^where\b/i, "").trim();
|
||||
}
|
||||
|
||||
export function buildTableSelectSql(options: BuildTableSelectSqlOptions): string {
|
||||
const databaseType = options.databaseType;
|
||||
const limit = options.limit ?? 100;
|
||||
if (databaseType === "neo4j") return buildNeo4jTableSelectSql(options, limit);
|
||||
|
||||
const table = qualifiedTableName(options);
|
||||
const predicate = normalizeWhereInput(options.whereInput);
|
||||
const where = predicate ? ` WHERE (${predicate})` : "";
|
||||
const rowIdAlias = options.includeRowId && databaseType === "oracle" ? "t" : undefined;
|
||||
const defaultOrderAlias = databaseType === "jdbc" ? "dbx_t" : rowIdAlias;
|
||||
const defaultOrderBy = options.primaryKeys?.length
|
||||
? options.primaryKeys.map((pk) => `${quoteOrderIdentifier(databaseType, pk, defaultOrderAlias)} ASC`).join(", ")
|
||||
: options.fallbackOrderColumns?.length
|
||||
? options.fallbackOrderColumns.map((column) => `${quoteTableIdentifier(databaseType, column)} ASC`).join(", ")
|
||||
: undefined;
|
||||
const orderBy = options.orderBy ?? defaultOrderBy;
|
||||
const order = orderBy ? ` ORDER BY ${orderBy}` : "";
|
||||
|
||||
const selectColumns =
|
||||
options.includeRowId && databaseType === "oracle"
|
||||
? `ROWIDTOCHAR(t.ROWID) AS "${DBX_ROWID_COLUMN}", t.*`
|
||||
: buildSelectColumns(databaseType, options.columns);
|
||||
const tableAlias =
|
||||
options.includeRowId && usesFetchFirst(databaseType)
|
||||
? `${table} t`
|
||||
: databaseType === "jdbc" && defaultOrderBy
|
||||
? `${table} dbx_t`
|
||||
: table;
|
||||
|
||||
if (usesFetchFirst(databaseType)) {
|
||||
const offset = options.offset ? ` OFFSET ${options.offset} ROWS` : "";
|
||||
return `SELECT ${selectColumns} FROM ${tableAlias}${where}${order}${offset} FETCH FIRST ${limit} ROWS ONLY`;
|
||||
}
|
||||
|
||||
if (databaseType === "sqlserver") {
|
||||
return buildSqlServerTableSelectSql({
|
||||
table,
|
||||
where,
|
||||
orderBy: orderBy ?? "(SELECT NULL)",
|
||||
columns: options.columns,
|
||||
limit,
|
||||
offset: options.offset ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
const offset = options.offset ? ` OFFSET ${options.offset}` : "";
|
||||
return `SELECT ${selectColumns} FROM ${tableAlias}${where}${order} LIMIT ${limit}${offset};`;
|
||||
}
|
||||
|
||||
function buildSelectColumns(databaseType: DatabaseType | undefined, columns?: string[]): string {
|
||||
if (!columns?.length) return "*";
|
||||
if (databaseType === "tdengine") {
|
||||
const tdengineColumns = columns.some((column) => column.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN)
|
||||
? columns
|
||||
: [DBX_TDENGINE_TBNAME_COLUMN, ...columns];
|
||||
return tdengineColumns
|
||||
.map((column) => {
|
||||
if (isTdengineTbname(databaseType, column)) return DBX_TDENGINE_TBNAME_COLUMN;
|
||||
const ident = quoteTableIdentifier(databaseType, column);
|
||||
return `${ident} AS ${ident}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
if (databaseType !== "hive") return "*";
|
||||
return columns
|
||||
.map((column) => {
|
||||
const ident = quoteTableIdentifier(databaseType, column);
|
||||
return `${ident} AS ${ident}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function buildSqlServerTableSelectSql(options: {
|
||||
table: string;
|
||||
where: string;
|
||||
orderBy: string;
|
||||
columns?: string[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): string {
|
||||
const columns = options.columns?.length
|
||||
? options.columns.map((column) => quoteTableIdentifier("sqlserver", column)).join(", ")
|
||||
: "*";
|
||||
const order = options.orderBy === "(SELECT NULL)" ? "" : ` ORDER BY ${options.orderBy}`;
|
||||
if (options.offset <= 0) {
|
||||
return `SELECT TOP (${options.limit}) ${columns} FROM ${options.table}${options.where}${order}`;
|
||||
}
|
||||
|
||||
const pageAlias = quoteTableIdentifier("sqlserver", "dbx_page");
|
||||
const rowNumberAlias = quoteTableIdentifier("sqlserver", "__dbx_row_num");
|
||||
const end = options.offset + options.limit;
|
||||
const rowNumberOrder = `ORDER BY ${options.orderBy}`;
|
||||
return (
|
||||
`WITH ${pageAlias} AS (` +
|
||||
`SELECT ${columns}, ROW_NUMBER() OVER (${rowNumberOrder}) AS ${rowNumberAlias} FROM ${options.table}${options.where}` +
|
||||
`) SELECT ${columns} FROM ${pageAlias}` +
|
||||
` WHERE ${rowNumberAlias} > ${options.offset} AND ${rowNumberAlias} <= ${end} ORDER BY ${rowNumberAlias}`
|
||||
);
|
||||
}
|
||||
|
||||
function isTdengineTbname(databaseType: DatabaseType | undefined, name: string): boolean {
|
||||
return databaseType === "tdengine" && name.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN;
|
||||
}
|
||||
|
||||
function buildNeo4jTableSelectSql(options: BuildTableSelectSqlOptions, limit: number): string {
|
||||
const label = quoteCypherIdentifier(options.tableName);
|
||||
const predicate = normalizeWhereInput(options.whereInput);
|
||||
const where = predicate ? ` WHERE ${predicate}` : "";
|
||||
const returnedColumns = options.columns?.length
|
||||
? options.columns
|
||||
.map((column) => `n.${quoteCypherIdentifier(column)} AS ${quoteCypherIdentifier(column)}`)
|
||||
.join(", ")
|
||||
: "n";
|
||||
const returns = `elementId(n) AS ${quoteCypherIdentifier(DBX_NEO4J_ELEMENT_ID_COLUMN)}, ${returnedColumns}`;
|
||||
const defaultOrderBy = options.primaryKeys?.length
|
||||
? options.primaryKeys.map((pk) => `n.${quoteCypherIdentifier(pk)} ASC`).join(", ")
|
||||
: undefined;
|
||||
const orderBy = options.orderBy ?? defaultOrderBy;
|
||||
const order = orderBy ? ` ORDER BY ${orderBy}` : "";
|
||||
const skip = options.offset ? ` SKIP ${options.offset}` : "";
|
||||
return `MATCH (n:${label})${where} RETURN ${returns}${order}${skip} LIMIT ${limit};`;
|
||||
export async function buildTableSelectSql(options: BuildTableSelectSqlOptions): Promise<string> {
|
||||
return api.buildTableSelectSql(options);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ColumnInfo, DatabaseType, IndexInfo } from "../types/database.ts";
|
||||
import { getTableStructureCapabilities, type TableStructureDialect } from "./tableStructureCapabilities.ts";
|
||||
|
||||
export interface EditableStructureColumn {
|
||||
id: string;
|
||||
@@ -40,689 +39,3 @@ export interface TableStructureChangeSql {
|
||||
statements: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
type StructureSqlFlavor = DatabaseType | TableStructureDialect | undefined;
|
||||
|
||||
function quoteIdent(databaseType: StructureSqlFlavor, name: string): string {
|
||||
if (
|
||||
databaseType === "mysql" ||
|
||||
databaseType === "doris" ||
|
||||
databaseType === "starrocks" ||
|
||||
databaseType === "goldendb" ||
|
||||
databaseType === "sundb"
|
||||
)
|
||||
return `\`${name.replace(/`/g, "``")}\``;
|
||||
if (databaseType === "sqlserver") return `[${name.replace(/\]/g, "]]")}]`;
|
||||
return `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function isOracleLike(databaseType: StructureSqlFlavor): boolean {
|
||||
return databaseType === "oracle" || databaseType === "dameng" || databaseType === "oceanbase-oracle";
|
||||
}
|
||||
|
||||
function qualifiedTable(databaseType: StructureSqlFlavor, schema: string | undefined, tableName: string): string {
|
||||
if (
|
||||
(databaseType === "postgres" ||
|
||||
isOracleLike(databaseType) ||
|
||||
databaseType === "sqlserver" ||
|
||||
databaseType === "h2") &&
|
||||
schema
|
||||
) {
|
||||
return `${quoteIdent(databaseType, schema)}.${quoteIdent(databaseType, tableName)}`;
|
||||
}
|
||||
return quoteIdent(databaseType, tableName);
|
||||
}
|
||||
|
||||
function quoteString(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function clean(value: string | null | undefined): string {
|
||||
return (value ?? "").trim();
|
||||
}
|
||||
|
||||
function normalizeDefault(value: string | null | undefined): string {
|
||||
const trimmed = clean(value);
|
||||
return trimmed.toLowerCase() === "null" ? "" : trimmed;
|
||||
}
|
||||
|
||||
function unwrapClickHouseNullableType(dataType: string): string {
|
||||
const match = dataType.trim().match(/^Nullable\s*\((.*)\)$/is);
|
||||
return match ? match[1].trim() : dataType.trim();
|
||||
}
|
||||
|
||||
function clickHouseColumnType(column: EditableStructureColumn): string {
|
||||
const dataType = column.dataType.trim();
|
||||
if (column.isNullable) {
|
||||
return /^Nullable\s*\(/i.test(dataType) ? dataType : `Nullable(${dataType})`;
|
||||
}
|
||||
return unwrapClickHouseNullableType(dataType);
|
||||
}
|
||||
|
||||
function columnDefinition(databaseType: StructureSqlFlavor, column: EditableStructureColumn): string {
|
||||
const dataType = databaseType === "clickhouse" ? clickHouseColumnType(column) : column.dataType.trim();
|
||||
const parts = [quoteIdent(databaseType, column.name), dataType];
|
||||
if (!column.isNullable && !isOracleLike(databaseType) && databaseType !== "clickhouse") parts.push("NOT NULL");
|
||||
const defaultValue = normalizeDefault(column.defaultValue);
|
||||
if (defaultValue) parts.push(`DEFAULT ${defaultValue}`);
|
||||
if (databaseType === "mysql" && clean(column.comment)) {
|
||||
parts.push(`COMMENT ${quoteString(clean(column.comment))}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function originalDefault(column: EditableStructureColumn): string {
|
||||
return normalizeDefault(column.original?.column_default);
|
||||
}
|
||||
|
||||
function originalComment(column: EditableStructureColumn): string {
|
||||
return clean(column.original?.comment);
|
||||
}
|
||||
|
||||
function hasExistingColumnAttributeChange(column: EditableStructureColumn): boolean {
|
||||
const original = column.original;
|
||||
if (!original) return false;
|
||||
return (
|
||||
column.name !== original.name ||
|
||||
column.dataType.trim() !== original.data_type.trim() ||
|
||||
column.isNullable !== original.is_nullable ||
|
||||
normalizeDefault(column.defaultValue) !== originalDefault(column) ||
|
||||
clean(column.comment) !== originalComment(column)
|
||||
);
|
||||
}
|
||||
|
||||
function buildAddColumnSql(
|
||||
databaseType: StructureSqlFlavor,
|
||||
table: string,
|
||||
column: EditableStructureColumn,
|
||||
positionClause = "",
|
||||
): string[] {
|
||||
const addKeyword = databaseType === "sqlserver" ? "ADD" : "ADD COLUMN";
|
||||
const definition = columnDefinition(databaseType, column);
|
||||
const statements = isOracleLike(databaseType)
|
||||
? [`ALTER TABLE ${table} ADD (${definition});`]
|
||||
: [`ALTER TABLE ${table} ${addKeyword} ${definition}${positionClause};`];
|
||||
if ((databaseType === "postgres" || isOracleLike(databaseType)) && clean(column.comment)) {
|
||||
statements.push(
|
||||
`COMMENT ON COLUMN ${table}.${quoteIdent(databaseType, column.name)} IS ${quoteString(clean(column.comment))};`,
|
||||
);
|
||||
}
|
||||
if (databaseType === "clickhouse" && clean(column.comment)) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} COMMENT COLUMN ${quoteIdent(databaseType, column.name)} ${quoteString(clean(column.comment))};`,
|
||||
);
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
function buildOracleLikeExistingColumnSql(
|
||||
databaseType: StructureSqlFlavor,
|
||||
table: string,
|
||||
column: EditableStructureColumn,
|
||||
): string[] {
|
||||
const original = column.original;
|
||||
if (!original) return [];
|
||||
|
||||
const statements: string[] = [];
|
||||
let currentName = original.name;
|
||||
if (column.name !== original.name) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} RENAME COLUMN ${quoteIdent(databaseType, original.name)} TO ${quoteIdent(databaseType, column.name)};`,
|
||||
);
|
||||
currentName = column.name;
|
||||
}
|
||||
if (column.dataType.trim() !== original.data_type.trim()) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} MODIFY (${quoteIdent(databaseType, currentName)} ${column.dataType.trim()});`,
|
||||
);
|
||||
}
|
||||
if (column.isNullable !== original.is_nullable) {
|
||||
const nullability = column.isNullable ? "NULL" : "NOT NULL";
|
||||
statements.push(`ALTER TABLE ${table} MODIFY (${quoteIdent(databaseType, currentName)} ${nullability});`);
|
||||
}
|
||||
if (normalizeDefault(column.defaultValue) !== originalDefault(column)) {
|
||||
const defaultValue = normalizeDefault(column.defaultValue) || "NULL";
|
||||
statements.push(`ALTER TABLE ${table} MODIFY (${quoteIdent(databaseType, currentName)} DEFAULT ${defaultValue});`);
|
||||
}
|
||||
if (clean(column.comment) !== originalComment(column)) {
|
||||
const commentValue = clean(column.comment) ? quoteString(clean(column.comment)) : "NULL";
|
||||
statements.push(`COMMENT ON COLUMN ${table}.${quoteIdent(databaseType, currentName)} IS ${commentValue};`);
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
function buildMysqlExistingColumnSql(table: string, column: EditableStructureColumn, positionClause = ""): string[] {
|
||||
const originalName = column.original?.name ?? column.name;
|
||||
const operation =
|
||||
column.name === originalName
|
||||
? `MODIFY COLUMN ${columnDefinition("mysql", column)}`
|
||||
: `CHANGE COLUMN ${quoteIdent("mysql", originalName)} ${columnDefinition("mysql", column)}`;
|
||||
return [`ALTER TABLE ${table} ${operation}${positionClause};`];
|
||||
}
|
||||
|
||||
function buildPostgresExistingColumnSql(table: string, column: EditableStructureColumn): string[] {
|
||||
const original = column.original;
|
||||
if (!original) return [];
|
||||
|
||||
const statements: string[] = [];
|
||||
const currentName = column.name;
|
||||
if (column.name !== original.name) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} RENAME COLUMN ${quoteIdent("postgres", original.name)} TO ${quoteIdent("postgres", column.name)};`,
|
||||
);
|
||||
}
|
||||
if (column.dataType.trim() !== original.data_type.trim()) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} ALTER COLUMN ${quoteIdent("postgres", currentName)} TYPE ${column.dataType.trim()};`,
|
||||
);
|
||||
}
|
||||
if (column.isNullable !== original.is_nullable) {
|
||||
const action = column.isNullable ? "DROP NOT NULL" : "SET NOT NULL";
|
||||
statements.push(`ALTER TABLE ${table} ALTER COLUMN ${quoteIdent("postgres", currentName)} ${action};`);
|
||||
}
|
||||
if (normalizeDefault(column.defaultValue) !== originalDefault(column)) {
|
||||
const defaultValue = normalizeDefault(column.defaultValue);
|
||||
const action = defaultValue ? `SET DEFAULT ${defaultValue}` : "DROP DEFAULT";
|
||||
statements.push(`ALTER TABLE ${table} ALTER COLUMN ${quoteIdent("postgres", currentName)} ${action};`);
|
||||
}
|
||||
if (clean(column.comment) !== originalComment(column)) {
|
||||
const commentValue = clean(column.comment) ? quoteString(clean(column.comment)) : "NULL";
|
||||
statements.push(`COMMENT ON COLUMN ${table}.${quoteIdent("postgres", currentName)} IS ${commentValue};`);
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
function buildH2ExistingColumnSql(table: string, column: EditableStructureColumn): string[] {
|
||||
const original = column.original;
|
||||
if (!original) return [];
|
||||
|
||||
const statements: string[] = [];
|
||||
let currentName = original.name;
|
||||
if (column.name !== original.name) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} ALTER COLUMN ${quoteIdent("h2", original.name)} RENAME TO ${quoteIdent("h2", column.name)};`,
|
||||
);
|
||||
currentName = column.name;
|
||||
}
|
||||
if (column.dataType.trim() !== original.data_type.trim()) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} ALTER COLUMN ${quoteIdent("h2", currentName)} SET DATA TYPE ${column.dataType.trim()};`,
|
||||
);
|
||||
}
|
||||
if (column.isNullable !== original.is_nullable) {
|
||||
const action = column.isNullable ? "DROP NOT NULL" : "SET NOT NULL";
|
||||
statements.push(`ALTER TABLE ${table} ALTER COLUMN ${quoteIdent("h2", currentName)} ${action};`);
|
||||
}
|
||||
if (normalizeDefault(column.defaultValue) !== originalDefault(column)) {
|
||||
const defaultValue = normalizeDefault(column.defaultValue);
|
||||
const action = defaultValue ? `SET DEFAULT ${defaultValue}` : "DROP DEFAULT";
|
||||
statements.push(`ALTER TABLE ${table} ALTER COLUMN ${quoteIdent("h2", currentName)} ${action};`);
|
||||
}
|
||||
if (clean(column.comment) !== originalComment(column)) {
|
||||
const commentValue = clean(column.comment) ? quoteString(clean(column.comment)) : "NULL";
|
||||
statements.push(`COMMENT ON COLUMN ${table}.${quoteIdent("h2", currentName)} IS ${commentValue};`);
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
function buildClickHouseExistingColumnSql(
|
||||
table: string,
|
||||
column: EditableStructureColumn,
|
||||
positionClause = "",
|
||||
): string[] {
|
||||
const original = column.original;
|
||||
if (!original) return [];
|
||||
|
||||
const statements: string[] = [];
|
||||
let currentName = original.name;
|
||||
if (column.name !== original.name) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} RENAME COLUMN ${quoteIdent("clickhouse", original.name)} TO ${quoteIdent("clickhouse", column.name)};`,
|
||||
);
|
||||
currentName = column.name;
|
||||
}
|
||||
if (
|
||||
clickHouseColumnType(column) !== original.data_type.trim() ||
|
||||
normalizeDefault(column.defaultValue) !== originalDefault(column)
|
||||
) {
|
||||
const defaultValue = normalizeDefault(column.defaultValue);
|
||||
if (defaultValue) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)} DEFAULT ${defaultValue}${positionClause};`,
|
||||
);
|
||||
} else if (originalDefault(column)) {
|
||||
statements.push(`ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} REMOVE DEFAULT;`);
|
||||
if (clickHouseColumnType(column) !== original.data_type.trim()) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)}${positionClause};`,
|
||||
);
|
||||
} else if (positionClause) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)}${positionClause};`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)}${positionClause};`,
|
||||
);
|
||||
}
|
||||
} else if (positionClause) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)}${positionClause};`,
|
||||
);
|
||||
}
|
||||
if (clean(column.comment) !== originalComment(column)) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} COMMENT COLUMN ${quoteIdent("clickhouse", currentName)} ${quoteString(clean(column.comment))};`,
|
||||
);
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
function buildSqliteExistingColumnSql(table: string, column: EditableStructureColumn, warnings: string[]): string[] {
|
||||
const original = column.original;
|
||||
if (!original) return [];
|
||||
|
||||
const statements: string[] = [];
|
||||
const unsupportedChange =
|
||||
column.dataType.trim() !== original.data_type.trim() ||
|
||||
column.isNullable !== original.is_nullable ||
|
||||
normalizeDefault(column.defaultValue) !== originalDefault(column) ||
|
||||
clean(column.comment) !== originalComment(column);
|
||||
if (column.name !== original.name) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} RENAME COLUMN ${quoteIdent("sqlite", original.name)} TO ${quoteIdent("sqlite", column.name)};`,
|
||||
);
|
||||
}
|
||||
if (unsupportedChange) {
|
||||
warnings.push(`SQLite cannot safely alter existing column "${original.name}" without rebuilding the table.`);
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
function columnPositionClause(
|
||||
dialect: TableStructureDialect,
|
||||
columns: EditableStructureColumn[],
|
||||
index: number,
|
||||
): string {
|
||||
if (dialect !== "mysql" && dialect !== "clickhouse") return "";
|
||||
if (index <= 0) return " FIRST";
|
||||
return ` AFTER ${quoteIdent(dialect, columns[index - 1]?.name ?? "")}`;
|
||||
}
|
||||
|
||||
function originalPreviousColumnName(
|
||||
columns: EditableStructureColumn[],
|
||||
column: EditableStructureColumn,
|
||||
): string | null {
|
||||
if (!column.original || typeof column.originalPosition !== "number") return null;
|
||||
const originalColumns = columns
|
||||
.filter((item) => item.original && typeof item.originalPosition === "number")
|
||||
.sort((a, b) => (a.originalPosition ?? 0) - (b.originalPosition ?? 0));
|
||||
const index = originalColumns.findIndex((item) => item.id === column.id);
|
||||
if (index < 0) return null;
|
||||
return index === 0 ? null : (originalColumns[index - 1]?.original?.name ?? null);
|
||||
}
|
||||
|
||||
function currentPreviousOriginalColumnName(columns: EditableStructureColumn[], index: number): string | null {
|
||||
if (index <= 0) return null;
|
||||
const previous = columns[index - 1];
|
||||
return previous?.original?.name ?? previous?.name ?? null;
|
||||
}
|
||||
|
||||
function mysqlColumnPositionChanged(columns: EditableStructureColumn[], index: number): boolean {
|
||||
const column = columns[index];
|
||||
if (!column?.original || typeof column.originalPosition !== "number") return false;
|
||||
return currentPreviousOriginalColumnName(columns, index) !== originalPreviousColumnName(columns, column);
|
||||
}
|
||||
|
||||
function buildColumnSql(options: BuildTableStructureChangeSqlOptions, warnings: string[]): string[] {
|
||||
const databaseType = options.databaseType;
|
||||
const capabilities = getTableStructureCapabilities(databaseType);
|
||||
const dialect = capabilities.dialect;
|
||||
const table = qualifiedTable(dialect, options.schema, options.tableName);
|
||||
const statements: string[] = [];
|
||||
const databaseLabel = databaseType ?? "this database";
|
||||
const activeColumns = options.columns.filter((column) => !column.markedForDrop);
|
||||
const hasOriginalColumnPositions = activeColumns.some((column) => typeof column.originalPosition === "number");
|
||||
|
||||
for (const column of options.columns) {
|
||||
if (column.markedForDrop) {
|
||||
if (!column.original) continue;
|
||||
if (!capabilities.dropColumn) {
|
||||
warnings.push(`Dropping columns is not supported for ${databaseLabel} from this editor.`);
|
||||
continue;
|
||||
}
|
||||
if (column.original.is_primary_key) {
|
||||
warnings.push(`Primary key column "${column.original.name}" cannot be dropped from this editor.`);
|
||||
continue;
|
||||
}
|
||||
statements.push(`ALTER TABLE ${table} DROP COLUMN ${quoteIdent(dialect, column.original.name)};`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const activeIndex = activeColumns.indexOf(column);
|
||||
const positionClause = hasOriginalColumnPositions ? columnPositionClause(dialect, activeColumns, activeIndex) : "";
|
||||
const hasPositionChange =
|
||||
hasOriginalColumnPositions &&
|
||||
(dialect === "mysql" || dialect === "clickhouse") &&
|
||||
!!column.original &&
|
||||
mysqlColumnPositionChanged(activeColumns, activeIndex);
|
||||
|
||||
if (!column.original) {
|
||||
if (!capabilities.addColumn) {
|
||||
warnings.push(`Adding columns is not supported for ${databaseLabel} from this editor.`);
|
||||
continue;
|
||||
}
|
||||
statements.push(...buildAddColumnSql(dialect, table, column, positionClause));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasExistingColumnAttributeChange(column) && !hasPositionChange) continue;
|
||||
const original = column.original;
|
||||
const hasRename = column.name !== original.name;
|
||||
const hasAttributeChange =
|
||||
column.dataType.trim() !== original.data_type.trim() ||
|
||||
column.isNullable !== original.is_nullable ||
|
||||
normalizeDefault(column.defaultValue) !== originalDefault(column) ||
|
||||
clean(column.comment) !== originalComment(column);
|
||||
if (hasPositionChange && !capabilities.reorderColumn) {
|
||||
warnings.push(`Reordering columns is not supported for ${databaseLabel} from this editor.`);
|
||||
}
|
||||
if (hasRename && !capabilities.renameColumn) {
|
||||
warnings.push(`Renaming columns is not supported for ${databaseLabel} from this editor.`);
|
||||
}
|
||||
if (hasAttributeChange && !capabilities.alterExistingColumn && dialect !== "sqlite") {
|
||||
warnings.push(`Editing existing columns is not supported for ${databaseLabel} yet.`);
|
||||
}
|
||||
if (
|
||||
(hasPositionChange && !capabilities.reorderColumn) ||
|
||||
(hasRename && !capabilities.renameColumn) ||
|
||||
(hasAttributeChange && !capabilities.alterExistingColumn && dialect !== "sqlite")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (dialect === "mysql") {
|
||||
statements.push(...buildMysqlExistingColumnSql(table, column, hasPositionChange ? positionClause : ""));
|
||||
} else if (dialect === "postgres") {
|
||||
statements.push(...buildPostgresExistingColumnSql(table, column));
|
||||
} else if (dialect === "oracle") {
|
||||
statements.push(...buildOracleLikeExistingColumnSql(dialect, table, column));
|
||||
} else if (dialect === "h2") {
|
||||
statements.push(...buildH2ExistingColumnSql(table, column));
|
||||
} else if (dialect === "clickhouse") {
|
||||
statements.push(...buildClickHouseExistingColumnSql(table, column, hasPositionChange ? positionClause : ""));
|
||||
} else if (dialect === "sqlite") {
|
||||
statements.push(...buildSqliteExistingColumnSql(table, column, warnings));
|
||||
} else {
|
||||
warnings.push(`Editing existing columns is not supported for ${databaseLabel} yet.`);
|
||||
}
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
function buildDropIndexSql(
|
||||
databaseType: StructureSqlFlavor,
|
||||
table: string,
|
||||
schema: string | undefined,
|
||||
indexName: string,
|
||||
): string {
|
||||
if (databaseType === "mysql") return `DROP INDEX ${quoteIdent(databaseType, indexName)} ON ${table};`;
|
||||
if (databaseType === "sqlserver") return `DROP INDEX ${quoteIdent(databaseType, indexName)} ON ${table};`;
|
||||
if ((databaseType === "postgres" || isOracleLike(databaseType)) && schema) {
|
||||
return `DROP INDEX ${quoteIdent(databaseType, schema)}.${quoteIdent(databaseType, indexName)};`;
|
||||
}
|
||||
return `DROP INDEX ${quoteIdent(databaseType, indexName)};`;
|
||||
}
|
||||
|
||||
function indexListChanged(next: string[], previous: string[] | null | undefined): boolean {
|
||||
const nextClean = next.map(clean).filter(Boolean);
|
||||
const previousClean = (previous ?? []).map(clean).filter(Boolean);
|
||||
return nextClean.length !== previousClean.length || nextClean.some((value, i) => value !== previousClean[i]);
|
||||
}
|
||||
|
||||
function originalIndexType(index: EditableStructureIndex): string {
|
||||
return clean(index.original?.index_type).toUpperCase();
|
||||
}
|
||||
|
||||
function normalizedIndexType(index: EditableStructureIndex): string {
|
||||
return clean(index.indexType).toUpperCase();
|
||||
}
|
||||
|
||||
function hasExistingIndexChange(index: EditableStructureIndex): boolean {
|
||||
const original = index.original;
|
||||
if (!original) return false;
|
||||
return (
|
||||
clean(index.name) !== clean(original.name) ||
|
||||
indexListChanged(index.columns, original.columns) ||
|
||||
index.isUnique !== original.is_unique ||
|
||||
normalizedIndexType(index) !== originalIndexType(index) ||
|
||||
indexListChanged(index.includedColumns, original.included_columns) ||
|
||||
clean(index.filter) !== clean(original.filter) ||
|
||||
clean(index.comment) !== clean(original.comment)
|
||||
);
|
||||
}
|
||||
|
||||
function mysqlIndexParts(indexType: string): { prefix: string; usingClause: string } {
|
||||
const upper = indexType.toUpperCase();
|
||||
if (upper === "FULLTEXT" || upper === "SPATIAL") return { prefix: `${upper} `, usingClause: "" };
|
||||
if (upper === "RTREE") return { prefix: "SPATIAL ", usingClause: "" };
|
||||
if (upper === "BTREE" || upper === "HASH") return { prefix: "", usingClause: ` USING ${upper}` };
|
||||
return { prefix: "", usingClause: "" };
|
||||
}
|
||||
|
||||
function buildCreateIndexStatements(
|
||||
databaseType: StructureSqlFlavor,
|
||||
table: string,
|
||||
index: EditableStructureIndex,
|
||||
warnings: string[],
|
||||
): string[] {
|
||||
const capabilities = getTableStructureCapabilities(databaseType as DatabaseType | undefined);
|
||||
const name = clean(index.name);
|
||||
const columns = index.columns.map(clean).filter(Boolean);
|
||||
if (!name || columns.length === 0) return [];
|
||||
|
||||
const unique = index.isUnique ? "UNIQUE " : "";
|
||||
const cols = columns.map((column) => quoteIdent(databaseType, column)).join(", ");
|
||||
const idxType = normalizedIndexType(index);
|
||||
let typePrefix = "";
|
||||
let usingClause = "";
|
||||
|
||||
if (idxType && capabilities.indexType) {
|
||||
if (databaseType === "postgres") {
|
||||
usingClause = ` USING ${idxType}`;
|
||||
} else if (databaseType === "sqlserver") {
|
||||
typePrefix = `${idxType} `;
|
||||
} else if (databaseType === "mysql") {
|
||||
const mysqlParts = mysqlIndexParts(idxType);
|
||||
typePrefix = mysqlParts.prefix;
|
||||
usingClause = mysqlParts.usingClause;
|
||||
} else if (isOracleLike(databaseType) && idxType === "BITMAP") {
|
||||
typePrefix = "BITMAP ";
|
||||
}
|
||||
}
|
||||
|
||||
const incCols = index.includedColumns.map(clean).filter(Boolean);
|
||||
const includeClause =
|
||||
incCols.length > 0 && capabilities.indexInclude && (databaseType === "postgres" || databaseType === "sqlserver")
|
||||
? ` INCLUDE (${incCols.map((c) => quoteIdent(databaseType, c)).join(", ")})`
|
||||
: "";
|
||||
const filter = clean(index.filter);
|
||||
const supportsWhere =
|
||||
capabilities.indexFilter &&
|
||||
(databaseType === "postgres" || databaseType === "sqlserver" || databaseType === "sqlite");
|
||||
const whereClause = filter && supportsWhere ? ` WHERE ${filter}` : "";
|
||||
const createSql =
|
||||
databaseType === "postgres"
|
||||
? `CREATE ${unique}${typePrefix}INDEX ${quoteIdent(databaseType, name)} ON ${table}${usingClause} (${cols})${includeClause}${whereClause};`
|
||||
: `CREATE ${unique}${typePrefix}INDEX ${quoteIdent(databaseType, name)}${usingClause} ON ${table} (${cols})${includeClause}${whereClause};`;
|
||||
const statements = [createSql];
|
||||
|
||||
const comment = clean(index.comment);
|
||||
if (comment && capabilities.indexComment && databaseType === "postgres") {
|
||||
statements.push(`COMMENT ON INDEX ${quoteIdent(databaseType, name)} IS ${quoteString(comment)};`);
|
||||
} else if (comment && capabilities.indexComment && databaseType !== "postgres") {
|
||||
warnings.push(`Index comments are not supported for ${databaseType ?? "this database"} from this editor.`);
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
function buildIndexSql(options: BuildTableStructureChangeSqlOptions, warnings: string[]): string[] {
|
||||
const databaseType = options.databaseType;
|
||||
const capabilities = getTableStructureCapabilities(databaseType);
|
||||
const dialect = capabilities.dialect;
|
||||
const table = qualifiedTable(dialect, options.schema, options.tableName);
|
||||
const statements: string[] = [];
|
||||
const databaseLabel = databaseType ?? "this database";
|
||||
|
||||
for (const index of options.indexes) {
|
||||
if (index.markedForDrop) {
|
||||
if (!index.original) continue;
|
||||
if (!capabilities.dropIndex) {
|
||||
warnings.push(`Dropping indexes is not supported for ${databaseLabel} from this editor.`);
|
||||
continue;
|
||||
}
|
||||
if (index.original.is_primary) {
|
||||
warnings.push(`Primary index "${index.original.name}" cannot be dropped from this editor.`);
|
||||
continue;
|
||||
}
|
||||
statements.push(buildDropIndexSql(dialect, table, options.schema, index.original.name));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index.original) {
|
||||
if (!hasExistingIndexChange(index)) continue;
|
||||
if (!capabilities.rebuildIndex || !capabilities.dropIndex || !capabilities.createIndex) {
|
||||
warnings.push(`Editing existing indexes is not supported for ${databaseLabel} from this editor.`);
|
||||
continue;
|
||||
}
|
||||
if (index.original.is_primary) {
|
||||
warnings.push(`Primary index "${index.original.name}" cannot be edited from this editor.`);
|
||||
continue;
|
||||
}
|
||||
statements.push(buildDropIndexSql(dialect, table, options.schema, index.original.name));
|
||||
statements.push(...buildCreateIndexStatements(dialect, table, index, warnings));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!capabilities.createIndex) {
|
||||
warnings.push(`Creating indexes is not supported for ${databaseLabel} from this editor.`);
|
||||
continue;
|
||||
}
|
||||
statements.push(...buildCreateIndexStatements(dialect, table, index, warnings));
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
function validateDraft(options: BuildTableStructureChangeSqlOptions): string[] {
|
||||
const warnings: string[] = [];
|
||||
const activeColumns = options.columns.filter((column) => !column.markedForDrop);
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const column of activeColumns) {
|
||||
if (!clean(column.name)) warnings.push("Column name cannot be empty.");
|
||||
if (!clean(column.dataType)) warnings.push(`Column "${column.name || "(new)"}" type cannot be empty.`);
|
||||
const key = clean(column.name).toLowerCase();
|
||||
if (key && names.has(key)) warnings.push(`Column "${column.name}" is duplicated.`);
|
||||
if (key) names.add(key);
|
||||
}
|
||||
|
||||
for (const index of options.indexes.filter(
|
||||
(idx) => !idx.markedForDrop && (!idx.original || hasExistingIndexChange(idx)),
|
||||
)) {
|
||||
if (!clean(index.name)) warnings.push("Index name cannot be empty.");
|
||||
if (index.columns.map(clean).filter(Boolean).length === 0) {
|
||||
warnings.push(`Index "${index.name || "(new)"}" needs at least one column.`);
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
export function buildTableStructureChangeSql(options: BuildTableStructureChangeSqlOptions): TableStructureChangeSql {
|
||||
const warnings = validateDraft(options);
|
||||
const statements = [...buildColumnSql(options, warnings), ...buildIndexSql(options, warnings)];
|
||||
|
||||
return { statements, warnings };
|
||||
}
|
||||
|
||||
export function buildCreateTableSql(options: BuildTableStructureChangeSqlOptions): TableStructureChangeSql {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!clean(options.tableName)) {
|
||||
warnings.push("Table name is required.");
|
||||
}
|
||||
|
||||
const activeColumns = options.columns.filter((c) => !c.markedForDrop);
|
||||
if (activeColumns.length === 0) {
|
||||
warnings.push("At least one column is required.");
|
||||
}
|
||||
|
||||
const names = new Set<string>();
|
||||
for (const col of activeColumns) {
|
||||
if (!clean(col.name)) warnings.push("Column name cannot be empty.");
|
||||
if (!clean(col.dataType)) warnings.push(`Column "${col.name || "(new)"}" type cannot be empty.`);
|
||||
const key = clean(col.name).toLowerCase();
|
||||
if (key && names.has(key)) warnings.push(`Column "${col.name}" is duplicated.`);
|
||||
if (key) names.add(key);
|
||||
}
|
||||
|
||||
if (warnings.length > 0) return { statements: [], warnings };
|
||||
|
||||
const databaseType = options.databaseType;
|
||||
const capabilities = getTableStructureCapabilities(databaseType);
|
||||
const dialect = capabilities.dialect;
|
||||
const table = qualifiedTable(dialect, options.schema, options.tableName);
|
||||
const statements: string[] = [];
|
||||
|
||||
const pkColumns = activeColumns.filter((c) => c.isPrimaryKey);
|
||||
const colDefs = activeColumns.map((col) => {
|
||||
const dataType = dialect === "clickhouse" ? clickHouseColumnType(col) : col.dataType.trim();
|
||||
const parts = [quoteIdent(dialect, col.name), dataType];
|
||||
if (!col.isNullable && !col.isPrimaryKey && dialect !== "clickhouse") parts.push("NOT NULL");
|
||||
const defaultValue = normalizeDefault(col.defaultValue);
|
||||
if (defaultValue) parts.push(`DEFAULT ${defaultValue}`);
|
||||
if (dialect === "mysql" && capabilities.comment && clean(col.comment)) {
|
||||
parts.push(`COMMENT ${quoteString(clean(col.comment))}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
});
|
||||
|
||||
if (pkColumns.length > 0) {
|
||||
const pkList = pkColumns.map((c) => quoteIdent(dialect, c.name)).join(", ");
|
||||
colDefs.push(`PRIMARY KEY (${pkList})`);
|
||||
}
|
||||
|
||||
statements.push(`CREATE TABLE ${table} (\n ${colDefs.join(",\n ")}\n);`);
|
||||
|
||||
if (capabilities.comment && (dialect === "postgres" || dialect === "oracle" || dialect === "h2")) {
|
||||
for (const col of activeColumns) {
|
||||
if (clean(col.comment)) {
|
||||
statements.push(
|
||||
`COMMENT ON COLUMN ${table}.${quoteIdent(dialect, col.name)} IS ${quoteString(clean(col.comment))};`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (capabilities.comment && dialect === "clickhouse") {
|
||||
for (const col of activeColumns) {
|
||||
if (clean(col.comment)) {
|
||||
statements.push(
|
||||
`ALTER TABLE ${table} COMMENT COLUMN ${quoteIdent(dialect, col.name)} ${quoteString(clean(col.comment))};`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const index of options.indexes.filter((idx) => !idx.markedForDrop && !idx.isPrimary)) {
|
||||
if (!capabilities.createIndex) {
|
||||
warnings.push(`Creating indexes is not supported for ${databaseType ?? "this database"} from this editor.`);
|
||||
continue;
|
||||
}
|
||||
statements.push(...buildCreateIndexStatements(dialect, table, index, warnings));
|
||||
}
|
||||
|
||||
return { statements, warnings };
|
||||
}
|
||||
|
||||
@@ -23,7 +23,15 @@ import type {
|
||||
} from "@/types/database";
|
||||
import type { AiConfig } from "@/stores/settingsStore";
|
||||
import type { QueryEditability } from "@/lib/sqlAnalysis";
|
||||
import type { DataGridSaveStatementOptions } from "@/lib/dataGridSql";
|
||||
import type {
|
||||
DataGridColumnValueFilterConditionOptions,
|
||||
DataGridContextFilterConditionOptions,
|
||||
DataGridCountSqlOptions,
|
||||
DataGridCopyInsertStatementOptions,
|
||||
DataGridCopyUpdateStatementOptions,
|
||||
DataGridSaveStatementOptions,
|
||||
HiveTablePropertiesSqlOptions,
|
||||
} from "@/lib/dataGridSql";
|
||||
import type {
|
||||
DataCompareFromTablesOptions,
|
||||
DataCompareFromTablesPreparation,
|
||||
@@ -31,6 +39,21 @@ import type {
|
||||
DataComparePreparationOptions,
|
||||
} from "@/lib/dataCompare";
|
||||
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff } from "@/lib/schemaDiff";
|
||||
import type { BuildTableStructureChangeSqlOptions, TableStructureChangeSql } from "@/lib/tableStructureEditorSql";
|
||||
import type { BuildTableSelectSqlOptions } from "@/lib/tableSelectSql";
|
||||
import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/databaseSearch";
|
||||
import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceInput } from "@/lib/objectSourceEditor";
|
||||
import type { BuildViewDdlInput } from "@/lib/viewDdl";
|
||||
import type { BuildRenameObjectSqlOptions } from "@/lib/objectRenameSql";
|
||||
import type { CreateDatabaseSqlOptions } from "@/lib/createDatabaseSql";
|
||||
import type {
|
||||
DatabaseNameSqlOptions,
|
||||
DropObjectSqlOptions,
|
||||
DuplicateTableStructureSqlOptions,
|
||||
SchemaNameSqlOptions,
|
||||
TableAdminSqlOptions,
|
||||
} from "@/lib/dbAdminSql";
|
||||
import type { BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions } from "@/lib/databaseExport";
|
||||
|
||||
export interface AgentDriverInfo {
|
||||
db_type: string;
|
||||
@@ -70,6 +93,62 @@ export interface DesktopSettings {
|
||||
show_tray_icon: boolean;
|
||||
}
|
||||
|
||||
export interface QueryPagination {
|
||||
limit: number;
|
||||
offset: number;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface QueryPaginationExecutionPlanOptions {
|
||||
sql: string;
|
||||
queryBaseSql: string;
|
||||
databaseType?: DatabaseType;
|
||||
pagination: QueryPagination;
|
||||
useAgentCursor: boolean;
|
||||
}
|
||||
|
||||
export interface QueryPaginationExecutionPlan {
|
||||
sqlToExecute: string;
|
||||
pageSql?: string;
|
||||
pageLimit?: number;
|
||||
pageOffset?: number;
|
||||
countSql?: string;
|
||||
useAgentResultSession: boolean;
|
||||
}
|
||||
|
||||
export type QuerySortDirection = "asc" | "desc";
|
||||
|
||||
export interface SortedQuerySqlOptions {
|
||||
originalSql: string;
|
||||
databaseType?: DatabaseType;
|
||||
resultColumns: string[];
|
||||
columnIndex: number;
|
||||
column: string;
|
||||
direction: QuerySortDirection;
|
||||
}
|
||||
|
||||
export interface QuerySqlBuildResult {
|
||||
ok: boolean;
|
||||
sql?: string;
|
||||
reason?: "empty" | "multi" | "not_select" | "unsupported" | "with";
|
||||
}
|
||||
|
||||
export interface BuildExplainSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
export interface ExplainSqlBuildResult {
|
||||
ok: boolean;
|
||||
sql?: string;
|
||||
reason?: "unsupported" | "empty" | "unsafe";
|
||||
}
|
||||
|
||||
export interface DroppedFilePreviewSqlOptions {
|
||||
path: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export type XlsxCellValue = string | number | boolean | null;
|
||||
|
||||
export interface DriverInstallProgress {
|
||||
@@ -337,6 +416,117 @@ export async function analyzeSqlReferences(sql: string, dialect?: string): Promi
|
||||
return invoke("analyze_sql_references", { sql, dialect });
|
||||
}
|
||||
|
||||
export async function findStatementAtCursor(sql: string, cursorPos: number): Promise<string> {
|
||||
return invoke("find_statement_at_cursor", { sql, cursorPos });
|
||||
}
|
||||
|
||||
export async function prepareQueryPaginationExecutionPlan(
|
||||
options: QueryPaginationExecutionPlanOptions,
|
||||
): Promise<QueryPaginationExecutionPlan> {
|
||||
return invoke("prepare_query_pagination_execution_plan", { options });
|
||||
}
|
||||
|
||||
export async function buildSortedQuerySql(options: SortedQuerySqlOptions): Promise<QuerySqlBuildResult> {
|
||||
return invoke("build_sorted_query_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildExplainSql(options: BuildExplainSqlOptions): Promise<ExplainSqlBuildResult> {
|
||||
return invoke("build_explain_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlOptions): Promise<string | undefined> {
|
||||
const result = await invoke<string | null>("build_dropped_file_preview_sql", { options });
|
||||
return result ?? undefined;
|
||||
}
|
||||
|
||||
export async function buildTableSelectSql(options: BuildTableSelectSqlOptions): Promise<string> {
|
||||
return invoke("build_table_select_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDatabaseSearchSql(options: DatabaseSearchSqlOptions): Promise<DatabaseSearchSql | null> {
|
||||
return invoke("build_database_search_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildSearchResultWhere(options: SearchResultWhereOptions): Promise<string> {
|
||||
return invoke("build_search_result_where", { options });
|
||||
}
|
||||
|
||||
export async function buildRenameObjectSql(options: BuildRenameObjectSqlOptions): Promise<string> {
|
||||
return invoke("build_rename_object_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildCreateDatabaseSql(options: CreateDatabaseSqlOptions): Promise<string> {
|
||||
return invoke("build_create_database_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDuckDbAttachDatabaseSql(path: string, name: string): Promise<string> {
|
||||
return invoke("build_duckdb_attach_database_sql", { options: { path, name } });
|
||||
}
|
||||
|
||||
export async function buildDropObjectSql(options: DropObjectSqlOptions): Promise<string> {
|
||||
return invoke("build_drop_object_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDropTableSql(options: TableAdminSqlOptions): Promise<string> {
|
||||
return invoke("build_drop_table_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildEmptyTableSql(options: TableAdminSqlOptions): Promise<string> {
|
||||
return invoke("build_empty_table_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildTruncateTableSql(options: TableAdminSqlOptions): Promise<string> {
|
||||
return invoke("build_truncate_table_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDropDatabaseSql(options: DatabaseNameSqlOptions): Promise<string> {
|
||||
return invoke("build_drop_database_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildCreateSchemaSql(options: SchemaNameSqlOptions): Promise<string> {
|
||||
return invoke("build_create_schema_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDropSchemaSql(options: SchemaNameSqlOptions): Promise<string> {
|
||||
return invoke("build_drop_schema_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDuplicateTableStructureSql(options: DuplicateTableStructureSqlOptions): Promise<string> {
|
||||
return invoke("build_duplicate_table_structure_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildExecutableObjectSourceStatements(
|
||||
input: BuildEditableObjectSourceSqlInput,
|
||||
): Promise<string[]> {
|
||||
return invoke("build_executable_object_source_statements", { input });
|
||||
}
|
||||
|
||||
export async function buildExecutableObjectSourceSql(input: BuildEditableObjectSourceSqlInput): Promise<string> {
|
||||
return invoke("build_executable_object_source_sql", { input });
|
||||
}
|
||||
|
||||
export async function buildRoutineRenameObjectSourceStatements(
|
||||
input: BuildRoutineRenameObjectSourceInput,
|
||||
): Promise<string[]> {
|
||||
return invoke("build_routine_rename_object_source_statements", { input });
|
||||
}
|
||||
|
||||
export async function buildViewDdlSql(input: BuildViewDdlInput): Promise<string> {
|
||||
return invoke("build_view_ddl_sql", { input });
|
||||
}
|
||||
|
||||
export async function buildTableStructureChangeSql(
|
||||
options: BuildTableStructureChangeSqlOptions,
|
||||
): Promise<TableStructureChangeSql> {
|
||||
return invoke("build_table_structure_change_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildCreateTableSql(
|
||||
options: BuildTableStructureChangeSqlOptions,
|
||||
): Promise<TableStructureChangeSql> {
|
||||
return invoke("build_create_table_sql", { options });
|
||||
}
|
||||
|
||||
export async function analyzeEditableQueryEditability(sql: string): Promise<QueryEditability> {
|
||||
return invoke("analyze_editable_query_editability", { sql });
|
||||
}
|
||||
@@ -352,6 +542,53 @@ export async function prepareDataGridSave(options: DataGridSaveStatementOptions)
|
||||
return invoke("prepare_data_grid_save", { options });
|
||||
}
|
||||
|
||||
export async function buildDataGridCopyUpdateStatements(
|
||||
options: DataGridCopyUpdateStatementOptions,
|
||||
): Promise<string[]> {
|
||||
return invoke("build_data_grid_copy_update_statements", { options });
|
||||
}
|
||||
|
||||
export async function buildDataGridCopyInsertStatement(
|
||||
options: DataGridCopyInsertStatementOptions,
|
||||
): Promise<string | undefined> {
|
||||
const result = await invoke<string | null>("build_data_grid_copy_insert_statement", { options });
|
||||
return result ?? undefined;
|
||||
}
|
||||
|
||||
export async function buildDataGridContextFilterCondition(
|
||||
options: DataGridContextFilterConditionOptions,
|
||||
): Promise<string | undefined> {
|
||||
const result = await invoke<string | null>("build_data_grid_context_filter_condition", { options });
|
||||
return result ?? undefined;
|
||||
}
|
||||
|
||||
export async function buildDataGridColumnValueFilterCondition(
|
||||
options: DataGridColumnValueFilterConditionOptions,
|
||||
): Promise<string | undefined> {
|
||||
const result = await invoke<string | null>("build_data_grid_column_value_filter_condition", { options });
|
||||
return result ?? undefined;
|
||||
}
|
||||
|
||||
export async function buildDataGridCountSql(options: DataGridCountSqlOptions): Promise<string> {
|
||||
return invoke("build_data_grid_count_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildHiveTablePropertiesSql(options: HiveTablePropertiesSqlOptions): Promise<string> {
|
||||
return invoke("build_hive_table_properties_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildExportInsertStatements(options: BuildExportInsertStatementsOptions): Promise<string[]> {
|
||||
return invoke("build_export_insert_statements", { options });
|
||||
}
|
||||
|
||||
export async function buildExportSqlInsert(options: BuildExportInsertStatementsOptions): Promise<string> {
|
||||
return invoke("build_export_sql_insert", { options });
|
||||
}
|
||||
|
||||
export async function buildDatabaseSqlExport(options: BuildDatabaseSqlExportOptions): Promise<string> {
|
||||
return invoke("build_database_sql_export", { options });
|
||||
}
|
||||
|
||||
export async function prepareDataCompare(options: DataComparePreparationOptions): Promise<DataComparePreparation> {
|
||||
return invoke("prepare_data_compare", { options });
|
||||
}
|
||||
|
||||
@@ -1,50 +1,13 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
type BuildViewDdlInput = {
|
||||
export type BuildViewDdlInput = {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string | null;
|
||||
name: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
const postgresLikeTypes = new Set<DatabaseType>([
|
||||
"postgres",
|
||||
"redshift",
|
||||
"gaussdb",
|
||||
"kingbase",
|
||||
"highgo",
|
||||
"vastbase",
|
||||
"opengauss",
|
||||
]);
|
||||
|
||||
function ensureSemicolon(sql: string): string {
|
||||
const trimmed = sql.trim();
|
||||
return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
|
||||
}
|
||||
|
||||
function quotePostgresIdentifier(value: string): string {
|
||||
return `"${value.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function quoteMysqlIdentifier(value: string): string {
|
||||
return `\`${value.replaceAll("`", "``")}\``;
|
||||
}
|
||||
|
||||
function qualifiedName(input: BuildViewDdlInput): string {
|
||||
const parts = [input.schema, input.name].filter(Boolean) as string[];
|
||||
if (input.databaseType === "mysql" || input.databaseType === "goldendb") {
|
||||
return parts.map(quoteMysqlIdentifier).join(".");
|
||||
}
|
||||
return parts.map(quotePostgresIdentifier).join(".");
|
||||
}
|
||||
|
||||
export function buildViewDdl(input: BuildViewDdlInput): string {
|
||||
const source = input.source.trim();
|
||||
if (/^(?:CREATE|ALTER)\s+/i.test(source)) return ensureSemicolon(source);
|
||||
|
||||
if (!input.databaseType || postgresLikeTypes.has(input.databaseType)) {
|
||||
return `CREATE OR REPLACE VIEW ${qualifiedName(input)} AS\n${ensureSemicolon(source)}`;
|
||||
}
|
||||
|
||||
return `CREATE VIEW ${qualifiedName(input)} AS\n${ensureSemicolon(source)}`;
|
||||
export function buildViewDdl(input: BuildViewDdlInput): Promise<string> {
|
||||
return api.buildViewDdlSql(input);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
parseMongoCountDocumentsCommand,
|
||||
parseMongoFindCommand,
|
||||
} from "@/lib/mongoShellCommand";
|
||||
import { buildQueryPaginationExecutionPlan } from "@/lib/queryResultPagination";
|
||||
import { AGENT_DRIVER_TYPES } from "@/lib/databaseCapabilities";
|
||||
import { editablePrimaryKeys } from "@/lib/tableEditing";
|
||||
import * as api from "@/lib/api";
|
||||
@@ -484,7 +483,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
await closeResultSession(tab, options?.pagination?.sessionId);
|
||||
if (tab.mode === "query") {
|
||||
const pagination = options?.pagination ?? { limit: settingsStore.editorSettings.pageSize, offset: 0 };
|
||||
const plan = buildQueryPaginationExecutionPlan({
|
||||
const plan = await api.prepareQueryPaginationExecutionPlan({
|
||||
sql,
|
||||
queryBaseSql,
|
||||
databaseType: conn?.db_type,
|
||||
@@ -659,7 +658,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
if (!tab) return { ok: false as const, reason: "empty" as const };
|
||||
|
||||
const built = buildExplainSql(databaseType, sql);
|
||||
const built = await buildExplainSql(databaseType, sql);
|
||||
if (!built.ok) {
|
||||
tab.explainPlan = undefined;
|
||||
tab.explainError = built.reason;
|
||||
|
||||
@@ -10,6 +10,7 @@ duckdb-bundled = ["duckdb/bundled"]
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
regex = "1"
|
||||
percent-encoding = "2"
|
||||
log = "0.4"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
@@ -55,6 +55,92 @@ pub struct DataGridSaveStatementOptions {
|
||||
pub new_rows: Vec<Vec<Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataGridCopyUpdateStatementOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub table_meta: DataGridTableMeta,
|
||||
pub columns: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_columns: Option<Vec<Option<String>>>,
|
||||
#[serde(default)]
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataGridCopyInsertStatementOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table_meta: Option<DataGridTableMeta>,
|
||||
pub columns: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_columns: Option<Vec<Option<String>>>,
|
||||
#[serde(default)]
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
#[serde(default)]
|
||||
pub exclude_primary_keys: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DataGridContextFilterMode {
|
||||
Equals,
|
||||
NotEquals,
|
||||
IsNull,
|
||||
IsNotNull,
|
||||
Like,
|
||||
NotLike,
|
||||
LessThan,
|
||||
GreaterThan,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataGridContextFilterConditionOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub column_name: String,
|
||||
pub mode: DataGridContextFilterMode,
|
||||
pub value: Value,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub column_info: Option<DataGridColumnInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataGridColumnValueFilterConditionOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub column_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub column_info: Option<DataGridColumnInfo>,
|
||||
pub raw_value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataGridCountSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub where_input: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HiveTablePropertiesSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
pub property_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataGridSavePreparation {
|
||||
@@ -85,6 +171,219 @@ pub fn prepare_data_grid_save(options: DataGridSaveStatementOptions) -> DataGrid
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_data_grid_copy_update_statements(options: DataGridCopyUpdateStatementOptions) -> Vec<String> {
|
||||
if matches!(options.database_type, Some(DatabaseType::Neo4j | DatabaseType::Tdengine)) {
|
||||
return Vec::new();
|
||||
}
|
||||
let primary_keys = &options.table_meta.primary_keys;
|
||||
if primary_keys.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let save_columns = effective_copy_columns(options.source_columns.as_deref(), &options.columns);
|
||||
let column_info = options.table_meta.columns.as_deref().unwrap_or(&[]);
|
||||
let primary_key_indexes: Vec<Option<usize>> =
|
||||
primary_keys.iter().map(|primary_key| find_column_index(&save_columns, primary_key)).collect();
|
||||
if primary_key_indexes.iter().any(Option::is_none) {
|
||||
return Vec::new();
|
||||
}
|
||||
let primary_key_indexes: Vec<usize> = primary_key_indexes.into_iter().flatten().collect();
|
||||
let primary_key_set: Vec<String> =
|
||||
primary_keys.iter().map(|primary_key| normalize_column_name(primary_key)).collect();
|
||||
let writable_indexes: Vec<(&str, usize)> = save_columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, column)| Some((column.as_deref()?, index)))
|
||||
.filter(|(column, _)| !primary_key_set.contains(&normalize_column_name(column)))
|
||||
.filter(|(column, _)| !is_oracle_row_id(options.database_type, Some(column)))
|
||||
.collect();
|
||||
|
||||
if writable_indexes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let table = qualified_table_name(
|
||||
options.database_type,
|
||||
options.table_meta.schema.as_deref(),
|
||||
&options.table_meta.table_name,
|
||||
);
|
||||
let mut statements = Vec::new();
|
||||
for row in &options.rows {
|
||||
if primary_key_indexes.iter().any(|index| row.get(*index).unwrap_or(&Value::Null).is_null()) {
|
||||
continue;
|
||||
}
|
||||
let sets = writable_indexes
|
||||
.iter()
|
||||
.map(|(column, index)| {
|
||||
format!(
|
||||
"{} = {}",
|
||||
quote_ident(options.database_type, column),
|
||||
format_grid_sql_literal(
|
||||
row.get(*index).unwrap_or(&Value::Null),
|
||||
options.database_type,
|
||||
column_info_for(column_info, column)
|
||||
)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if sets.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let where_clause = primary_keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, primary_key)| {
|
||||
build_column_predicate(
|
||||
options.database_type,
|
||||
primary_key,
|
||||
row.get(primary_key_indexes[index]).unwrap_or(&Value::Null),
|
||||
column_info_for(column_info, primary_key),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ");
|
||||
statements.push(format!("UPDATE {table} SET {sets} WHERE {where_clause};"));
|
||||
}
|
||||
statements
|
||||
}
|
||||
|
||||
pub fn build_data_grid_copy_insert_statement(options: DataGridCopyInsertStatementOptions) -> Option<String> {
|
||||
let save_columns = effective_copy_columns(options.source_columns.as_deref(), &options.columns);
|
||||
let column_info = options.table_meta.as_ref().and_then(|meta| meta.columns.as_deref()).unwrap_or(&[]);
|
||||
let primary_key_set: Vec<String> = options
|
||||
.table_meta
|
||||
.as_ref()
|
||||
.map(|meta| meta.primary_keys.iter().map(|primary_key| normalize_column_name(primary_key)).collect())
|
||||
.unwrap_or_default();
|
||||
let insertable_columns: Vec<(&str, usize)> = save_columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, column)| Some((column.as_deref()?, index)))
|
||||
.filter(|(column, _)| !is_oracle_row_id(options.database_type, Some(column)))
|
||||
.collect();
|
||||
let insert_columns: Vec<(&str, usize)> = insertable_columns
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(column, _)| {
|
||||
!options.exclude_primary_keys || !primary_key_set.contains(&normalize_column_name(column))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if options.exclude_primary_keys && insert_columns.len() == insertable_columns.len() {
|
||||
return None;
|
||||
}
|
||||
if insert_columns.is_empty() || options.rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let table = options.table_meta.as_ref().map_or_else(
|
||||
|| "table_name".to_string(),
|
||||
|meta| qualified_table_name(options.database_type, meta.schema.as_deref(), &meta.table_name),
|
||||
);
|
||||
let columns = insert_columns
|
||||
.iter()
|
||||
.map(|(column, _)| quote_ident(options.database_type, column))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let value_rows = options
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
format!(
|
||||
"({})",
|
||||
insert_columns
|
||||
.iter()
|
||||
.map(|(column, index)| {
|
||||
format_grid_sql_literal(
|
||||
row.get(*index).unwrap_or(&Value::Null),
|
||||
options.database_type,
|
||||
column_info_for(column_info, column),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Some(format!(
|
||||
"INSERT INTO {table} ({columns}) VALUES{}{};",
|
||||
if value_rows.len() == 1 { " " } else { "\n" },
|
||||
value_rows.join(",\n")
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_data_grid_context_filter_condition(options: DataGridContextFilterConditionOptions) -> Option<String> {
|
||||
let column = column_filter_ref(options.database_type, &options.column_name);
|
||||
let value = &options.value;
|
||||
match options.mode {
|
||||
DataGridContextFilterMode::IsNull => Some(format!("{column} IS NULL")),
|
||||
DataGridContextFilterMode::IsNotNull => Some(format!("{column} IS NOT NULL")),
|
||||
DataGridContextFilterMode::Equals if value.is_null() => Some(format!("{column} IS NULL")),
|
||||
DataGridContextFilterMode::NotEquals if value.is_null() => Some(format!("{column} IS NOT NULL")),
|
||||
DataGridContextFilterMode::Like => Some(format!(
|
||||
"{column} LIKE {}",
|
||||
format_grid_sql_literal(
|
||||
&Value::String(format!("%{}%", value_to_filter_text(value))),
|
||||
options.database_type,
|
||||
None
|
||||
)
|
||||
)),
|
||||
DataGridContextFilterMode::NotLike => Some(format!(
|
||||
"{column} NOT LIKE {}",
|
||||
format_grid_sql_literal(
|
||||
&Value::String(format!("%{}%", value_to_filter_text(value))),
|
||||
options.database_type,
|
||||
None
|
||||
)
|
||||
)),
|
||||
DataGridContextFilterMode::LessThan => Some(format!(
|
||||
"{column} < {}",
|
||||
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
|
||||
)),
|
||||
DataGridContextFilterMode::GreaterThan => Some(format!(
|
||||
"{column} > {}",
|
||||
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
|
||||
)),
|
||||
DataGridContextFilterMode::Equals => Some(format!(
|
||||
"{column} = {}",
|
||||
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
|
||||
)),
|
||||
DataGridContextFilterMode::NotEquals => Some(format!(
|
||||
"{column} <> {}",
|
||||
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_data_grid_column_value_filter_condition(
|
||||
options: DataGridColumnValueFilterConditionOptions,
|
||||
) -> Option<String> {
|
||||
let text = options.raw_value.trim();
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let column = column_filter_ref(options.database_type, &options.column_name);
|
||||
if text.eq_ignore_ascii_case("null") {
|
||||
return Some(format!("{column} IS NULL"));
|
||||
}
|
||||
let value = parse_typed_filter_value(text, options.column_info.as_ref());
|
||||
Some(format!("{column} = {}", format_grid_sql_literal(&value, options.database_type, options.column_info.as_ref())))
|
||||
}
|
||||
|
||||
pub fn build_data_grid_count_sql(options: DataGridCountSqlOptions) -> String {
|
||||
let table = qualified_table_name(options.database_type, options.schema.as_deref(), &options.table_name);
|
||||
let predicate = crate::sql_dialect::normalize_where_input(options.where_input.as_deref());
|
||||
let where_clause = if predicate.is_empty() { String::new() } else { format!(" WHERE ({predicate})") };
|
||||
format!("SELECT COUNT(*) AS cnt FROM {table}{where_clause}")
|
||||
}
|
||||
|
||||
pub fn build_hive_table_properties_sql(options: HiveTablePropertiesSqlOptions) -> String {
|
||||
let table = qualified_table_name(Some(DatabaseType::Hive), options.schema.as_deref(), &options.table_name);
|
||||
let property = options.property_name.replace('\'', "''");
|
||||
format!("SHOW TBLPROPERTIES {table} ('{property}')")
|
||||
}
|
||||
|
||||
fn validate_data_grid_save(options: &DataGridSaveStatementOptions) -> Option<String> {
|
||||
if let Some(error) = validate_inserted_primary_keys(options) {
|
||||
return Some(error);
|
||||
@@ -590,6 +889,13 @@ fn effective_columns(options: &DataGridSaveStatementOptions) -> Vec<Option<Strin
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_copy_columns(source_columns: Option<&[Option<String>]>, columns: &[String]) -> Vec<Option<String>> {
|
||||
match source_columns {
|
||||
Some(source_columns) if source_columns.len() == columns.len() => source_columns.to_vec(),
|
||||
_ => columns.iter().map(|column| Some(column.clone())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_column(options: &DataGridSaveStatementOptions, index: usize) -> Option<&str> {
|
||||
match &options.source_columns {
|
||||
Some(source_columns) if source_columns.len() == options.columns.len() => source_columns.get(index)?.as_deref(),
|
||||
@@ -984,6 +1290,93 @@ fn qualified_table_name(database_type: Option<DatabaseType>, schema: Option<&str
|
||||
crate::sql_dialect::qualified_table_name(database_type, schema, table_name)
|
||||
}
|
||||
|
||||
fn column_filter_ref(database_type: Option<DatabaseType>, column_name: &str) -> String {
|
||||
let quoted = quote_ident(database_type, column_name);
|
||||
if database_type == Some(DatabaseType::Neo4j) {
|
||||
format!("n.{quoted}")
|
||||
} else {
|
||||
quoted
|
||||
}
|
||||
}
|
||||
|
||||
fn value_to_filter_text(value: &Value) -> String {
|
||||
if let Some(value) = value.as_str() {
|
||||
value.to_string()
|
||||
} else if value.is_null() {
|
||||
String::new()
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_typed_filter_value(text: &str, column_info: Option<&DataGridColumnInfo>) -> Value {
|
||||
let unquoted = unwrap_matching_quotes(text);
|
||||
let data_type = column_info.map(|column| column.data_type.to_ascii_lowercase()).unwrap_or_default();
|
||||
if is_boolean_type(&data_type) && unquoted.eq_ignore_ascii_case("true") {
|
||||
return Value::Bool(true);
|
||||
}
|
||||
if is_boolean_type(&data_type) && unquoted.eq_ignore_ascii_case("false") {
|
||||
return Value::Bool(false);
|
||||
}
|
||||
if (is_numeric_type(&data_type) || data_type.is_empty()) && is_numeric_literal(&unquoted) {
|
||||
if let Ok(number) = unquoted.parse::<serde_json::Number>() {
|
||||
return Value::Number(number);
|
||||
}
|
||||
}
|
||||
Value::String(unquoted)
|
||||
}
|
||||
|
||||
fn unwrap_matching_quotes(text: &str) -> String {
|
||||
let mut chars = text.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return String::new();
|
||||
};
|
||||
let Some(last) = text.chars().last() else {
|
||||
return String::new();
|
||||
};
|
||||
if text.len() >= 2 && ((first == '\'' && last == '\'') || (first == '"' && last == '"')) {
|
||||
text[1..text.len() - 1].to_string()
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_numeric_type(data_type: &str) -> bool {
|
||||
let lower = data_type.to_ascii_lowercase();
|
||||
[
|
||||
"int",
|
||||
"integer",
|
||||
"bigint",
|
||||
"smallint",
|
||||
"tinyint",
|
||||
"mediumint",
|
||||
"serial",
|
||||
"number",
|
||||
"numeric",
|
||||
"decimal",
|
||||
"float",
|
||||
"double",
|
||||
"real",
|
||||
"money",
|
||||
]
|
||||
.iter()
|
||||
.any(|part| lower.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|token| token == *part))
|
||||
}
|
||||
|
||||
fn is_boolean_type(data_type: &str) -> bool {
|
||||
let lower = data_type.to_ascii_lowercase();
|
||||
lower.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|token| matches!(token, "bool" | "boolean" | "bit"))
|
||||
}
|
||||
|
||||
fn is_numeric_literal(text: &str) -> bool {
|
||||
if text.trim() != text || text.is_empty() {
|
||||
return false;
|
||||
}
|
||||
text.parse::<f64>().is_ok_and(f64::is_finite)
|
||||
&& text.chars().all(|ch| ch.is_ascii_digit() || matches!(ch, '+' | '-' | '.' | 'e' | 'E'))
|
||||
&& text.chars().any(|ch| ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn uses_keyless_row_predicate(database_type: Option<DatabaseType>) -> bool {
|
||||
matches!(
|
||||
database_type,
|
||||
@@ -1110,6 +1503,109 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_copy_update_statements() {
|
||||
let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
table_meta: DataGridTableMeta {
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "users".to_string(),
|
||||
primary_keys: vec!["id".to_string()],
|
||||
columns: None,
|
||||
},
|
||||
columns: vec!["id".to_string(), "name".to_string(), "status".to_string()],
|
||||
source_columns: None,
|
||||
rows: vec![vec![json!(1), json!("Ada"), json!("active")]],
|
||||
});
|
||||
assert_eq!(
|
||||
statements,
|
||||
vec!["UPDATE \"public\".\"users\" SET \"name\" = 'Ada', \"status\" = 'active' WHERE \"id\" = 1;"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_copy_insert_statement_without_primary_keys() {
|
||||
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
table_meta: Some(DataGridTableMeta {
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
primary_keys: vec!["id".to_string()],
|
||||
columns: None,
|
||||
}),
|
||||
columns: vec!["id".to_string(), "login_name".to_string(), "display_name".to_string()],
|
||||
source_columns: None,
|
||||
rows: vec![vec![json!(1), json!("ada"), json!("Ada")], vec![json!(2), json!("linus"), json!("Linus")]],
|
||||
exclude_primary_keys: true,
|
||||
});
|
||||
assert_eq!(
|
||||
statement.as_deref(),
|
||||
Some("INSERT INTO `users` (`login_name`, `display_name`) VALUES\n('ada', 'Ada'),\n('linus', 'Linus');")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_filter_conditions() {
|
||||
assert_eq!(
|
||||
build_data_grid_column_value_filter_condition(DataGridColumnValueFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
column_name: "id".to_string(),
|
||||
column_info: Some(column("id", "int", false, None)),
|
||||
raw_value: "49436".to_string(),
|
||||
})
|
||||
.as_deref(),
|
||||
Some("`id` = 49436")
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
column_name: "status".to_string(),
|
||||
mode: DataGridContextFilterMode::Like,
|
||||
value: json!("active"),
|
||||
column_info: Some(column("status", "varchar", true, None)),
|
||||
})
|
||||
.as_deref(),
|
||||
Some("\"status\" LIKE '%active%'")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_grid_count_sql() {
|
||||
assert_eq!(
|
||||
build_data_grid_count_sql(DataGridCountSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "users".to_string(),
|
||||
where_input: Some("WHERE active = true;".to_string()),
|
||||
}),
|
||||
"SELECT COUNT(*) AS cnt FROM \"public\".\"users\" WHERE (active = true)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_hive_table_properties_sql() {
|
||||
assert_eq!(
|
||||
build_hive_table_properties_sql(HiveTablePropertiesSqlOptions {
|
||||
schema: Some("default".to_string()),
|
||||
table_name: "events".to_string(),
|
||||
property_name: "transactional".to_string(),
|
||||
}),
|
||||
"SHOW TBLPROPERTIES `events` ('transactional')"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_temporal_copy_literals() {
|
||||
assert_eq!(
|
||||
format_grid_sql_literal(&json!("2026-05-12T00:00:00+00:00"), Some(DatabaseType::Mysql), None),
|
||||
"'2026-05-12 00:00:00'"
|
||||
);
|
||||
assert_eq!(
|
||||
format_grid_sql_literal(&json!("2026-05-12T00:00:00.123456Z"), Some(DatabaseType::Mysql), None),
|
||||
"'2026-05-12 00:00:00.123456'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepares_sqlserver_save_statements() {
|
||||
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::io::Write;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::sql_dialect::{qualified_table_name, quote_table_identifier};
|
||||
|
||||
static EXPORT_CANCELLED: std::sync::LazyLock<RwLock<HashSet<String>>> =
|
||||
std::sync::LazyLock::new(|| RwLock::new(HashSet::new()));
|
||||
@@ -47,6 +49,183 @@ pub enum ExportStatus {
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
pub const DATABASE_EXPORT_ROW_LIMIT: usize = 10_000;
|
||||
pub const DATABASE_EXPORT_PAGE_SIZE: usize = 500;
|
||||
pub const DATABASE_EXPORT_INSERT_BATCH_SIZE: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportedTableSql {
|
||||
pub display_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub qualified_table_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ddl: Option<String>,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
#[serde(default)]
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildExportInsertStatementsOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub qualified_table_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildExportSqlInsertOptions {
|
||||
#[serde(flatten)]
|
||||
pub insert: BuildExportInsertStatementsOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDatabaseSqlExportOptions {
|
||||
pub database_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exported_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tables: Vec<ExportedTableSql>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub row_limit_per_table: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub insert_batch_size: Option<usize>,
|
||||
}
|
||||
|
||||
pub fn format_export_sql_literal(value: &Value) -> String {
|
||||
if value.is_null() {
|
||||
return "NULL".to_string();
|
||||
}
|
||||
if let Some(number) = value.as_number() {
|
||||
return number.to_string();
|
||||
}
|
||||
if let Some(value) = value.as_bool() {
|
||||
return if value { "TRUE" } else { "FALSE" }.to_string();
|
||||
}
|
||||
let text = value.as_str().map_or_else(|| value.to_string(), ToString::to_string);
|
||||
format!("'{}'", text.replace('\'', "''"))
|
||||
}
|
||||
|
||||
pub fn build_export_insert_statements(options: BuildExportInsertStatementsOptions) -> Result<Vec<String>, String> {
|
||||
if options.columns.is_empty() || options.rows.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let table = export_qualified_table_name(
|
||||
options.database_type,
|
||||
options.schema.as_deref(),
|
||||
options.table_name.as_deref(),
|
||||
options.qualified_table_name.as_deref(),
|
||||
)?;
|
||||
let batch_size = options.batch_size.unwrap_or(DATABASE_EXPORT_INSERT_BATCH_SIZE).max(1);
|
||||
let columns = options
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| quote_table_identifier(options.database_type, column))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let mut statements = Vec::new();
|
||||
|
||||
for rows in options.rows.chunks(batch_size) {
|
||||
let values = rows
|
||||
.iter()
|
||||
.map(|row| format!("({})", row.iter().map(format_export_sql_literal).collect::<Vec<_>>().join(", ")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
statements.push(format!("INSERT INTO {table} ({columns}) VALUES {values};"));
|
||||
}
|
||||
|
||||
Ok(statements)
|
||||
}
|
||||
|
||||
pub fn build_export_sql_insert(options: BuildExportSqlInsertOptions) -> Result<String, String> {
|
||||
build_export_insert_statements(options.insert).map(|statements| statements.join("\n"))
|
||||
}
|
||||
|
||||
pub fn build_database_sql_export(options: BuildDatabaseSqlExportOptions) -> Result<String, String> {
|
||||
let exported_at = options.exported_at.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
|
||||
let row_limit = options.row_limit_per_table.unwrap_or(DATABASE_EXPORT_ROW_LIMIT);
|
||||
let insert_batch_size = options.insert_batch_size.unwrap_or(DATABASE_EXPORT_INSERT_BATCH_SIZE);
|
||||
let mut lines = vec![
|
||||
"-- DBX database export".to_string(),
|
||||
format!("-- Database: {}", options.database_name),
|
||||
format!("-- Exported at: {exported_at}"),
|
||||
format!("-- Row limit per table: {row_limit}"),
|
||||
String::new(),
|
||||
];
|
||||
|
||||
for table in options.tables {
|
||||
if let Some(ddl) = table.ddl.as_ref().map(|ddl| ddl.trim()).filter(|ddl| !ddl.is_empty()) {
|
||||
lines.push(format!("-- Structure for {}", table.display_name));
|
||||
lines.push(format!("{};", ddl.trim_end_matches(';')));
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
lines.push(format!("-- Data for {}", table.display_name));
|
||||
if table.truncated {
|
||||
lines.push(format!("-- Exported rows: {} (truncated at {row_limit})", table.rows.len()));
|
||||
} else {
|
||||
lines.push(format!("-- Exported rows: {}", table.rows.len()));
|
||||
}
|
||||
|
||||
let inserts = build_export_insert_statements(BuildExportInsertStatementsOptions {
|
||||
database_type: table.database_type,
|
||||
schema: table.schema,
|
||||
table_name: table.table_name,
|
||||
qualified_table_name: table.qualified_table_name,
|
||||
columns: table.columns,
|
||||
rows: table.rows,
|
||||
batch_size: Some(insert_batch_size),
|
||||
})?;
|
||||
if inserts.is_empty() {
|
||||
lines.push("-- No rows".to_string());
|
||||
} else {
|
||||
lines.extend(inserts);
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn export_qualified_table_name(
|
||||
database_type: Option<DatabaseType>,
|
||||
schema: Option<&str>,
|
||||
table_name: Option<&str>,
|
||||
qualified_name: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
if let Some(name) = qualified_name.filter(|name| !name.trim().is_empty()) {
|
||||
return Ok(name.to_string());
|
||||
}
|
||||
let table_name = table_name
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.ok_or_else(|| "tableName is required when qualifiedTableName is not provided".to_string())?;
|
||||
Ok(qualified_table_name(database_type, schema, table_name))
|
||||
}
|
||||
|
||||
pub async fn is_export_cancelled(export_id: &str) -> bool {
|
||||
EXPORT_CANCELLED.read().await.contains(export_id)
|
||||
}
|
||||
@@ -463,9 +642,15 @@ fn drop_table_if_exists_sql(table_name: &str, schema: &str, db_type: &DatabaseTy
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{drop_table_if_exists_sql, filter_selected_table_infos};
|
||||
use super::{
|
||||
build_database_sql_export, build_export_insert_statements, drop_table_if_exists_sql,
|
||||
filter_selected_table_infos, format_export_sql_literal, BuildDatabaseSqlExportOptions,
|
||||
BuildExportInsertStatementsOptions, ExportedTableSql, DATABASE_EXPORT_INSERT_BATCH_SIZE,
|
||||
DATABASE_EXPORT_ROW_LIMIT,
|
||||
};
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::types::TableInfo;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn table(name: &str, table_type: &str) -> TableInfo {
|
||||
TableInfo { name: name.to_string(), table_type: table_type.to_string(), comment: None }
|
||||
@@ -502,4 +687,75 @@ mod tests {
|
||||
|
||||
assert_eq!(sql, "DROP TABLE IF EXISTS \"users\";");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_sql_literals_for_export_inserts() {
|
||||
assert_eq!(format_export_sql_literal(&Value::Null), "NULL");
|
||||
assert_eq!(format_export_sql_literal(&json!(42)), "42");
|
||||
assert_eq!(format_export_sql_literal(&json!(true)), "TRUE");
|
||||
assert_eq!(format_export_sql_literal(&json!("O'Hara")), "'O''Hara'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_batched_insert_statements_for_export() {
|
||||
let statements = build_export_insert_statements(BuildExportInsertStatementsOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: Some("users".to_string()),
|
||||
qualified_table_name: None,
|
||||
columns: vec!["id".to_string(), "name".to_string()],
|
||||
rows: vec![vec![json!(1), json!("Ada")], vec![json!(2), json!("O'Hara")], vec![json!(3), json!("Linus")]],
|
||||
batch_size: Some(2),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
statements,
|
||||
vec![
|
||||
"INSERT INTO `users` (`id`, `name`) VALUES (1, 'Ada'), (2, 'O''Hara');",
|
||||
"INSERT INTO `users` (`id`, `name`) VALUES (3, 'Linus');",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_database_sql_export_with_ddl_before_data() {
|
||||
let sql = build_database_sql_export(BuildDatabaseSqlExportOptions {
|
||||
database_name: "app".to_string(),
|
||||
exported_at: Some("2026-05-02T00:00:00.000Z".to_string()),
|
||||
tables: vec![ExportedTableSql {
|
||||
display_name: "users".to_string(),
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: Some("users".to_string()),
|
||||
qualified_table_name: None,
|
||||
ddl: Some("CREATE TABLE `users` (`id` int);".to_string()),
|
||||
columns: vec!["id".to_string()],
|
||||
rows: vec![vec![json!(1)]],
|
||||
truncated: true,
|
||||
}],
|
||||
row_limit_per_table: Some(DATABASE_EXPORT_ROW_LIMIT),
|
||||
insert_batch_size: Some(DATABASE_EXPORT_INSERT_BATCH_SIZE),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sql,
|
||||
vec![
|
||||
"-- DBX database export".to_string(),
|
||||
"-- Database: app".to_string(),
|
||||
"-- Exported at: 2026-05-02T00:00:00.000Z".to_string(),
|
||||
format!("-- Row limit per table: {DATABASE_EXPORT_ROW_LIMIT}"),
|
||||
String::new(),
|
||||
"-- Structure for users".to_string(),
|
||||
"CREATE TABLE `users` (`id` int);".to_string(),
|
||||
String::new(),
|
||||
"-- Data for users".to_string(),
|
||||
format!("-- Exported rows: 1 (truncated at {DATABASE_EXPORT_ROW_LIMIT})"),
|
||||
"INSERT INTO `users` (`id`) VALUES (1);".to_string(),
|
||||
String::new(),
|
||||
]
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::sql_dialect::{qualified_table_name, quote_table_identifier, uses_fetch_first};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DatabaseSearchColumn {
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
#[serde(default)]
|
||||
pub is_primary_key: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DatabaseSearchSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<DatabaseSearchColumn>,
|
||||
pub term: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DatabaseSearchSql {
|
||||
pub sql: String,
|
||||
pub searchable_columns: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchResultWhereOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<DatabaseSearchColumn>,
|
||||
#[serde(default)]
|
||||
pub result_columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub row: Vec<Value>,
|
||||
#[serde(default)]
|
||||
pub matched_columns: Vec<String>,
|
||||
}
|
||||
|
||||
const TEXT_TYPES: &[&str] =
|
||||
&["char", "text", "clob", "varchar", "nvarchar", "nchar", "uuid", "uniqueidentifier", "enum"];
|
||||
const NUMBER_TYPES: &[&str] = &["int", "serial", "number", "numeric", "decimal", "float", "double", "real", "money"];
|
||||
const SKIPPED_TYPES: &[&str] = &["blob", "binary", "bytea", "image", "geometry", "geography"];
|
||||
|
||||
pub fn is_text_search_column(column: &DatabaseSearchColumn) -> bool {
|
||||
let data_type = column.data_type.to_ascii_lowercase();
|
||||
if SKIPPED_TYPES.iter().any(|skipped| data_type.contains(skipped)) {
|
||||
return false;
|
||||
}
|
||||
TEXT_TYPES.iter().any(|text_type| data_type.contains(text_type))
|
||||
}
|
||||
|
||||
pub fn is_numeric_search_column(column: &DatabaseSearchColumn) -> bool {
|
||||
let data_type = column.data_type.to_ascii_lowercase();
|
||||
if SKIPPED_TYPES.iter().any(|skipped| data_type.contains(skipped)) {
|
||||
return false;
|
||||
}
|
||||
NUMBER_TYPES.iter().any(|number_type| data_type.contains(number_type))
|
||||
}
|
||||
|
||||
pub fn build_database_search_sql(options: DatabaseSearchSqlOptions) -> Option<DatabaseSearchSql> {
|
||||
let term = clean_term(&options.term);
|
||||
if term.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text_columns: Vec<_> = options.columns.iter().filter(|column| is_text_search_column(column)).collect();
|
||||
let numeric_term = parse_numeric_term(&term);
|
||||
let numeric_columns: Vec<_> = if numeric_term.is_some() {
|
||||
options.columns.iter().filter(|column| is_numeric_search_column(column)).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let mut conditions = Vec::new();
|
||||
for column in &text_columns {
|
||||
let identifier = quote_table_identifier(options.database_type, &column.name);
|
||||
conditions.push(format!(
|
||||
"{} LIKE {} ESCAPE '~'",
|
||||
text_cast_expression(options.database_type, &identifier),
|
||||
sql_string_literal(&like_pattern(&term))
|
||||
));
|
||||
}
|
||||
for column in &numeric_columns {
|
||||
let identifier = quote_table_identifier(options.database_type, &column.name);
|
||||
conditions.push(format!("{identifier} = {}", numeric_term.as_deref().unwrap_or_default()));
|
||||
}
|
||||
if conditions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let table = qualified_table_name(options.database_type, options.schema.as_deref(), &options.table_name);
|
||||
let where_clause = conditions.join(" OR ");
|
||||
let limit = clamp_limit(options.limit);
|
||||
let searchable_columns =
|
||||
text_columns.iter().chain(numeric_columns.iter()).map(|column| column.name.clone()).collect();
|
||||
|
||||
let sql = if options.database_type == Some(DatabaseType::SqlServer) {
|
||||
format!("SELECT TOP {limit} * FROM {table} WHERE ({where_clause})")
|
||||
} else if options.database_type.is_some_and(uses_fetch_first) {
|
||||
format!("SELECT * FROM {table} WHERE ({where_clause}) FETCH FIRST {limit} ROWS ONLY")
|
||||
} else {
|
||||
format!("SELECT * FROM {table} WHERE ({where_clause}) LIMIT {limit};")
|
||||
};
|
||||
|
||||
Some(DatabaseSearchSql { sql, searchable_columns })
|
||||
}
|
||||
|
||||
pub fn build_search_result_where(options: SearchResultWhereOptions) -> String {
|
||||
let mut value_by_column = std::collections::HashMap::new();
|
||||
for (index, column) in options.result_columns.iter().enumerate() {
|
||||
if let Some(value) = options.row.get(index) {
|
||||
value_by_column.insert(column.to_ascii_lowercase(), value);
|
||||
}
|
||||
}
|
||||
|
||||
let primary_columns: Vec<_> = options
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| column.is_primary_key && value_by_column.contains_key(&column.name.to_ascii_lowercase()))
|
||||
.collect();
|
||||
let fallback_columns: Vec<_> = options
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| {
|
||||
options.matched_columns.iter().any(|name| name.eq_ignore_ascii_case(&column.name))
|
||||
&& value_by_column.contains_key(&column.name.to_ascii_lowercase())
|
||||
})
|
||||
.collect();
|
||||
let selected_columns = if primary_columns.is_empty() { fallback_columns } else { primary_columns };
|
||||
|
||||
selected_columns
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|column| {
|
||||
let identifier = quote_table_identifier(options.database_type, &column.name);
|
||||
let value = value_by_column.get(&column.name.to_ascii_lowercase()).copied().unwrap_or(&Value::Null);
|
||||
if value.is_null() {
|
||||
format!("{identifier} IS NULL")
|
||||
} else {
|
||||
format!("{identifier} = {}", sql_value_literal(options.database_type, column, value))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ")
|
||||
}
|
||||
|
||||
fn clean_term(term: &str) -> String {
|
||||
term.trim().to_string()
|
||||
}
|
||||
|
||||
fn clamp_limit(limit: Option<usize>) -> usize {
|
||||
limit.unwrap_or(20).clamp(1, 100)
|
||||
}
|
||||
|
||||
fn parse_numeric_term(term: &str) -> Option<String> {
|
||||
let trimmed = clean_term(term);
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bytes = trimmed.as_bytes();
|
||||
let start = usize::from(matches!(bytes.first(), Some(b'+') | Some(b'-')));
|
||||
let number = &trimmed[start..];
|
||||
if number.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut dot_count = 0;
|
||||
let mut digit_count = 0;
|
||||
for ch in number.chars() {
|
||||
if ch == '.' {
|
||||
dot_count += 1;
|
||||
if dot_count > 1 {
|
||||
return None;
|
||||
}
|
||||
} else if ch.is_ascii_digit() {
|
||||
digit_count += 1;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if digit_count == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn sql_string_literal(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn like_pattern(term: &str) -> String {
|
||||
let mut pattern = String::from("%");
|
||||
for ch in clean_term(term).to_ascii_lowercase().chars() {
|
||||
if matches!(ch, '~' | '%' | '_') {
|
||||
pattern.push('~');
|
||||
}
|
||||
pattern.push(ch);
|
||||
}
|
||||
pattern.push('%');
|
||||
pattern
|
||||
}
|
||||
|
||||
fn text_cast_expression(database_type: Option<DatabaseType>, identifier: &str) -> String {
|
||||
match database_type {
|
||||
Some(DatabaseType::Mysql) => format!("LOWER(CAST({identifier} AS CHAR))"),
|
||||
Some(DatabaseType::SqlServer) => format!("LOWER(CAST({identifier} AS NVARCHAR(MAX)))"),
|
||||
Some(DatabaseType::Oracle) => format!("LOWER(CAST({identifier} AS VARCHAR2(4000)))"),
|
||||
Some(DatabaseType::ClickHouse) => format!("lower(toString({identifier}))"),
|
||||
_ => format!("LOWER(CAST({identifier} AS TEXT))"),
|
||||
}
|
||||
}
|
||||
|
||||
fn sql_value_literal(database_type: Option<DatabaseType>, column: &DatabaseSearchColumn, value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => "NULL".to_string(),
|
||||
Value::Number(number) => number.to_string(),
|
||||
Value::Bool(value) => {
|
||||
if *value {
|
||||
"TRUE".to_string()
|
||||
} else {
|
||||
"FALSE".to_string()
|
||||
}
|
||||
}
|
||||
Value::String(value) => {
|
||||
if is_numeric_search_column(column) && parse_numeric_term(value).is_some() {
|
||||
return value.trim().to_string();
|
||||
}
|
||||
if database_type == Some(DatabaseType::SqlServer) {
|
||||
format!("N{}", sql_string_literal(value))
|
||||
} else {
|
||||
sql_string_literal(value)
|
||||
}
|
||||
}
|
||||
other => sql_string_literal(&other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn col(name: &str, data_type: &str, is_primary_key: bool) -> DatabaseSearchColumn {
|
||||
DatabaseSearchColumn { name: name.to_string(), data_type: data_type.to_string(), is_primary_key }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_table_search_query_over_text_columns() {
|
||||
let query = build_database_search_sql(DatabaseSearchSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![col("id", "bigint", true), col("email", "varchar", false), col("avatar", "blob", false)],
|
||||
term: "alice@example.com".to_string(),
|
||||
limit: Some(20),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(query.searchable_columns, vec!["email"]);
|
||||
assert_eq!(
|
||||
query.sql,
|
||||
"SELECT * FROM `users` WHERE (LOWER(CAST(`email` AS CHAR)) LIKE '%alice@example.com%' ESCAPE '~') LIMIT 20;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adds_numeric_predicates_only_for_numeric_terms() {
|
||||
let query = build_database_search_sql(DatabaseSearchSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "orders".to_string(),
|
||||
columns: vec![col("id", "integer", true), col("note", "text", false)],
|
||||
term: "42".to_string(),
|
||||
limit: Some(10),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(query.searchable_columns, vec!["note", "id"]);
|
||||
assert!(query.sql.contains("\"id\" = 42"));
|
||||
assert!(query.sql.contains("FROM \"public\".\"orders\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_tables_without_searchable_columns() {
|
||||
assert_eq!(
|
||||
build_database_search_sql(DatabaseSearchSqlOptions {
|
||||
database_type: Some(DatabaseType::Sqlite),
|
||||
schema: None,
|
||||
table_name: "files".to_string(),
|
||||
columns: vec![col("payload", "blob", false)],
|
||||
term: "needle".to_string(),
|
||||
limit: None,
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_stable_where_predicate_for_opening_result_rows() {
|
||||
let where_clause = build_search_result_where(SearchResultWhereOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
columns: vec![col("id", "integer", true), col("email", "varchar", false)],
|
||||
result_columns: vec!["id".to_string(), "email".to_string()],
|
||||
row: vec![Value::from(42), Value::from("alice@example.com")],
|
||||
matched_columns: Vec::new(),
|
||||
});
|
||||
|
||||
assert_eq!(where_clause, "[id] = 42");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::sql_dialect::{is_schema_aware, quote_table_identifier};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum DatabaseObjectType {
|
||||
Table,
|
||||
View,
|
||||
Procedure,
|
||||
Function,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RenameObjectSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub object_type: DatabaseObjectType,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub old_name: String,
|
||||
pub new_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDatabaseSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub driver_profile: Option<String>,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub charset: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub collation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DuckDbAttachDatabaseSqlOptions {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DropObjectSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub object_type: DatabaseObjectType,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableAdminSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DatabaseNameSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SchemaNameSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DuplicateTableStructureSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub source_name: String,
|
||||
pub target_name: String,
|
||||
}
|
||||
|
||||
const MYSQL_COMPATIBLE_PROFILES: &[&str] =
|
||||
&["mysql", "mariadb", "tidb", "oceanbase", "doris", "starrocks", "custom_mysql"];
|
||||
|
||||
pub fn supports_create_database_charset(database_type: Option<DatabaseType>, driver_profile: Option<&str>) -> bool {
|
||||
matches!(
|
||||
database_type,
|
||||
Some(DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::Goldendb)
|
||||
) || driver_profile.is_some_and(|profile| MYSQL_COMPATIBLE_PROFILES.contains(&profile))
|
||||
}
|
||||
|
||||
pub fn build_create_database_sql(options: CreateDatabaseSqlOptions) -> String {
|
||||
let name = quote_table_identifier(options.database_type, &options.name);
|
||||
let charset = clean_sql_option(options.charset.as_deref());
|
||||
let collation = clean_sql_option(options.collation.as_deref());
|
||||
if !supports_create_database_charset(options.database_type, options.driver_profile.as_deref()) || charset.is_empty()
|
||||
{
|
||||
return format!("CREATE DATABASE {name};");
|
||||
}
|
||||
let collate_clause = if collation.is_empty() { String::new() } else { format!(" COLLATE {collation}") };
|
||||
format!("CREATE DATABASE {name} CHARACTER SET {charset}{collate_clause};")
|
||||
}
|
||||
|
||||
pub fn build_duckdb_attach_database_sql(options: DuckDbAttachDatabaseSqlOptions) -> String {
|
||||
format!(
|
||||
"ATTACH {} AS {};",
|
||||
quote_sql_string(&options.path),
|
||||
quote_table_identifier(Some(DatabaseType::DuckDb), &options.name)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_drop_object_sql(options: DropObjectSqlOptions) -> String {
|
||||
format!(
|
||||
"DROP {} {};",
|
||||
object_type_keyword(options.object_type),
|
||||
qualified_name(options.database_type, options.schema.as_deref(), &options.name)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_drop_table_sql(options: TableAdminSqlOptions) -> String {
|
||||
format!("DROP TABLE {};", qualified_name(options.database_type, options.schema.as_deref(), &options.table_name))
|
||||
}
|
||||
|
||||
pub fn build_empty_table_sql(options: TableAdminSqlOptions) -> String {
|
||||
format!("DELETE FROM {};", qualified_name(options.database_type, options.schema.as_deref(), &options.table_name))
|
||||
}
|
||||
|
||||
pub fn build_truncate_table_sql(options: TableAdminSqlOptions) -> String {
|
||||
let table = qualified_name(options.database_type, options.schema.as_deref(), &options.table_name);
|
||||
if matches!(options.database_type, Some(DatabaseType::Sqlite | DatabaseType::DuckDb)) {
|
||||
format!("DELETE FROM {table};")
|
||||
} else {
|
||||
format!("TRUNCATE TABLE {table};")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_drop_database_sql(options: DatabaseNameSqlOptions) -> String {
|
||||
format!("DROP DATABASE {};", quote_table_identifier(options.database_type, &options.name))
|
||||
}
|
||||
|
||||
pub fn build_create_schema_sql(options: SchemaNameSqlOptions) -> String {
|
||||
format!("CREATE SCHEMA {};", quote_table_identifier(options.database_type, &options.name))
|
||||
}
|
||||
|
||||
pub fn build_drop_schema_sql(options: SchemaNameSqlOptions) -> String {
|
||||
let schema = quote_table_identifier(options.database_type, &options.name);
|
||||
if matches!(options.database_type, Some(DatabaseType::Postgres | DatabaseType::Gaussdb)) {
|
||||
format!("DROP SCHEMA {schema} CASCADE;")
|
||||
} else {
|
||||
format!("DROP SCHEMA {schema};")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_duplicate_table_structure_sql(options: DuplicateTableStructureSqlOptions) -> String {
|
||||
let source = qualified_name(options.database_type, options.schema.as_deref(), &options.source_name);
|
||||
let target = qualified_name(options.database_type, options.schema.as_deref(), &options.target_name);
|
||||
if options.database_type == Some(DatabaseType::Mysql) {
|
||||
return format!("CREATE TABLE {target} LIKE {source};");
|
||||
}
|
||||
if options.database_type.is_some_and(is_postgres_like_structure_copy) {
|
||||
return format!("CREATE TABLE {target} (LIKE {source} INCLUDING ALL);");
|
||||
}
|
||||
if options.database_type == Some(DatabaseType::SqlServer) {
|
||||
return format!("SELECT TOP 0 * INTO {target} FROM {source};");
|
||||
}
|
||||
if options.database_type.is_some_and(uses_fetch_first_duplicate_structure) {
|
||||
return format!("CREATE TABLE {target} AS SELECT * FROM {source} WHERE 1=0");
|
||||
}
|
||||
format!("CREATE TABLE {target} AS SELECT * FROM {source} WHERE 0;")
|
||||
}
|
||||
|
||||
pub fn supports_object_rename(database_type: Option<DatabaseType>, object_type: DatabaseObjectType) -> bool {
|
||||
let Some(database_type) = database_type else {
|
||||
return false;
|
||||
};
|
||||
if database_type == DatabaseType::SqlServer {
|
||||
return true;
|
||||
}
|
||||
if matches!(object_type, DatabaseObjectType::Procedure | DatabaseObjectType::Function) {
|
||||
return false;
|
||||
}
|
||||
if database_type == DatabaseType::Sqlite {
|
||||
return object_type == DatabaseObjectType::Table;
|
||||
}
|
||||
if matches!(database_type, DatabaseType::Mysql | DatabaseType::Goldendb) {
|
||||
return matches!(object_type, DatabaseObjectType::Table | DatabaseObjectType::View);
|
||||
}
|
||||
if is_postgres_like_rename(database_type) || is_oracle_like_rename(database_type) {
|
||||
return matches!(object_type, DatabaseObjectType::Table | DatabaseObjectType::View);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn build_rename_object_sql(options: RenameObjectSqlOptions) -> Result<String, String> {
|
||||
let database_type = options.database_type;
|
||||
if !supports_object_rename(database_type, options.object_type) {
|
||||
return Err(format!(
|
||||
"Renaming {} is not supported for {}.",
|
||||
object_type_keyword(options.object_type),
|
||||
database_label(database_type)
|
||||
));
|
||||
}
|
||||
|
||||
if database_type == Some(DatabaseType::SqlServer) {
|
||||
return Ok(format!(
|
||||
"EXEC sp_rename {}, {}, N'OBJECT';",
|
||||
sqlserver_string(&sqlserver_object_name(options.schema.as_deref(), &options.old_name)),
|
||||
sqlserver_string(&options.new_name)
|
||||
));
|
||||
}
|
||||
|
||||
if matches!(database_type, Some(DatabaseType::Mysql | DatabaseType::Goldendb)) {
|
||||
return Ok(format!(
|
||||
"RENAME TABLE {} TO {};",
|
||||
qualified_name(database_type, options.schema.as_deref(), &options.old_name),
|
||||
qualified_name(database_type, options.schema.as_deref(), &options.new_name)
|
||||
));
|
||||
}
|
||||
|
||||
if database_type == Some(DatabaseType::Sqlite) {
|
||||
return Ok(format!(
|
||||
"ALTER TABLE {} RENAME TO {};",
|
||||
qualified_name(database_type, options.schema.as_deref(), &options.old_name),
|
||||
quote_rename_identifier(database_type, &options.new_name)
|
||||
));
|
||||
}
|
||||
|
||||
if database_type
|
||||
.is_some_and(|database_type| is_postgres_like_rename(database_type) || is_oracle_like_rename(database_type))
|
||||
{
|
||||
return Ok(format!(
|
||||
"ALTER {} {} RENAME TO {};",
|
||||
object_type_keyword(options.object_type),
|
||||
qualified_name(database_type, options.schema.as_deref(), &options.old_name),
|
||||
quote_rename_identifier(database_type, &options.new_name)
|
||||
));
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Renaming {} is not supported for {}.",
|
||||
object_type_keyword(options.object_type),
|
||||
database_label(database_type)
|
||||
))
|
||||
}
|
||||
|
||||
fn is_postgres_like_rename(database_type: DatabaseType) -> bool {
|
||||
matches!(
|
||||
database_type,
|
||||
DatabaseType::Postgres
|
||||
| DatabaseType::Redshift
|
||||
| DatabaseType::Gaussdb
|
||||
| DatabaseType::Kingbase
|
||||
| DatabaseType::Highgo
|
||||
| DatabaseType::Vastbase
|
||||
)
|
||||
}
|
||||
|
||||
fn is_oracle_like_rename(database_type: DatabaseType) -> bool {
|
||||
matches!(database_type, DatabaseType::Oracle | DatabaseType::Dameng)
|
||||
}
|
||||
|
||||
fn is_postgres_like_structure_copy(database_type: DatabaseType) -> bool {
|
||||
matches!(
|
||||
database_type,
|
||||
DatabaseType::Postgres | DatabaseType::Redshift | DatabaseType::Gaussdb | DatabaseType::OpenGauss
|
||||
)
|
||||
}
|
||||
|
||||
fn uses_fetch_first_duplicate_structure(database_type: DatabaseType) -> bool {
|
||||
matches!(database_type, DatabaseType::Oracle | DatabaseType::Dameng)
|
||||
}
|
||||
|
||||
fn sqlserver_string(value: &str) -> String {
|
||||
format!("N'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn quote_rename_identifier(database_type: Option<DatabaseType>, name: &str) -> String {
|
||||
if matches!(database_type, Some(DatabaseType::Mysql | DatabaseType::Goldendb)) {
|
||||
format!("`{}`", name.replace('`', "``"))
|
||||
} else {
|
||||
quote_table_identifier(database_type, name)
|
||||
}
|
||||
}
|
||||
|
||||
fn qualified_name(database_type: Option<DatabaseType>, schema: Option<&str>, name: &str) -> String {
|
||||
if database_type.is_some_and(is_schema_aware) && schema.is_some_and(|schema| !schema.is_empty()) {
|
||||
format!(
|
||||
"{}.{}",
|
||||
quote_rename_identifier(database_type, schema.unwrap()),
|
||||
quote_rename_identifier(database_type, name)
|
||||
)
|
||||
} else {
|
||||
quote_rename_identifier(database_type, name)
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlserver_object_name(schema: Option<&str>, name: &str) -> String {
|
||||
schema
|
||||
.filter(|schema| !schema.is_empty())
|
||||
.map(|schema| format!("{schema}.{name}"))
|
||||
.unwrap_or_else(|| name.to_string())
|
||||
}
|
||||
|
||||
fn object_type_keyword(object_type: DatabaseObjectType) -> &'static str {
|
||||
match object_type {
|
||||
DatabaseObjectType::Table => "TABLE",
|
||||
DatabaseObjectType::View => "VIEW",
|
||||
DatabaseObjectType::Procedure => "PROCEDURE",
|
||||
DatabaseObjectType::Function => "FUNCTION",
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_sql_option(value: Option<&str>) -> String {
|
||||
value.unwrap_or("").trim().replace([';', ' ', '\n', '\r', '\t'], "")
|
||||
}
|
||||
|
||||
fn quote_sql_string(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn database_label(database_type: Option<DatabaseType>) -> String {
|
||||
database_type
|
||||
.and_then(|database_type| serde_json::to_value(database_type).ok())
|
||||
.and_then(|value| value.as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "this database".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_mysql_create_database_sql_with_charset_and_collation() {
|
||||
assert_eq!(
|
||||
build_create_database_sql(CreateDatabaseSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
driver_profile: Some("mysql".to_string()),
|
||||
name: "app db".to_string(),
|
||||
charset: Some("utf8mb4".to_string()),
|
||||
collation: Some("utf8mb4_unicode_ci".to_string()),
|
||||
}),
|
||||
"CREATE DATABASE `app db` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_create_database_charset_for_non_mysql_types() {
|
||||
assert_eq!(
|
||||
build_create_database_sql(CreateDatabaseSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
driver_profile: None,
|
||||
name: "analytics".to_string(),
|
||||
charset: Some("utf8mb4".to_string()),
|
||||
collation: Some("utf8mb4_unicode_ci".to_string()),
|
||||
}),
|
||||
"CREATE DATABASE \"analytics\";"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_mysql_compatible_create_database_profiles() {
|
||||
assert!(supports_create_database_charset(Some(DatabaseType::Mysql), Some("oceanbase")));
|
||||
assert!(supports_create_database_charset(Some(DatabaseType::Mysql), Some("doris")));
|
||||
assert!(!supports_create_database_charset(Some(DatabaseType::Postgres), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_duckdb_attach_sql() {
|
||||
assert_eq!(
|
||||
build_duckdb_attach_database_sql(DuckDbAttachDatabaseSqlOptions {
|
||||
path: "/Users/me/O'Reilly analytics.duckdb".to_string(),
|
||||
name: "report db".to_string(),
|
||||
}),
|
||||
"ATTACH '/Users/me/O''Reilly analytics.duckdb' AS \"report db\";"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_drop_and_clear_table_sql() {
|
||||
let options = TableAdminSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "events".to_string(),
|
||||
};
|
||||
assert_eq!(build_drop_table_sql(options.clone()), "DROP TABLE \"public\".\"events\";");
|
||||
assert_eq!(build_empty_table_sql(options.clone()), "DELETE FROM \"public\".\"events\";");
|
||||
assert_eq!(build_truncate_table_sql(options), "TRUNCATE TABLE \"public\".\"events\";");
|
||||
assert_eq!(
|
||||
build_truncate_table_sql(TableAdminSqlOptions {
|
||||
database_type: Some(DatabaseType::DuckDb),
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
}),
|
||||
"DELETE FROM \"events\";"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_drop_object_database_and_schema_sql() {
|
||||
assert_eq!(
|
||||
build_drop_object_sql(DropObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
object_type: DatabaseObjectType::Procedure,
|
||||
schema: Some("dbo".to_string()),
|
||||
name: "refresh_cache".to_string(),
|
||||
}),
|
||||
"DROP PROCEDURE [dbo].[refresh_cache];"
|
||||
);
|
||||
assert_eq!(
|
||||
build_drop_database_sql(DatabaseNameSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
name: "app db".to_string(),
|
||||
}),
|
||||
"DROP DATABASE `app db`;"
|
||||
);
|
||||
assert_eq!(
|
||||
build_create_schema_sql(SchemaNameSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
name: "analytics".to_string(),
|
||||
}),
|
||||
"CREATE SCHEMA \"analytics\";"
|
||||
);
|
||||
assert_eq!(
|
||||
build_drop_schema_sql(SchemaNameSqlOptions {
|
||||
database_type: Some(DatabaseType::Gaussdb),
|
||||
name: "analytics".to_string(),
|
||||
}),
|
||||
"DROP SCHEMA \"analytics\" CASCADE;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_duplicate_table_structure_sql() {
|
||||
assert_eq!(
|
||||
build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
source_name: "users".to_string(),
|
||||
target_name: "users_copy".to_string(),
|
||||
}),
|
||||
"CREATE TABLE `users_copy` LIKE `users`;"
|
||||
);
|
||||
assert_eq!(
|
||||
build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
source_name: "users".to_string(),
|
||||
target_name: "users_copy".to_string(),
|
||||
}),
|
||||
"CREATE TABLE \"public\".\"users_copy\" (LIKE \"public\".\"users\" INCLUDING ALL);"
|
||||
);
|
||||
assert_eq!(
|
||||
build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
schema: Some("dbo".to_string()),
|
||||
source_name: "users".to_string(),
|
||||
target_name: "users_copy".to_string(),
|
||||
}),
|
||||
"SELECT TOP 0 * INTO [dbo].[users_copy] FROM [dbo].[users];"
|
||||
);
|
||||
assert_eq!(
|
||||
build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
schema: Some("HR".to_string()),
|
||||
source_name: "USERS".to_string(),
|
||||
target_name: "USERS_COPY".to_string(),
|
||||
}),
|
||||
"CREATE TABLE \"HR\".\"USERS_COPY\" AS SELECT * FROM \"HR\".\"USERS\" WHERE 1=0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mysql_table_and_view_rename_sql() {
|
||||
assert_eq!(
|
||||
build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
object_type: DatabaseObjectType::Table,
|
||||
schema: None,
|
||||
old_name: "users".to_string(),
|
||||
new_name: "app users".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"RENAME TABLE `users` TO `app users`;"
|
||||
);
|
||||
assert_eq!(
|
||||
build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Goldendb),
|
||||
object_type: DatabaseObjectType::View,
|
||||
schema: None,
|
||||
old_name: "active_users".to_string(),
|
||||
new_name: "enabled_users".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"RENAME TABLE `active_users` TO `enabled_users`;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_postgres_table_and_view_rename_sql() {
|
||||
assert_eq!(
|
||||
build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
object_type: DatabaseObjectType::Table,
|
||||
schema: Some("public".to_string()),
|
||||
old_name: "orders".to_string(),
|
||||
new_name: "archived orders".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"ALTER TABLE \"public\".\"orders\" RENAME TO \"archived orders\";"
|
||||
);
|
||||
assert_eq!(
|
||||
build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
object_type: DatabaseObjectType::View,
|
||||
schema: Some("public".to_string()),
|
||||
old_name: "active_users".to_string(),
|
||||
new_name: "enabled_users".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"ALTER VIEW \"public\".\"active_users\" RENAME TO \"enabled_users\";"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_sqlserver_routine_rename_sql() {
|
||||
assert_eq!(
|
||||
build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
object_type: DatabaseObjectType::Function,
|
||||
schema: Some("dbo".to_string()),
|
||||
old_name: "fn_total".to_string(),
|
||||
new_name: "fn_order_total".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"EXEC sp_rename N'dbo.fn_total', N'fn_order_total', N'OBJECT';"
|
||||
);
|
||||
assert!(supports_object_rename(Some(DatabaseType::SqlServer), DatabaseObjectType::Procedure));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_oracle_family_table_and_view_rename_sql() {
|
||||
assert_eq!(
|
||||
build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
object_type: DatabaseObjectType::Table,
|
||||
schema: Some("HR".to_string()),
|
||||
old_name: "EMPLOYEES".to_string(),
|
||||
new_name: "STAFF".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"ALTER TABLE \"HR\".\"EMPLOYEES\" RENAME TO \"STAFF\";"
|
||||
);
|
||||
assert_eq!(
|
||||
build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Dameng),
|
||||
object_type: DatabaseObjectType::View,
|
||||
schema: Some("SYSDBA".to_string()),
|
||||
old_name: "ACTIVE_USERS".to_string(),
|
||||
new_name: "ENABLED_USERS".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"ALTER VIEW \"SYSDBA\".\"ACTIVE_USERS\" RENAME TO \"ENABLED_USERS\";"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_direct_routine_renames() {
|
||||
assert!(!supports_object_rename(Some(DatabaseType::Oracle), DatabaseObjectType::Function));
|
||||
assert!(!supports_object_rename(Some(DatabaseType::Dameng), DatabaseObjectType::Procedure));
|
||||
assert!(build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Dameng),
|
||||
object_type: DatabaseObjectType::Procedure,
|
||||
schema: Some("SYSDBA".to_string()),
|
||||
old_name: "REFRESH_CACHE".to_string(),
|
||||
new_name: "REFRESH_CACHE_V2".to_string(),
|
||||
})
|
||||
.unwrap_err()
|
||||
.contains("Renaming PROCEDURE is not supported"));
|
||||
assert!(build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
object_type: DatabaseObjectType::Procedure,
|
||||
schema: None,
|
||||
old_name: "refresh_cache".to_string(),
|
||||
new_name: "refresh_cache_v2".to_string(),
|
||||
})
|
||||
.unwrap_err()
|
||||
.contains("Renaming PROCEDURE is not supported"));
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,19 @@ pub mod data_compare;
|
||||
pub mod data_grid_sql;
|
||||
pub mod database_capabilities;
|
||||
pub mod database_export;
|
||||
pub mod database_search_sql;
|
||||
pub mod db;
|
||||
pub mod db_admin_sql;
|
||||
pub mod external;
|
||||
pub mod history;
|
||||
pub mod models;
|
||||
pub mod mongo_ops;
|
||||
pub mod object_source_sql;
|
||||
pub mod plugins;
|
||||
pub mod query;
|
||||
pub mod query_cancel;
|
||||
pub mod query_execution_sql;
|
||||
pub mod query_result_sql;
|
||||
pub mod redis_ops;
|
||||
pub mod saved_sql;
|
||||
pub mod schema;
|
||||
@@ -26,6 +31,7 @@ pub mod sql_dialect;
|
||||
pub mod sql_editability;
|
||||
pub mod storage;
|
||||
pub mod table_import;
|
||||
pub mod table_structure_sql;
|
||||
pub mod text_export;
|
||||
pub mod transfer;
|
||||
pub mod types;
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::types::ObjectSourceKind;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditableObjectSourceSqlInput {
|
||||
pub database_type: DatabaseType,
|
||||
pub object_type: ObjectSourceKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RoutineRenameObjectSourceInput {
|
||||
pub database_type: DatabaseType,
|
||||
pub object_type: ObjectSourceKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub name: String,
|
||||
pub new_name: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildViewDdlInput {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ObjectSourceSaveExecutionMode {
|
||||
#[serde(rename = "single")]
|
||||
Single,
|
||||
#[serde(rename = "script")]
|
||||
Script,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct RoutineDeclaration {
|
||||
kind: ObjectSourceKind,
|
||||
name: String,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
pub fn supports_source_backed_routine_rename(
|
||||
database_type: Option<DatabaseType>,
|
||||
object_type: ObjectSourceKind,
|
||||
) -> bool {
|
||||
if !matches!(object_type, ObjectSourceKind::Function | ObjectSourceKind::Procedure) {
|
||||
return false;
|
||||
}
|
||||
let Some(database_type) = database_type else {
|
||||
return false;
|
||||
};
|
||||
database_type != DatabaseType::SqlServer
|
||||
&& (is_mysql_like(database_type) || is_postgres_like(database_type) || is_oracle_like(database_type))
|
||||
}
|
||||
|
||||
pub fn build_routine_rename_object_source_statements(
|
||||
input: RoutineRenameObjectSourceInput,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if !supports_source_backed_routine_rename(Some(input.database_type), input.object_type.clone()) {
|
||||
return Err(format!(
|
||||
"Renaming {:?} from source is not supported for {:?}.",
|
||||
input.object_type, input.database_type
|
||||
));
|
||||
}
|
||||
|
||||
let source = input.source.trim();
|
||||
let declaration = if is_mysql_like(input.database_type) {
|
||||
mysql_routine_declaration(source)
|
||||
} else {
|
||||
routine_declaration(source)
|
||||
};
|
||||
let Some(declaration) = declaration else {
|
||||
return Err(format!("Cannot find a CREATE {:?} declaration in the object source.", input.object_type));
|
||||
};
|
||||
if declaration.kind != input.object_type {
|
||||
return Err(format!("Cannot find a CREATE {:?} declaration in the object source.", input.object_type));
|
||||
}
|
||||
|
||||
let renamed_source = if is_mysql_like(input.database_type) {
|
||||
replace_mysql_routine_declaration_name(source, &input.new_name)
|
||||
} else {
|
||||
replace_sql_routine_declaration_name(source, input.schema.as_deref(), &input.new_name)
|
||||
};
|
||||
let Some(renamed_source) = renamed_source else {
|
||||
return Err(format!("Cannot rewrite the {:?} name in the object source.", input.object_type));
|
||||
};
|
||||
|
||||
if is_oracle_like(input.database_type) {
|
||||
return Ok(vec![
|
||||
ensure_semicolon(&renamed_source),
|
||||
format!(
|
||||
"DROP {} {};",
|
||||
object_type_keyword(&input.object_type),
|
||||
postgres_qualified_name(input.schema.as_deref(), &input.name)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
build_executable_object_source_statements(EditableObjectSourceSqlInput {
|
||||
database_type: input.database_type,
|
||||
object_type: input.object_type,
|
||||
schema: input.schema,
|
||||
name: input.name,
|
||||
source: renamed_source,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_executable_object_source_statements(input: EditableObjectSourceSqlInput) -> Result<Vec<String>, String> {
|
||||
let source = input.source.trim();
|
||||
if input.database_type == DatabaseType::SqlServer {
|
||||
return Ok(vec![replace_sqlserver_create_with_alter(source)]);
|
||||
}
|
||||
|
||||
if matches!(input.database_type, DatabaseType::Postgres | DatabaseType::Gaussdb)
|
||||
&& input.object_type == ObjectSourceKind::View
|
||||
{
|
||||
return Ok(vec![format!(
|
||||
"CREATE OR REPLACE VIEW {} AS\n{}",
|
||||
postgres_qualified_name(input.schema.as_deref(), &input.name),
|
||||
ensure_semicolon(source)
|
||||
)]);
|
||||
}
|
||||
|
||||
let create_statement = ensure_semicolon(source);
|
||||
let cleanup = build_routine_rename_cleanup(&input, source);
|
||||
Ok(if let Some(cleanup) = cleanup { vec![create_statement, cleanup] } else { vec![create_statement] })
|
||||
}
|
||||
|
||||
pub fn build_executable_object_source_sql(input: EditableObjectSourceSqlInput) -> Result<String, String> {
|
||||
Ok(build_executable_object_source_statements(input)?.join("\n"))
|
||||
}
|
||||
|
||||
pub fn build_view_ddl_sql(input: BuildViewDdlInput) -> String {
|
||||
let source = input.source.trim();
|
||||
if Regex::new(r"(?i)^(?:CREATE|ALTER)\s+").unwrap().is_match(source) {
|
||||
return ensure_semicolon(source);
|
||||
}
|
||||
|
||||
let qualified_name = if matches!(input.database_type, Some(DatabaseType::Mysql | DatabaseType::Goldendb)) {
|
||||
mysql_qualified_name(input.schema.as_deref(), &input.name)
|
||||
} else {
|
||||
postgres_qualified_name(input.schema.as_deref(), &input.name)
|
||||
};
|
||||
|
||||
if input.database_type.is_none()
|
||||
|| input
|
||||
.database_type
|
||||
.is_some_and(|database_type| is_postgres_like(database_type) || database_type == DatabaseType::OpenGauss)
|
||||
{
|
||||
return format!("CREATE OR REPLACE VIEW {qualified_name} AS\n{}", ensure_semicolon(source));
|
||||
}
|
||||
|
||||
format!("CREATE VIEW {qualified_name} AS\n{}", ensure_semicolon(source))
|
||||
}
|
||||
|
||||
pub fn object_source_save_execution_mode(_database_type: DatabaseType) -> ObjectSourceSaveExecutionMode {
|
||||
ObjectSourceSaveExecutionMode::Single
|
||||
}
|
||||
|
||||
fn build_routine_rename_cleanup(input: &EditableObjectSourceSqlInput, source: &str) -> Option<String> {
|
||||
if !matches!(input.object_type, ObjectSourceKind::Function | ObjectSourceKind::Procedure) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if is_mysql_like(input.database_type) {
|
||||
let declaration = mysql_routine_declaration(source)?;
|
||||
if declaration.kind != input.object_type || !routine_name_changed(&declaration.name, &input.name) {
|
||||
return None;
|
||||
}
|
||||
return Some(format!(
|
||||
"DROP {} IF EXISTS {};",
|
||||
object_type_keyword(&input.object_type),
|
||||
mysql_qualified_name(input.schema.as_deref(), &input.name)
|
||||
));
|
||||
}
|
||||
|
||||
if !is_postgres_like(input.database_type) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let declaration = routine_declaration(source)?;
|
||||
if declaration.kind != input.object_type || !routine_name_changed(&declaration.name, &input.name) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"DROP {} IF EXISTS {}{};",
|
||||
object_type_keyword(&input.object_type),
|
||||
postgres_qualified_name(input.schema.as_deref(), &input.name),
|
||||
declaration.signature
|
||||
))
|
||||
}
|
||||
|
||||
fn is_postgres_like(database_type: DatabaseType) -> bool {
|
||||
matches!(
|
||||
database_type,
|
||||
DatabaseType::Postgres
|
||||
| DatabaseType::Redshift
|
||||
| DatabaseType::Gaussdb
|
||||
| DatabaseType::Kingbase
|
||||
| DatabaseType::Highgo
|
||||
| DatabaseType::Vastbase
|
||||
)
|
||||
}
|
||||
|
||||
fn is_mysql_like(database_type: DatabaseType) -> bool {
|
||||
matches!(database_type, DatabaseType::Mysql | DatabaseType::Goldendb)
|
||||
}
|
||||
|
||||
fn is_oracle_like(database_type: DatabaseType) -> bool {
|
||||
matches!(database_type, DatabaseType::Oracle | DatabaseType::Dameng)
|
||||
}
|
||||
|
||||
fn object_type_keyword(object_type: &ObjectSourceKind) -> &'static str {
|
||||
match object_type {
|
||||
ObjectSourceKind::View => "VIEW",
|
||||
ObjectSourceKind::Procedure => "PROCEDURE",
|
||||
ObjectSourceKind::Function => "FUNCTION",
|
||||
}
|
||||
}
|
||||
|
||||
fn quote_postgres_identifier(value: &str) -> String {
|
||||
format!("\"{}\"", value.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
fn quote_mysql_identifier(value: &str) -> String {
|
||||
format!("`{}`", value.replace('`', "``"))
|
||||
}
|
||||
|
||||
fn ensure_semicolon(sql: &str) -> String {
|
||||
let trimmed = sql.trim();
|
||||
if trimmed.ends_with(';') {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{trimmed};")
|
||||
}
|
||||
}
|
||||
|
||||
fn postgres_qualified_name(schema: Option<&str>, name: &str) -> String {
|
||||
schema
|
||||
.into_iter()
|
||||
.chain(std::iter::once(name))
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(quote_postgres_identifier)
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
|
||||
fn mysql_qualified_name(schema: Option<&str>, name: &str) -> String {
|
||||
schema
|
||||
.into_iter()
|
||||
.chain(std::iter::once(name))
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(quote_mysql_identifier)
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
|
||||
fn unquote_postgres_identifier(value: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2 {
|
||||
trimmed[1..trimmed.len() - 1].replace("\"\"", "\"")
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn split_qualified_routine_name(value: &str) -> Vec<String> {
|
||||
Regex::new(r#""(?:""|[^"])+"|[A-Za-z_][\w$]*"#)
|
||||
.unwrap()
|
||||
.find_iter(value)
|
||||
.map(|part| unquote_postgres_identifier(part.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unquote_mysql_identifier(value: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.starts_with('`') && trimmed.ends_with('`') && trimmed.len() >= 2 {
|
||||
trimmed[1..trimmed.len() - 1].replace("``", "`")
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn split_mysql_qualified_routine_name(value: &str) -> Vec<String> {
|
||||
Regex::new(r"`(?:``|[^`])+`|[A-Za-z_][\w$]*")
|
||||
.unwrap()
|
||||
.find_iter(value)
|
||||
.map(|part| unquote_mysql_identifier(part.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn routine_declaration(source: &str) -> Option<RoutineDeclaration> {
|
||||
let re = Regex::new(
|
||||
r#"(?is)^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:NON)?EDITIONABLE\s+)?(FUNCTION|PROCEDURE)\s+((?:"(?:""|[^"])+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"(?:""|[^"])+"|[A-Za-z_][\w$]*))?)\s*(\(.*?\))?"#,
|
||||
)
|
||||
.unwrap();
|
||||
let captures = re.captures(source)?;
|
||||
let kind = parse_object_source_kind(captures.get(1)?.as_str())?;
|
||||
let name_parts = split_qualified_routine_name(captures.get(2)?.as_str());
|
||||
let name = name_parts.last()?.clone();
|
||||
let signature = captures.get(3).map(|value| value.as_str().trim().to_string()).unwrap_or_default();
|
||||
Some(RoutineDeclaration { kind, name, signature })
|
||||
}
|
||||
|
||||
fn replace_sql_routine_declaration_name(source: &str, schema: Option<&str>, new_name: &str) -> Option<String> {
|
||||
let re = Regex::new(
|
||||
r#"(?is)^(\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:NON)?EDITIONABLE\s+)?(?:FUNCTION|PROCEDURE)\s+)((?:"(?:""|[^"])+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"(?:""|[^"])+"|[A-Za-z_][\w$]*))?)"#,
|
||||
)
|
||||
.unwrap();
|
||||
let captures = re.captures(source)?;
|
||||
let full = captures.get(0)?;
|
||||
let prefix = captures.get(1)?.as_str();
|
||||
let existing_name = captures.get(2)?.as_str();
|
||||
let existing_parts = split_qualified_routine_name(existing_name);
|
||||
let schema_name =
|
||||
schema.or_else(|| existing_parts.first().filter(|_| existing_parts.len() > 1).map(String::as_str));
|
||||
let replacement = if let Some(schema_name) = schema_name {
|
||||
format!("{}.{}", quote_postgres_identifier(schema_name), quote_postgres_identifier(new_name))
|
||||
} else {
|
||||
quote_postgres_identifier(new_name)
|
||||
};
|
||||
Some(format!("{}{}{}{}", &source[..full.start()], prefix, replacement, &source[full.end()..]))
|
||||
}
|
||||
|
||||
fn mysql_routine_declaration(source: &str) -> Option<RoutineDeclaration> {
|
||||
let re = Regex::new(
|
||||
r"(?is)^\s*CREATE\s+(?:DEFINER\s*=\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s*@\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s+)?(FUNCTION|PROCEDURE)\s+((?:`(?:``|[^`])+`|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:`(?:``|[^`])+`|[A-Za-z_][\w$]*))?)",
|
||||
)
|
||||
.unwrap();
|
||||
let captures = re.captures(source)?;
|
||||
let kind = parse_object_source_kind(captures.get(1)?.as_str())?;
|
||||
let name_parts = split_mysql_qualified_routine_name(captures.get(2)?.as_str());
|
||||
let name = name_parts.last()?.clone();
|
||||
Some(RoutineDeclaration { kind, name, signature: String::new() })
|
||||
}
|
||||
|
||||
fn replace_mysql_routine_declaration_name(source: &str, new_name: &str) -> Option<String> {
|
||||
let re = Regex::new(
|
||||
r"(?is)^(\s*CREATE\s+(?:DEFINER\s*=\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s*@\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s+)?(?:FUNCTION|PROCEDURE)\s+)((?:`(?:``|[^`])+`|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:`(?:``|[^`])+`|[A-Za-z_][\w$]*))?)",
|
||||
)
|
||||
.unwrap();
|
||||
let captures = re.captures(source)?;
|
||||
let full = captures.get(0)?;
|
||||
let prefix = captures.get(1)?.as_str();
|
||||
Some(format!("{}{}{}{}", &source[..full.start()], prefix, quote_mysql_identifier(new_name), &source[full.end()..]))
|
||||
}
|
||||
|
||||
fn routine_name_changed(source_name: &str, saved_name: &str) -> bool {
|
||||
!source_name.eq_ignore_ascii_case(saved_name)
|
||||
}
|
||||
|
||||
fn replace_sqlserver_create_with_alter(source: &str) -> String {
|
||||
Regex::new(r"(?i)^CREATE\s+(?:OR\s+ALTER\s+)?").unwrap().replace(source, "ALTER ").to_string()
|
||||
}
|
||||
|
||||
fn parse_object_source_kind(value: &str) -> Option<ObjectSourceKind> {
|
||||
if value.eq_ignore_ascii_case("VIEW") {
|
||||
Some(ObjectSourceKind::View)
|
||||
} else if value.eq_ignore_ascii_case("PROCEDURE") {
|
||||
Some(ObjectSourceKind::Procedure)
|
||||
} else if value.eq_ignore_ascii_case("FUNCTION") {
|
||||
Some(ObjectSourceKind::Function)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn input(database_type: DatabaseType, object_type: ObjectSourceKind, source: &str) -> EditableObjectSourceSqlInput {
|
||||
EditableObjectSourceSqlInput {
|
||||
database_type,
|
||||
object_type,
|
||||
schema: Some("public".to_string()),
|
||||
name: "refresh_cache".to_string(),
|
||||
source: source.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_edited_source_saves_as_alter() {
|
||||
let sql = build_executable_object_source_sql(EditableObjectSourceSqlInput {
|
||||
database_type: DatabaseType::SqlServer,
|
||||
object_type: ObjectSourceKind::Procedure,
|
||||
schema: Some("dbo".to_string()),
|
||||
name: "usp_demo".to_string(),
|
||||
source: "CREATE PROCEDURE dbo.usp_demo AS SELECT 1;".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(sql, "ALTER PROCEDURE dbo.usp_demo AS SELECT 1;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_view_body_opens_as_create_or_replace_view() {
|
||||
let sql = build_executable_object_source_sql(EditableObjectSourceSqlInput {
|
||||
database_type: DatabaseType::Postgres,
|
||||
object_type: ObjectSourceKind::View,
|
||||
schema: Some("public".to_string()),
|
||||
name: "active users".to_string(),
|
||||
source: " SELECT id, name FROM users WHERE active ".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sql,
|
||||
"CREATE OR REPLACE VIEW \"public\".\"active users\" AS\nSELECT id, name FROM users WHERE active;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_ddl_wraps_postgres_body_as_create_or_replace_view() {
|
||||
let sql = build_view_ddl_sql(BuildViewDdlInput {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
name: "active users".to_string(),
|
||||
source: " SELECT id, name FROM users WHERE active ".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
sql,
|
||||
"CREATE OR REPLACE VIEW \"public\".\"active users\" AS\nSELECT id, name FROM users WHERE active;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_ddl_keeps_existing_create_view_statement() {
|
||||
let sql = build_view_ddl_sql(BuildViewDdlInput {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: Some("reporting".to_string()),
|
||||
name: "active_users".to_string(),
|
||||
source: "CREATE ALGORITHM=UNDEFINED VIEW `active_users` AS SELECT `id` FROM `users`".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(sql, "CREATE ALGORITHM=UNDEFINED VIEW `active_users` AS SELECT `id` FROM `users`;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_ddl_uses_create_view_for_non_postgres_like_databases() {
|
||||
let sql = build_view_ddl_sql(BuildViewDdlInput {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: Some("reporting".to_string()),
|
||||
name: "active_users".to_string(),
|
||||
source: "SELECT id FROM users".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(sql, "CREATE VIEW `reporting`.`active_users` AS\nSELECT id FROM users;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_procedure_rename_adds_drop_cleanup() {
|
||||
let statements = build_executable_object_source_statements(input(
|
||||
DatabaseType::Postgres,
|
||||
ObjectSourceKind::Procedure,
|
||||
"CREATE OR REPLACE PROCEDURE \"public\".\"refresh_cache_v2\"(mode text)\nLANGUAGE SQL\nAS $$ SELECT 1 $$;",
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
statements,
|
||||
vec![
|
||||
"CREATE OR REPLACE PROCEDURE \"public\".\"refresh_cache_v2\"(mode text)\nLANGUAGE SQL\nAS $$ SELECT 1 $$;",
|
||||
"DROP PROCEDURE IF EXISTS \"public\".\"refresh_cache\"(mode text);",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_routine_rename_adds_drop_cleanup() {
|
||||
let statements = build_executable_object_source_statements(EditableObjectSourceSqlInput {
|
||||
database_type: DatabaseType::Mysql,
|
||||
object_type: ObjectSourceKind::Procedure,
|
||||
schema: Some("app".to_string()),
|
||||
name: "refresh_cache".to_string(),
|
||||
source:
|
||||
"CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache_v2`(IN mode_name varchar(20)) BEGIN SELECT 1; END"
|
||||
.to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
statements,
|
||||
vec![
|
||||
"CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache_v2`(IN mode_name varchar(20)) BEGIN SELECT 1; END;",
|
||||
"DROP PROCEDURE IF EXISTS `app`.`refresh_cache`;",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_family_routine_rename_rewrites_source_and_drops_original() {
|
||||
let statements = build_routine_rename_object_source_statements(RoutineRenameObjectSourceInput {
|
||||
database_type: DatabaseType::Dameng,
|
||||
object_type: ObjectSourceKind::Procedure,
|
||||
schema: Some("SYSDBA".to_string()),
|
||||
name: "SP_TAB_BAKSET_REMOVE_BATCH".to_string(),
|
||||
new_name: "SP_TAB_BAKSET_REMOVE_BATCH_2".to_string(),
|
||||
source:
|
||||
"CREATE OR REPLACE PROCEDURE \"SYSDBA\".\"SP_TAB_BAKSET_REMOVE_BATCH\" AS\nBEGIN\n SELECT 1;\nEND;"
|
||||
.to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
statements,
|
||||
vec![
|
||||
"CREATE OR REPLACE PROCEDURE \"SYSDBA\".\"SP_TAB_BAKSET_REMOVE_BATCH_2\" AS\nBEGIN\n SELECT 1;\nEND;",
|
||||
"DROP PROCEDURE \"SYSDBA\".\"SP_TAB_BAKSET_REMOVE_BATCH\";",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExplainSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub sql: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExplainSqlBuildResult {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sql: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DroppedFilePreviewSqlOptions {
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub fn build_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult {
|
||||
if !supports_explain_plan(options.database_type) {
|
||||
return explain_err("unsupported");
|
||||
}
|
||||
|
||||
let source = strip_trailing_semicolons(options.sql.trim());
|
||||
if source.is_empty() {
|
||||
return explain_err("empty");
|
||||
}
|
||||
if !is_safe_explain_source(&source) {
|
||||
return explain_err("unsafe");
|
||||
}
|
||||
|
||||
let sql = if options.database_type == Some(DatabaseType::Postgres) {
|
||||
format!("EXPLAIN (FORMAT JSON) {source}")
|
||||
} else {
|
||||
format!("EXPLAIN FORMAT=JSON {source}")
|
||||
};
|
||||
ExplainSqlBuildResult { ok: true, sql: Some(sql), reason: None }
|
||||
}
|
||||
|
||||
pub fn build_dropped_file_preview_sql(options: DroppedFilePreviewSqlOptions) -> Option<String> {
|
||||
let lower = options.path.to_lowercase();
|
||||
let escaped = options.path.replace('\'', "''");
|
||||
let limit = options.limit.unwrap_or(1000).max(1);
|
||||
if lower.ends_with(".parquet") {
|
||||
return Some(format!("SELECT * FROM read_parquet('{escaped}') LIMIT {limit}"));
|
||||
}
|
||||
if lower.ends_with(".csv") {
|
||||
return Some(format!("SELECT * FROM read_csv('{escaped}') LIMIT {limit}"));
|
||||
}
|
||||
if lower.ends_with(".tsv") {
|
||||
return Some(format!("SELECT * FROM read_csv('{escaped}', delim='\\t') LIMIT {limit}"));
|
||||
}
|
||||
if lower.ends_with(".json") {
|
||||
return Some(format!("SELECT * FROM read_json('{escaped}') LIMIT {limit}"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn supports_explain_plan(database_type: Option<DatabaseType>) -> bool {
|
||||
matches!(database_type, Some(DatabaseType::Mysql | DatabaseType::Postgres))
|
||||
}
|
||||
|
||||
fn explain_err(reason: &str) -> ExplainSqlBuildResult {
|
||||
ExplainSqlBuildResult { ok: false, sql: None, reason: Some(reason.to_string()) }
|
||||
}
|
||||
|
||||
fn strip_trailing_semicolons(sql: &str) -> String {
|
||||
sql.trim_end().trim_end_matches(';').trim_end().to_string()
|
||||
}
|
||||
|
||||
fn is_safe_explain_source(sql: &str) -> bool {
|
||||
let source = strip_sql_comments(sql).trim_start().to_lowercase();
|
||||
["select", "with", "table", "values"].iter().any(|keyword| {
|
||||
source == *keyword || source.starts_with(&format!("{keyword} ")) || source.starts_with(&format!("{keyword}\n"))
|
||||
})
|
||||
}
|
||||
|
||||
fn strip_sql_comments(sql: &str) -> String {
|
||||
let mut output = String::with_capacity(sql.len());
|
||||
let mut chars = sql.chars().peekable();
|
||||
let mut in_line_comment = false;
|
||||
let mut in_block_comment = false;
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if in_line_comment {
|
||||
if ch == '\n' {
|
||||
in_line_comment = false;
|
||||
output.push(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_block_comment {
|
||||
if ch == '*' && chars.peek() == Some(&'/') {
|
||||
chars.next();
|
||||
in_block_comment = false;
|
||||
output.push(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '-' && chars.peek() == Some(&'-') {
|
||||
chars.next();
|
||||
in_line_comment = true;
|
||||
continue;
|
||||
}
|
||||
if ch == '#' {
|
||||
in_line_comment = true;
|
||||
continue;
|
||||
}
|
||||
if ch == '/' && chars.peek() == Some(&'*') {
|
||||
chars.next();
|
||||
in_block_comment = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push(ch);
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_postgres_json_explain_sql() {
|
||||
let result = build_explain_sql(ExplainSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
sql: " select * from users where id = 1; ".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
ExplainSqlBuildResult {
|
||||
ok: true,
|
||||
sql: Some("EXPLAIN (FORMAT JSON) select * from users where id = 1".to_string()),
|
||||
reason: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mysql_json_explain_and_rejects_unsafe_sql() {
|
||||
assert_eq!(
|
||||
build_explain_sql(ExplainSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
sql: "SELECT * FROM users;".to_string(),
|
||||
}),
|
||||
ExplainSqlBuildResult {
|
||||
ok: true,
|
||||
sql: Some("EXPLAIN FORMAT=JSON SELECT * FROM users".to_string()),
|
||||
reason: None,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
build_explain_sql(ExplainSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
sql: "delete from users".to_string(),
|
||||
}),
|
||||
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_dropped_file_preview_sql() {
|
||||
assert_eq!(
|
||||
build_dropped_file_preview_sql(DroppedFilePreviewSqlOptions {
|
||||
path: "/tmp/O'Hara.csv".to_string(),
|
||||
limit: Some(25),
|
||||
}),
|
||||
Some("SELECT * FROM read_csv('/tmp/O''Hara.csv') LIMIT 25".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
build_dropped_file_preview_sql(DroppedFilePreviewSqlOptions {
|
||||
path: "/tmp/data.tsv".to_string(),
|
||||
limit: None,
|
||||
}),
|
||||
Some("SELECT * FROM read_csv('/tmp/data.tsv', delim='\\t') LIMIT 1000".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
build_dropped_file_preview_sql(DroppedFilePreviewSqlOptions {
|
||||
path: "/tmp/data.txt".to_string(),
|
||||
limit: None,
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::sql::find_statement_at_cursor;
|
||||
use crate::sql_dialect::{quote_table_identifier, uses_fetch_first};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuerySqlBuildResult {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sql: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QueryPagination {
|
||||
pub limit: usize,
|
||||
pub offset: usize,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QueryPaginationExecutionPlanOptions {
|
||||
pub sql: String,
|
||||
pub query_base_sql: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub pagination: QueryPagination,
|
||||
pub use_agent_cursor: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QueryPaginationExecutionPlan {
|
||||
pub sql_to_execute: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page_sql: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page_limit: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page_offset: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub count_sql: Option<String>,
|
||||
pub use_agent_result_session: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PaginatedQuerySqlOptions {
|
||||
pub original_sql: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub limit: usize,
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CountQuerySqlOptions {
|
||||
pub original_sql: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum QuerySortDirection {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
impl QuerySortDirection {
|
||||
fn as_sql(self) -> &'static str {
|
||||
match self {
|
||||
Self::Asc => "ASC",
|
||||
Self::Desc => "DESC",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SortedQuerySqlOptions {
|
||||
pub original_sql: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default)]
|
||||
pub result_columns: Vec<String>,
|
||||
pub column_index: usize,
|
||||
pub column: String,
|
||||
pub direction: QuerySortDirection,
|
||||
}
|
||||
|
||||
pub fn build_query_pagination_execution_plan(
|
||||
options: QueryPaginationExecutionPlanOptions,
|
||||
) -> QueryPaginationExecutionPlan {
|
||||
let mut plan = QueryPaginationExecutionPlan {
|
||||
sql_to_execute: options.sql.clone(),
|
||||
page_sql: None,
|
||||
page_limit: None,
|
||||
page_offset: None,
|
||||
count_sql: None,
|
||||
use_agent_result_session: false,
|
||||
};
|
||||
|
||||
let counted = build_count_query_sql(CountQuerySqlOptions {
|
||||
original_sql: options.query_base_sql.clone(),
|
||||
database_type: options.database_type,
|
||||
});
|
||||
if counted.ok {
|
||||
plan.count_sql = counted.sql;
|
||||
}
|
||||
|
||||
if options.pagination.session_id.is_some() {
|
||||
plan.page_limit = Some(options.pagination.limit);
|
||||
plan.page_offset = Some(options.pagination.offset);
|
||||
plan.use_agent_result_session = true;
|
||||
return plan;
|
||||
}
|
||||
|
||||
if options.use_agent_cursor && options.pagination.offset == 0 {
|
||||
plan.sql_to_execute = options.query_base_sql;
|
||||
plan.page_limit = Some(options.pagination.limit);
|
||||
plan.page_offset = Some(options.pagination.offset);
|
||||
plan.use_agent_result_session = true;
|
||||
return plan;
|
||||
}
|
||||
|
||||
let paginated = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: options.sql,
|
||||
database_type: options.database_type,
|
||||
limit: options.pagination.limit,
|
||||
offset: options.pagination.offset,
|
||||
});
|
||||
if paginated.ok {
|
||||
plan.sql_to_execute = paginated.sql.clone().unwrap_or_default();
|
||||
plan.page_sql = paginated.sql;
|
||||
plan.page_limit = Some(options.pagination.limit);
|
||||
plan.page_offset = Some(options.pagination.offset);
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
pub fn build_paginated_query_sql(options: PaginatedQuerySqlOptions) -> QuerySqlBuildResult {
|
||||
let Ok(statement) = single_selectable_statement(&options.original_sql) else {
|
||||
return err(single_statement_error_reason(&options.original_sql));
|
||||
};
|
||||
if unsupported_pagination_type(options.database_type) {
|
||||
return err("unsupported");
|
||||
}
|
||||
|
||||
let safe_limit = options.limit.max(1);
|
||||
let safe_offset = options.offset;
|
||||
let alias = quote_table_identifier(options.database_type, "dbx_page");
|
||||
let wrapped_sql = if options.database_type == Some(DatabaseType::SqlServer) {
|
||||
sql_server_statement_for_derived_table(&statement)
|
||||
} else {
|
||||
statement
|
||||
};
|
||||
let base = format!("SELECT * FROM ({wrapped_sql}) {alias}");
|
||||
|
||||
if options.database_type == Some(DatabaseType::SqlServer) {
|
||||
if safe_offset > 0 {
|
||||
return err("unsupported");
|
||||
}
|
||||
return ok(format!("SELECT TOP ({safe_limit}) * FROM ({wrapped_sql}) {alias}"));
|
||||
}
|
||||
|
||||
if options.database_type.is_some_and(uses_fetch_first) {
|
||||
let offset_sql = if safe_offset > 0 { format!(" OFFSET {safe_offset} ROWS") } else { String::new() };
|
||||
return ok(format!("{base}{offset_sql} FETCH FIRST {safe_limit} ROWS ONLY"));
|
||||
}
|
||||
|
||||
let offset_sql = if safe_offset > 0 { format!(" OFFSET {safe_offset}") } else { String::new() };
|
||||
ok(format!("{base} LIMIT {safe_limit}{offset_sql};"))
|
||||
}
|
||||
|
||||
pub fn build_count_query_sql(options: CountQuerySqlOptions) -> QuerySqlBuildResult {
|
||||
let Ok(statement) = single_selectable_statement(&options.original_sql) else {
|
||||
return err(single_statement_error_reason(&options.original_sql));
|
||||
};
|
||||
if unsupported_pagination_type(options.database_type) {
|
||||
return err("unsupported");
|
||||
}
|
||||
|
||||
let alias = quote_table_identifier(options.database_type, "dbx_count");
|
||||
let wrapped_sql = if options.database_type == Some(DatabaseType::SqlServer) {
|
||||
sql_server_statement_for_derived_table(&statement)
|
||||
} else {
|
||||
statement
|
||||
};
|
||||
ok(format!("SELECT COUNT(*) AS dbx_total_rows FROM ({wrapped_sql}) {alias};"))
|
||||
}
|
||||
|
||||
pub fn build_sorted_query_sql(options: SortedQuerySqlOptions) -> QuerySqlBuildResult {
|
||||
let base_sql = options.original_sql.trim();
|
||||
if base_sql.is_empty() {
|
||||
return err("empty");
|
||||
}
|
||||
|
||||
let statement = find_statement_at_cursor(base_sql, 0).trim().trim_end_matches(';').trim().to_string();
|
||||
if statement.is_empty() {
|
||||
return err("empty");
|
||||
}
|
||||
if statement.len() != base_sql.trim_end_matches(';').trim().len() {
|
||||
return err("multi");
|
||||
}
|
||||
if statement.trim_start().to_ascii_uppercase().starts_with("WITH") {
|
||||
return err("with");
|
||||
}
|
||||
if !statement.trim_start().to_ascii_uppercase().starts_with("SELECT") {
|
||||
return err("not_select");
|
||||
}
|
||||
|
||||
let aliases = build_derived_column_aliases(&options.result_columns);
|
||||
let use_derived_column_aliases = options.database_type != Some(DatabaseType::Mysql);
|
||||
let sort_alias = if use_derived_column_aliases {
|
||||
aliases
|
||||
.get(options.column_index)
|
||||
.or_else(|| {
|
||||
options
|
||||
.result_columns
|
||||
.iter()
|
||||
.position(|column| column == &options.column)
|
||||
.and_then(|index| aliases.get(index))
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or_else(|| fallback_alias(options.column_index))
|
||||
} else {
|
||||
options.result_columns.get(options.column_index).cloned().unwrap_or_else(|| options.column.clone())
|
||||
};
|
||||
let quoted_column = quote_table_identifier(options.database_type, &sort_alias);
|
||||
let wrapped_statement = if options.database_type == Some(DatabaseType::SqlServer) {
|
||||
sql_server_statement_for_derived_table(&statement)
|
||||
} else {
|
||||
statement
|
||||
};
|
||||
|
||||
if use_derived_column_aliases {
|
||||
let alias_list = aliases
|
||||
.iter()
|
||||
.map(|alias| quote_table_identifier(options.database_type, alias))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
ok(format!(
|
||||
"SELECT * FROM ({wrapped_statement}) t({alias_list}) ORDER BY {quoted_column} {};",
|
||||
options.direction.as_sql()
|
||||
))
|
||||
} else {
|
||||
ok(format!("SELECT * FROM ({wrapped_statement}) t ORDER BY {quoted_column} {};", options.direction.as_sql()))
|
||||
}
|
||||
}
|
||||
|
||||
fn ok(sql: String) -> QuerySqlBuildResult {
|
||||
QuerySqlBuildResult { ok: true, sql: Some(sql), reason: None }
|
||||
}
|
||||
|
||||
fn err(reason: &str) -> QuerySqlBuildResult {
|
||||
QuerySqlBuildResult { ok: false, sql: None, reason: Some(reason.to_string()) }
|
||||
}
|
||||
|
||||
fn unsupported_pagination_type(database_type: Option<DatabaseType>) -> bool {
|
||||
matches!(
|
||||
database_type,
|
||||
Some(DatabaseType::Neo4j | DatabaseType::MongoDb | DatabaseType::Redis | DatabaseType::Elasticsearch)
|
||||
)
|
||||
}
|
||||
|
||||
fn single_selectable_statement(original_sql: &str) -> Result<String, ()> {
|
||||
let base_sql = original_sql.trim();
|
||||
if base_sql.is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let statement = find_statement_at_cursor(base_sql, 0).trim().trim_end_matches(';').trim().to_string();
|
||||
if statement.is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
if statement.len() != base_sql.trim_end_matches(';').trim().len() {
|
||||
return Err(());
|
||||
}
|
||||
let upper = statement.trim_start().to_ascii_uppercase();
|
||||
if !(upper.starts_with("SELECT") || upper.starts_with("WITH")) {
|
||||
return Err(());
|
||||
}
|
||||
if has_top_level_select_into(&statement) {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
Ok(statement)
|
||||
}
|
||||
|
||||
fn single_statement_error_reason(original_sql: &str) -> &'static str {
|
||||
let base_sql = original_sql.trim();
|
||||
if base_sql.is_empty() {
|
||||
return "empty";
|
||||
}
|
||||
let statement = find_statement_at_cursor(base_sql, 0).trim().trim_end_matches(';').trim().to_string();
|
||||
if statement.is_empty() {
|
||||
return "empty";
|
||||
}
|
||||
if statement.len() != base_sql.trim_end_matches(';').trim().len() {
|
||||
return "multi";
|
||||
}
|
||||
"not_select"
|
||||
}
|
||||
|
||||
fn has_top_level_select_into(sql: &str) -> bool {
|
||||
let mut saw_select = false;
|
||||
for token in top_level_sql_tokens(sql) {
|
||||
if !saw_select {
|
||||
saw_select = token.text == "SELECT";
|
||||
continue;
|
||||
}
|
||||
if token.text == "INTO" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn sql_server_statement_for_derived_table(statement: &str) -> String {
|
||||
let Some(order_by) = find_top_level_trailing_order_by(statement) else {
|
||||
return statement.to_string();
|
||||
};
|
||||
if has_top_level_select_top(statement) || has_top_level_for_xml(statement) {
|
||||
return statement.to_string();
|
||||
}
|
||||
statement[..order_by].trim_end().to_string()
|
||||
}
|
||||
|
||||
fn find_top_level_trailing_order_by(sql: &str) -> Option<usize> {
|
||||
let tokens = top_level_sql_tokens(sql);
|
||||
for index in (0..tokens.len().saturating_sub(1)).rev() {
|
||||
if tokens[index].text == "ORDER" && tokens.get(index + 1).is_some_and(|token| token.text == "BY") {
|
||||
return Some(tokens[index].start);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn has_top_level_select_top(sql: &str) -> bool {
|
||||
let tokens = top_level_sql_tokens(sql);
|
||||
let Some(select_index) = tokens.iter().position(|token| token.text == "SELECT") else {
|
||||
return false;
|
||||
};
|
||||
let from_index = tokens
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(index, token)| *index > select_index && token.text == "FROM")
|
||||
.map(|(index, _)| index)
|
||||
.unwrap_or(tokens.len());
|
||||
tokens[select_index + 1..from_index].iter().any(|token| token.text == "TOP")
|
||||
}
|
||||
|
||||
fn has_top_level_for_xml(sql: &str) -> bool {
|
||||
let tokens = top_level_sql_tokens(sql);
|
||||
tokens
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(index, token)| token.text == "FOR" && tokens.get(index + 1).is_some_and(|next| next.text == "XML"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SqlToken {
|
||||
text: String,
|
||||
start: usize,
|
||||
}
|
||||
|
||||
fn top_level_sql_tokens(sql: &str) -> Vec<SqlToken> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut i = 0;
|
||||
let mut depth = 0usize;
|
||||
|
||||
while i < sql.len() {
|
||||
let ch = next_char(sql, i);
|
||||
let next = next_char_at(sql, i + ch.len_utf8());
|
||||
|
||||
if ch == '-' && next == Some('-') {
|
||||
i += 2;
|
||||
while i < sql.len() && next_char(sql, i) != '\n' {
|
||||
i += next_char(sql, i).len_utf8();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '/' && next == Some('*') {
|
||||
i += 2;
|
||||
while i < sql.len() {
|
||||
let current = next_char(sql, i);
|
||||
let following = next_char_at(sql, i + current.len_utf8());
|
||||
if current == '*' && following == Some('/') {
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
i += current.len_utf8();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(ch, '\'' | '"' | '`') {
|
||||
i = skip_sql_quoted(sql, i, ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '[' {
|
||||
i = skip_sql_bracket_identifier(sql, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '(' {
|
||||
depth += 1;
|
||||
i += ch.len_utf8();
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == ')' {
|
||||
depth = depth.saturating_sub(1);
|
||||
i += ch.len_utf8();
|
||||
continue;
|
||||
}
|
||||
|
||||
if depth == 0 && is_sql_token_start(ch) {
|
||||
let start = i;
|
||||
i += ch.len_utf8();
|
||||
while i < sql.len() && is_sql_token_part(next_char(sql, i)) {
|
||||
i += next_char(sql, i).len_utf8();
|
||||
}
|
||||
tokens.push(SqlToken { text: sql[start..i].to_ascii_uppercase(), start });
|
||||
continue;
|
||||
}
|
||||
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
|
||||
tokens
|
||||
}
|
||||
|
||||
fn skip_sql_quoted(sql: &str, pos: usize, quote: char) -> usize {
|
||||
let mut i = pos + quote.len_utf8();
|
||||
while i < sql.len() {
|
||||
let ch = next_char(sql, i);
|
||||
let next = next_char_at(sql, i + ch.len_utf8());
|
||||
if ch == quote {
|
||||
if next == Some(quote) {
|
||||
i += ch.len_utf8() + quote.len_utf8();
|
||||
continue;
|
||||
}
|
||||
return i + ch.len_utf8();
|
||||
}
|
||||
if quote == '\'' && ch == '\\' {
|
||||
i += ch.len_utf8();
|
||||
if i < sql.len() {
|
||||
i += next_char(sql, i).len_utf8();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
sql.len()
|
||||
}
|
||||
|
||||
fn skip_sql_bracket_identifier(sql: &str, pos: usize) -> usize {
|
||||
let mut i = pos + 1;
|
||||
while i < sql.len() {
|
||||
let ch = next_char(sql, i);
|
||||
let next = next_char_at(sql, i + ch.len_utf8());
|
||||
if ch == ']' {
|
||||
if next == Some(']') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
return i + 1;
|
||||
}
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
sql.len()
|
||||
}
|
||||
|
||||
fn is_sql_token_start(ch: char) -> bool {
|
||||
ch.is_ascii_alphabetic() || ch == '_'
|
||||
}
|
||||
|
||||
fn is_sql_token_part(ch: char) -> bool {
|
||||
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$' | '#')
|
||||
}
|
||||
|
||||
fn next_char(sql: &str, index: usize) -> char {
|
||||
sql[index..].chars().next().unwrap_or('\0')
|
||||
}
|
||||
|
||||
fn next_char_at(sql: &str, index: usize) -> Option<char> {
|
||||
if index >= sql.len() {
|
||||
None
|
||||
} else {
|
||||
sql[index..].chars().next()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_derived_column_aliases(result_columns: &[String]) -> Vec<String> {
|
||||
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
result_columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, column)| {
|
||||
let base = normalize_alias_base(column, index);
|
||||
let count = seen.entry(base.clone()).and_modify(|value| *value += 1).or_insert(1);
|
||||
if *count == 1 {
|
||||
base
|
||||
} else {
|
||||
format!("{base}_{count}")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_alias_base(column: &str, index: usize) -> String {
|
||||
let compact = column.split_whitespace().collect::<Vec<_>>().join("_");
|
||||
let safe = compact
|
||||
.chars()
|
||||
.map(|ch| if ch.is_alphanumeric() || matches!(ch, '_' | '$') { ch } else { '_' })
|
||||
.collect::<String>()
|
||||
.trim_matches('_')
|
||||
.to_string();
|
||||
if safe.is_empty() {
|
||||
fallback_alias(index)
|
||||
} else {
|
||||
safe
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_alias(index: usize) -> String {
|
||||
format!("column_{}", index + 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wraps_single_select_query_with_limit_and_offset() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: "SELECT id, name FROM users;".to_string(),
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
limit: 100,
|
||||
offset: 200,
|
||||
});
|
||||
|
||||
assert_eq!(result.ok, true);
|
||||
assert_eq!(
|
||||
result.sql.unwrap(),
|
||||
"SELECT * FROM (SELECT id, name FROM users) \"dbx_page\" LIMIT 100 OFFSET 200;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_sqlserver_top_pagination_for_first_page() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: "SELECT id FROM users ORDER BY id DESC".to_string(),
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
assert_eq!(result.ok, true);
|
||||
assert_eq!(result.sql.unwrap(), "SELECT TOP (100) * FROM (SELECT id FROM users) [dbx_page]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_sqlserver_offset_pagination_for_later_pages() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: "SELECT id FROM users".to_string(),
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
limit: 100,
|
||||
offset: 300,
|
||||
});
|
||||
|
||||
assert_eq!(result, err("unsupported"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_fetch_first_pagination_for_oracle() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: "SELECT id FROM users".to_string(),
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
assert_eq!(result.sql.unwrap(), "SELECT * FROM (SELECT id FROM users) \"dbx_page\" FETCH FIRST 100 ROWS ONLY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_mysql_style_alias_for_pagination() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: "SELECT id FROM users WHERE active = 1".to_string(),
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
assert_eq!(result.sql.unwrap(), "SELECT * FROM (SELECT id FROM users WHERE active = 1) `dbx_page` LIMIT 50;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_multiple_statements_for_pagination() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: "SELECT 1; SELECT 2;".to_string(),
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
assert_eq!(result, err("multi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_select_into_for_pagination() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: "SELECT * INTO copy_users FROM users WHERE active = 1".to_string(),
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
assert_eq!(result, err("not_select"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_count_query() {
|
||||
let result = build_count_query_sql(CountQuerySqlOptions {
|
||||
original_sql: "WITH cte AS (SELECT 1 AS id) SELECT * FROM cte".to_string(),
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
result.sql.unwrap(),
|
||||
"SELECT COUNT(*) AS dbx_total_rows FROM (WITH cte AS (SELECT 1 AS id) SELECT * FROM cte) `dbx_count`;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_agent_cursor_pagination_plan() {
|
||||
let plan = build_query_pagination_execution_plan(QueryPaginationExecutionPlanOptions {
|
||||
sql: "SELECT * FROM events".to_string(),
|
||||
query_base_sql: "SELECT * FROM events".to_string(),
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
pagination: QueryPagination { limit: 500, offset: 0, session_id: None },
|
||||
use_agent_cursor: true,
|
||||
});
|
||||
|
||||
assert_eq!(plan.sql_to_execute, "SELECT * FROM events");
|
||||
assert_eq!(plan.page_limit, Some(500));
|
||||
assert_eq!(plan.page_offset, Some(0));
|
||||
assert!(plan.page_sql.is_none());
|
||||
assert!(plan.use_agent_result_session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_sorted_query_sql() {
|
||||
let result = build_sorted_query_sql(SortedQuerySqlOptions {
|
||||
original_sql: "SELECT c.id, m.id FROM t_campaign c LEFT JOIN t_campaign_mdf m ON m.campaign_id = c.id"
|
||||
.to_string(),
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
result_columns: vec!["id".to_string(), "id".to_string()],
|
||||
column_index: 1,
|
||||
column: "id".to_string(),
|
||||
direction: QuerySortDirection::Asc,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
result.sql.unwrap(),
|
||||
"SELECT * FROM (SELECT c.id, m.id FROM t_campaign c LEFT JOIN t_campaign_mdf m ON m.campaign_id = c.id) t(\"id\", \"id_2\") ORDER BY \"id_2\" ASC;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mysql_sorted_query_without_alias_list() {
|
||||
let result = build_sorted_query_sql(SortedQuerySqlOptions {
|
||||
original_sql: "SELECT * FROM admin LIMIT 100;".to_string(),
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
result_columns: vec![
|
||||
"id".to_string(),
|
||||
"guid".to_string(),
|
||||
"role_guid".to_string(),
|
||||
"login_name".to_string(),
|
||||
"password".to_string(),
|
||||
],
|
||||
column_index: 3,
|
||||
column: "login_name".to_string(),
|
||||
direction: QuerySortDirection::Asc,
|
||||
});
|
||||
|
||||
assert_eq!(result.sql.unwrap(), "SELECT * FROM (SELECT * FROM admin LIMIT 100) t ORDER BY `login_name` ASC;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_sqlserver_order_by_for_sorted_query() {
|
||||
let result = build_sorted_query_sql(SortedQuerySqlOptions {
|
||||
original_sql: "SELECT id, name FROM users ORDER BY id DESC".to_string(),
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
result_columns: vec!["id".to_string(), "name".to_string()],
|
||||
column_index: 1,
|
||||
column: "name".to_string(),
|
||||
direction: QuerySortDirection::Asc,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
result.sql.unwrap(),
|
||||
"SELECT * FROM (SELECT id, name FROM users) t([id], [name]) ORDER BY [name] ASC;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_with_query_sorting() {
|
||||
let result = build_sorted_query_sql(SortedQuerySqlOptions {
|
||||
original_sql: "WITH cte AS (SELECT 1) SELECT * FROM cte".to_string(),
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
result_columns: vec!["id".to_string()],
|
||||
column_index: 0,
|
||||
column: "id".to_string(),
|
||||
direction: QuerySortDirection::Asc,
|
||||
});
|
||||
|
||||
assert_eq!(result, err("with"));
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,223 @@ pub fn split_sql_statements(sql: &str) -> Vec<String> {
|
||||
statements
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SqlStatementRange {
|
||||
pub text: String,
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
pub fn find_statement_at_cursor(sql: &str, cursor_pos: usize) -> String {
|
||||
let statements = split_sql_statement_ranges(sql);
|
||||
let cursor = utf16_offset_to_byte_index(sql, cursor_pos);
|
||||
|
||||
for statement in &statements {
|
||||
if cursor >= statement.start && cursor <= statement.end {
|
||||
return statement.text.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(last) = statements.last() {
|
||||
if cursor >= last.end {
|
||||
return last.text.clone();
|
||||
}
|
||||
}
|
||||
|
||||
sql.trim().to_string()
|
||||
}
|
||||
|
||||
fn split_sql_statement_ranges(sql: &str) -> Vec<SqlStatementRange> {
|
||||
let mut ranges = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut i = 0;
|
||||
let mut in_single_quote = false;
|
||||
let mut in_double_quote = false;
|
||||
let mut in_backtick = false;
|
||||
let mut in_line_comment = false;
|
||||
let mut in_block_comment = false;
|
||||
let mut dollar_quote_tag: Option<String> = None;
|
||||
let mut custom_delimiter: Option<String> = None;
|
||||
|
||||
while i < sql.len() {
|
||||
if let Some(tag) = &dollar_quote_tag {
|
||||
if sql[i..].starts_with(tag) {
|
||||
i += tag.len();
|
||||
dollar_quote_tag = None;
|
||||
continue;
|
||||
}
|
||||
i += next_char_len(sql, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
let ch = next_char(sql, i);
|
||||
let next = next_char_at(sql, i + ch.len_utf8());
|
||||
|
||||
if in_line_comment {
|
||||
i += ch.len_utf8();
|
||||
if ch == '\n' {
|
||||
in_line_comment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_block_comment {
|
||||
if ch == '*' && next == Some('/') {
|
||||
i += 2;
|
||||
in_block_comment = false;
|
||||
} else {
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if !in_single_quote && !in_double_quote && !in_backtick {
|
||||
if ch == '-' && next == Some('-') {
|
||||
in_line_comment = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ch == '/' && next == Some('*') {
|
||||
in_block_comment = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if let Some(tag) = dollar_quote_tag_at_str(sql, i) {
|
||||
if custom_delimiter.is_none() && !is_on_delimiter_line(sql, start, i) {
|
||||
i += tag.len();
|
||||
dollar_quote_tag = Some(tag);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ch == '\n' {
|
||||
let line_start = sql[..i].rfind('\n').map_or(0, |pos| pos + 1);
|
||||
let line = sql[line_start..i].trim();
|
||||
if let Some(new_delimiter) = parse_delimiter_command(line) {
|
||||
let before = sql[start..line_start].trim();
|
||||
if has_executable_sql(before) {
|
||||
push_statement_range(&mut ranges, sql, start, line_start);
|
||||
}
|
||||
custom_delimiter = if new_delimiter == ";" { None } else { Some(new_delimiter.to_string()) };
|
||||
start = i + ch.len_utf8();
|
||||
i = start;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match ch {
|
||||
'\'' if !in_double_quote && !in_backtick && !is_escaped_single_quote(sql, i) => {
|
||||
in_single_quote = !in_single_quote;
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
'"' if !in_single_quote && !in_backtick => {
|
||||
in_double_quote = !in_double_quote;
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
'`' if !in_single_quote && !in_double_quote => {
|
||||
in_backtick = !in_backtick;
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
';' if !in_single_quote && !in_double_quote && !in_backtick && custom_delimiter.is_none() => {
|
||||
push_statement_range(&mut ranges, sql, start, i);
|
||||
i += ch.len_utf8();
|
||||
start = i;
|
||||
}
|
||||
_ => {
|
||||
i += ch.len_utf8();
|
||||
if !in_single_quote && !in_double_quote && !in_backtick {
|
||||
if let Some(delimiter) = &custom_delimiter {
|
||||
if sql[start..i].ends_with(delimiter) {
|
||||
let end = i - delimiter.len();
|
||||
push_statement_range(&mut ranges, sql, start, end);
|
||||
start = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = sql[start..].trim();
|
||||
let last_line = trimmed.rsplit('\n').next().unwrap_or(trimmed).trim();
|
||||
if parse_delimiter_command(last_line).is_some() {
|
||||
if let Some(line_start) = sql[start..].rfind('\n').map(|pos| start + pos + 1) {
|
||||
push_statement_range(&mut ranges, sql, start, line_start);
|
||||
}
|
||||
} else {
|
||||
push_statement_range(&mut ranges, sql, start, sql.len());
|
||||
}
|
||||
|
||||
ranges
|
||||
}
|
||||
|
||||
fn push_statement_range(ranges: &mut Vec<SqlStatementRange>, sql: &str, start: usize, end: usize) {
|
||||
let text = sql[start..end].trim();
|
||||
if has_executable_sql(text) {
|
||||
ranges.push(SqlStatementRange { text: text.to_string(), start, end });
|
||||
}
|
||||
}
|
||||
|
||||
fn utf16_offset_to_byte_index(sql: &str, offset: usize) -> usize {
|
||||
let mut utf16_seen = 0;
|
||||
for (byte_index, ch) in sql.char_indices() {
|
||||
if utf16_seen >= offset {
|
||||
return byte_index;
|
||||
}
|
||||
utf16_seen += ch.len_utf16();
|
||||
if utf16_seen > offset {
|
||||
return byte_index + ch.len_utf8();
|
||||
}
|
||||
}
|
||||
sql.len()
|
||||
}
|
||||
|
||||
fn next_char(sql: &str, index: usize) -> char {
|
||||
sql[index..].chars().next().unwrap_or('\0')
|
||||
}
|
||||
|
||||
fn next_char_at(sql: &str, index: usize) -> Option<char> {
|
||||
if index >= sql.len() {
|
||||
None
|
||||
} else {
|
||||
sql[index..].chars().next()
|
||||
}
|
||||
}
|
||||
|
||||
fn next_char_len(sql: &str, index: usize) -> usize {
|
||||
next_char(sql, index).len_utf8()
|
||||
}
|
||||
|
||||
fn is_escaped_single_quote(sql: &str, index: usize) -> bool {
|
||||
index > 0 && sql.as_bytes().get(index - 1) == Some(&b'\\')
|
||||
}
|
||||
|
||||
fn is_on_delimiter_line(sql: &str, range_start: usize, index: usize) -> bool {
|
||||
let line_start = sql[range_start..index].rfind('\n').map_or(range_start, |pos| range_start + pos + 1);
|
||||
sql[line_start..index]
|
||||
.trim_start()
|
||||
.as_bytes()
|
||||
.get(..9)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"delimiter"))
|
||||
}
|
||||
|
||||
fn dollar_quote_tag_at_str(sql: &str, index: usize) -> Option<String> {
|
||||
let rest = &sql[index..];
|
||||
if !rest.starts_with('$') {
|
||||
return None;
|
||||
}
|
||||
let end = rest[1..].find('$')? + 1;
|
||||
let tag = &rest[..=end];
|
||||
if tag.len() == 2 {
|
||||
return Some(tag.to_string());
|
||||
}
|
||||
let name = &tag[1..tag.len() - 1];
|
||||
if !name.chars().all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
|
||||
return None;
|
||||
}
|
||||
Some(tag.to_string())
|
||||
}
|
||||
|
||||
pub fn split_sql_batches(sql: &str) -> Vec<String> {
|
||||
let mut batches = Vec::new();
|
||||
let mut current_start = 0;
|
||||
@@ -972,4 +1189,47 @@ SELECT 2 $$
|
||||
DELIMITER ;";
|
||||
assert_eq!(super::split_sql_statements(sql), vec!["SELECT 1", "SELECT 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_statement_at_cursor() {
|
||||
let sql = "SELECT 1; SELECT 2";
|
||||
|
||||
assert_eq!(super::find_statement_at_cursor(sql, 3), "SELECT 1");
|
||||
assert_eq!(super::find_statement_at_cursor(sql, 12), "SELECT 2");
|
||||
assert_eq!(super::find_statement_at_cursor(sql, 18), "SELECT 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_statement_at_cursor_after_unicode_comment() {
|
||||
let sql = "-- 判断字段是否存在\nSELECT 1; SELECT 2";
|
||||
let cursor_byte = sql.find("SELECT 2").unwrap();
|
||||
let cursor = sql[..cursor_byte].encode_utf16().count();
|
||||
|
||||
assert_eq!(super::find_statement_at_cursor(sql, cursor), "SELECT 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_statement_with_dollar_quote() {
|
||||
let sql = "SELECT $$a;b$$; SELECT 2";
|
||||
|
||||
assert_eq!(super::find_statement_at_cursor(sql, 3), "SELECT $$a;b$$");
|
||||
assert_eq!(super::find_statement_at_cursor(sql, 17), "SELECT 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_statement_with_custom_delimiter() {
|
||||
let sql = "\
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE foo()
|
||||
BEGIN
|
||||
SELECT 1;
|
||||
END //
|
||||
DELIMITER ;
|
||||
SELECT 2;";
|
||||
let cursor = sql.find("SELECT 1").unwrap();
|
||||
let next_cursor = sql.rfind("SELECT 2").unwrap();
|
||||
|
||||
assert_eq!(super::find_statement_at_cursor(sql, cursor), "CREATE PROCEDURE foo()\nBEGIN\n SELECT 1;\nEND");
|
||||
assert_eq!(super::find_statement_at_cursor(sql, next_cursor), "SELECT 2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
use crate::models::connection::DatabaseType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const DBX_ROWID_COLUMN: &str = "__DBX_ROWID";
|
||||
pub const DBX_NEO4J_ELEMENT_ID_COLUMN: &str = "__DBX_ELEMENT_ID";
|
||||
pub const DBX_TDENGINE_TBNAME_COLUMN: &str = "tbname";
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TableSelectSqlOptions<'a> {
|
||||
@@ -10,10 +15,113 @@ pub struct TableSelectSqlOptions<'a> {
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableDataSelectSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
#[serde(default)]
|
||||
pub primary_keys: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub fallback_order_columns: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub order_by: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub where_input: Option<String>,
|
||||
#[serde(default)]
|
||||
pub include_row_id: bool,
|
||||
}
|
||||
|
||||
pub fn build_count_table_sql(database_type: Option<DatabaseType>, schema: Option<&str>, table_name: &str) -> String {
|
||||
format!("SELECT COUNT(*) AS row_count FROM {}", qualified_table_name(database_type, schema, table_name))
|
||||
}
|
||||
|
||||
pub fn build_table_data_select_sql(options: TableDataSelectSqlOptions) -> String {
|
||||
let database_type = options.database_type;
|
||||
let limit = options.limit.unwrap_or(100);
|
||||
if database_type == Some(DatabaseType::Neo4j) {
|
||||
return build_neo4j_table_select_sql(&options, limit);
|
||||
}
|
||||
|
||||
let table = qualified_table_name(database_type, options.schema.as_deref(), &options.table_name);
|
||||
let predicate = normalize_where_input(options.where_input.as_deref());
|
||||
let where_clause = if predicate.is_empty() { String::new() } else { format!(" WHERE ({predicate})") };
|
||||
let row_id_alias =
|
||||
if options.include_row_id && database_type == Some(DatabaseType::Oracle) { Some("t") } else { None };
|
||||
let default_order_alias = if database_type == Some(DatabaseType::Jdbc) { Some("dbx_t") } else { row_id_alias };
|
||||
let default_order_by = if !options.primary_keys.is_empty() {
|
||||
Some(
|
||||
options
|
||||
.primary_keys
|
||||
.iter()
|
||||
.map(|pk| format!("{} ASC", quote_order_identifier(database_type, pk, default_order_alias)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
} else if !options.fallback_order_columns.is_empty() {
|
||||
Some(
|
||||
options
|
||||
.fallback_order_columns
|
||||
.iter()
|
||||
.map(|column| format!("{} ASC", quote_table_identifier(database_type, column)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let order_by = options.order_by.as_deref().filter(|order| !order.trim().is_empty()).or(default_order_by.as_deref());
|
||||
let order = order_by.map(|order_by| format!(" ORDER BY {order_by}")).unwrap_or_default();
|
||||
|
||||
let select_columns = if options.include_row_id && database_type == Some(DatabaseType::Oracle) {
|
||||
format!("ROWIDTOCHAR(t.ROWID) AS \"{DBX_ROWID_COLUMN}\", t.*")
|
||||
} else {
|
||||
build_select_columns(database_type, &options.columns)
|
||||
};
|
||||
let table_alias = if options.include_row_id && database_type.is_some_and(uses_fetch_first) {
|
||||
format!("{table} t")
|
||||
} else if database_type == Some(DatabaseType::Jdbc) && default_order_by.is_some() {
|
||||
format!("{table} dbx_t")
|
||||
} else {
|
||||
table
|
||||
};
|
||||
|
||||
if database_type.is_some_and(uses_fetch_first) {
|
||||
let offset = options
|
||||
.offset
|
||||
.filter(|offset| *offset > 0)
|
||||
.map(|offset| format!(" OFFSET {offset} ROWS"))
|
||||
.unwrap_or_default();
|
||||
return format!(
|
||||
"SELECT {select_columns} FROM {table_alias}{where_clause}{order}{offset} FETCH FIRST {limit} ROWS ONLY"
|
||||
);
|
||||
}
|
||||
|
||||
if database_type == Some(DatabaseType::SqlServer) {
|
||||
return build_sqlserver_table_select_sql(
|
||||
&table_alias,
|
||||
&where_clause,
|
||||
order_by.unwrap_or("(SELECT NULL)"),
|
||||
&options.columns,
|
||||
limit,
|
||||
options.offset.unwrap_or(0),
|
||||
);
|
||||
}
|
||||
|
||||
let offset =
|
||||
options.offset.filter(|offset| *offset > 0).map(|offset| format!(" OFFSET {offset}")).unwrap_or_default();
|
||||
format!("SELECT {select_columns} FROM {table_alias}{where_clause}{order} LIMIT {limit}{offset};")
|
||||
}
|
||||
|
||||
pub fn build_table_select_sql(options: TableSelectSqlOptions<'_>) -> String {
|
||||
let database_type = options.database_type;
|
||||
let table = qualified_table_name(database_type, options.schema, options.table_name);
|
||||
@@ -76,6 +184,139 @@ pub fn quote_table_identifier(database_type: Option<DatabaseType>, name: &str) -
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_where_input(where_input: Option<&str>) -> String {
|
||||
let trimmed = where_input.unwrap_or("").trim().trim_end_matches(';').trim();
|
||||
if trimmed.len() >= 5 && trimmed[..5].eq_ignore_ascii_case("where") {
|
||||
trimmed[5..].trim().to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_oracle_row_id(database_type: Option<DatabaseType>, name: &str) -> bool {
|
||||
database_type == Some(DatabaseType::Oracle) && name.eq_ignore_ascii_case(DBX_ROWID_COLUMN)
|
||||
}
|
||||
|
||||
fn is_tdengine_tbname(database_type: Option<DatabaseType>, name: &str) -> bool {
|
||||
database_type == Some(DatabaseType::Tdengine) && name.eq_ignore_ascii_case(DBX_TDENGINE_TBNAME_COLUMN)
|
||||
}
|
||||
|
||||
fn quote_order_identifier(database_type: Option<DatabaseType>, name: &str, table_alias: Option<&str>) -> String {
|
||||
if is_oracle_row_id(database_type, name) {
|
||||
return table_alias.map(|alias| format!("{alias}.ROWID")).unwrap_or_else(|| "ROWID".to_string());
|
||||
}
|
||||
if is_tdengine_tbname(database_type, name) {
|
||||
return DBX_TDENGINE_TBNAME_COLUMN.to_string();
|
||||
}
|
||||
let quoted = quote_table_identifier(database_type, name);
|
||||
table_alias.map(|alias| format!("{alias}.{quoted}")).unwrap_or(quoted)
|
||||
}
|
||||
|
||||
fn build_select_columns(database_type: Option<DatabaseType>, columns: &[String]) -> String {
|
||||
if columns.is_empty() {
|
||||
return "*".to_string();
|
||||
}
|
||||
if database_type == Some(DatabaseType::Tdengine) {
|
||||
let mut tdengine_columns = Vec::new();
|
||||
if !columns.iter().any(|column| column.eq_ignore_ascii_case(DBX_TDENGINE_TBNAME_COLUMN)) {
|
||||
tdengine_columns.push(DBX_TDENGINE_TBNAME_COLUMN.to_string());
|
||||
}
|
||||
tdengine_columns.extend(columns.iter().cloned());
|
||||
return tdengine_columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
if is_tdengine_tbname(database_type, column) {
|
||||
DBX_TDENGINE_TBNAME_COLUMN.to_string()
|
||||
} else {
|
||||
let ident = quote_table_identifier(database_type, column);
|
||||
format!("{ident} AS {ident}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
}
|
||||
if database_type != Some(DatabaseType::Hive) {
|
||||
return "*".to_string();
|
||||
}
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let ident = quote_table_identifier(database_type, column);
|
||||
format!("{ident} AS {ident}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn build_sqlserver_table_select_sql(
|
||||
table: &str,
|
||||
where_clause: &str,
|
||||
order_by: &str,
|
||||
columns: &[String],
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> String {
|
||||
let columns_sql = if columns.is_empty() {
|
||||
"*".to_string()
|
||||
} else {
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| quote_table_identifier(Some(DatabaseType::SqlServer), column))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
let order = if order_by == "(SELECT NULL)" { String::new() } else { format!(" ORDER BY {order_by}") };
|
||||
if offset == 0 {
|
||||
return format!("SELECT TOP ({limit}) {columns_sql} FROM {table}{where_clause}{order}");
|
||||
}
|
||||
|
||||
let page_alias = quote_table_identifier(Some(DatabaseType::SqlServer), "dbx_page");
|
||||
let row_number_alias = quote_table_identifier(Some(DatabaseType::SqlServer), "__dbx_row_num");
|
||||
let end = offset + limit;
|
||||
format!(
|
||||
"WITH {page_alias} AS (SELECT {columns_sql}, ROW_NUMBER() OVER (ORDER BY {order_by}) AS {row_number_alias} FROM {table}{where_clause}) SELECT {columns_sql} FROM {page_alias} WHERE {row_number_alias} > {offset} AND {row_number_alias} <= {end} ORDER BY {row_number_alias}"
|
||||
)
|
||||
}
|
||||
|
||||
fn build_neo4j_table_select_sql(options: &TableDataSelectSqlOptions, limit: usize) -> String {
|
||||
let label = quote_table_identifier(Some(DatabaseType::Neo4j), &options.table_name);
|
||||
let predicate = normalize_where_input(options.where_input.as_deref());
|
||||
let where_clause = if predicate.is_empty() { String::new() } else { format!(" WHERE {predicate}") };
|
||||
let returned_columns = if options.columns.is_empty() {
|
||||
"n".to_string()
|
||||
} else {
|
||||
options
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let ident = quote_table_identifier(Some(DatabaseType::Neo4j), column);
|
||||
format!("n.{ident} AS {ident}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
let returns = format!(
|
||||
"elementId(n) AS {}, {returned_columns}",
|
||||
quote_table_identifier(Some(DatabaseType::Neo4j), DBX_NEO4J_ELEMENT_ID_COLUMN)
|
||||
);
|
||||
let default_order_by = if options.primary_keys.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
options
|
||||
.primary_keys
|
||||
.iter()
|
||||
.map(|pk| format!("n.{} ASC", quote_table_identifier(Some(DatabaseType::Neo4j), pk)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
};
|
||||
let order_by = options.order_by.as_deref().filter(|order| !order.trim().is_empty()).or(default_order_by.as_deref());
|
||||
let order = order_by.map(|order_by| format!(" ORDER BY {order_by}")).unwrap_or_default();
|
||||
let skip = options.offset.filter(|offset| *offset > 0).map(|offset| format!(" SKIP {offset}")).unwrap_or_default();
|
||||
format!("MATCH (n:{label}){where_clause} RETURN {returns}{order}{skip} LIMIT {limit};")
|
||||
}
|
||||
|
||||
pub fn is_schema_aware(database_type: DatabaseType) -> bool {
|
||||
matches!(
|
||||
database_type,
|
||||
@@ -165,4 +406,154 @@ mod tests {
|
||||
"SELECT TOP (100) [id], [name] FROM [dbo].[users] ORDER BY [id] ASC"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_table_data_where_and_schema_queries() {
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
primary_keys: vec!["id".to_string()],
|
||||
columns: Vec::new(),
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(100),
|
||||
offset: None,
|
||||
where_input: Some("where status = 'active'".to_string()),
|
||||
include_row_id: false,
|
||||
}),
|
||||
"SELECT * FROM `users` WHERE (status = 'active') ORDER BY `id` ASC LIMIT 100;"
|
||||
);
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "orders".to_string(),
|
||||
primary_keys: Vec::new(),
|
||||
columns: Vec::new(),
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(50),
|
||||
offset: Some(100),
|
||||
where_input: Some("WHERE amount > 10".to_string()),
|
||||
include_row_id: false,
|
||||
}),
|
||||
"SELECT * FROM \"public\".\"orders\" WHERE (amount > 10) LIMIT 50 OFFSET 100;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_table_data_special_column_queries() {
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::Tdengine),
|
||||
schema: Some("test_db".to_string()),
|
||||
table_name: "meters".to_string(),
|
||||
primary_keys: vec!["ts".to_string()],
|
||||
columns: vec![
|
||||
"ts".to_string(),
|
||||
"current".to_string(),
|
||||
"voltage".to_string(),
|
||||
"location".to_string(),
|
||||
"groupid".to_string(),
|
||||
],
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(100),
|
||||
offset: None,
|
||||
where_input: None,
|
||||
include_row_id: false,
|
||||
}),
|
||||
"SELECT tbname, `ts` AS `ts`, `current` AS `current`, `voltage` AS `voltage`, `location` AS `location`, `groupid` AS `groupid` FROM `test_db`.`meters` ORDER BY `ts` ASC LIMIT 100;"
|
||||
);
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::Hive),
|
||||
schema: None,
|
||||
table_name: "departments".to_string(),
|
||||
primary_keys: Vec::new(),
|
||||
columns: vec!["id".to_string(), "name".to_string()],
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(100),
|
||||
offset: None,
|
||||
where_input: None,
|
||||
include_row_id: false,
|
||||
}),
|
||||
"SELECT `id` AS `id`, `name` AS `name` FROM `departments` LIMIT 100;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_sqlserver_table_data_pages() {
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
schema: Some("dbo".to_string()),
|
||||
table_name: "accounts".to_string(),
|
||||
primary_keys: vec!["id".to_string()],
|
||||
columns: Vec::new(),
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(25),
|
||||
offset: None,
|
||||
where_input: Some("where id = 1".to_string()),
|
||||
include_row_id: false,
|
||||
}),
|
||||
"SELECT TOP (25) * FROM [dbo].[accounts] WHERE (id = 1) ORDER BY [id] ASC"
|
||||
);
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
schema: Some("sales".to_string()),
|
||||
table_name: "orders".to_string(),
|
||||
primary_keys: vec!["order_id".to_string()],
|
||||
columns: vec!["order_id".to_string(), "customer".to_string()],
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(50),
|
||||
offset: Some(100),
|
||||
where_input: None,
|
||||
include_row_id: false,
|
||||
}),
|
||||
"WITH [dbx_page] AS (SELECT [order_id], [customer], ROW_NUMBER() OVER (ORDER BY [order_id] ASC) AS [__dbx_row_num] FROM [sales].[orders]) SELECT [order_id], [customer] FROM [dbx_page] WHERE [__dbx_row_num] > 100 AND [__dbx_row_num] <= 150 ORDER BY [__dbx_row_num]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_oracle_and_neo4j_table_data_queries() {
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
schema: Some("DBXTEST".to_string()),
|
||||
table_name: "DBX_LOAD_TABLE_006".to_string(),
|
||||
primary_keys: vec![DBX_ROWID_COLUMN.to_string()],
|
||||
columns: Vec::new(),
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(100),
|
||||
offset: None,
|
||||
where_input: None,
|
||||
include_row_id: true,
|
||||
}),
|
||||
"SELECT ROWIDTOCHAR(t.ROWID) AS \"__DBX_ROWID\", t.* FROM \"DBXTEST\".\"DBX_LOAD_TABLE_006\" t ORDER BY t.ROWID ASC FETCH FIRST 100 ROWS ONLY"
|
||||
);
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::Neo4j),
|
||||
schema: None,
|
||||
table_name: "Employee".to_string(),
|
||||
primary_keys: vec!["id".to_string()],
|
||||
columns: vec!["id".to_string(), "first name".to_string(), "role".to_string()],
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(100),
|
||||
offset: None,
|
||||
where_input: None,
|
||||
include_row_id: false,
|
||||
}),
|
||||
"MATCH (n:`Employee`) RETURN elementId(n) AS `__DBX_ELEMENT_ID`, n.`id` AS `id`, n.`first name` AS `first name`, n.`role` AS `role` ORDER BY n.`id` ASC LIMIT 100;"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -127,8 +127,60 @@ async fn main() {
|
||||
.route("/query/execute-script", post(routes::query::execute_script))
|
||||
.route("/query/execute-in-transaction", post(routes::query::execute_in_transaction))
|
||||
.route("/query/analyze-sql-references", post(routes::query::analyze_sql_references))
|
||||
.route("/query/find-statement-at-cursor", post(routes::query::find_statement_at_cursor))
|
||||
.route("/query/prepare-pagination-plan", post(routes::query::prepare_query_pagination_execution_plan))
|
||||
.route("/query/build-sorted-sql", post(routes::query::build_sorted_query_sql))
|
||||
.route("/query/build-explain-sql", post(routes::query::build_explain_sql))
|
||||
.route("/query/build-dropped-file-preview-sql", post(routes::query::build_dropped_file_preview_sql))
|
||||
.route("/query/build-table-select-sql", post(routes::query::build_table_select_sql))
|
||||
.route("/query/build-database-search-sql", post(routes::query::build_database_search_sql))
|
||||
.route("/query/build-search-result-where", post(routes::query::build_search_result_where))
|
||||
.route("/query/build-rename-object-sql", post(routes::query::build_rename_object_sql))
|
||||
.route("/query/build-create-database-sql", post(routes::query::build_create_database_sql))
|
||||
.route("/query/build-duckdb-attach-database-sql", post(routes::query::build_duckdb_attach_database_sql))
|
||||
.route("/query/build-drop-object-sql", post(routes::query::build_drop_object_sql))
|
||||
.route("/query/build-drop-table-sql", post(routes::query::build_drop_table_sql))
|
||||
.route("/query/build-empty-table-sql", post(routes::query::build_empty_table_sql))
|
||||
.route("/query/build-truncate-table-sql", post(routes::query::build_truncate_table_sql))
|
||||
.route("/query/build-drop-database-sql", post(routes::query::build_drop_database_sql))
|
||||
.route("/query/build-create-schema-sql", post(routes::query::build_create_schema_sql))
|
||||
.route("/query/build-drop-schema-sql", post(routes::query::build_drop_schema_sql))
|
||||
.route("/query/build-duplicate-table-structure-sql", post(routes::query::build_duplicate_table_structure_sql))
|
||||
.route(
|
||||
"/query/build-executable-object-source-statements",
|
||||
post(routes::query::build_executable_object_source_statements),
|
||||
)
|
||||
.route("/query/build-executable-object-source-sql", post(routes::query::build_executable_object_source_sql))
|
||||
.route(
|
||||
"/query/build-routine-rename-object-source-statements",
|
||||
post(routes::query::build_routine_rename_object_source_statements),
|
||||
)
|
||||
.route("/query/build-view-ddl-sql", post(routes::query::build_view_ddl_sql))
|
||||
.route("/query/build-table-structure-change-sql", post(routes::query::build_table_structure_change_sql))
|
||||
.route("/query/build-create-table-sql", post(routes::query::build_create_table_sql))
|
||||
.route("/query/analyze-editability", post(routes::query::analyze_editable_query_editability))
|
||||
.route("/query/prepare-data-grid-save", post(routes::query::prepare_data_grid_save))
|
||||
.route(
|
||||
"/query/build-data-grid-copy-update-statements",
|
||||
post(routes::query::build_data_grid_copy_update_statements),
|
||||
)
|
||||
.route(
|
||||
"/query/build-data-grid-copy-insert-statement",
|
||||
post(routes::query::build_data_grid_copy_insert_statement),
|
||||
)
|
||||
.route(
|
||||
"/query/build-data-grid-context-filter-condition",
|
||||
post(routes::query::build_data_grid_context_filter_condition),
|
||||
)
|
||||
.route(
|
||||
"/query/build-data-grid-column-value-filter-condition",
|
||||
post(routes::query::build_data_grid_column_value_filter_condition),
|
||||
)
|
||||
.route("/query/build-data-grid-count-sql", post(routes::query::build_data_grid_count_sql))
|
||||
.route("/query/build-hive-table-properties-sql", post(routes::query::build_hive_table_properties_sql))
|
||||
.route("/query/build-export-insert-statements", post(routes::query::build_export_insert_statements))
|
||||
.route("/query/build-export-sql-insert", post(routes::query::build_export_sql_insert))
|
||||
.route("/query/build-database-sql-export", post(routes::query::build_database_sql_export))
|
||||
.route("/data-compare/prepare", post(routes::data_compare::prepare_data_compare))
|
||||
.route("/data-compare/prepare-from-tables", post(routes::data_compare::prepare_data_compare_from_tables))
|
||||
.route("/query/cancel", post(routes::query::cancel_query))
|
||||
|
||||
@@ -57,12 +57,187 @@ pub struct AnalyzeEditableQueryRequest {
|
||||
pub sql: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FindStatementAtCursorRequest {
|
||||
pub sql: String,
|
||||
pub cursor_pos: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrepareQueryPaginationExecutionPlanRequest {
|
||||
pub options: dbx_core::query_result_sql::QueryPaginationExecutionPlanOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildSortedQuerySqlRequest {
|
||||
pub options: dbx_core::query_result_sql::SortedQuerySqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildExplainSqlRequest {
|
||||
pub options: dbx_core::query_execution_sql::ExplainSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDroppedFilePreviewSqlRequest {
|
||||
pub options: dbx_core::query_execution_sql::DroppedFilePreviewSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildTableSelectSqlRequest {
|
||||
pub options: dbx_core::sql_dialect::TableDataSelectSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDatabaseSearchSqlRequest {
|
||||
pub options: dbx_core::database_search_sql::DatabaseSearchSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildSearchResultWhereRequest {
|
||||
pub options: dbx_core::database_search_sql::SearchResultWhereOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildRenameObjectSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::RenameObjectSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildCreateDatabaseSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::CreateDatabaseSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDuckDbAttachDatabaseSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::DuckDbAttachDatabaseSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDropObjectSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::DropObjectSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildTableAdminSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::TableAdminSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDatabaseNameSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::DatabaseNameSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildSchemaNameSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::SchemaNameSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDuplicateTableStructureSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::DuplicateTableStructureSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildExecutableObjectSourceRequest {
|
||||
pub input: dbx_core::object_source_sql::EditableObjectSourceSqlInput,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildRoutineRenameObjectSourceRequest {
|
||||
pub input: dbx_core::object_source_sql::RoutineRenameObjectSourceInput,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildViewDdlRequest {
|
||||
pub input: dbx_core::object_source_sql::BuildViewDdlInput,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildTableStructureSqlRequest {
|
||||
pub options: dbx_core::table_structure_sql::TableStructureSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrepareDataGridSaveRequest {
|
||||
pub options: dbx_core::data_grid_sql::DataGridSaveStatementOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDataGridCopyUpdateStatementsRequest {
|
||||
pub options: dbx_core::data_grid_sql::DataGridCopyUpdateStatementOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDataGridCopyInsertStatementRequest {
|
||||
pub options: dbx_core::data_grid_sql::DataGridCopyInsertStatementOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDataGridContextFilterConditionRequest {
|
||||
pub options: dbx_core::data_grid_sql::DataGridContextFilterConditionOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDataGridColumnValueFilterConditionRequest {
|
||||
pub options: dbx_core::data_grid_sql::DataGridColumnValueFilterConditionOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDataGridCountSqlRequest {
|
||||
pub options: dbx_core::data_grid_sql::DataGridCountSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildHiveTablePropertiesSqlRequest {
|
||||
pub options: dbx_core::data_grid_sql::HiveTablePropertiesSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildExportInsertStatementsRequest {
|
||||
pub options: dbx_core::database_export::BuildExportInsertStatementsOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildExportSqlInsertRequest {
|
||||
pub options: dbx_core::database_export::BuildExportSqlInsertOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDatabaseSqlExportRequest {
|
||||
pub options: dbx_core::database_export::BuildDatabaseSqlExportOptions,
|
||||
}
|
||||
|
||||
pub async fn execute_query(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<ExecuteQueryRequest>,
|
||||
@@ -200,6 +375,128 @@ pub async fn analyze_sql_references(
|
||||
dbx_core::sql_analysis::analyze_sql_references(&req.sql, req.dialect.as_deref()).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn find_statement_at_cursor(Json(req): Json<FindStatementAtCursorRequest>) -> Json<String> {
|
||||
Json(dbx_core::sql::find_statement_at_cursor(&req.sql, req.cursor_pos))
|
||||
}
|
||||
|
||||
pub async fn prepare_query_pagination_execution_plan(
|
||||
Json(req): Json<PrepareQueryPaginationExecutionPlanRequest>,
|
||||
) -> Json<dbx_core::query_result_sql::QueryPaginationExecutionPlan> {
|
||||
Json(dbx_core::query_result_sql::build_query_pagination_execution_plan(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_sorted_query_sql(
|
||||
Json(req): Json<BuildSortedQuerySqlRequest>,
|
||||
) -> Json<dbx_core::query_result_sql::QuerySqlBuildResult> {
|
||||
Json(dbx_core::query_result_sql::build_sorted_query_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_explain_sql(
|
||||
Json(req): Json<BuildExplainSqlRequest>,
|
||||
) -> Json<dbx_core::query_execution_sql::ExplainSqlBuildResult> {
|
||||
Json(dbx_core::query_execution_sql::build_explain_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_dropped_file_preview_sql(
|
||||
Json(req): Json<BuildDroppedFilePreviewSqlRequest>,
|
||||
) -> Json<Option<String>> {
|
||||
Json(dbx_core::query_execution_sql::build_dropped_file_preview_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_table_select_sql(Json(req): Json<BuildTableSelectSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::sql_dialect::build_table_data_select_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_database_search_sql(
|
||||
Json(req): Json<BuildDatabaseSearchSqlRequest>,
|
||||
) -> Json<Option<dbx_core::database_search_sql::DatabaseSearchSql>> {
|
||||
Json(dbx_core::database_search_sql::build_database_search_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_search_result_where(Json(req): Json<BuildSearchResultWhereRequest>) -> Json<String> {
|
||||
Json(dbx_core::database_search_sql::build_search_result_where(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_rename_object_sql(Json(req): Json<BuildRenameObjectSqlRequest>) -> Result<Json<String>, AppError> {
|
||||
dbx_core::db_admin_sql::build_rename_object_sql(req.options).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn build_create_database_sql(Json(req): Json<BuildCreateDatabaseSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_create_database_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_duckdb_attach_database_sql(Json(req): Json<BuildDuckDbAttachDatabaseSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_duckdb_attach_database_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_drop_object_sql(Json(req): Json<BuildDropObjectSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_drop_object_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_drop_table_sql(Json(req): Json<BuildTableAdminSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_drop_table_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_empty_table_sql(Json(req): Json<BuildTableAdminSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_empty_table_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_truncate_table_sql(Json(req): Json<BuildTableAdminSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_truncate_table_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_drop_database_sql(Json(req): Json<BuildDatabaseNameSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_drop_database_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_create_schema_sql(Json(req): Json<BuildSchemaNameSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_create_schema_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_drop_schema_sql(Json(req): Json<BuildSchemaNameSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_drop_schema_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_duplicate_table_structure_sql(
|
||||
Json(req): Json<BuildDuplicateTableStructureSqlRequest>,
|
||||
) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_duplicate_table_structure_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_executable_object_source_statements(
|
||||
Json(req): Json<BuildExecutableObjectSourceRequest>,
|
||||
) -> Result<Json<Vec<String>>, AppError> {
|
||||
dbx_core::object_source_sql::build_executable_object_source_statements(req.input).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn build_executable_object_source_sql(
|
||||
Json(req): Json<BuildExecutableObjectSourceRequest>,
|
||||
) -> Result<Json<String>, AppError> {
|
||||
dbx_core::object_source_sql::build_executable_object_source_sql(req.input).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn build_routine_rename_object_source_statements(
|
||||
Json(req): Json<BuildRoutineRenameObjectSourceRequest>,
|
||||
) -> Result<Json<Vec<String>>, AppError> {
|
||||
dbx_core::object_source_sql::build_routine_rename_object_source_statements(req.input).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn build_view_ddl_sql(Json(req): Json<BuildViewDdlRequest>) -> Json<String> {
|
||||
Json(dbx_core::object_source_sql::build_view_ddl_sql(req.input))
|
||||
}
|
||||
|
||||
pub async fn build_table_structure_change_sql(
|
||||
Json(req): Json<BuildTableStructureSqlRequest>,
|
||||
) -> Json<dbx_core::table_structure_sql::TableStructureSqlResult> {
|
||||
Json(dbx_core::table_structure_sql::build_table_structure_change_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_create_table_sql(
|
||||
Json(req): Json<BuildTableStructureSqlRequest>,
|
||||
) -> Json<dbx_core::table_structure_sql::TableStructureSqlResult> {
|
||||
Json(dbx_core::table_structure_sql::build_create_table_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn analyze_editable_query_editability(
|
||||
Json(req): Json<AnalyzeEditableQueryRequest>,
|
||||
) -> Json<dbx_core::sql_editability::QueryEditability> {
|
||||
@@ -211,3 +508,51 @@ pub async fn prepare_data_grid_save(
|
||||
) -> Json<dbx_core::data_grid_sql::DataGridSavePreparation> {
|
||||
Json(dbx_core::data_grid_sql::prepare_data_grid_save(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_data_grid_copy_update_statements(
|
||||
Json(req): Json<BuildDataGridCopyUpdateStatementsRequest>,
|
||||
) -> Json<Vec<String>> {
|
||||
Json(dbx_core::data_grid_sql::build_data_grid_copy_update_statements(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_data_grid_copy_insert_statement(
|
||||
Json(req): Json<BuildDataGridCopyInsertStatementRequest>,
|
||||
) -> Json<Option<String>> {
|
||||
Json(dbx_core::data_grid_sql::build_data_grid_copy_insert_statement(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_data_grid_context_filter_condition(
|
||||
Json(req): Json<BuildDataGridContextFilterConditionRequest>,
|
||||
) -> Json<Option<String>> {
|
||||
Json(dbx_core::data_grid_sql::build_data_grid_context_filter_condition(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_data_grid_column_value_filter_condition(
|
||||
Json(req): Json<BuildDataGridColumnValueFilterConditionRequest>,
|
||||
) -> Json<Option<String>> {
|
||||
Json(dbx_core::data_grid_sql::build_data_grid_column_value_filter_condition(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_data_grid_count_sql(Json(req): Json<BuildDataGridCountSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::data_grid_sql::build_data_grid_count_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_hive_table_properties_sql(Json(req): Json<BuildHiveTablePropertiesSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::data_grid_sql::build_hive_table_properties_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_export_insert_statements(
|
||||
Json(req): Json<BuildExportInsertStatementsRequest>,
|
||||
) -> Result<Json<Vec<String>>, AppError> {
|
||||
dbx_core::database_export::build_export_insert_statements(req.options).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn build_export_sql_insert(Json(req): Json<BuildExportSqlInsertRequest>) -> Result<Json<String>, AppError> {
|
||||
dbx_core::database_export::build_export_sql_insert(req.options).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn build_database_sql_export(
|
||||
Json(req): Json<BuildDatabaseSqlExportRequest>,
|
||||
) -> Result<Json<String>, AppError> {
|
||||
dbx_core::database_export::build_database_sql_export(req.options).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
@@ -1,51 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildCreateDatabaseSql,
|
||||
buildDuckDbAttachDatabaseSql,
|
||||
duckDbAttachedDatabaseNameFromPath,
|
||||
uniqueDuckDbAttachedDatabaseName,
|
||||
supportsCreateDatabaseCharset,
|
||||
} from "../../apps/desktop/src/lib/createDatabaseSql.ts";
|
||||
|
||||
test("builds MySQL create database SQL with charset and collation", () => {
|
||||
assert.equal(
|
||||
buildCreateDatabaseSql({
|
||||
databaseType: "mysql",
|
||||
driverProfile: "mysql",
|
||||
name: "app db",
|
||||
charset: "utf8mb4",
|
||||
collation: "utf8mb4_unicode_ci",
|
||||
}),
|
||||
"CREATE DATABASE `app db` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;",
|
||||
);
|
||||
});
|
||||
|
||||
test("omits charset clauses for non-MySQL database types", () => {
|
||||
assert.equal(
|
||||
buildCreateDatabaseSql({
|
||||
databaseType: "postgres",
|
||||
name: "analytics",
|
||||
charset: "utf8mb4",
|
||||
collation: "utf8mb4_unicode_ci",
|
||||
}),
|
||||
'CREATE DATABASE "analytics";',
|
||||
);
|
||||
});
|
||||
|
||||
test("recognizes MySQL-compatible driver profiles", () => {
|
||||
assert.equal(supportsCreateDatabaseCharset("mysql", "oceanbase"), true);
|
||||
assert.equal(supportsCreateDatabaseCharset("mysql", "doris"), true);
|
||||
assert.equal(supportsCreateDatabaseCharset("postgres", undefined), false);
|
||||
});
|
||||
|
||||
test("builds DuckDB attach SQL with escaped path and alias", () => {
|
||||
assert.equal(
|
||||
buildDuckDbAttachDatabaseSql("/Users/me/O'Reilly analytics.duckdb", "report db"),
|
||||
`ATTACH '/Users/me/O''Reilly analytics.duckdb' AS "report db";`,
|
||||
);
|
||||
});
|
||||
|
||||
test("derives a stable DuckDB attached database name from a file path", () => {
|
||||
assert.equal(duckDbAttachedDatabaseNameFromPath("/Users/me/sales.duckdb"), "sales");
|
||||
assert.equal(duckDbAttachedDatabaseNameFromPath("C:\\data\\2026 report.db"), "2026_report");
|
||||
|
||||
@@ -5,8 +5,30 @@ import {
|
||||
buildColumnValueFilterCondition,
|
||||
} from "../../apps/desktop/src/lib/dataGridColumnFilter.ts";
|
||||
|
||||
test("builds a numeric server-side column filter from typed text", () => {
|
||||
const condition = buildColumnValueFilterCondition({
|
||||
function installFilterFetchMock() {
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
if (String(input) !== "/api/query/build-data-grid-column-value-filter-condition") {
|
||||
return new Response("unexpected request", { status: 500 });
|
||||
}
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
const options = body.options;
|
||||
const quote =
|
||||
options.databaseType === "mysql"
|
||||
? (name: string) => `\`${name}\``
|
||||
: options.databaseType === "sqlserver"
|
||||
? (name: string) => `[${name}]`
|
||||
: (name: string) => `"${name}"`;
|
||||
const text = String(options.rawValue ?? "").trim();
|
||||
const result = /^null$/i.test(text)
|
||||
? `${quote(options.columnName)} IS NULL`
|
||||
: `${quote(options.columnName)} = ${/^\d+$/.test(text) ? text : `'${text}'`}`;
|
||||
return new Response(JSON.stringify(result), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
test("builds a numeric server-side column filter from typed text", async () => {
|
||||
installFilterFetchMock();
|
||||
const condition = await buildColumnValueFilterCondition({
|
||||
databaseType: "mysql",
|
||||
columnName: "id",
|
||||
columnInfo: { name: "id", data_type: "int", is_nullable: false, is_primary_key: true },
|
||||
@@ -16,8 +38,9 @@ test("builds a numeric server-side column filter from typed text", () => {
|
||||
assert.equal(condition, "`id` = 49436");
|
||||
});
|
||||
|
||||
test("quotes text server-side column filters and appends them to existing WHERE input", () => {
|
||||
const condition = buildColumnValueFilterCondition({
|
||||
test("quotes text server-side column filters and appends them to existing WHERE input", async () => {
|
||||
installFilterFetchMock();
|
||||
const condition = await buildColumnValueFilterCondition({
|
||||
databaseType: "postgres",
|
||||
columnName: "status",
|
||||
columnInfo: { name: "status", data_type: "varchar", is_nullable: true, is_primary_key: false },
|
||||
@@ -28,8 +51,9 @@ test("quotes text server-side column filters and appends them to existing WHERE
|
||||
assert.equal(appendColumnValueFilterCondition("deleted_at IS NULL", condition), `(deleted_at IS NULL) AND ("status" = 'active')`);
|
||||
});
|
||||
|
||||
test("builds IS NULL for typed NULL filters", () => {
|
||||
const condition = buildColumnValueFilterCondition({
|
||||
test("builds IS NULL for typed NULL filters", async () => {
|
||||
installFilterFetchMock();
|
||||
const condition = await buildColumnValueFilterCondition({
|
||||
databaseType: "sqlserver",
|
||||
columnName: "archived_at",
|
||||
rawValue: "NULL",
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from "node:test";
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useDataGridEditor } from "../../apps/desktop/src/composables/useDataGridEditor.ts";
|
||||
import { formatGridSqlLiteral, type DataGridSaveStatementOptions } from "../../apps/desktop/src/lib/dataGridSql.ts";
|
||||
import type { DataGridSaveStatementOptions } from "../../apps/desktop/src/lib/dataGridSql.ts";
|
||||
import type { ColumnInfo } from "../../apps/desktop/src/types/database.ts";
|
||||
|
||||
type CellValue = string | number | boolean | null;
|
||||
@@ -78,6 +78,14 @@ function quotePgIdentifier(name: string): string {
|
||||
return `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function formatGridSqlLiteral(value: CellValue, databaseType?: string): string {
|
||||
if (value === null) return "NULL";
|
||||
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
||||
if (typeof value === "number") return String(value);
|
||||
const escaped = `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "''")}'`;
|
||||
return databaseType === "sqlserver" ? `N${escaped}` : escaped;
|
||||
}
|
||||
|
||||
function column(name: string, isPrimaryKey = false, extra: string | null = null): ColumnInfo {
|
||||
return {
|
||||
name,
|
||||
|
||||
@@ -12,10 +12,22 @@ const rustCoreLibSource = readFileSync("crates/dbx-core/src/lib.rs", "utf8");
|
||||
|
||||
test("shared API exposes backend data grid save preparation", () => {
|
||||
assert.match(apiSource, /export const prepareDataGridSave = forward\("prepareDataGridSave"\)/);
|
||||
assert.match(apiSource, /export const buildDataGridCopyUpdateStatements = forward\("buildDataGridCopyUpdateStatements"\)/);
|
||||
assert.match(apiSource, /export const buildDataGridContextFilterCondition = forward\("buildDataGridContextFilterCondition"\)/);
|
||||
assert.match(apiSource, /export const buildDataGridCountSql = forward\("buildDataGridCountSql"\)/);
|
||||
assert.match(apiSource, /export const buildHiveTablePropertiesSql = forward\("buildHiveTablePropertiesSql"\)/);
|
||||
assert.match(tauriSource, /export async function prepareDataGridSave\(/);
|
||||
assert.match(tauriSource, /invoke\("prepare_data_grid_save"/);
|
||||
assert.match(tauriSource, /invoke\("build_data_grid_copy_update_statements"/);
|
||||
assert.match(tauriSource, /invoke<string \| null>\("build_data_grid_context_filter_condition"/);
|
||||
assert.match(tauriSource, /invoke\("build_data_grid_count_sql"/);
|
||||
assert.match(tauriSource, /invoke\("build_hive_table_properties_sql"/);
|
||||
assert.match(httpSource, /export async function prepareDataGridSave\(/);
|
||||
assert.match(httpSource, /\/api\/query\/prepare-data-grid-save/);
|
||||
assert.match(httpSource, /\/api\/query\/build-data-grid-copy-update-statements/);
|
||||
assert.match(httpSource, /\/api\/query\/build-data-grid-context-filter-condition/);
|
||||
assert.match(httpSource, /\/api\/query\/build-data-grid-count-sql/);
|
||||
assert.match(httpSource, /\/api\/query\/build-hive-table-properties-sql/);
|
||||
});
|
||||
|
||||
test("data grid save flow uses backend save preparation", () => {
|
||||
@@ -28,5 +40,13 @@ test("data grid save flow uses backend save preparation", () => {
|
||||
test("Rust backends register data grid save preparation", () => {
|
||||
assert.match(rustCoreLibSource, /pub mod data_grid_sql/);
|
||||
assert.match(tauriLibSource, /commands::query::prepare_data_grid_save/);
|
||||
assert.match(tauriLibSource, /commands::query::build_data_grid_copy_update_statements/);
|
||||
assert.match(tauriLibSource, /commands::query::build_data_grid_context_filter_condition/);
|
||||
assert.match(tauriLibSource, /commands::query::build_data_grid_count_sql/);
|
||||
assert.match(tauriLibSource, /commands::query::build_hive_table_properties_sql/);
|
||||
assert.match(webMainSource, /\/query\/prepare-data-grid-save/);
|
||||
assert.match(webMainSource, /\/query\/build-data-grid-copy-update-statements/);
|
||||
assert.match(webMainSource, /\/query\/build-data-grid-context-filter-condition/);
|
||||
assert.match(webMainSource, /\/query\/build-data-grid-count-sql/);
|
||||
assert.match(webMainSource, /\/query\/build-hive-table-properties-sql/);
|
||||
});
|
||||
|
||||
@@ -1,139 +1,43 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildDataGridCopyInsertStatement,
|
||||
buildDataGridCopyUpdateStatements,
|
||||
normalizeDataGridSaveError,
|
||||
formatGridSqlLiteral,
|
||||
} from "../../apps/desktop/src/lib/dataGridSql.ts";
|
||||
import { normalizeDataGridSaveError } from "../../apps/desktop/src/lib/dataGridSql.ts";
|
||||
|
||||
test("builds copy-as-update statements using primary keys and non-primary-key columns", () => {
|
||||
const statements = buildDataGridCopyUpdateStatements({
|
||||
databaseType: "postgres",
|
||||
tableMeta: {
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
primaryKeys: ["id"],
|
||||
},
|
||||
columns: ["id", "name", "status"],
|
||||
rows: [[1, "Ada", "active"]],
|
||||
});
|
||||
const dataGridSqlSource = readFileSync("apps/desktop/src/lib/dataGridSql.ts", "utf8");
|
||||
const dataGridExportSource = readFileSync("apps/desktop/src/composables/useDataGridExport.ts", "utf8");
|
||||
const columnFilterSource = readFileSync("apps/desktop/src/lib/dataGridColumnFilter.ts", "utf8");
|
||||
const gridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const rustSource = readFileSync("crates/dbx-core/src/data_grid_sql.rs", "utf8");
|
||||
|
||||
assert.deepEqual(statements, [`UPDATE "public"."users" SET "name" = 'Ada', "status" = 'active' WHERE "id" = 1;`]);
|
||||
test("frontend data grid SQL helpers delegate executable SQL generation to backend APIs", () => {
|
||||
assert.match(dataGridSqlSource, /return api\.buildDataGridCopyUpdateStatements\(options\)/);
|
||||
assert.match(dataGridSqlSource, /return api\.buildDataGridCopyInsertStatement\(options\)/);
|
||||
assert.match(dataGridSqlSource, /return api\.buildDataGridContextFilterCondition\(options\)/);
|
||||
assert.match(dataGridSqlSource, /return api\.buildDataGridColumnValueFilterCondition\(options\)/);
|
||||
assert.match(dataGridSqlSource, /return api\.buildDataGridCountSql\(options\)/);
|
||||
assert.match(dataGridSqlSource, /return api\.buildHiveTablePropertiesSql\(options\)/);
|
||||
assert.doesNotMatch(dataGridSqlSource, /INSERT INTO|UPDATE .* SET|DELETE FROM|formatGridSqlLiteral|quoteTableIdentifier/);
|
||||
});
|
||||
|
||||
test("builds copy-as-insert statement excluding primary key columns", () => {
|
||||
const statement = buildDataGridCopyInsertStatement({
|
||||
databaseType: "mysql",
|
||||
tableMeta: {
|
||||
tableName: "users",
|
||||
primaryKeys: ["id"],
|
||||
},
|
||||
columns: ["id", "login_name", "display_name"],
|
||||
rows: [
|
||||
[1, "ada", "Ada"],
|
||||
[2, "linus", "Linus"],
|
||||
],
|
||||
excludePrimaryKeys: true,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
statement,
|
||||
"INSERT INTO `users` (`login_name`, `display_name`) VALUES\n('ada', 'Ada'),\n('linus', 'Linus');",
|
||||
);
|
||||
test("data grid copy and filter callers await backend SQL helpers", () => {
|
||||
assert.match(dataGridExportSource, /await buildDataGridCopyInsertStatement\(/);
|
||||
assert.match(dataGridExportSource, /await buildDataGridCopyUpdateStatements\(/);
|
||||
assert.match(columnFilterSource, /return buildDataGridColumnValueFilterCondition\(/);
|
||||
assert.match(gridSource, /await buildDataGridContextFilterCondition\(/);
|
||||
assert.match(gridSource, /await buildHiveTablePropertiesSql\(/);
|
||||
assert.match(gridSource, /await buildDataGridCountSql\(/);
|
||||
assert.doesNotMatch(gridSource, /formatGridSqlLiteral|SHOW TBLPROPERTIES|SELECT COUNT\(\*\) AS cnt/);
|
||||
});
|
||||
|
||||
test("copy-as-insert excludes primary keys using source column names", () => {
|
||||
const statement = buildDataGridCopyInsertStatement({
|
||||
databaseType: "mysql",
|
||||
tableMeta: {
|
||||
tableName: "users",
|
||||
primaryKeys: ["user_id"],
|
||||
},
|
||||
columns: ["id", "name"],
|
||||
sourceColumns: ["user_id", "name"],
|
||||
rows: [[7, "Ada"]],
|
||||
excludePrimaryKeys: true,
|
||||
});
|
||||
|
||||
assert.equal(statement, "INSERT INTO `users` (`name`) VALUES ('Ada');");
|
||||
test("Rust data grid SQL exposes copy and filter builders", () => {
|
||||
assert.match(rustSource, /pub fn build_data_grid_copy_update_statements/);
|
||||
assert.match(rustSource, /pub fn build_data_grid_copy_insert_statement/);
|
||||
assert.match(rustSource, /pub fn build_data_grid_context_filter_condition/);
|
||||
assert.match(rustSource, /pub fn build_data_grid_column_value_filter_condition/);
|
||||
assert.match(rustSource, /pub fn build_data_grid_count_sql/);
|
||||
assert.match(rustSource, /pub fn build_hive_table_properties_sql/);
|
||||
});
|
||||
|
||||
test("copy-as-insert without primary keys is unavailable when no primary key columns are visible", () => {
|
||||
const statement = buildDataGridCopyInsertStatement({
|
||||
databaseType: "postgres",
|
||||
tableMeta: {
|
||||
tableName: "users",
|
||||
primaryKeys: ["id"],
|
||||
},
|
||||
columns: ["name"],
|
||||
rows: [["Ada"]],
|
||||
excludePrimaryKeys: true,
|
||||
});
|
||||
|
||||
assert.equal(statement, undefined);
|
||||
});
|
||||
|
||||
test("skips copy-as-update statements when primary keys are unavailable", () => {
|
||||
const statements = buildDataGridCopyUpdateStatements({
|
||||
databaseType: "postgres",
|
||||
tableMeta: {
|
||||
tableName: "users",
|
||||
primaryKeys: [],
|
||||
},
|
||||
columns: ["id", "name"],
|
||||
rows: [[1, "Ada"]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, []);
|
||||
});
|
||||
|
||||
test("formats TDengine timestamp literals with the local timezone offset", () => {
|
||||
assert.equal(
|
||||
formatGridSqlLiteral("2026-05-16 09:35:57.975", "tdengine"),
|
||||
tdengineTimestampLiteral("2026-05-16 09:35:57.975"),
|
||||
);
|
||||
});
|
||||
|
||||
test("formats MySQL RFC3339 datetime strings as DATETIME-compatible literals", () => {
|
||||
assert.equal(formatGridSqlLiteral("2026-05-12T00:00:00+00:00", "mysql"), "'2026-05-12 00:00:00'");
|
||||
assert.equal(formatGridSqlLiteral("2026-05-12T00:00:00.123456Z", "mysql"), "'2026-05-12 00:00:00.123456'");
|
||||
});
|
||||
|
||||
test("formats MySQL copy-as-update statements using target column temporal types", () => {
|
||||
const statements = buildDataGridCopyUpdateStatements({
|
||||
databaseType: "mysql",
|
||||
tableMeta: {
|
||||
tableName: "policies",
|
||||
primaryKeys: ["id"],
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_nullable: false, is_primary_key: true },
|
||||
{ name: "insurance_start_time", data_type: "timestamp", is_nullable: true, is_primary_key: false },
|
||||
{ name: "raw_text", data_type: "varchar(64)", is_nullable: true, is_primary_key: false },
|
||||
],
|
||||
},
|
||||
columns: ["id", "insurance_start_time", "raw_text"],
|
||||
rows: [[1, "2026-05-12T00:00:00+00:00", "2026-05-12T00:00:00+00:00"]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
"UPDATE `policies` SET `insurance_start_time` = '2026-05-12 00:00:00', `raw_text` = '2026-05-12T00:00:00+00:00' WHERE `id` = 1;",
|
||||
]);
|
||||
});
|
||||
|
||||
function tdengineTimestampLiteral(text: string): string {
|
||||
const [datePart, timePart] = text.split(" ");
|
||||
const [time, rawFraction = ""] = timePart.split(".");
|
||||
const fraction = `.${rawFraction.padEnd(3, "0").slice(0, 3)}`;
|
||||
const date = new Date(`${datePart}T${time}${fraction}`);
|
||||
const offsetMinutes = date.getTimezoneOffset();
|
||||
const sign = offsetMinutes <= 0 ? "+" : "-";
|
||||
const abs = Math.abs(offsetMinutes);
|
||||
const hours = String(Math.floor(abs / 60)).padStart(2, "0");
|
||||
const minutes = String(abs % 60).padStart(2, "0");
|
||||
return `'${datePart}T${time}${fraction}${sign}${hours}:${minutes}'`;
|
||||
}
|
||||
|
||||
test("normalizes Hive ACID update and delete errors", () => {
|
||||
const error = normalizeDataGridSaveError(
|
||||
"hive",
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
DATABASE_EXPORT_INSERT_BATCH_SIZE,
|
||||
DATABASE_EXPORT_ROW_LIMIT,
|
||||
buildExportPageSql,
|
||||
buildDatabaseSqlExport,
|
||||
buildInsertStatements,
|
||||
formatSqlLiteral,
|
||||
generateDatabaseExportId,
|
||||
} from "../../apps/desktop/src/lib/databaseExport.ts";
|
||||
import { generateDatabaseExportId } from "../../apps/desktop/src/lib/databaseExport.ts";
|
||||
|
||||
test("formats SQL literals for exported INSERT statements", () => {
|
||||
assert.equal(formatSqlLiteral(null), "NULL");
|
||||
assert.equal(formatSqlLiteral(42), "42");
|
||||
assert.equal(formatSqlLiteral(true), "TRUE");
|
||||
assert.equal(formatSqlLiteral("O'Hara"), "'O''Hara'");
|
||||
});
|
||||
const databaseExportSource = readFileSync("apps/desktop/src/lib/databaseExport.ts", "utf8");
|
||||
const exportFormatsSource = readFileSync("apps/desktop/src/lib/exportFormats.ts", "utf8");
|
||||
const treeItemSource = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
|
||||
const objectBrowserSource = readFileSync("apps/desktop/src/components/objects/ObjectBrowser.vue", "utf8");
|
||||
const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8");
|
||||
const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8");
|
||||
const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8");
|
||||
const rustSource = readFileSync("crates/dbx-core/src/database_export.rs", "utf8");
|
||||
|
||||
test("generates export ids when crypto.randomUUID is unavailable", () => {
|
||||
const originalCrypto = globalThis.crypto;
|
||||
@@ -35,81 +30,34 @@ test("generates export ids when crypto.randomUUID is unavailable", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("builds batched INSERT statements for one exported table", () => {
|
||||
const statements = buildInsertStatements({
|
||||
qualifiedTableName: "`users`",
|
||||
columns: ["id", "name"],
|
||||
rows: [
|
||||
[1, "Ada"],
|
||||
[2, "O'Hara"],
|
||||
[3, "Linus"],
|
||||
],
|
||||
quoteIdentifier: (name) => `\`${name}\``,
|
||||
batchSize: 2,
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
"INSERT INTO `users` (`id`, `name`) VALUES (1, 'Ada'), (2, 'O''Hara');",
|
||||
"INSERT INTO `users` (`id`, `name`) VALUES (3, 'Linus');",
|
||||
]);
|
||||
test("frontend database SQL export helpers delegate executable SQL generation to backend APIs", () => {
|
||||
assert.match(databaseExportSource, /return api\.buildExportInsertStatements\(options\)/);
|
||||
assert.match(databaseExportSource, /return api\.buildDatabaseSqlExport\(/);
|
||||
assert.match(exportFormatsSource, /return api\.buildExportSqlInsert\(/);
|
||||
assert.doesNotMatch(databaseExportSource, /INSERT INTO|formatSqlLiteral|replace\(\s*\/'/);
|
||||
assert.doesNotMatch(exportFormatsSource, /INSERT INTO|VALUES|quoteIdent|replace\(\s*\/'/);
|
||||
});
|
||||
|
||||
test("builds capped export page queries", () => {
|
||||
assert.equal(
|
||||
buildExportPageSql({
|
||||
databaseType: "mysql",
|
||||
tableName: "users",
|
||||
limit: 500,
|
||||
offset: 1000,
|
||||
}),
|
||||
"SELECT * FROM `users` LIMIT 500 OFFSET 1000;",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
buildExportPageSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "accounts",
|
||||
limit: DATABASE_EXPORT_ROW_LIMIT,
|
||||
}),
|
||||
`SELECT TOP (${DATABASE_EXPORT_ROW_LIMIT}) * FROM [dbo].[accounts]`,
|
||||
);
|
||||
test("SQL export callers await backend INSERT builders", () => {
|
||||
assert.match(treeItemSource, /await formatSqlInsert\(/);
|
||||
assert.match(objectBrowserSource, /await formatSqlInsert\(/);
|
||||
});
|
||||
|
||||
test("builds a database SQL export with DDL before data", () => {
|
||||
const sql = buildDatabaseSqlExport({
|
||||
databaseName: "app",
|
||||
exportedAt: new Date("2026-05-02T00:00:00.000Z"),
|
||||
rowLimitPerTable: DATABASE_EXPORT_ROW_LIMIT,
|
||||
tables: [
|
||||
{
|
||||
displayName: "users",
|
||||
qualifiedTableName: "`users`",
|
||||
ddl: "CREATE TABLE `users` (`id` int);",
|
||||
columns: ["id"],
|
||||
rows: [[1]],
|
||||
truncated: true,
|
||||
},
|
||||
],
|
||||
quoteIdentifier: (name) => `\`${name}\``,
|
||||
insertBatchSize: DATABASE_EXPORT_INSERT_BATCH_SIZE,
|
||||
});
|
||||
test("shared API exposes backend database export SQL builders", () => {
|
||||
assert.match(apiSource, /export const buildExportInsertStatements = forward\("buildExportInsertStatements"\)/);
|
||||
assert.match(apiSource, /export const buildExportSqlInsert = forward\("buildExportSqlInsert"\)/);
|
||||
assert.match(apiSource, /export const buildDatabaseSqlExport = forward\("buildDatabaseSqlExport"\)/);
|
||||
assert.match(tauriSource, /invoke\("build_export_insert_statements"/);
|
||||
assert.match(tauriSource, /invoke\("build_export_sql_insert"/);
|
||||
assert.match(tauriSource, /invoke\("build_database_sql_export"/);
|
||||
assert.match(httpSource, /\/api\/query\/build-export-insert-statements/);
|
||||
assert.match(httpSource, /\/api\/query\/build-export-sql-insert/);
|
||||
assert.match(httpSource, /\/api\/query\/build-database-sql-export/);
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sql,
|
||||
[
|
||||
"-- DBX database export",
|
||||
"-- Database: app",
|
||||
"-- Exported at: 2026-05-02T00:00:00.000Z",
|
||||
`-- Row limit per table: ${DATABASE_EXPORT_ROW_LIMIT}`,
|
||||
"",
|
||||
"-- Structure for users",
|
||||
"CREATE TABLE `users` (`id` int);",
|
||||
"",
|
||||
"-- Data for users",
|
||||
`-- Exported rows: 1 (truncated at ${DATABASE_EXPORT_ROW_LIMIT})`,
|
||||
"INSERT INTO `users` (`id`) VALUES (1);",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
test("Rust database export SQL exposes INSERT and full export builders", () => {
|
||||
assert.match(rustSource, /pub fn format_export_sql_literal/);
|
||||
assert.match(rustSource, /pub fn build_export_insert_statements/);
|
||||
assert.match(rustSource, /pub fn build_export_sql_insert/);
|
||||
assert.match(rustSource, /pub fn build_database_sql_export/);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { ColumnInfo } from "../../apps/desktop/src/types/database.ts";
|
||||
import {
|
||||
buildDatabaseSearchSql,
|
||||
buildSearchResultWhere,
|
||||
findMatchedSearchColumns,
|
||||
} from "../../apps/desktop/src/lib/databaseSearch.ts";
|
||||
import { findMatchedSearchColumns, isNumericSearchColumn, isTextSearchColumn } from "../../apps/desktop/src/lib/databaseSearch.ts";
|
||||
|
||||
function col(name: string, dataType: string, primary = false): ColumnInfo {
|
||||
return {
|
||||
@@ -18,48 +14,11 @@ function col(name: string, dataType: string, primary = false): ColumnInfo {
|
||||
};
|
||||
}
|
||||
|
||||
test("builds a table search query over text columns", () => {
|
||||
const query = buildDatabaseSearchSql({
|
||||
databaseType: "mysql",
|
||||
tableName: "users",
|
||||
columns: [col("id", "bigint", true), col("email", "varchar"), col("avatar", "blob")],
|
||||
term: "alice@example.com",
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
assert.ok(query);
|
||||
assert.deepEqual(query.searchableColumns, ["email"]);
|
||||
assert.equal(
|
||||
query.sql,
|
||||
"SELECT * FROM `users` WHERE (LOWER(CAST(`email` AS CHAR)) LIKE '%alice@example.com%' ESCAPE '~') LIMIT 20;",
|
||||
);
|
||||
});
|
||||
|
||||
test("adds exact numeric predicates only when the search term is numeric", () => {
|
||||
const query = buildDatabaseSearchSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "orders",
|
||||
columns: [col("id", "integer", true), col("note", "text")],
|
||||
term: "42",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
assert.ok(query);
|
||||
assert.deepEqual(query.searchableColumns, ["note", "id"]);
|
||||
assert.match(query.sql, /"id" = 42/);
|
||||
assert.match(query.sql, /FROM "public"\."orders"/);
|
||||
});
|
||||
|
||||
test("returns null when a table has no searchable columns", () => {
|
||||
const query = buildDatabaseSearchSql({
|
||||
databaseType: "sqlite",
|
||||
tableName: "files",
|
||||
columns: [col("payload", "blob")],
|
||||
term: "needle",
|
||||
});
|
||||
|
||||
assert.equal(query, null);
|
||||
test("classifies searchable database search columns", () => {
|
||||
assert.equal(isTextSearchColumn(col("email", "varchar")), true);
|
||||
assert.equal(isNumericSearchColumn(col("id", "bigint")), true);
|
||||
assert.equal(isTextSearchColumn(col("payload", "blob")), false);
|
||||
assert.equal(isNumericSearchColumn(col("payload", "blob")), false);
|
||||
});
|
||||
|
||||
test("finds matched columns from returned rows", () => {
|
||||
@@ -72,14 +31,3 @@ test("finds matched columns from returned rows", () => {
|
||||
|
||||
assert.deepEqual(matches, ["email"]);
|
||||
});
|
||||
|
||||
test("builds a stable where predicate for opening a result row", () => {
|
||||
const where = buildSearchResultWhere({
|
||||
databaseType: "sqlserver",
|
||||
columns: [col("id", "integer", true), col("email", "varchar")],
|
||||
resultColumns: ["id", "email"],
|
||||
row: [42, "alice@example.com"],
|
||||
});
|
||||
|
||||
assert.equal(where, "[id] = 42");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8");
|
||||
const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8");
|
||||
const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8");
|
||||
const searchSource = readFileSync("apps/desktop/src/lib/databaseSearch.ts", "utf8");
|
||||
const dialogSource = readFileSync("apps/desktop/src/components/search/DatabaseSearchDialog.vue", "utf8");
|
||||
const rustSearchSource = readFileSync("crates/dbx-core/src/database_search_sql.rs", "utf8");
|
||||
const rustCoreLibSource = readFileSync("crates/dbx-core/src/lib.rs", "utf8");
|
||||
const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8");
|
||||
const webMainSource = readFileSync("crates/dbx-web/src/main.rs", "utf8");
|
||||
|
||||
test("shared API exposes backend database search SQL builders", () => {
|
||||
assert.match(apiSource, /export const buildDatabaseSearchSql = forward\("buildDatabaseSearchSql"\)/);
|
||||
assert.match(apiSource, /export const buildSearchResultWhere = forward\("buildSearchResultWhere"\)/);
|
||||
assert.match(tauriSource, /invoke\("build_database_search_sql"/);
|
||||
assert.match(tauriSource, /invoke\("build_search_result_where"/);
|
||||
assert.match(httpSource, /\/api\/query\/build-database-search-sql/);
|
||||
assert.match(httpSource, /\/api\/query\/build-search-result-where/);
|
||||
});
|
||||
|
||||
test("database search frontend delegates executable SQL generation to backend APIs", () => {
|
||||
assert.match(searchSource, /return api\.buildDatabaseSearchSql\(options\)/);
|
||||
assert.match(searchSource, /return api\.buildSearchResultWhere\(options\)/);
|
||||
assert.match(dialogSource, /await buildDatabaseSearchSql\(/);
|
||||
assert.match(dialogSource, /await buildSearchResultWhere\(/);
|
||||
assert.doesNotMatch(searchSource, /function textCastExpression/);
|
||||
assert.doesNotMatch(searchSource, /function sqlValueLiteral/);
|
||||
assert.doesNotMatch(searchSource, /SELECT \* FROM/);
|
||||
});
|
||||
|
||||
test("Rust backends register database search SQL builders", () => {
|
||||
assert.match(rustCoreLibSource, /pub mod database_search_sql/);
|
||||
assert.match(rustSearchSource, /pub fn build_database_search_sql/);
|
||||
assert.match(rustSearchSource, /pub fn build_search_result_where/);
|
||||
assert.match(tauriLibSource, /commands::query::build_database_search_sql/);
|
||||
assert.match(tauriLibSource, /commands::query::build_search_result_where/);
|
||||
assert.match(webMainSource, /\/query\/build-database-search-sql/);
|
||||
assert.match(webMainSource, /\/query\/build-search-result-where/);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8");
|
||||
const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8");
|
||||
const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8");
|
||||
const objectRenameSource = readFileSync("apps/desktop/src/lib/objectRenameSql.ts", "utf8");
|
||||
const createDatabaseSource = readFileSync("apps/desktop/src/lib/createDatabaseSql.ts", "utf8");
|
||||
const dbAdminSource = readFileSync("apps/desktop/src/lib/dbAdminSql.ts", "utf8");
|
||||
const objectBrowserSource = readFileSync("apps/desktop/src/components/objects/ObjectBrowser.vue", "utf8");
|
||||
const treeItemSource = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
|
||||
const rustAdminSource = readFileSync("crates/dbx-core/src/db_admin_sql.rs", "utf8");
|
||||
const rustCoreLibSource = readFileSync("crates/dbx-core/src/lib.rs", "utf8");
|
||||
const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8");
|
||||
const webMainSource = readFileSync("crates/dbx-web/src/main.rs", "utf8");
|
||||
|
||||
test("shared API exposes backend admin SQL builders", () => {
|
||||
assert.match(apiSource, /export const buildRenameObjectSql = forward\("buildRenameObjectSql"\)/);
|
||||
assert.match(apiSource, /export const buildCreateDatabaseSql = forward\("buildCreateDatabaseSql"\)/);
|
||||
assert.match(apiSource, /export const buildDuckDbAttachDatabaseSql = forward\("buildDuckDbAttachDatabaseSql"\)/);
|
||||
assert.match(apiSource, /export const buildDropObjectSql = forward\("buildDropObjectSql"\)/);
|
||||
assert.match(apiSource, /export const buildDuplicateTableStructureSql = forward\("buildDuplicateTableStructureSql"\)/);
|
||||
assert.match(tauriSource, /invoke\("build_rename_object_sql"/);
|
||||
assert.match(tauriSource, /invoke\("build_create_database_sql"/);
|
||||
assert.match(tauriSource, /invoke\("build_duckdb_attach_database_sql"/);
|
||||
assert.match(tauriSource, /invoke\("build_drop_object_sql"/);
|
||||
assert.match(tauriSource, /invoke\("build_duplicate_table_structure_sql"/);
|
||||
assert.match(httpSource, /\/api\/query\/build-rename-object-sql/);
|
||||
assert.match(httpSource, /\/api\/query\/build-create-database-sql/);
|
||||
assert.match(httpSource, /\/api\/query\/build-duckdb-attach-database-sql/);
|
||||
assert.match(httpSource, /\/api\/query\/build-drop-object-sql/);
|
||||
assert.match(httpSource, /\/api\/query\/build-duplicate-table-structure-sql/);
|
||||
});
|
||||
|
||||
test("frontend admin SQL helpers delegate executable SQL generation to backend APIs", () => {
|
||||
assert.match(objectRenameSource, /return api\.buildRenameObjectSql\(options\)/);
|
||||
assert.match(createDatabaseSource, /return api\.buildCreateDatabaseSql\(options\)/);
|
||||
assert.match(createDatabaseSource, /return api\.buildDuckDbAttachDatabaseSql\(path, name\)/);
|
||||
assert.match(dbAdminSource, /return api\.buildDropObjectSql\(options\)/);
|
||||
assert.match(dbAdminSource, /return api\.buildDuplicateTableStructureSql\(options\)/);
|
||||
assert.doesNotMatch(objectRenameSource, /EXEC sp_rename/);
|
||||
assert.doesNotMatch(objectRenameSource, /RENAME TABLE/);
|
||||
assert.doesNotMatch(createDatabaseSource, /CREATE DATABASE/);
|
||||
assert.doesNotMatch(createDatabaseSource, /ATTACH .* AS/);
|
||||
assert.doesNotMatch(dbAdminSource, /DROP |TRUNCATE |DELETE FROM|CREATE TABLE|CREATE SCHEMA/);
|
||||
});
|
||||
|
||||
test("admin SQL callers await backend builders and keep async previews out of templates", () => {
|
||||
assert.match(treeItemSource, /const sql = await buildRenameObjectSql\(/);
|
||||
assert.match(treeItemSource, /await buildDuckDbAttachDatabaseSql\(/);
|
||||
assert.match(treeItemSource, /const sql = await buildCreateDatabaseSql\(/);
|
||||
assert.match(treeItemSource, /await buildDuplicateTableStructureSql\(/);
|
||||
assert.match(treeItemSource, /dropTablePreviewSql/);
|
||||
assert.match(treeItemSource, /renameObjectPreviewSql/);
|
||||
assert.doesNotMatch(treeItemSource, /buildRenameObjectPreviewSql\(\)/);
|
||||
assert.doesNotMatch(treeItemSource, /CREATE TABLE .*LIKE|TRUNCATE TABLE|DROP SCHEMA|DROP DATABASE|CREATE SCHEMA/);
|
||||
assert.match(objectBrowserSource, /const sql = await buildRenameObjectSql\(/);
|
||||
assert.match(objectBrowserSource, /await buildDuplicateTableStructureSql\(/);
|
||||
assert.match(objectBrowserSource, /truncatePreviewSql/);
|
||||
assert.match(objectBrowserSource, /renamePreviewSqlText/);
|
||||
assert.doesNotMatch(objectBrowserSource, /renamePreviewSql\(\)/);
|
||||
assert.doesNotMatch(objectBrowserSource, /CREATE TABLE .*LIKE|TRUNCATE TABLE|DELETE FROM \$|DROP \$/);
|
||||
});
|
||||
|
||||
test("Rust backends register admin SQL builders", () => {
|
||||
assert.match(rustCoreLibSource, /pub mod db_admin_sql/);
|
||||
assert.match(rustAdminSource, /pub fn build_rename_object_sql/);
|
||||
assert.match(rustAdminSource, /pub fn build_create_database_sql/);
|
||||
assert.match(rustAdminSource, /pub fn build_duckdb_attach_database_sql/);
|
||||
assert.match(rustAdminSource, /pub fn build_drop_object_sql/);
|
||||
assert.match(rustAdminSource, /pub fn build_duplicate_table_structure_sql/);
|
||||
assert.match(tauriLibSource, /commands::query::build_rename_object_sql/);
|
||||
assert.match(tauriLibSource, /commands::query::build_create_database_sql/);
|
||||
assert.match(tauriLibSource, /commands::query::build_duckdb_attach_database_sql/);
|
||||
assert.match(tauriLibSource, /commands::query::build_drop_object_sql/);
|
||||
assert.match(tauriLibSource, /commands::query::build_duplicate_table_structure_sql/);
|
||||
assert.match(webMainSource, /\/query\/build-rename-object-sql/);
|
||||
assert.match(webMainSource, /\/query\/build-create-database-sql/);
|
||||
assert.match(webMainSource, /\/query\/build-duckdb-attach-database-sql/);
|
||||
assert.match(webMainSource, /\/query\/build-drop-object-sql/);
|
||||
assert.match(webMainSource, /\/query\/build-duplicate-table-structure-sql/);
|
||||
});
|
||||
@@ -1,28 +1,25 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildExplainSql,
|
||||
flattenExplainPlanNodes,
|
||||
parseExplainResult,
|
||||
supportsExplainPlan,
|
||||
} from "../../apps/desktop/src/lib/explainPlan.ts";
|
||||
|
||||
test("builds PostgreSQL JSON explain SQL from a selected query", () => {
|
||||
const result = buildExplainSql("postgres", " select * from users where id = 1; ");
|
||||
const explainPlanSource = readFileSync("apps/desktop/src/lib/explainPlan.ts", "utf8");
|
||||
const queryStoreSource = readFileSync("apps/desktop/src/stores/queryStore.ts", "utf8");
|
||||
const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8");
|
||||
const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8");
|
||||
const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8");
|
||||
const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8");
|
||||
const webMainSource = readFileSync("crates/dbx-web/src/main.rs", "utf8");
|
||||
const rustSource = readFileSync("crates/dbx-core/src/query_execution_sql.rs", "utf8");
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "EXPLAIN (FORMAT JSON) select * from users where id = 1",
|
||||
});
|
||||
});
|
||||
|
||||
test("builds MySQL JSON explain SQL and rejects unsafe statement kinds", () => {
|
||||
assert.deepEqual(buildExplainSql("mysql", "SELECT * FROM users;"), {
|
||||
ok: true,
|
||||
sql: "EXPLAIN FORMAT=JSON SELECT * FROM users",
|
||||
});
|
||||
|
||||
assert.equal(buildExplainSql("mysql", "delete from users").ok, false);
|
||||
test("frontend explain SQL builder delegates executable SQL wrapping to backend API", () => {
|
||||
assert.match(explainPlanSource, /return api\.buildExplainSql\(\{ databaseType, sql \}\)/);
|
||||
assert.doesNotMatch(explainPlanSource, /EXPLAIN \(FORMAT JSON\)|EXPLAIN FORMAT=JSON|SAFE_EXPLAIN_RE|stripSqlComments/);
|
||||
assert.match(queryStoreSource, /await buildExplainSql\(databaseType, sql\)/);
|
||||
});
|
||||
|
||||
test("reports explain support by database type", () => {
|
||||
@@ -31,6 +28,20 @@ test("reports explain support by database type", () => {
|
||||
assert.equal(supportsExplainPlan("sqlite"), false);
|
||||
});
|
||||
|
||||
test("shared API exposes backend explain SQL builder", () => {
|
||||
assert.match(apiSource, /export const buildExplainSql = forward\("buildExplainSql"\)/);
|
||||
assert.match(tauriSource, /invoke\("build_explain_sql"/);
|
||||
assert.match(httpSource, /\/api\/query\/build-explain-sql/);
|
||||
assert.match(tauriLibSource, /commands::query::build_explain_sql/);
|
||||
assert.match(webMainSource, /\/query\/build-explain-sql/);
|
||||
});
|
||||
|
||||
test("Rust query execution SQL exposes explain builder", () => {
|
||||
assert.match(rustSource, /pub fn build_explain_sql/);
|
||||
assert.match(rustSource, /EXPLAIN \(FORMAT JSON\)/);
|
||||
assert.match(rustSource, /EXPLAIN FORMAT=JSON/);
|
||||
});
|
||||
|
||||
test("parses PostgreSQL FORMAT JSON output into plan nodes", () => {
|
||||
const plan = parseExplainResult("postgres", {
|
||||
columns: ["QUERY PLAN"],
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
const fileDropSource = readFileSync("apps/desktop/src/composables/useFileDrop.ts", "utf8");
|
||||
const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8");
|
||||
const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8");
|
||||
const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8");
|
||||
const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8");
|
||||
const webMainSource = readFileSync("crates/dbx-web/src/main.rs", "utf8");
|
||||
const rustSource = readFileSync("crates/dbx-core/src/query_execution_sql.rs", "utf8");
|
||||
|
||||
test("file drop data preview delegates executable DuckDB SQL generation to backend API", () => {
|
||||
assert.match(fileDropSource, /return api\.buildDroppedFilePreviewSql\(\{ path \}\)/);
|
||||
assert.match(fileDropSource, /const dataQuery = await getDataFileQuery\(path\)/);
|
||||
assert.doesNotMatch(fileDropSource, /read_parquet|read_csv|read_json|SELECT \* FROM/);
|
||||
});
|
||||
|
||||
test("shared API exposes backend dropped file preview SQL builder", () => {
|
||||
assert.match(apiSource, /export const buildDroppedFilePreviewSql = forward\("buildDroppedFilePreviewSql"\)/);
|
||||
assert.match(tauriSource, /invoke<string \| null>\("build_dropped_file_preview_sql"/);
|
||||
assert.match(httpSource, /\/api\/query\/build-dropped-file-preview-sql/);
|
||||
assert.match(tauriLibSource, /commands::query::build_dropped_file_preview_sql/);
|
||||
assert.match(webMainSource, /\/query\/build-dropped-file-preview-sql/);
|
||||
});
|
||||
|
||||
test("Rust query execution SQL exposes dropped file preview builder", () => {
|
||||
assert.match(rustSource, /pub fn build_dropped_file_preview_sql/);
|
||||
assert.match(rustSource, /read_parquet/);
|
||||
assert.match(rustSource, /read_csv/);
|
||||
assert.match(rustSource, /read_json/);
|
||||
});
|
||||
@@ -1,115 +1,17 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildRenameObjectSql, supportsObjectRename } from "../../apps/desktop/src/lib/objectRenameSql.ts";
|
||||
import { supportsObjectRename } from "../../apps/desktop/src/lib/objectRenameSql.ts";
|
||||
|
||||
test("builds MySQL table and view rename statements", () => {
|
||||
assert.equal(
|
||||
buildRenameObjectSql({
|
||||
databaseType: "mysql",
|
||||
objectType: "TABLE",
|
||||
oldName: "users",
|
||||
newName: "app users",
|
||||
}),
|
||||
"RENAME TABLE `users` TO `app users`;",
|
||||
);
|
||||
assert.equal(
|
||||
buildRenameObjectSql({
|
||||
databaseType: "goldendb",
|
||||
objectType: "VIEW",
|
||||
oldName: "active_users",
|
||||
newName: "enabled_users",
|
||||
}),
|
||||
"RENAME TABLE `active_users` TO `enabled_users`;",
|
||||
);
|
||||
});
|
||||
|
||||
test("builds PostgreSQL table and view rename statements", () => {
|
||||
assert.equal(
|
||||
buildRenameObjectSql({
|
||||
databaseType: "postgres",
|
||||
objectType: "TABLE",
|
||||
schema: "public",
|
||||
oldName: "orders",
|
||||
newName: "archived orders",
|
||||
}),
|
||||
'ALTER TABLE "public"."orders" RENAME TO "archived orders";',
|
||||
);
|
||||
assert.equal(
|
||||
buildRenameObjectSql({
|
||||
databaseType: "postgres",
|
||||
objectType: "VIEW",
|
||||
schema: "public",
|
||||
oldName: "active_users",
|
||||
newName: "enabled_users",
|
||||
}),
|
||||
'ALTER VIEW "public"."active_users" RENAME TO "enabled_users";',
|
||||
);
|
||||
});
|
||||
|
||||
test("builds SQL Server rename statements for all object kinds", () => {
|
||||
assert.equal(
|
||||
buildRenameObjectSql({
|
||||
databaseType: "sqlserver",
|
||||
objectType: "FUNCTION",
|
||||
schema: "dbo",
|
||||
oldName: "fn_total",
|
||||
newName: "fn_order_total",
|
||||
}),
|
||||
"EXEC sp_rename N'dbo.fn_total', N'fn_order_total', N'OBJECT';",
|
||||
);
|
||||
test("recognizes object rename support for UI affordances", () => {
|
||||
assert.equal(supportsObjectRename("mysql", "TABLE"), true);
|
||||
assert.equal(supportsObjectRename("goldendb", "VIEW"), true);
|
||||
assert.equal(supportsObjectRename("postgres", "TABLE"), true);
|
||||
assert.equal(supportsObjectRename("oracle", "VIEW"), true);
|
||||
assert.equal(supportsObjectRename("sqlserver", "PROCEDURE"), true);
|
||||
});
|
||||
|
||||
test("builds Oracle-family table and view rename statements", () => {
|
||||
assert.equal(
|
||||
buildRenameObjectSql({
|
||||
databaseType: "oracle",
|
||||
objectType: "TABLE",
|
||||
schema: "HR",
|
||||
oldName: "EMPLOYEES",
|
||||
newName: "STAFF",
|
||||
}),
|
||||
'ALTER TABLE "HR"."EMPLOYEES" RENAME TO "STAFF";',
|
||||
);
|
||||
assert.equal(
|
||||
buildRenameObjectSql({
|
||||
databaseType: "dameng",
|
||||
objectType: "VIEW",
|
||||
schema: "SYSDBA",
|
||||
oldName: "ACTIVE_USERS",
|
||||
newName: "ENABLED_USERS",
|
||||
}),
|
||||
'ALTER VIEW "SYSDBA"."ACTIVE_USERS" RENAME TO "ENABLED_USERS";',
|
||||
);
|
||||
});
|
||||
|
||||
test("does not build direct Oracle-family routine rename statements", () => {
|
||||
assert.equal(supportsObjectRename("sqlite", "TABLE"), true);
|
||||
assert.equal(supportsObjectRename("sqlite", "VIEW"), false);
|
||||
assert.equal(supportsObjectRename("oracle", "FUNCTION"), false);
|
||||
assert.equal(supportsObjectRename("dameng", "PROCEDURE"), false);
|
||||
assert.throws(
|
||||
() =>
|
||||
buildRenameObjectSql({
|
||||
databaseType: "dameng",
|
||||
objectType: "PROCEDURE",
|
||||
schema: "SYSDBA",
|
||||
oldName: "REFRESH_CACHE",
|
||||
newName: "REFRESH_CACHE_V2",
|
||||
}),
|
||||
/Renaming PROCEDURE is not supported/,
|
||||
);
|
||||
});
|
||||
|
||||
test("reports unsupported routine rename cases", () => {
|
||||
assert.equal(supportsObjectRename("mysql", "PROCEDURE"), false);
|
||||
assert.equal(supportsObjectRename("postgres", "FUNCTION"), false);
|
||||
assert.throws(
|
||||
() =>
|
||||
buildRenameObjectSql({
|
||||
databaseType: "mysql",
|
||||
objectType: "PROCEDURE",
|
||||
oldName: "refresh_cache",
|
||||
newName: "refresh_cache_v2",
|
||||
}),
|
||||
/Renaming PROCEDURE is not supported/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,167 +1,20 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildExecutableObjectSourceSql,
|
||||
buildExecutableObjectSourceStatements,
|
||||
buildRoutineRenameObjectSourceStatements,
|
||||
objectSourceSaveExecutionMode,
|
||||
supportsSourceBackedRoutineRename,
|
||||
} from "../../apps/desktop/src/lib/objectSourceEditor.ts";
|
||||
import { objectSourceSaveExecutionMode, supportsSourceBackedRoutineRename } from "../../apps/desktop/src/lib/objectSourceEditor.ts";
|
||||
|
||||
test("SQL Server edited source saves as ALTER", () => {
|
||||
const sql = buildExecutableObjectSourceSql({
|
||||
databaseType: "sqlserver",
|
||||
objectType: "PROCEDURE",
|
||||
schema: "dbo",
|
||||
name: "usp_demo",
|
||||
source: "CREATE PROCEDURE dbo.usp_demo AS SELECT 1;",
|
||||
});
|
||||
|
||||
assert.equal(sql, "ALTER PROCEDURE dbo.usp_demo AS SELECT 1;");
|
||||
});
|
||||
|
||||
test("SQL Server edited CREATE OR ALTER source saves as ALTER", () => {
|
||||
const sql = buildExecutableObjectSourceSql({
|
||||
databaseType: "sqlserver",
|
||||
objectType: "VIEW",
|
||||
schema: "dbo",
|
||||
name: "vw_demo",
|
||||
source: "CREATE OR ALTER VIEW dbo.vw_demo AS SELECT 1 AS id;",
|
||||
});
|
||||
|
||||
assert.equal(sql, "ALTER VIEW dbo.vw_demo AS SELECT 1 AS id;");
|
||||
});
|
||||
|
||||
test("SQL Server object source saves as a single batch", () => {
|
||||
test("object source saves use single statement execution mode", () => {
|
||||
assert.equal(objectSourceSaveExecutionMode("sqlserver"), "single");
|
||||
});
|
||||
|
||||
test("Kingbase object source saves as a single statement", () => {
|
||||
assert.equal(objectSourceSaveExecutionMode("kingbase"), "single");
|
||||
});
|
||||
|
||||
test("Postgres-family object source saves as a single statement", () => {
|
||||
assert.equal(objectSourceSaveExecutionMode("postgres"), "single");
|
||||
assert.equal(objectSourceSaveExecutionMode("gaussdb"), "single");
|
||||
});
|
||||
|
||||
test("MySQL object source saves as a single statement", () => {
|
||||
assert.equal(objectSourceSaveExecutionMode("mysql"), "single");
|
||||
});
|
||||
|
||||
test("Postgres view body opens as CREATE OR REPLACE VIEW", () => {
|
||||
const sql = buildExecutableObjectSourceSql({
|
||||
databaseType: "postgres",
|
||||
objectType: "VIEW",
|
||||
schema: "public",
|
||||
name: "active users",
|
||||
source: " SELECT id, name FROM users WHERE active ",
|
||||
});
|
||||
|
||||
assert.equal(sql, 'CREATE OR REPLACE VIEW "public"."active users" AS\nSELECT id, name FROM users WHERE active;');
|
||||
});
|
||||
|
||||
test("Kingbase function rename creates the renamed routine and then drops the original routine", () => {
|
||||
const statements = buildExecutableObjectSourceStatements({
|
||||
databaseType: "kingbase",
|
||||
objectType: "FUNCTION",
|
||||
schema: "DLJPM",
|
||||
name: "CONVERTSPECIALNAME",
|
||||
source:
|
||||
'CREATE OR REPLACE function "DLJPM"."CONVERTSPECIALNAME1" (SpName varchar2)\nRETURN VARCHAR2\nas\nbegin\nreturn SpName;\nend;',
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
'CREATE OR REPLACE function "DLJPM"."CONVERTSPECIALNAME1" (SpName varchar2)\nRETURN VARCHAR2\nas\nbegin\nreturn SpName;\nend;',
|
||||
'DROP FUNCTION IF EXISTS "DLJPM"."CONVERTSPECIALNAME"(SpName varchar2);',
|
||||
]);
|
||||
});
|
||||
|
||||
test("Postgres procedure rename creates the renamed routine and then drops the original routine", () => {
|
||||
const statements = buildExecutableObjectSourceStatements({
|
||||
databaseType: "postgres",
|
||||
objectType: "PROCEDURE",
|
||||
schema: "public",
|
||||
name: "refresh_cache",
|
||||
source: 'CREATE OR REPLACE PROCEDURE "public"."refresh_cache_v2"(mode text)\nLANGUAGE SQL\nAS $$ SELECT 1 $$;',
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
'CREATE OR REPLACE PROCEDURE "public"."refresh_cache_v2"(mode text)\nLANGUAGE SQL\nAS $$ SELECT 1 $$;',
|
||||
'DROP PROCEDURE IF EXISTS "public"."refresh_cache"(mode text);',
|
||||
]);
|
||||
});
|
||||
|
||||
test("object source SQL joins generated save statements for previews", () => {
|
||||
const sql = buildExecutableObjectSourceSql({
|
||||
databaseType: "postgres",
|
||||
objectType: "PROCEDURE",
|
||||
schema: "public",
|
||||
name: "refresh_cache",
|
||||
source: 'CREATE OR REPLACE PROCEDURE "public"."refresh_cache_v2"(mode text)\nLANGUAGE SQL\nAS $$ SELECT 1 $$;',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sql,
|
||||
'CREATE OR REPLACE PROCEDURE "public"."refresh_cache_v2"(mode text)\nLANGUAGE SQL\nAS $$ SELECT 1 $$;\nDROP PROCEDURE IF EXISTS "public"."refresh_cache"(mode text);',
|
||||
);
|
||||
});
|
||||
|
||||
test("Oracle and Dameng object source saves as semicolon-terminated source", () => {
|
||||
assert.equal(
|
||||
buildExecutableObjectSourceSql({
|
||||
databaseType: "oracle",
|
||||
objectType: "VIEW",
|
||||
schema: "HR",
|
||||
name: "ACTIVE_USERS",
|
||||
source: "CREATE OR REPLACE VIEW HR.ACTIVE_USERS AS SELECT ID FROM USERS",
|
||||
}),
|
||||
"CREATE OR REPLACE VIEW HR.ACTIVE_USERS AS SELECT ID FROM USERS;",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
buildExecutableObjectSourceSql({
|
||||
databaseType: "dameng",
|
||||
objectType: "PROCEDURE",
|
||||
schema: "SYSDBA",
|
||||
name: "REFRESH_CACHE",
|
||||
source: "CREATE OR REPLACE PROCEDURE SYSDBA.REFRESH_CACHE AS BEGIN SELECT 1; END;",
|
||||
}),
|
||||
"CREATE OR REPLACE PROCEDURE SYSDBA.REFRESH_CACHE AS BEGIN SELECT 1; END;",
|
||||
);
|
||||
});
|
||||
|
||||
test("MySQL routine rename creates the renamed routine and drops the original routine", () => {
|
||||
const statements = buildExecutableObjectSourceStatements({
|
||||
databaseType: "mysql",
|
||||
objectType: "PROCEDURE",
|
||||
schema: "app",
|
||||
name: "refresh_cache",
|
||||
source: "CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache_v2`(IN mode_name varchar(20)) BEGIN SELECT 1; END",
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
"CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache_v2`(IN mode_name varchar(20)) BEGIN SELECT 1; END;",
|
||||
"DROP PROCEDURE IF EXISTS `app`.`refresh_cache`;",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Oracle-family routine rename rewrites source and drops the original routine", () => {
|
||||
test("routine rename capability stays available for source-backed engines", () => {
|
||||
assert.equal(supportsSourceBackedRoutineRename("dameng", "PROCEDURE"), true);
|
||||
assert.equal(supportsSourceBackedRoutineRename("oracle", "FUNCTION"), true);
|
||||
|
||||
const statements = buildRoutineRenameObjectSourceStatements({
|
||||
databaseType: "dameng",
|
||||
objectType: "PROCEDURE",
|
||||
schema: "SYSDBA",
|
||||
name: "SP_TAB_BAKSET_REMOVE_BATCH",
|
||||
newName: "SP_TAB_BAKSET_REMOVE_BATCH_2",
|
||||
source:
|
||||
'CREATE OR REPLACE PROCEDURE "SYSDBA"."SP_TAB_BAKSET_REMOVE_BATCH" AS\nBEGIN\n SELECT 1;\nEND;',
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
'CREATE OR REPLACE PROCEDURE "SYSDBA"."SP_TAB_BAKSET_REMOVE_BATCH_2" AS\nBEGIN\n SELECT 1;\nEND;',
|
||||
'DROP PROCEDURE "SYSDBA"."SP_TAB_BAKSET_REMOVE_BATCH";',
|
||||
]);
|
||||
assert.equal(supportsSourceBackedRoutineRename("mysql", "PROCEDURE"), true);
|
||||
assert.equal(supportsSourceBackedRoutineRename("postgres", "FUNCTION"), true);
|
||||
assert.equal(supportsSourceBackedRoutineRename("sqlserver", "PROCEDURE"), false);
|
||||
assert.equal(supportsSourceBackedRoutineRename("postgres", "VIEW"), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8");
|
||||
const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8");
|
||||
const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8");
|
||||
const objectSourceEditorSource = readFileSync("apps/desktop/src/lib/objectSourceEditor.ts", "utf8");
|
||||
const appSource = readFileSync("apps/desktop/src/App.vue", "utf8");
|
||||
const objectBrowserSource = readFileSync("apps/desktop/src/components/objects/ObjectBrowser.vue", "utf8");
|
||||
const treeItemSource = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
|
||||
const rustObjectSource = readFileSync("crates/dbx-core/src/object_source_sql.rs", "utf8");
|
||||
const rustCoreLibSource = readFileSync("crates/dbx-core/src/lib.rs", "utf8");
|
||||
const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8");
|
||||
const webMainSource = readFileSync("crates/dbx-web/src/main.rs", "utf8");
|
||||
|
||||
test("shared API exposes backend object source SQL builders", () => {
|
||||
assert.match(apiSource, /export const buildExecutableObjectSourceStatements = forward\("buildExecutableObjectSourceStatements"\)/);
|
||||
assert.match(apiSource, /export const buildRoutineRenameObjectSourceStatements = forward\("buildRoutineRenameObjectSourceStatements"\)/);
|
||||
assert.match(tauriSource, /invoke\("build_executable_object_source_statements"/);
|
||||
assert.match(tauriSource, /invoke\("build_routine_rename_object_source_statements"/);
|
||||
assert.match(httpSource, /\/api\/query\/build-executable-object-source-statements/);
|
||||
assert.match(httpSource, /\/api\/query\/build-routine-rename-object-source-statements/);
|
||||
});
|
||||
|
||||
test("frontend object source editor delegates executable SQL generation to backend APIs", () => {
|
||||
assert.match(objectSourceEditorSource, /return api\.buildExecutableObjectSourceStatements\(input\)/);
|
||||
assert.match(objectSourceEditorSource, /return api\.buildRoutineRenameObjectSourceStatements\(input\)/);
|
||||
assert.doesNotMatch(objectSourceEditorSource, /function routineDeclaration/);
|
||||
assert.doesNotMatch(objectSourceEditorSource, /function replaceSqlRoutineDeclarationName/);
|
||||
assert.doesNotMatch(objectSourceEditorSource, /DROP .* IF EXISTS/);
|
||||
assert.match(appSource, /await buildExecutableObjectSourceStatements\(/);
|
||||
assert.match(objectBrowserSource, /await buildExecutableObjectSourceStatements\(/);
|
||||
assert.match(treeItemSource, /await buildRoutineRenameObjectSourceStatements\(/);
|
||||
});
|
||||
|
||||
test("Rust backends register object source SQL builders", () => {
|
||||
assert.match(rustCoreLibSource, /pub mod object_source_sql/);
|
||||
assert.match(rustObjectSource, /pub fn build_executable_object_source_statements/);
|
||||
assert.match(rustObjectSource, /pub fn build_routine_rename_object_source_statements/);
|
||||
assert.match(tauriLibSource, /commands::query::build_executable_object_source_statements/);
|
||||
assert.match(tauriLibSource, /commands::query::build_routine_rename_object_source_statements/);
|
||||
assert.match(webMainSource, /\/query\/build-executable-object-source-statements/);
|
||||
assert.match(webMainSource, /\/query\/build-routine-rename-object-source-statements/);
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildCountQuerySql,
|
||||
buildPaginatedQuerySql,
|
||||
buildQueryPaginationExecutionPlan,
|
||||
} from "../../apps/desktop/src/lib/queryResultPagination.ts";
|
||||
|
||||
test("wraps a single select query with limit and offset", () => {
|
||||
const result = buildPaginatedQuerySql("SELECT id, name FROM users;", "postgres", 100, 200);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: 'SELECT * FROM (SELECT id, name FROM users) "dbx_page" LIMIT 100 OFFSET 200;',
|
||||
});
|
||||
});
|
||||
|
||||
test("uses MySQL style quoting for paginated query alias", () => {
|
||||
const result = buildPaginatedQuerySql("SELECT id FROM users WHERE active = 1", "mysql", 50, 0);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "SELECT * FROM (SELECT id FROM users WHERE active = 1) `dbx_page` LIMIT 50;",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses SQL Server TOP pagination for the first page to support SQL Server 2008", () => {
|
||||
const result = buildPaginatedQuerySql("SELECT id FROM users", "sqlserver", 100, 0);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "SELECT TOP (100) * FROM (SELECT id FROM users) [dbx_page]",
|
||||
});
|
||||
});
|
||||
|
||||
test("strips top-level SQL Server order by before wrapping paginated query", () => {
|
||||
const result = buildPaginatedQuerySql("SELECT id FROM users ORDER BY id DESC", "sqlserver", 100, 0);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "SELECT TOP (100) * FROM (SELECT id FROM users) [dbx_page]",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not generate SQL Server OFFSET pagination for later query-result pages", () => {
|
||||
const result = buildPaginatedQuerySql("SELECT id FROM users", "sqlserver", 100, 300);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
reason: "unsupported",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not paginate SQL Server SELECT INTO statements", () => {
|
||||
const result = buildPaginatedQuerySql(
|
||||
"SELECT * INTO DingTalk_SyncTask FROM YonSuite_SyncTask WHERE 1=2",
|
||||
"sqlserver",
|
||||
100,
|
||||
0,
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "not_select" });
|
||||
});
|
||||
|
||||
test("uses fetch first pagination for Oracle first page", () => {
|
||||
const result = buildPaginatedQuerySql("SELECT id FROM users", "oracle", 100, 0);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: 'SELECT * FROM (SELECT id FROM users) "dbx_page" FETCH FIRST 100 ROWS ONLY',
|
||||
});
|
||||
});
|
||||
|
||||
test("supports CTE select queries", () => {
|
||||
const result = buildPaginatedQuerySql("WITH cte AS (SELECT 1 AS id) SELECT * FROM cte", "clickhouse", 100, 0);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: 'SELECT * FROM (WITH cte AS (SELECT 1 AS id) SELECT * FROM cte) "dbx_page" LIMIT 100;',
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects multiple statements", () => {
|
||||
const result = buildPaginatedQuerySql("SELECT 1; SELECT 2;", "postgres", 100, 0);
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "multi" });
|
||||
});
|
||||
|
||||
test("rejects non select statements", () => {
|
||||
const result = buildPaginatedQuerySql("UPDATE users SET name = 'A'", "postgres", 100, 0);
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "not_select" });
|
||||
});
|
||||
|
||||
test("wraps a single select query for total row count", () => {
|
||||
const result = buildCountQuerySql("SELECT id, name FROM users;", "postgres");
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: 'SELECT COUNT(*) AS dbx_total_rows FROM (SELECT id, name FROM users) "dbx_count";',
|
||||
});
|
||||
});
|
||||
|
||||
test("strips top-level SQL Server order by before wrapping count query", () => {
|
||||
const result = buildCountQuerySql("SELECT id FROM users ORDER BY id DESC", "sqlserver");
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "SELECT COUNT(*) AS dbx_total_rows FROM (SELECT id FROM users) [dbx_count];",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps nested SQL Server order by expressions when wrapping count query", () => {
|
||||
const result = buildCountQuerySql(
|
||||
"SELECT ROW_NUMBER() OVER (ORDER BY created_at DESC) AS rn, id FROM users ORDER BY id DESC",
|
||||
"sqlserver",
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "SELECT COUNT(*) AS dbx_total_rows FROM (SELECT ROW_NUMBER() OVER (ORDER BY created_at DESC) AS rn, id FROM users) [dbx_count];",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses MySQL style quoting for count query alias", () => {
|
||||
const result = buildCountQuerySql("WITH cte AS (SELECT 1 AS id) SELECT * FROM cte", "mysql");
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "SELECT COUNT(*) AS dbx_total_rows FROM (WITH cte AS (SELECT 1 AS id) SELECT * FROM cte) `dbx_count`;",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not build a count query for SELECT INTO statements", () => {
|
||||
const result = buildCountQuerySql("SELECT * INTO copy_users FROM users WHERE active = 1", "sqlserver");
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "not_select" });
|
||||
});
|
||||
|
||||
test("rejects count query for unsupported database types", () => {
|
||||
const result = buildCountQuerySql("SELECT * FROM nodes", "neo4j");
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "unsupported" });
|
||||
});
|
||||
|
||||
test("rejects count query for multiple statements", () => {
|
||||
const result = buildCountQuerySql("SELECT 1; SELECT 2;", "postgres");
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "multi" });
|
||||
});
|
||||
|
||||
test("uses an agent result session for the first jdbc page", () => {
|
||||
const plan = buildQueryPaginationExecutionPlan({
|
||||
sql: "SELECT * FROM events",
|
||||
queryBaseSql: "SELECT * FROM events",
|
||||
databaseType: "oracle",
|
||||
pagination: { limit: 500, offset: 0 },
|
||||
useAgentCursor: true,
|
||||
});
|
||||
|
||||
assert.equal(plan.sqlToExecute, "SELECT * FROM events");
|
||||
assert.equal(plan.pageLimit, 500);
|
||||
assert.equal(plan.pageOffset, 0);
|
||||
assert.equal(plan.pageSql, undefined);
|
||||
assert.equal(plan.useAgentResultSession, true);
|
||||
});
|
||||
|
||||
test("keeps using an agent result session for sequential jdbc pages", () => {
|
||||
const plan = buildQueryPaginationExecutionPlan({
|
||||
sql: "SELECT * FROM events",
|
||||
queryBaseSql: "SELECT * FROM events",
|
||||
databaseType: "oracle",
|
||||
pagination: { limit: 500, offset: 500, sessionId: "session-1" },
|
||||
useAgentCursor: true,
|
||||
});
|
||||
|
||||
assert.equal(plan.sqlToExecute, "SELECT * FROM events");
|
||||
assert.equal(plan.pageLimit, 500);
|
||||
assert.equal(plan.pageOffset, 500);
|
||||
assert.equal(plan.useAgentResultSession, true);
|
||||
});
|
||||
|
||||
test("uses SQL pagination instead of jdbc cursor for random agent page jumps", () => {
|
||||
const plan = buildQueryPaginationExecutionPlan({
|
||||
sql: "SELECT * FROM events",
|
||||
queryBaseSql: "SELECT * FROM events",
|
||||
databaseType: "oracle",
|
||||
pagination: { limit: 500, offset: 1500 },
|
||||
useAgentCursor: true,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
plan.sqlToExecute,
|
||||
'SELECT * FROM (SELECT * FROM events) "dbx_page" OFFSET 1500 ROWS FETCH FIRST 500 ROWS ONLY',
|
||||
);
|
||||
assert.equal(plan.pageSql, plan.sqlToExecute);
|
||||
assert.equal(plan.pageLimit, 500);
|
||||
assert.equal(plan.pageOffset, 1500);
|
||||
assert.equal(plan.useAgentResultSession, false);
|
||||
});
|
||||
|
||||
test("executes SELECT INTO without pagination or count wrapping", () => {
|
||||
const sql = "SELECT * INTO DingTalk_SyncTask FROM YonSuite_SyncTask WHERE 1=2";
|
||||
const plan = buildQueryPaginationExecutionPlan({
|
||||
sql,
|
||||
queryBaseSql: sql,
|
||||
databaseType: "sqlserver",
|
||||
pagination: { limit: 500, offset: 0 },
|
||||
useAgentCursor: false,
|
||||
});
|
||||
|
||||
assert.equal(plan.sqlToExecute, sql);
|
||||
assert.equal(plan.pageSql, undefined);
|
||||
assert.equal(plan.countSql, undefined);
|
||||
assert.equal(plan.pageLimit, undefined);
|
||||
assert.equal(plan.pageOffset, undefined);
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildSortedQuerySql } from "../../apps/desktop/src/lib/queryResultSort.ts";
|
||||
|
||||
test("wraps a single select query with outer order by", () => {
|
||||
const result = buildSortedQuerySql("SELECT id, name FROM users;", "postgres", ["id", "name"], 1, "name", "asc");
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: 'SELECT * FROM (SELECT id, name FROM users) t("id", "name") ORDER BY "name" ASC;',
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves complex select body when wrapping sort sql", () => {
|
||||
const result = buildSortedQuerySql("SELECT id FROM users WHERE status = 'A'", "mysql", ["id"], 0, "id", "desc");
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "SELECT * FROM (SELECT id FROM users WHERE status = 'A') t ORDER BY `id` DESC;",
|
||||
});
|
||||
});
|
||||
|
||||
test("sorts MySQL wildcard query results without derived column alias list", () => {
|
||||
const result = buildSortedQuerySql(
|
||||
"SELECT * FROM admin LIMIT 100;",
|
||||
"mysql",
|
||||
["id", "guid", "role_guid", "login_name", "password"],
|
||||
3,
|
||||
"login_name",
|
||||
"asc",
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "SELECT * FROM (SELECT * FROM admin LIMIT 100) t ORDER BY `login_name` ASC;",
|
||||
});
|
||||
});
|
||||
|
||||
test("assigns unique aliases for duplicate result column names", () => {
|
||||
const result = buildSortedQuerySql(
|
||||
"SELECT c.id, m.id FROM t_campaign c LEFT JOIN t_campaign_mdf m ON m.campaign_id = c.id",
|
||||
"postgres",
|
||||
["id", "id"],
|
||||
1,
|
||||
"id",
|
||||
"asc",
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: 'SELECT * FROM (SELECT c.id, m.id FROM t_campaign c LEFT JOIN t_campaign_mdf m ON m.campaign_id = c.id) t("id", "id_2") ORDER BY "id_2" ASC;',
|
||||
});
|
||||
});
|
||||
|
||||
test("strips top-level SQL Server order by before wrapping result sort query", () => {
|
||||
const result = buildSortedQuerySql(
|
||||
"SELECT id, name FROM users ORDER BY id DESC",
|
||||
"sqlserver",
|
||||
["id", "name"],
|
||||
1,
|
||||
"name",
|
||||
"asc",
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "SELECT * FROM (SELECT id, name FROM users) t([id], [name]) ORDER BY [name] ASC;",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects multiple statements for result sorting", () => {
|
||||
const result = buildSortedQuerySql("SELECT 1; SELECT 2;", "postgres", ["id"], 0, "id", "asc");
|
||||
assert.deepEqual(result, { ok: false, reason: "multi" });
|
||||
});
|
||||
|
||||
test("rejects cte queries for result sorting", () => {
|
||||
const result = buildSortedQuerySql("WITH cte AS (SELECT 1) SELECT * FROM cte", "postgres", ["id"], 0, "id", "asc");
|
||||
assert.deepEqual(result, { ok: false, reason: "with" });
|
||||
});
|
||||
|
||||
test("rejects non select statements for result sorting", () => {
|
||||
const result = buildSortedQuerySql("UPDATE users SET name = 'A'", "postgres", ["name"], 0, "name", "asc");
|
||||
assert.deepEqual(result, { ok: false, reason: "not_select" });
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8");
|
||||
const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8");
|
||||
const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8");
|
||||
const queryStoreSource = readFileSync("apps/desktop/src/stores/queryStore.ts", "utf8");
|
||||
const dataGridActionsSource = readFileSync("apps/desktop/src/composables/useDataGridActions.ts", "utf8");
|
||||
const rustCoreLibSource = readFileSync("crates/dbx-core/src/lib.rs", "utf8");
|
||||
const rustQueryResultSqlSource = readFileSync("crates/dbx-core/src/query_result_sql.rs", "utf8");
|
||||
const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8");
|
||||
const webMainSource = readFileSync("crates/dbx-web/src/main.rs", "utf8");
|
||||
|
||||
test("shared API exposes backend query result SQL builders", () => {
|
||||
assert.match(
|
||||
apiSource,
|
||||
/export const prepareQueryPaginationExecutionPlan = forward\("prepareQueryPaginationExecutionPlan"\)/,
|
||||
);
|
||||
assert.match(apiSource, /export const buildSortedQuerySql = forward\("buildSortedQuerySql"\)/);
|
||||
|
||||
assert.match(tauriSource, /invoke\("prepare_query_pagination_execution_plan"/);
|
||||
assert.match(tauriSource, /invoke\("build_sorted_query_sql"/);
|
||||
|
||||
assert.match(httpSource, /\/api\/query\/prepare-pagination-plan/);
|
||||
assert.match(httpSource, /\/api\/query\/build-sorted-sql/);
|
||||
});
|
||||
|
||||
test("frontend query result pagination and sorting delegate to backend APIs", () => {
|
||||
assert.match(queryStoreSource, /await api\.prepareQueryPaginationExecutionPlan\(/);
|
||||
assert.match(dataGridActionsSource, /await api\.buildSortedQuerySql\(/);
|
||||
|
||||
assert.doesNotMatch(queryStoreSource, /queryResultPagination/);
|
||||
assert.doesNotMatch(dataGridActionsSource, /queryResultSort/);
|
||||
});
|
||||
|
||||
test("Rust backends register query result SQL builders", () => {
|
||||
assert.match(rustCoreLibSource, /pub mod query_result_sql/);
|
||||
assert.match(rustQueryResultSqlSource, /pub fn build_query_pagination_execution_plan/);
|
||||
assert.match(rustQueryResultSqlSource, /pub fn build_sorted_query_sql/);
|
||||
assert.match(tauriLibSource, /commands::query::prepare_query_pagination_execution_plan/);
|
||||
assert.match(tauriLibSource, /commands::query::build_sorted_query_sql/);
|
||||
assert.match(webMainSource, /\/query\/prepare-pagination-plan/);
|
||||
assert.match(webMainSource, /\/query\/build-sorted-sql/);
|
||||
});
|
||||
@@ -81,6 +81,19 @@ test("evicting cached tab results releases multi-result payloads and sessions",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/query/prepare-pagination-plan") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
sqlToExecute: body.options.sql,
|
||||
useAgentResultSession: false,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
return new Response("unexpected request", { status: 500 });
|
||||
}) as typeof fetch;
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8");
|
||||
const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8");
|
||||
const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8");
|
||||
const executionTargetSource = readFileSync("apps/desktop/src/lib/sqlExecutionTarget.ts", "utf8");
|
||||
const sqlSource = readFileSync("crates/dbx-core/src/sql.rs", "utf8");
|
||||
const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8");
|
||||
const webMainSource = readFileSync("crates/dbx-web/src/main.rs", "utf8");
|
||||
|
||||
test("shared API exposes backend current statement resolution", () => {
|
||||
assert.match(apiSource, /export const findStatementAtCursor = forward\("findStatementAtCursor"\)/);
|
||||
assert.match(tauriSource, /export async function findStatementAtCursor\(/);
|
||||
assert.match(tauriSource, /invoke\("find_statement_at_cursor"/);
|
||||
assert.match(httpSource, /export async function findStatementAtCursor\(/);
|
||||
assert.match(httpSource, /\/api\/query\/find-statement-at-cursor/);
|
||||
});
|
||||
|
||||
test("execution target prefers backend current statement resolution", () => {
|
||||
assert.match(executionTargetSource, /api\.findStatementAtCursor\(fullSql, options\.cursorPos\)/);
|
||||
assert.doesNotMatch(executionTargetSource, /sqlStatementSplit/);
|
||||
assert.doesNotMatch(executionTargetSource, /return findStatementAtCursor\(fullSql, options\.cursorPos\)/);
|
||||
});
|
||||
|
||||
test("Rust backends register current statement resolution", () => {
|
||||
assert.match(sqlSource, /pub fn find_statement_at_cursor/);
|
||||
assert.match(tauriLibSource, /commands::query::find_statement_at_cursor/);
|
||||
assert.match(webMainSource, /\/query\/find-statement-at-cursor/);
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { findStatementAtCursor } from "../../apps/desktop/src/lib/sqlStatementSplit.ts";
|
||||
|
||||
test("finds single statement", () => {
|
||||
assert.equal(findStatementAtCursor("SELECT 1", 3), "SELECT 1");
|
||||
});
|
||||
|
||||
test("finds first statement before semicolon", () => {
|
||||
const sql = "SELECT 1; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 3), "SELECT 1");
|
||||
});
|
||||
|
||||
test("finds second statement after semicolon", () => {
|
||||
const sql = "SELECT 1; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 12), "SELECT 2");
|
||||
});
|
||||
|
||||
test("cursor at semicolon belongs to first statement", () => {
|
||||
const sql = "SELECT 1; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 9), "SELECT 1");
|
||||
});
|
||||
|
||||
test("cursor at end belongs to last statement", () => {
|
||||
const sql = "SELECT 1; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 18), "SELECT 2");
|
||||
});
|
||||
|
||||
test("skips semicolons inside single-quoted strings", () => {
|
||||
const sql = "SELECT ';'; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 15), "SELECT 2");
|
||||
assert.equal(findStatementAtCursor(sql, 3), "SELECT ';'");
|
||||
});
|
||||
|
||||
test("skips semicolons inside double-quoted identifiers", () => {
|
||||
const sql = 'SELECT ";"; SELECT 2';
|
||||
assert.equal(findStatementAtCursor(sql, 3), 'SELECT ";"');
|
||||
});
|
||||
|
||||
test("skips semicolons inside line comments", () => {
|
||||
const sql = "SELECT 1 -- ; comment\n; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 3), "SELECT 1 -- ; comment");
|
||||
assert.equal(findStatementAtCursor(sql, 25), "SELECT 2");
|
||||
});
|
||||
|
||||
test("skips semicolons inside block comments", () => {
|
||||
const sql = "SELECT 1 /* ; */ ; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 3), "SELECT 1 /* ; */");
|
||||
assert.equal(findStatementAtCursor(sql, 22), "SELECT 2");
|
||||
});
|
||||
|
||||
test("handles multiple statements", () => {
|
||||
const sql = "INSERT INTO t VALUES (1); UPDATE t SET x = 2; DELETE FROM t";
|
||||
assert.equal(findStatementAtCursor(sql, 5), "INSERT INTO t VALUES (1)");
|
||||
assert.equal(findStatementAtCursor(sql, 30), "UPDATE t SET x = 2");
|
||||
assert.equal(findStatementAtCursor(sql, 50), "DELETE FROM t");
|
||||
});
|
||||
|
||||
test("handles trailing semicolon", () => {
|
||||
const sql = "SELECT 1;";
|
||||
assert.equal(findStatementAtCursor(sql, 3), "SELECT 1");
|
||||
});
|
||||
|
||||
test("handles backtick-quoted identifiers", () => {
|
||||
const sql = "SELECT `a;b`; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 3), "SELECT `a;b`");
|
||||
});
|
||||
@@ -24,6 +24,8 @@ test("fetchTableDataForExport pages past the 10000 row export boundary", async (
|
||||
const exported = await fetchTableDataForExport({
|
||||
databaseType: "mysql",
|
||||
tableName: "users",
|
||||
buildPageSql: ({ limit, offset }) =>
|
||||
offset ? `SELECT * FROM \`users\` LIMIT ${limit} OFFSET ${offset};` : `SELECT * FROM \`users\` LIMIT ${limit};`,
|
||||
executePage: async (sql) => {
|
||||
sqls.push(sql);
|
||||
return pages.shift() ?? result([]);
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { DBX_ROWID_COLUMN } from "../../apps/desktop/src/lib/tableEditing.ts";
|
||||
import { buildTableSelectSql } from "../../apps/desktop/src/lib/tableSelectSql.ts";
|
||||
|
||||
test("builds a MySQL table WHERE query from search input", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "mysql",
|
||||
tableName: "users",
|
||||
primaryKeys: ["id"],
|
||||
whereInput: "where status = 'active'",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT * FROM `users` WHERE (status = 'active') ORDER BY `id` ASC LIMIT 100;");
|
||||
});
|
||||
|
||||
test("builds a schema-qualified PostgreSQL table WHERE query", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "orders",
|
||||
whereInput: "WHERE amount > 10",
|
||||
limit: 50,
|
||||
offset: 100,
|
||||
});
|
||||
|
||||
assert.equal(sql, 'SELECT * FROM "public"."orders" WHERE (amount > 10) LIMIT 50 OFFSET 100;');
|
||||
});
|
||||
|
||||
test("builds schema-qualified Trino table data queries", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "trino",
|
||||
schema: "tiny",
|
||||
tableName: "nation",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(sql, 'SELECT * FROM "tiny"."nation" LIMIT 100;');
|
||||
});
|
||||
|
||||
test("builds Hive table data queries with backtick identifiers", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "hive",
|
||||
tableName: "departments",
|
||||
primaryKeys: ["dept id"],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT * FROM `departments` ORDER BY `dept id` ASC LIMIT 100;");
|
||||
});
|
||||
|
||||
test("builds TDengine table data queries with backtick identifiers", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "tdengine",
|
||||
schema: "test_db",
|
||||
tableName: "meters",
|
||||
columns: ["ts", "current", "voltage", "location", "groupid"],
|
||||
primaryKeys: ["ts"],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sql,
|
||||
"SELECT tbname, `ts` AS `ts`, `current` AS `current`, `voltage` AS `voltage`, `location` AS `location`, `groupid` AS `groupid` FROM `test_db`.`meters` ORDER BY `ts` ASC LIMIT 100;",
|
||||
);
|
||||
});
|
||||
|
||||
test("builds Informix table data queries without database-qualified delimited identifiers", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "informix",
|
||||
schema: "testdb",
|
||||
tableName: "dbx_grid_edit_probe",
|
||||
primaryKeys: ["id"],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT * FROM dbx_grid_edit_probe ORDER BY id ASC LIMIT 100;");
|
||||
});
|
||||
|
||||
test("builds Access table data queries with backtick identifiers", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "access",
|
||||
tableName: "Order Details",
|
||||
primaryKeys: ["Order ID"],
|
||||
limit: 100,
|
||||
offset: 200,
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT * FROM `Order Details` ORDER BY `Order ID` ASC LIMIT 100 OFFSET 200;");
|
||||
});
|
||||
|
||||
test("qualifies JDBC table data default order columns with a table alias", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "jdbc",
|
||||
schema: "SJT_THEME",
|
||||
tableName: "BATCH_QUERY",
|
||||
primaryKeys: ["TABLE_NAME"],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(sql, 'SELECT * FROM "SJT_THEME"."BATCH_QUERY" dbx_t ORDER BY dbx_t."TABLE_NAME" ASC LIMIT 100;');
|
||||
});
|
||||
|
||||
test("expands Hive table data queries into aliased table columns", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "hive",
|
||||
tableName: "departments",
|
||||
columns: ["id", "name"],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT `id` AS `id`, `name` AS `name` FROM `departments` LIMIT 100;");
|
||||
});
|
||||
|
||||
test("builds SQL Server first page query with TOP for SQL Server 2008 compatibility", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "accounts",
|
||||
whereInput: "where id = 1",
|
||||
limit: 25,
|
||||
primaryKeys: ["id"],
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT TOP (25) * FROM [dbo].[accounts] WHERE (id = 1) ORDER BY [id] ASC");
|
||||
});
|
||||
|
||||
test("builds SQL Server first page query without a synthetic ORDER BY when no order is available", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "logs",
|
||||
columns: ["message"],
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT TOP (25) [message] FROM [dbo].[logs]");
|
||||
});
|
||||
|
||||
test("builds SQL Server later pages with ROW_NUMBER for SQL Server 2008 compatibility", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "sales",
|
||||
tableName: "orders",
|
||||
columns: ["order_id", "customer"],
|
||||
primaryKeys: ["order_id"],
|
||||
limit: 50,
|
||||
offset: 100,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sql,
|
||||
"WITH [dbx_page] AS (SELECT [order_id], [customer], ROW_NUMBER() OVER (ORDER BY [order_id] ASC) AS [__dbx_row_num] FROM [sales].[orders]) SELECT [order_id], [customer] FROM [dbx_page] WHERE [__dbx_row_num] > 100 AND [__dbx_row_num] <= 150 ORDER BY [__dbx_row_num]",
|
||||
);
|
||||
});
|
||||
|
||||
test("builds SQL Server pages with fallback order columns when there is no primary key", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "logs",
|
||||
columns: ["created_at", "message"],
|
||||
fallbackOrderColumns: ["created_at"],
|
||||
limit: 50,
|
||||
offset: 50,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sql,
|
||||
"WITH [dbx_page] AS (SELECT [created_at], [message], ROW_NUMBER() OVER (ORDER BY [created_at] ASC) AS [__dbx_row_num] FROM [dbo].[logs]) SELECT [created_at], [message] FROM [dbx_page] WHERE [__dbx_row_num] > 50 AND [__dbx_row_num] <= 100 ORDER BY [__dbx_row_num]",
|
||||
);
|
||||
});
|
||||
|
||||
test("builds Oracle table data queries with ROWID for keyless editing", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "oracle",
|
||||
schema: "DBXTEST",
|
||||
tableName: "DBX_LOAD_TABLE_006",
|
||||
primaryKeys: [DBX_ROWID_COLUMN],
|
||||
includeRowId: true,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sql,
|
||||
`SELECT ROWIDTOCHAR(t.ROWID) AS "__DBX_ROWID", t.* FROM "DBXTEST"."DBX_LOAD_TABLE_006" t ORDER BY t.ROWID ASC FETCH FIRST 100 ROWS ONLY`,
|
||||
);
|
||||
});
|
||||
|
||||
test("builds Neo4j table data queries as Cypher label matches", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "neo4j",
|
||||
tableName: "Employee",
|
||||
primaryKeys: ["id"],
|
||||
limit: 100,
|
||||
offset: 200,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sql,
|
||||
"MATCH (n:`Employee`) RETURN elementId(n) AS `__DBX_ELEMENT_ID`, n ORDER BY n.`id` ASC SKIP 200 LIMIT 100;",
|
||||
);
|
||||
});
|
||||
|
||||
test("expands Neo4j table data queries into node property columns", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "neo4j",
|
||||
tableName: "Employee",
|
||||
columns: ["id", "first name", "role"],
|
||||
primaryKeys: ["id"],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sql,
|
||||
"MATCH (n:`Employee`) RETURN elementId(n) AS `__DBX_ELEMENT_ID`, n.`id` AS `id`, n.`first name` AS `first name`, n.`role` AS `role` ORDER BY n.`id` ASC LIMIT 100;",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8");
|
||||
const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8");
|
||||
const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8");
|
||||
const tableSelectSource = readFileSync("apps/desktop/src/lib/tableSelectSql.ts", "utf8");
|
||||
const dataGridActionsSource = readFileSync("apps/desktop/src/composables/useDataGridActions.ts", "utf8");
|
||||
const navigationTargetsSource = readFileSync("apps/desktop/src/composables/useNavigationTargets.ts", "utf8");
|
||||
const dataGridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const rustSqlDialectSource = readFileSync("crates/dbx-core/src/sql_dialect.rs", "utf8");
|
||||
const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8");
|
||||
const webMainSource = readFileSync("crates/dbx-web/src/main.rs", "utf8");
|
||||
|
||||
test("shared API exposes backend table select SQL builder", () => {
|
||||
assert.match(apiSource, /export const buildTableSelectSql = forward\("buildTableSelectSql"\)/);
|
||||
assert.match(tauriSource, /invoke\("build_table_select_sql"/);
|
||||
assert.match(httpSource, /\/api\/query\/build-table-select-sql/);
|
||||
});
|
||||
|
||||
test("frontend table select builder delegates to backend API", () => {
|
||||
assert.match(tableSelectSource, /return api\.buildTableSelectSql\(options\)/);
|
||||
assert.doesNotMatch(tableSelectSource, /function buildSqlServerTableSelectSql/);
|
||||
assert.doesNotMatch(tableSelectSource, /function buildNeo4jTableSelectSql/);
|
||||
assert.doesNotMatch(tableSelectSource, /ROW_NUMBER\(\) OVER/);
|
||||
});
|
||||
|
||||
test("table data callers await backend table select SQL", () => {
|
||||
assert.match(dataGridActionsSource, /await buildTableSql\(tab/);
|
||||
assert.match(navigationTargetsSource, /await buildTableSelectSql\(/);
|
||||
assert.match(dataGridSource, /await buildTableSelectSql\(/);
|
||||
});
|
||||
|
||||
test("Rust backends register table select SQL builder", () => {
|
||||
assert.match(rustSqlDialectSource, /pub fn build_table_data_select_sql/);
|
||||
assert.match(tauriLibSource, /commands::query::build_table_select_sql/);
|
||||
assert.match(webMainSource, /\/query\/build-table-select-sql/);
|
||||
});
|
||||
@@ -1,969 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildCreateTableSql,
|
||||
buildTableStructureChangeSql,
|
||||
type EditableStructureColumn,
|
||||
type EditableStructureIndex,
|
||||
} from "../../apps/desktop/src/lib/tableStructureEditorSql.ts";
|
||||
|
||||
function column(overrides: Partial<EditableStructureColumn>): EditableStructureColumn {
|
||||
return {
|
||||
id: overrides.id ?? "col",
|
||||
name: overrides.name ?? "name",
|
||||
dataType: overrides.dataType ?? "varchar(255)",
|
||||
isNullable: overrides.isNullable ?? true,
|
||||
defaultValue: overrides.defaultValue ?? "",
|
||||
comment: overrides.comment ?? "",
|
||||
isPrimaryKey: overrides.isPrimaryKey ?? false,
|
||||
original: overrides.original,
|
||||
originalPosition: overrides.originalPosition,
|
||||
markedForDrop: overrides.markedForDrop ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function index(overrides: Partial<EditableStructureIndex>): EditableStructureIndex {
|
||||
return {
|
||||
id: overrides.id ?? "idx",
|
||||
name: overrides.name ?? "idx_name",
|
||||
columns: overrides.columns ?? ["name"],
|
||||
isUnique: overrides.isUnique ?? false,
|
||||
isPrimary: overrides.isPrimary ?? false,
|
||||
filter: overrides.filter ?? "",
|
||||
indexType: overrides.indexType ?? "",
|
||||
includedColumns: overrides.includedColumns ?? [],
|
||||
comment: overrides.comment ?? "",
|
||||
original: overrides.original,
|
||||
markedForDrop: overrides.markedForDrop ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
test("builds MySQL column and index change statements", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "mysql",
|
||||
tableName: "users",
|
||||
columns: [
|
||||
column({
|
||||
id: "name",
|
||||
name: "display_name",
|
||||
dataType: "varchar(120)",
|
||||
isNullable: false,
|
||||
defaultValue: "'guest'",
|
||||
comment: "Shown name",
|
||||
original: {
|
||||
name: "name",
|
||||
data_type: "varchar(80)",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
comment: "",
|
||||
},
|
||||
}),
|
||||
column({ id: "email", name: "email", dataType: "varchar(255)", isNullable: false }),
|
||||
column({
|
||||
id: "legacy",
|
||||
name: "legacy",
|
||||
markedForDrop: true,
|
||||
original: {
|
||||
name: "legacy",
|
||||
data_type: "text",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
indexes: [
|
||||
index({
|
||||
id: "old",
|
||||
name: "idx_old",
|
||||
markedForDrop: true,
|
||||
original: { name: "idx_old", columns: ["name"], is_unique: false, is_primary: false },
|
||||
}),
|
||||
index({ id: "email_idx", name: "uniq_users_email", columns: ["email"], isUnique: true }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
"ALTER TABLE `users` CHANGE COLUMN `name` `display_name` varchar(120) NOT NULL DEFAULT 'guest' COMMENT 'Shown name';",
|
||||
"ALTER TABLE `users` ADD COLUMN `email` varchar(255) NOT NULL;",
|
||||
"ALTER TABLE `users` DROP COLUMN `legacy`;",
|
||||
"DROP INDEX `idx_old` ON `users`;",
|
||||
"CREATE UNIQUE INDEX `uniq_users_email` ON `users` (`email`);",
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds MySQL column reorder statements", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "mysql",
|
||||
tableName: "users",
|
||||
columns: [
|
||||
column({
|
||||
id: "id",
|
||||
name: "id",
|
||||
dataType: "int",
|
||||
isNullable: false,
|
||||
isPrimaryKey: true,
|
||||
originalPosition: 0,
|
||||
original: {
|
||||
name: "id",
|
||||
data_type: "int",
|
||||
is_nullable: false,
|
||||
column_default: null,
|
||||
is_primary_key: true,
|
||||
extra: null,
|
||||
},
|
||||
}),
|
||||
column({
|
||||
id: "email",
|
||||
name: "email",
|
||||
dataType: "varchar(255)",
|
||||
originalPosition: 2,
|
||||
original: {
|
||||
name: "email",
|
||||
data_type: "varchar(255)",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
},
|
||||
}),
|
||||
column({
|
||||
id: "name",
|
||||
name: "display_name",
|
||||
dataType: "varchar(120)",
|
||||
originalPosition: 1,
|
||||
original: {
|
||||
name: "name",
|
||||
data_type: "varchar(80)",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
indexes: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
"ALTER TABLE `users` MODIFY COLUMN `email` varchar(255) AFTER `id`;",
|
||||
"ALTER TABLE `users` CHANGE COLUMN `name` `display_name` varchar(120) AFTER `email`;",
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds PostgreSQL rename, type, default, comment, and index statements", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "accounts",
|
||||
columns: [
|
||||
column({
|
||||
id: "status",
|
||||
name: "account_status",
|
||||
dataType: "text",
|
||||
isNullable: false,
|
||||
defaultValue: "'active'",
|
||||
comment: "Current status",
|
||||
original: {
|
||||
name: "status",
|
||||
data_type: "varchar",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
comment: "",
|
||||
},
|
||||
}),
|
||||
],
|
||||
indexes: [index({ id: "new", name: "idx_accounts_status", columns: ["account_status"] })],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'ALTER TABLE "public"."accounts" RENAME COLUMN "status" TO "account_status";',
|
||||
'ALTER TABLE "public"."accounts" ALTER COLUMN "account_status" TYPE text;',
|
||||
'ALTER TABLE "public"."accounts" ALTER COLUMN "account_status" SET NOT NULL;',
|
||||
'ALTER TABLE "public"."accounts" ALTER COLUMN "account_status" SET DEFAULT \'active\';',
|
||||
'COMMENT ON COLUMN "public"."accounts"."account_status" IS \'Current status\';',
|
||||
'CREATE INDEX "idx_accounts_status" ON "public"."accounts" ("account_status");',
|
||||
]);
|
||||
});
|
||||
|
||||
test("warns when SQLite cannot safely alter existing column attributes", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "sqlite",
|
||||
tableName: "notes",
|
||||
columns: [
|
||||
column({
|
||||
id: "title",
|
||||
name: "title",
|
||||
dataType: "text",
|
||||
isNullable: false,
|
||||
original: {
|
||||
name: "title",
|
||||
data_type: "varchar(100)",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
},
|
||||
}),
|
||||
column({ id: "body", name: "body", dataType: "text", isNullable: true }),
|
||||
],
|
||||
indexes: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.statements, ['ALTER TABLE "notes" ADD COLUMN "body" text;']);
|
||||
assert.deepEqual(result.warnings, [
|
||||
'SQLite cannot safely alter existing column "title" without rebuilding the table.',
|
||||
]);
|
||||
});
|
||||
|
||||
test("quotes SQL Server table, column, and index names with brackets", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "users",
|
||||
columns: [column({ id: "email", name: "email", dataType: "nvarchar(255)", isNullable: false })],
|
||||
indexes: [index({ id: "idx", name: "idx_users_email", columns: ["email"] })],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
"ALTER TABLE [dbo].[users] ADD [email] nvarchar(255) NOT NULL;",
|
||||
"CREATE INDEX [idx_users_email] ON [dbo].[users] ([email]);",
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds DuckDB create table statements", () => {
|
||||
const result = buildCreateTableSql({
|
||||
databaseType: "duckdb",
|
||||
tableName: "events",
|
||||
columns: [
|
||||
column({ id: "name", name: "name", dataType: "VARCHAR", isNullable: false }),
|
||||
column({ id: "created_at", name: "created_at", dataType: "TIMESTAMP", defaultValue: "current_timestamp" }),
|
||||
],
|
||||
indexes: [index({ id: "idx_name", name: "idx_events_name", columns: ["name"] })],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'CREATE TABLE "events" (\n "name" VARCHAR NOT NULL,\n "created_at" TIMESTAMP DEFAULT current_timestamp\n);',
|
||||
'CREATE INDEX "idx_events_name" ON "events" ("name");',
|
||||
]);
|
||||
});
|
||||
|
||||
test("PostgreSQL index with INCLUDE clause", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "orders",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_orders_status",
|
||||
columns: ["status"],
|
||||
includedColumns: ["total", "created_at"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'CREATE INDEX "idx_orders_status" ON "public"."orders" ("status") INCLUDE ("total", "created_at");',
|
||||
]);
|
||||
});
|
||||
|
||||
test("PostgreSQL index with USING clause (index type)", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "docs",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_docs_body",
|
||||
columns: ["body"],
|
||||
indexType: "GIN",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, ['CREATE INDEX "idx_docs_body" ON "public"."docs" USING GIN ("body");']);
|
||||
});
|
||||
|
||||
test("PostgreSQL index with WHERE filter", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_users_active",
|
||||
columns: ["email"],
|
||||
filter: "deleted_at IS NULL",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'CREATE INDEX "idx_users_active" ON "public"."users" ("email") WHERE deleted_at IS NULL;',
|
||||
]);
|
||||
});
|
||||
|
||||
test("PostgreSQL index with COMMENT", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_users_email",
|
||||
columns: ["email"],
|
||||
comment: "Fast lookup by email",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'CREATE INDEX "idx_users_email" ON "public"."users" ("email");',
|
||||
"COMMENT ON INDEX \"idx_users_email\" IS 'Fast lookup by email';",
|
||||
]);
|
||||
});
|
||||
|
||||
test("PostgreSQL index with single quote in comment is escaped", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_users_email",
|
||||
columns: ["email"],
|
||||
comment: "User's primary email",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'CREATE INDEX "idx_users_email" ON "public"."users" ("email");',
|
||||
"COMMENT ON INDEX \"idx_users_email\" IS 'User''s primary email';",
|
||||
]);
|
||||
});
|
||||
|
||||
test("PostgreSQL index with all options combined (unique + type + include + filter + comment)", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "orders",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_orders_covering",
|
||||
columns: ["user_id", "status"],
|
||||
isUnique: true,
|
||||
indexType: "BTREE",
|
||||
includedColumns: ["total"],
|
||||
filter: "status = 'active'",
|
||||
comment: "Covering index for active orders",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'CREATE UNIQUE INDEX "idx_orders_covering" ON "public"."orders" USING BTREE ("user_id", "status") INCLUDE ("total") WHERE status = \'active\';',
|
||||
"COMMENT ON INDEX \"idx_orders_covering\" IS 'Covering index for active orders';",
|
||||
]);
|
||||
});
|
||||
|
||||
test("SQL Server index with type prefix", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "logs",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_logs_message",
|
||||
columns: ["message"],
|
||||
indexType: "CLUSTERED",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, ["CREATE CLUSTERED INDEX [idx_logs_message] ON [dbo].[logs] ([message]);"]);
|
||||
});
|
||||
|
||||
test("SQL Server index with INCLUDE clause", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "orders",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_orders_status",
|
||||
columns: ["status"],
|
||||
includedColumns: ["total", "created_at"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
"CREATE INDEX [idx_orders_status] ON [dbo].[orders] ([status]) INCLUDE ([total], [created_at]);",
|
||||
]);
|
||||
});
|
||||
|
||||
test("SQL Server index with type + include combined", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "orders",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_orders_covering",
|
||||
columns: ["user_id"],
|
||||
indexType: "NONCLUSTERED",
|
||||
includedColumns: ["total", "status"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
"CREATE NONCLUSTERED INDEX [idx_orders_covering] ON [dbo].[orders] ([user_id]) INCLUDE ([total], [status]);",
|
||||
]);
|
||||
});
|
||||
|
||||
test("MySQL index uses BTREE/HASH with USING and still omits unsupported INCLUDE and WHERE", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "mysql",
|
||||
tableName: "orders",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_orders_status",
|
||||
columns: ["status"],
|
||||
indexType: "BTREE",
|
||||
includedColumns: ["total"],
|
||||
filter: "deleted = 0",
|
||||
comment: "Some comment",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, ["CREATE INDEX `idx_orders_status` USING BTREE ON `orders` (`status`);"]);
|
||||
});
|
||||
|
||||
test("MySQL fulltext and spatial index types are emitted as index prefixes", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "mysql",
|
||||
tableName: "docs",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "fulltext",
|
||||
name: "idx_docs_body",
|
||||
columns: ["body"],
|
||||
indexType: "FULLTEXT",
|
||||
}),
|
||||
index({
|
||||
id: "spatial",
|
||||
name: "idx_docs_location",
|
||||
columns: ["location"],
|
||||
indexType: "SPATIAL",
|
||||
}),
|
||||
index({
|
||||
id: "rtree",
|
||||
name: "idx_docs_shape",
|
||||
columns: ["shape"],
|
||||
indexType: "RTREE",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
"CREATE FULLTEXT INDEX `idx_docs_body` ON `docs` (`body`);",
|
||||
"CREATE SPATIAL INDEX `idx_docs_location` ON `docs` (`location`);",
|
||||
"CREATE SPATIAL INDEX `idx_docs_shape` ON `docs` (`shape`);",
|
||||
]);
|
||||
});
|
||||
|
||||
test("changed existing indexes are rebuilt with drop and create when supported", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "existing",
|
||||
name: "uniq_users_email",
|
||||
columns: ["email"],
|
||||
isUnique: true,
|
||||
indexType: "BTREE",
|
||||
original: {
|
||||
name: "idx_users_name",
|
||||
columns: ["name"],
|
||||
is_unique: false,
|
||||
is_primary: false,
|
||||
index_type: "BTREE",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'DROP INDEX "public"."idx_users_name";',
|
||||
'CREATE UNIQUE INDEX "uniq_users_email" ON "public"."users" USING BTREE ("email");',
|
||||
]);
|
||||
});
|
||||
|
||||
test("changed existing MySQL indexes are rebuilt with MySQL index type syntax", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "mysql",
|
||||
tableName: "docs",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "existing",
|
||||
name: "idx_docs_body",
|
||||
columns: ["body"],
|
||||
indexType: "FULLTEXT",
|
||||
original: {
|
||||
name: "idx_docs_title",
|
||||
columns: ["title"],
|
||||
is_unique: false,
|
||||
is_primary: false,
|
||||
index_type: "BTREE",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
"DROP INDEX `idx_docs_title` ON `docs`;",
|
||||
"CREATE FULLTEXT INDEX `idx_docs_body` ON `docs` (`body`);",
|
||||
]);
|
||||
});
|
||||
|
||||
test("SQLite index with filter (partial index)", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "sqlite",
|
||||
tableName: "users",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_users_active",
|
||||
columns: ["email"],
|
||||
filter: "deleted_at IS NULL",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'CREATE INDEX "idx_users_active" ON "users" ("email") WHERE deleted_at IS NULL;',
|
||||
]);
|
||||
});
|
||||
|
||||
test("index with empty name and columns produces warnings and no statements", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
columns: [],
|
||||
indexes: [index({ id: "empty", name: "", columns: [] })],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, ["Index name cannot be empty.", 'Index "(new)" needs at least one column.']);
|
||||
assert.deepEqual(result.statements, []);
|
||||
});
|
||||
|
||||
test("builds Oracle column, comment, and index change statements", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "oracle",
|
||||
schema: "HR",
|
||||
tableName: "EMPLOYEES",
|
||||
columns: [
|
||||
column({
|
||||
id: "status",
|
||||
name: "EMP_STATUS",
|
||||
dataType: "VARCHAR2(20)",
|
||||
isNullable: false,
|
||||
defaultValue: "'ACTIVE'",
|
||||
comment: "Employment status",
|
||||
original: {
|
||||
name: "STATUS",
|
||||
data_type: "VARCHAR2(10)",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
comment: "",
|
||||
},
|
||||
}),
|
||||
column({ id: "email", name: "EMAIL", dataType: "VARCHAR2(255)", isNullable: true, comment: "Work email" }),
|
||||
column({
|
||||
id: "legacy",
|
||||
name: "LEGACY_CODE",
|
||||
markedForDrop: true,
|
||||
original: {
|
||||
name: "LEGACY_CODE",
|
||||
data_type: "VARCHAR2(20)",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
indexes: [
|
||||
index({
|
||||
id: "old",
|
||||
name: "IDX_EMP_OLD",
|
||||
markedForDrop: true,
|
||||
original: { name: "IDX_EMP_OLD", columns: ["STATUS"], is_unique: false, is_primary: false },
|
||||
}),
|
||||
index({ id: "new", name: "IDX_EMP_STATUS", columns: ["EMP_STATUS"], isUnique: true }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'ALTER TABLE "HR"."EMPLOYEES" RENAME COLUMN "STATUS" TO "EMP_STATUS";',
|
||||
'ALTER TABLE "HR"."EMPLOYEES" MODIFY ("EMP_STATUS" VARCHAR2(20));',
|
||||
'ALTER TABLE "HR"."EMPLOYEES" MODIFY ("EMP_STATUS" NOT NULL);',
|
||||
'ALTER TABLE "HR"."EMPLOYEES" MODIFY ("EMP_STATUS" DEFAULT \'ACTIVE\');',
|
||||
'COMMENT ON COLUMN "HR"."EMPLOYEES"."EMP_STATUS" IS \'Employment status\';',
|
||||
'ALTER TABLE "HR"."EMPLOYEES" ADD ("EMAIL" VARCHAR2(255));',
|
||||
'COMMENT ON COLUMN "HR"."EMPLOYEES"."EMAIL" IS \'Work email\';',
|
||||
'ALTER TABLE "HR"."EMPLOYEES" DROP COLUMN "LEGACY_CODE";',
|
||||
'DROP INDEX "HR"."IDX_EMP_OLD";',
|
||||
'CREATE UNIQUE INDEX "IDX_EMP_STATUS" ON "HR"."EMPLOYEES" ("EMP_STATUS");',
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds Dameng existing column and create table statements", () => {
|
||||
const change = buildTableStructureChangeSql({
|
||||
databaseType: "dameng",
|
||||
schema: "SYSDBA",
|
||||
tableName: "USERS",
|
||||
columns: [
|
||||
column({
|
||||
id: "name",
|
||||
name: "DISPLAY_NAME",
|
||||
dataType: "VARCHAR(120)",
|
||||
isNullable: true,
|
||||
defaultValue: "",
|
||||
comment: "",
|
||||
original: {
|
||||
name: "NAME",
|
||||
data_type: "VARCHAR(80)",
|
||||
is_nullable: false,
|
||||
column_default: "'guest'",
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
comment: "Old name",
|
||||
},
|
||||
}),
|
||||
],
|
||||
indexes: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(change.warnings, []);
|
||||
assert.deepEqual(change.statements, [
|
||||
'ALTER TABLE "SYSDBA"."USERS" RENAME COLUMN "NAME" TO "DISPLAY_NAME";',
|
||||
'ALTER TABLE "SYSDBA"."USERS" MODIFY ("DISPLAY_NAME" VARCHAR(120));',
|
||||
'ALTER TABLE "SYSDBA"."USERS" MODIFY ("DISPLAY_NAME" NULL);',
|
||||
'ALTER TABLE "SYSDBA"."USERS" MODIFY ("DISPLAY_NAME" DEFAULT NULL);',
|
||||
'COMMENT ON COLUMN "SYSDBA"."USERS"."DISPLAY_NAME" IS NULL;',
|
||||
]);
|
||||
|
||||
const create = buildCreateTableSql({
|
||||
databaseType: "dameng",
|
||||
schema: "SYSDBA",
|
||||
tableName: "USERS",
|
||||
columns: [
|
||||
column({ id: "id", name: "ID", dataType: "NUMBER", isNullable: false, isPrimaryKey: true }),
|
||||
column({ id: "name", name: "NAME", dataType: "VARCHAR(120)", isNullable: false, comment: "Display name" }),
|
||||
],
|
||||
indexes: [index({ id: "idx", name: "IDX_USERS_NAME", columns: ["NAME"] })],
|
||||
});
|
||||
|
||||
assert.deepEqual(create.warnings, []);
|
||||
assert.deepEqual(create.statements, [
|
||||
'CREATE TABLE "SYSDBA"."USERS" (\n "ID" NUMBER,\n "NAME" VARCHAR(120) NOT NULL,\n PRIMARY KEY ("ID")\n);',
|
||||
'COMMENT ON COLUMN "SYSDBA"."USERS"."NAME" IS \'Display name\';',
|
||||
'CREATE INDEX "IDX_USERS_NAME" ON "SYSDBA"."USERS" ("NAME");',
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds GaussDB statements with PostgreSQL-compatible DDL", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "gaussdb",
|
||||
schema: "public",
|
||||
tableName: "accounts",
|
||||
columns: [
|
||||
column({
|
||||
id: "status",
|
||||
name: "account_status",
|
||||
dataType: "text",
|
||||
isNullable: false,
|
||||
defaultValue: "'active'",
|
||||
comment: "Current status",
|
||||
original: {
|
||||
name: "status",
|
||||
data_type: "varchar",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
comment: "",
|
||||
},
|
||||
}),
|
||||
],
|
||||
indexes: [index({ id: "idx", name: "idx_accounts_status", columns: ["account_status"] })],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'ALTER TABLE "public"."accounts" RENAME COLUMN "status" TO "account_status";',
|
||||
'ALTER TABLE "public"."accounts" ALTER COLUMN "account_status" TYPE text;',
|
||||
'ALTER TABLE "public"."accounts" ALTER COLUMN "account_status" SET NOT NULL;',
|
||||
'ALTER TABLE "public"."accounts" ALTER COLUMN "account_status" SET DEFAULT \'active\';',
|
||||
'COMMENT ON COLUMN "public"."accounts"."account_status" IS \'Current status\';',
|
||||
'CREATE INDEX "idx_accounts_status" ON "public"."accounts" ("account_status");',
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds openGauss statements with PostgreSQL-compatible DDL", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "opengauss",
|
||||
schema: "public",
|
||||
tableName: "accounts",
|
||||
columns: [column({ id: "email", name: "email", dataType: "text", isNullable: true })],
|
||||
indexes: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, ['ALTER TABLE "public"."accounts" ADD COLUMN "email" text;']);
|
||||
});
|
||||
|
||||
test("Redshift skips unsupported index operations while keeping column DDL", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "redshift",
|
||||
schema: "public",
|
||||
tableName: "events",
|
||||
columns: [column({ id: "email", name: "email", dataType: "varchar(255)", isNullable: true })],
|
||||
indexes: [index({ id: "idx", name: "idx_events_email", columns: ["email"], filter: "email IS NOT NULL" })],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.statements, ['ALTER TABLE "public"."events" ADD COLUMN "email" varchar(255);']);
|
||||
assert.deepEqual(result.warnings, ["Creating indexes is not supported for redshift from this editor."]);
|
||||
});
|
||||
|
||||
test("builds ClickHouse column DDL and skips indexes", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "clickhouse",
|
||||
tableName: "events",
|
||||
columns: [
|
||||
column({ id: "new", name: "name", dataType: "String", isNullable: false }),
|
||||
column({
|
||||
id: "legacy",
|
||||
name: "legacy",
|
||||
markedForDrop: true,
|
||||
original: {
|
||||
name: "legacy",
|
||||
data_type: "String",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
},
|
||||
}),
|
||||
column({
|
||||
id: "kind",
|
||||
name: "event_kind",
|
||||
dataType: "LowCardinality(String)",
|
||||
isNullable: false,
|
||||
defaultValue: "'view'",
|
||||
original: {
|
||||
name: "kind",
|
||||
data_type: "String",
|
||||
is_nullable: false,
|
||||
column_default: "'click'",
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
indexes: [index({ id: "idx", name: "idx_events_name", columns: ["name"] })],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.statements, [
|
||||
'ALTER TABLE "events" ADD COLUMN "name" String;',
|
||||
'ALTER TABLE "events" DROP COLUMN "legacy";',
|
||||
'ALTER TABLE "events" RENAME COLUMN "kind" TO "event_kind";',
|
||||
'ALTER TABLE "events" MODIFY COLUMN "event_kind" LowCardinality(String) DEFAULT \'view\';',
|
||||
]);
|
||||
assert.deepEqual(result.warnings, ["Creating indexes is not supported for clickhouse from this editor."]);
|
||||
});
|
||||
|
||||
test("builds ClickHouse nullable and comment column changes", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "clickhouse",
|
||||
tableName: "events",
|
||||
columns: [
|
||||
column({
|
||||
id: "new",
|
||||
name: "source",
|
||||
dataType: "String",
|
||||
isNullable: true,
|
||||
comment: "traffic source",
|
||||
}),
|
||||
column({
|
||||
id: "status",
|
||||
name: "status",
|
||||
dataType: "Nullable(String)",
|
||||
isNullable: false,
|
||||
defaultValue: "",
|
||||
comment: "current status",
|
||||
original: {
|
||||
name: "status",
|
||||
data_type: "Nullable(String)",
|
||||
is_nullable: true,
|
||||
column_default: "'pending'",
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
comment: "old status",
|
||||
},
|
||||
}),
|
||||
],
|
||||
indexes: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'ALTER TABLE "events" ADD COLUMN "source" Nullable(String);',
|
||||
'ALTER TABLE "events" COMMENT COLUMN "source" \'traffic source\';',
|
||||
'ALTER TABLE "events" MODIFY COLUMN "status" REMOVE DEFAULT;',
|
||||
'ALTER TABLE "events" MODIFY COLUMN "status" String;',
|
||||
'ALTER TABLE "events" COMMENT COLUMN "status" \'current status\';',
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds ClickHouse column reorder statements", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "clickhouse",
|
||||
tableName: "events",
|
||||
columns: [
|
||||
column({
|
||||
id: "event_type",
|
||||
name: "event_type",
|
||||
dataType: "String",
|
||||
isNullable: false,
|
||||
originalPosition: 1,
|
||||
original: {
|
||||
name: "event_type",
|
||||
data_type: "String",
|
||||
is_nullable: false,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
},
|
||||
}),
|
||||
column({
|
||||
id: "ts",
|
||||
name: "ts",
|
||||
dataType: "DateTime",
|
||||
isNullable: false,
|
||||
originalPosition: 0,
|
||||
original: {
|
||||
name: "ts",
|
||||
data_type: "DateTime",
|
||||
is_nullable: false,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
indexes: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'ALTER TABLE "events" MODIFY COLUMN "event_type" String FIRST;',
|
||||
'ALTER TABLE "events" MODIFY COLUMN "ts" DateTime AFTER "event_type";',
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds H2 schema-qualified existing column statements", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "h2",
|
||||
schema: "PUBLIC",
|
||||
tableName: "USERS",
|
||||
columns: [
|
||||
column({
|
||||
id: "name",
|
||||
name: "DISPLAY_NAME",
|
||||
dataType: "VARCHAR(120)",
|
||||
isNullable: false,
|
||||
defaultValue: "'guest'",
|
||||
comment: "Display name",
|
||||
original: {
|
||||
name: "NAME",
|
||||
data_type: "VARCHAR(80)",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
comment: "",
|
||||
},
|
||||
}),
|
||||
],
|
||||
indexes: [index({ id: "idx", name: "IDX_USERS_DISPLAY_NAME", columns: ["DISPLAY_NAME"] })],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'ALTER TABLE "PUBLIC"."USERS" ALTER COLUMN "NAME" RENAME TO "DISPLAY_NAME";',
|
||||
'ALTER TABLE "PUBLIC"."USERS" ALTER COLUMN "DISPLAY_NAME" SET DATA TYPE VARCHAR(120);',
|
||||
'ALTER TABLE "PUBLIC"."USERS" ALTER COLUMN "DISPLAY_NAME" SET NOT NULL;',
|
||||
'ALTER TABLE "PUBLIC"."USERS" ALTER COLUMN "DISPLAY_NAME" SET DEFAULT \'guest\';',
|
||||
'COMMENT ON COLUMN "PUBLIC"."USERS"."DISPLAY_NAME" IS \'Display name\';',
|
||||
'CREATE INDEX "IDX_USERS_DISPLAY_NAME" ON "PUBLIC"."USERS" ("DISPLAY_NAME");',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8");
|
||||
const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8");
|
||||
const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8");
|
||||
const dialogSource = readFileSync("apps/desktop/src/components/structure/TableStructureEditorDialog.vue", "utf8");
|
||||
const tableStructureTypesSource = readFileSync("apps/desktop/src/lib/tableStructureEditorSql.ts", "utf8");
|
||||
const rustCoreLibSource = readFileSync("crates/dbx-core/src/lib.rs", "utf8");
|
||||
const rustTableStructureSqlSource = readFileSync("crates/dbx-core/src/table_structure_sql.rs", "utf8");
|
||||
const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8");
|
||||
const webMainSource = readFileSync("crates/dbx-web/src/main.rs", "utf8");
|
||||
|
||||
test("shared API exposes backend table structure SQL builders", () => {
|
||||
assert.match(apiSource, /export const buildTableStructureChangeSql = forward\("buildTableStructureChangeSql"\)/);
|
||||
assert.match(apiSource, /export const buildCreateTableSql = forward\("buildCreateTableSql"\)/);
|
||||
|
||||
assert.match(tauriSource, /invoke\("build_table_structure_change_sql"/);
|
||||
assert.match(tauriSource, /invoke\("build_create_table_sql"/);
|
||||
|
||||
assert.match(httpSource, /\/api\/query\/build-table-structure-change-sql/);
|
||||
assert.match(httpSource, /\/api\/query\/build-create-table-sql/);
|
||||
});
|
||||
|
||||
test("table structure editor delegates SQL preview generation to backend APIs", () => {
|
||||
assert.match(dialogSource, /await api\.buildCreateTableSql\(options\)/);
|
||||
assert.match(dialogSource, /await api\.buildTableStructureChangeSql\(options\)/);
|
||||
assert.doesNotMatch(dialogSource, /buildCreateTableSql,\s*buildTableStructureChangeSql/);
|
||||
});
|
||||
|
||||
test("frontend keeps table structure SQL file as types only", () => {
|
||||
assert.match(tableStructureTypesSource, /export interface EditableStructureColumn/);
|
||||
assert.match(tableStructureTypesSource, /export interface BuildTableStructureChangeSqlOptions/);
|
||||
assert.doesNotMatch(tableStructureTypesSource, /export function buildTableStructureChangeSql/);
|
||||
assert.doesNotMatch(tableStructureTypesSource, /export function buildCreateTableSql/);
|
||||
assert.doesNotMatch(tableStructureTypesSource, /function quoteIdent/);
|
||||
assert.doesNotMatch(tableStructureTypesSource, /function columnDefinition/);
|
||||
});
|
||||
|
||||
test("Rust backends register table structure SQL builders", () => {
|
||||
assert.match(rustCoreLibSource, /pub mod table_structure_sql/);
|
||||
assert.match(rustTableStructureSqlSource, /pub fn build_table_structure_change_sql/);
|
||||
assert.match(rustTableStructureSqlSource, /pub fn build_create_table_sql/);
|
||||
assert.match(tauriLibSource, /commands::query::build_table_structure_change_sql/);
|
||||
assert.match(tauriLibSource, /commands::query::build_create_table_sql/);
|
||||
assert.match(webMainSource, /\/query\/build-table-structure-change-sql/);
|
||||
assert.match(webMainSource, /\/query\/build-create-table-sql/);
|
||||
});
|
||||
@@ -1,35 +1,25 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildViewDdl } from "../../apps/desktop/src/lib/viewDdl";
|
||||
|
||||
test("Postgres view DDL wraps a view definition body in CREATE OR REPLACE VIEW", () => {
|
||||
const ddl = buildViewDdl({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
name: "active users",
|
||||
source: " SELECT id, name FROM users WHERE active ",
|
||||
});
|
||||
const viewDdlSource = readFileSync("apps/desktop/src/lib/viewDdl.ts", "utf8");
|
||||
const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8");
|
||||
const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8");
|
||||
const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8");
|
||||
const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8");
|
||||
const webMainSource = readFileSync("crates/dbx-web/src/main.rs", "utf8");
|
||||
const rustSource = readFileSync("crates/dbx-core/src/object_source_sql.rs", "utf8");
|
||||
|
||||
assert.equal(ddl, 'CREATE OR REPLACE VIEW "public"."active users" AS\nSELECT id, name FROM users WHERE active;');
|
||||
});
|
||||
|
||||
test("view DDL keeps existing CREATE VIEW statements intact", () => {
|
||||
const ddl = buildViewDdl({
|
||||
databaseType: "mysql",
|
||||
schema: "reporting",
|
||||
name: "active_users",
|
||||
source: "CREATE ALGORITHM=UNDEFINED VIEW `active_users` AS SELECT `id` FROM `users`",
|
||||
});
|
||||
|
||||
assert.equal(ddl, "CREATE ALGORITHM=UNDEFINED VIEW `active_users` AS SELECT `id` FROM `users`;");
|
||||
test("frontend view DDL helper delegates executable DDL generation to backend API", () => {
|
||||
assert.match(viewDdlSource, /return api\.buildViewDdlSql\(input\)/);
|
||||
assert.doesNotMatch(viewDdlSource, /CREATE OR REPLACE VIEW|CREATE VIEW|ensureSemicolon|quotePostgresIdentifier/);
|
||||
});
|
||||
|
||||
test("sidebar view context menu exposes a separate DDL action", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
|
||||
|
||||
assert.match(source, /function viewObjectDdl/);
|
||||
assert.match(source, /buildViewDdl/);
|
||||
assert.match(source, /await buildViewDdl\(/);
|
||||
assert.match(source, /contextMenu\.viewDdl/);
|
||||
});
|
||||
|
||||
@@ -37,6 +27,20 @@ test("object browser view context menu exposes a separate DDL action", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/objects/ObjectBrowser.vue", "utf8");
|
||||
|
||||
assert.match(source, /function openViewDdl/);
|
||||
assert.match(source, /buildViewDdl/);
|
||||
assert.match(source, /await buildViewDdl\(/);
|
||||
assert.match(source, /contextMenu\.viewDdl/);
|
||||
});
|
||||
|
||||
test("shared API exposes backend view DDL builder", () => {
|
||||
assert.match(apiSource, /export const buildViewDdlSql = forward\("buildViewDdlSql"\)/);
|
||||
assert.match(tauriSource, /invoke\("build_view_ddl_sql"/);
|
||||
assert.match(httpSource, /\/api\/query\/build-view-ddl-sql/);
|
||||
assert.match(tauriLibSource, /commands::query::build_view_ddl_sql/);
|
||||
assert.match(webMainSource, /\/query\/build-view-ddl-sql/);
|
||||
});
|
||||
|
||||
test("Rust object source SQL exposes view DDL builder", () => {
|
||||
assert.match(rustSource, /pub struct BuildViewDdlInput/);
|
||||
assert.match(rustSource, /pub fn build_view_ddl_sql/);
|
||||
assert.match(rustSource, /CREATE OR REPLACE VIEW/);
|
||||
});
|
||||
|
||||
@@ -155,6 +155,157 @@ pub async fn analyze_sql_references(
|
||||
dbx_core::sql_analysis::analyze_sql_references(&sql, dialect.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn find_statement_at_cursor(sql: String, cursor_pos: usize) -> Result<String, String> {
|
||||
Ok(dbx_core::sql::find_statement_at_cursor(&sql, cursor_pos))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn prepare_query_pagination_execution_plan(
|
||||
options: dbx_core::query_result_sql::QueryPaginationExecutionPlanOptions,
|
||||
) -> Result<dbx_core::query_result_sql::QueryPaginationExecutionPlan, String> {
|
||||
Ok(dbx_core::query_result_sql::build_query_pagination_execution_plan(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_sorted_query_sql(
|
||||
options: dbx_core::query_result_sql::SortedQuerySqlOptions,
|
||||
) -> Result<dbx_core::query_result_sql::QuerySqlBuildResult, String> {
|
||||
Ok(dbx_core::query_result_sql::build_sorted_query_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_explain_sql(
|
||||
options: dbx_core::query_execution_sql::ExplainSqlOptions,
|
||||
) -> Result<dbx_core::query_execution_sql::ExplainSqlBuildResult, String> {
|
||||
Ok(dbx_core::query_execution_sql::build_explain_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_dropped_file_preview_sql(
|
||||
options: dbx_core::query_execution_sql::DroppedFilePreviewSqlOptions,
|
||||
) -> Result<Option<String>, String> {
|
||||
Ok(dbx_core::query_execution_sql::build_dropped_file_preview_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_table_select_sql(options: dbx_core::sql_dialect::TableDataSelectSqlOptions) -> Result<String, String> {
|
||||
Ok(dbx_core::sql_dialect::build_table_data_select_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_database_search_sql(
|
||||
options: dbx_core::database_search_sql::DatabaseSearchSqlOptions,
|
||||
) -> Result<Option<dbx_core::database_search_sql::DatabaseSearchSql>, String> {
|
||||
Ok(dbx_core::database_search_sql::build_database_search_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_search_result_where(
|
||||
options: dbx_core::database_search_sql::SearchResultWhereOptions,
|
||||
) -> Result<String, String> {
|
||||
Ok(dbx_core::database_search_sql::build_search_result_where(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_rename_object_sql(options: dbx_core::db_admin_sql::RenameObjectSqlOptions) -> Result<String, String> {
|
||||
dbx_core::db_admin_sql::build_rename_object_sql(options)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_create_database_sql(options: dbx_core::db_admin_sql::CreateDatabaseSqlOptions) -> Result<String, String> {
|
||||
Ok(dbx_core::db_admin_sql::build_create_database_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_duckdb_attach_database_sql(
|
||||
options: dbx_core::db_admin_sql::DuckDbAttachDatabaseSqlOptions,
|
||||
) -> Result<String, String> {
|
||||
Ok(dbx_core::db_admin_sql::build_duckdb_attach_database_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_drop_object_sql(options: dbx_core::db_admin_sql::DropObjectSqlOptions) -> Result<String, String> {
|
||||
Ok(dbx_core::db_admin_sql::build_drop_object_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_drop_table_sql(options: dbx_core::db_admin_sql::TableAdminSqlOptions) -> Result<String, String> {
|
||||
Ok(dbx_core::db_admin_sql::build_drop_table_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_empty_table_sql(options: dbx_core::db_admin_sql::TableAdminSqlOptions) -> Result<String, String> {
|
||||
Ok(dbx_core::db_admin_sql::build_empty_table_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_truncate_table_sql(options: dbx_core::db_admin_sql::TableAdminSqlOptions) -> Result<String, String> {
|
||||
Ok(dbx_core::db_admin_sql::build_truncate_table_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_drop_database_sql(options: dbx_core::db_admin_sql::DatabaseNameSqlOptions) -> Result<String, String> {
|
||||
Ok(dbx_core::db_admin_sql::build_drop_database_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_create_schema_sql(options: dbx_core::db_admin_sql::SchemaNameSqlOptions) -> Result<String, String> {
|
||||
Ok(dbx_core::db_admin_sql::build_create_schema_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_drop_schema_sql(options: dbx_core::db_admin_sql::SchemaNameSqlOptions) -> Result<String, String> {
|
||||
Ok(dbx_core::db_admin_sql::build_drop_schema_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_duplicate_table_structure_sql(
|
||||
options: dbx_core::db_admin_sql::DuplicateTableStructureSqlOptions,
|
||||
) -> Result<String, String> {
|
||||
Ok(dbx_core::db_admin_sql::build_duplicate_table_structure_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_executable_object_source_statements(
|
||||
input: dbx_core::object_source_sql::EditableObjectSourceSqlInput,
|
||||
) -> Result<Vec<String>, String> {
|
||||
dbx_core::object_source_sql::build_executable_object_source_statements(input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_executable_object_source_sql(
|
||||
input: dbx_core::object_source_sql::EditableObjectSourceSqlInput,
|
||||
) -> Result<String, String> {
|
||||
dbx_core::object_source_sql::build_executable_object_source_sql(input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_routine_rename_object_source_statements(
|
||||
input: dbx_core::object_source_sql::RoutineRenameObjectSourceInput,
|
||||
) -> Result<Vec<String>, String> {
|
||||
dbx_core::object_source_sql::build_routine_rename_object_source_statements(input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_view_ddl_sql(input: dbx_core::object_source_sql::BuildViewDdlInput) -> Result<String, String> {
|
||||
Ok(dbx_core::object_source_sql::build_view_ddl_sql(input))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_table_structure_change_sql(
|
||||
options: dbx_core::table_structure_sql::TableStructureSqlOptions,
|
||||
) -> Result<dbx_core::table_structure_sql::TableStructureSqlResult, String> {
|
||||
Ok(dbx_core::table_structure_sql::build_table_structure_change_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_create_table_sql(
|
||||
options: dbx_core::table_structure_sql::TableStructureSqlOptions,
|
||||
) -> Result<dbx_core::table_structure_sql::TableStructureSqlResult, String> {
|
||||
Ok(dbx_core::table_structure_sql::build_create_table_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analyze_editable_query_editability(sql: String) -> Result<dbx_core::sql_editability::QueryEditability, String> {
|
||||
Ok(dbx_core::sql_editability::analyze_editable_query_editability(&sql))
|
||||
@@ -166,3 +317,64 @@ pub fn prepare_data_grid_save(
|
||||
) -> Result<dbx_core::data_grid_sql::DataGridSavePreparation, String> {
|
||||
Ok(dbx_core::data_grid_sql::prepare_data_grid_save(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_data_grid_copy_update_statements(
|
||||
options: dbx_core::data_grid_sql::DataGridCopyUpdateStatementOptions,
|
||||
) -> Result<Vec<String>, String> {
|
||||
Ok(dbx_core::data_grid_sql::build_data_grid_copy_update_statements(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_data_grid_copy_insert_statement(
|
||||
options: dbx_core::data_grid_sql::DataGridCopyInsertStatementOptions,
|
||||
) -> Result<Option<String>, String> {
|
||||
Ok(dbx_core::data_grid_sql::build_data_grid_copy_insert_statement(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_data_grid_context_filter_condition(
|
||||
options: dbx_core::data_grid_sql::DataGridContextFilterConditionOptions,
|
||||
) -> Result<Option<String>, String> {
|
||||
Ok(dbx_core::data_grid_sql::build_data_grid_context_filter_condition(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_data_grid_column_value_filter_condition(
|
||||
options: dbx_core::data_grid_sql::DataGridColumnValueFilterConditionOptions,
|
||||
) -> Result<Option<String>, String> {
|
||||
Ok(dbx_core::data_grid_sql::build_data_grid_column_value_filter_condition(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_data_grid_count_sql(options: dbx_core::data_grid_sql::DataGridCountSqlOptions) -> Result<String, String> {
|
||||
Ok(dbx_core::data_grid_sql::build_data_grid_count_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_hive_table_properties_sql(
|
||||
options: dbx_core::data_grid_sql::HiveTablePropertiesSqlOptions,
|
||||
) -> Result<String, String> {
|
||||
Ok(dbx_core::data_grid_sql::build_hive_table_properties_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_export_insert_statements(
|
||||
options: dbx_core::database_export::BuildExportInsertStatementsOptions,
|
||||
) -> Result<Vec<String>, String> {
|
||||
dbx_core::database_export::build_export_insert_statements(options)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_export_sql_insert(
|
||||
options: dbx_core::database_export::BuildExportSqlInsertOptions,
|
||||
) -> Result<String, String> {
|
||||
dbx_core::database_export::build_export_sql_insert(options)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_database_sql_export(
|
||||
options: dbx_core::database_export::BuildDatabaseSqlExportOptions,
|
||||
) -> Result<String, String> {
|
||||
dbx_core::database_export::build_database_sql_export(options)
|
||||
}
|
||||
|
||||
@@ -290,8 +290,42 @@ pub fn run() {
|
||||
commands::query::execute_script,
|
||||
commands::query::execute_in_transaction,
|
||||
commands::query::analyze_sql_references,
|
||||
commands::query::find_statement_at_cursor,
|
||||
commands::query::prepare_query_pagination_execution_plan,
|
||||
commands::query::build_sorted_query_sql,
|
||||
commands::query::build_explain_sql,
|
||||
commands::query::build_dropped_file_preview_sql,
|
||||
commands::query::build_table_select_sql,
|
||||
commands::query::build_database_search_sql,
|
||||
commands::query::build_search_result_where,
|
||||
commands::query::build_rename_object_sql,
|
||||
commands::query::build_create_database_sql,
|
||||
commands::query::build_duckdb_attach_database_sql,
|
||||
commands::query::build_drop_object_sql,
|
||||
commands::query::build_drop_table_sql,
|
||||
commands::query::build_empty_table_sql,
|
||||
commands::query::build_truncate_table_sql,
|
||||
commands::query::build_drop_database_sql,
|
||||
commands::query::build_create_schema_sql,
|
||||
commands::query::build_drop_schema_sql,
|
||||
commands::query::build_duplicate_table_structure_sql,
|
||||
commands::query::build_executable_object_source_statements,
|
||||
commands::query::build_executable_object_source_sql,
|
||||
commands::query::build_routine_rename_object_source_statements,
|
||||
commands::query::build_view_ddl_sql,
|
||||
commands::query::build_table_structure_change_sql,
|
||||
commands::query::build_create_table_sql,
|
||||
commands::query::analyze_editable_query_editability,
|
||||
commands::query::prepare_data_grid_save,
|
||||
commands::query::build_data_grid_copy_update_statements,
|
||||
commands::query::build_data_grid_copy_insert_statement,
|
||||
commands::query::build_data_grid_context_filter_condition,
|
||||
commands::query::build_data_grid_column_value_filter_condition,
|
||||
commands::query::build_data_grid_count_sql,
|
||||
commands::query::build_hive_table_properties_sql,
|
||||
commands::query::build_export_insert_statements,
|
||||
commands::query::build_export_sql_insert,
|
||||
commands::query::build_database_sql_export,
|
||||
commands::data_compare::prepare_data_compare,
|
||||
commands::data_compare::prepare_data_compare_from_tables,
|
||||
commands::sql_file::preview_sql_file,
|
||||
|
||||
Reference in New Issue
Block a user