Merge pull request #55 from SuLea-IT/codex/issue-51-sql-table-ux
[codex] Fix SQL editor and table query UX
This commit is contained in:
commit
4efd7c2517
97
src/App.vue
97
src/App.vue
|
|
@ -50,6 +50,7 @@ import { getVersion } from "@tauri-apps/api/app";
|
|||
import * as api from "@/lib/tauri";
|
||||
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/queryExecutionState";
|
||||
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
|
||||
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
|
||||
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
import { isCloseTabShortcut, isExecuteSqlShortcut } from "@/lib/keyboardShortcuts";
|
||||
|
||||
|
|
@ -305,6 +306,14 @@ function tabDisplayTitle(tab: typeof queryStore.tabs[number]): string {
|
|||
return tab.title;
|
||||
}
|
||||
|
||||
function tabModeLabel(tab: typeof queryStore.tabs[number]): string {
|
||||
if (tab.mode === "data") return t("tabs.table");
|
||||
if (tab.mode === "query") return t("tabs.sql");
|
||||
if (tab.mode === "mongo") return t("tabs.mongo");
|
||||
if (tab.mode === "redis") return t("tabs.redis");
|
||||
return tab.mode;
|
||||
}
|
||||
|
||||
function databaseDisplayNameForTab(connectionId: string, database: string): string {
|
||||
const connection = connectionStore.getConfig(connectionId);
|
||||
if (connection?.db_type === "redis" && database !== "") return `db${database}`;
|
||||
|
|
@ -470,7 +479,8 @@ async function changeActiveConnection(connectionId: any) {
|
|||
async function onExecuteSql(sql: string) {
|
||||
const tab = activeTab.value;
|
||||
if (!tab) return;
|
||||
await api.executeQuery(tab.connectionId, tab.database, sql);
|
||||
queryStore.updateSql(tab.id, sql);
|
||||
await queryStore.executeTabSql(tab.id, sql);
|
||||
}
|
||||
|
||||
async function onReloadData() {
|
||||
|
|
@ -486,61 +496,36 @@ type ActiveTab = NonNullable<typeof activeTab.value>;
|
|||
|
||||
function quoteIdent(tab: ActiveTab, name: string): string {
|
||||
const config = connectionStore.getConfig(tab.connectionId);
|
||||
return config?.db_type === "mysql"
|
||||
? `\`${name.replace(/`/g, "``")}\``
|
||||
: `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function qualifiedTableName(tab: NonNullable<typeof activeTab.value>): string {
|
||||
const config = connectionStore.getConfig(tab.connectionId);
|
||||
if (!tab.tableMeta) return "";
|
||||
if ((config?.db_type === "postgres" || config?.db_type === "oracle" || config?.db_type === "sqlserver") && tab.tableMeta.schema) {
|
||||
return `${quoteIdent(tab, tab.tableMeta.schema)}.${quoteIdent(tab, tab.tableMeta.tableName)}`;
|
||||
}
|
||||
return quoteIdent(tab, tab.tableMeta.tableName);
|
||||
}
|
||||
|
||||
function defaultOrderBy(tab: NonNullable<typeof activeTab.value>): string | undefined {
|
||||
const primaryKeys = tab.tableMeta?.primaryKeys ?? [];
|
||||
if (primaryKeys.length === 0) return undefined;
|
||||
return primaryKeys.map((pk) => `${quoteIdent(tab, pk)} ASC`).join(", ");
|
||||
return quoteTableIdentifier(config?.db_type, name);
|
||||
}
|
||||
|
||||
function buildTableSql(
|
||||
tab: NonNullable<typeof activeTab.value>,
|
||||
options: { orderBy?: string; limit?: number; offset?: number } = {},
|
||||
options: { orderBy?: string; limit?: number; offset?: number; whereInput?: string } = {},
|
||||
): string {
|
||||
const config = connectionStore.getConfig(tab.connectionId);
|
||||
const limit = options.limit ?? 100;
|
||||
const orderBy = options.orderBy ?? defaultOrderBy(tab);
|
||||
const order = orderBy ? ` ORDER BY ${orderBy}` : "";
|
||||
|
||||
if (config?.db_type === "oracle") {
|
||||
const offset = options.offset ? ` OFFSET ${options.offset} ROWS` : "";
|
||||
return `SELECT * FROM ${qualifiedTableName(tab)}${order}${offset} FETCH FIRST ${limit} ROWS ONLY`;
|
||||
}
|
||||
|
||||
if (config?.db_type === "sqlserver") {
|
||||
return `SELECT TOP ${limit} * FROM ${qualifiedTableName(tab)}${order}`;
|
||||
}
|
||||
|
||||
const offset = options.offset ? ` OFFSET ${options.offset}` : "";
|
||||
return `SELECT * FROM ${qualifiedTableName(tab)}${order} LIMIT ${limit}${offset};`;
|
||||
return buildTableSelectSql({
|
||||
databaseType: config?.db_type,
|
||||
schema: tab.tableMeta?.schema,
|
||||
tableName: tab.tableMeta?.tableName ?? "",
|
||||
primaryKeys: tab.tableMeta?.primaryKeys,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async function onPaginate(offset: number, limit: number) {
|
||||
async function onPaginate(offset: number, limit: number, whereInput?: string) {
|
||||
const tab = activeTab.value;
|
||||
if (!tab?.tableMeta) return;
|
||||
const sql = buildTableSql(tab, { limit, offset });
|
||||
const sql = buildTableSql(tab, { limit, offset, whereInput });
|
||||
queryStore.updateSql(tab.id, sql);
|
||||
await queryStore.executeCurrentTab();
|
||||
}
|
||||
|
||||
async function onSort(column: string, direction: "asc" | "desc" | null) {
|
||||
async function onSort(column: string, direction: "asc" | "desc" | null, whereInput?: string) {
|
||||
const tab = activeTab.value;
|
||||
if (!tab?.tableMeta) return;
|
||||
const orderBy = direction ? `${quoteIdent(tab, column)} ${direction.toUpperCase()}` : defaultOrderBy(tab);
|
||||
const sql = buildTableSql(tab, { orderBy });
|
||||
const orderBy = direction ? `${quoteIdent(tab, column)} ${direction.toUpperCase()}` : undefined;
|
||||
const sql = buildTableSql(tab, { orderBy, whereInput });
|
||||
queryStore.updateSql(tab.id, sql);
|
||||
await queryStore.executeCurrentTab();
|
||||
}
|
||||
|
|
@ -852,7 +837,18 @@ async function setupFileDrop() {
|
|||
@click="queryStore.activeTabId = tab.id"
|
||||
>
|
||||
<span class="h-4 w-1 rounded-full shrink-0" :style="{ backgroundColor: connectionColor(tab.connectionId) || '#9ca3af' }" />
|
||||
<component
|
||||
:is="tab.mode === 'data' ? Table2 : FileCode"
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
:class="tab.mode === 'data' ? 'text-emerald-600' : 'text-blue-600'"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ tabDisplayTitle(tab) }}</span>
|
||||
<span
|
||||
class="shrink-0 rounded border px-1 text-[10px] leading-4"
|
||||
:class="tab.mode === 'data' ? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300' : 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-300'"
|
||||
>
|
||||
{{ tabModeLabel(tab) }}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
|
|
@ -1017,13 +1013,13 @@ async function setupFileDrop() {
|
|||
{{ t('ai.fixWithAi') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="activeTab.isExecuting" class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
|
||||
<div v-else-if="!activeTab.result && activeTab.isExecuting" class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
|
||||
<div class="flex items-center">
|
||||
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
||||
{{ t(queryExecutionLabelKey(activeTab)) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex-1 min-h-0 flex items-center justify-center text-muted-foreground text-sm">
|
||||
<div v-else-if="!activeTab.result" class="flex-1 min-h-0 flex items-center justify-center text-muted-foreground text-sm">
|
||||
{{ t('editor.pressToExecute') }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1033,9 +1029,24 @@ async function setupFileDrop() {
|
|||
|
||||
<!-- Data mode: full-height grid -->
|
||||
<template v-else-if="activeTab.mode === 'data'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
<div class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-2 text-xs">
|
||||
<span class="inline-flex items-center gap-1 rounded border border-emerald-200 bg-emerald-50 px-2 py-0.5 font-medium text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300">
|
||||
<Table2 class="h-3.5 w-3.5" />
|
||||
{{ t('tabs.tableData') }}
|
||||
</span>
|
||||
<span class="font-medium truncate">{{ activeTab.tableMeta?.tableName || activeTab.title }}</span>
|
||||
<span class="text-muted-foreground truncate">
|
||||
{{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database) }}
|
||||
<template v-if="activeTab.tableMeta?.schema"> · {{ activeTab.tableMeta.schema }}</template>
|
||||
</span>
|
||||
<span v-if="activeTab.tableMeta" class="ml-auto text-muted-foreground">
|
||||
{{ activeTab.tableMeta.columns.length }} {{ t('tree.columns') }}
|
||||
</span>
|
||||
</div>
|
||||
<DataGrid
|
||||
v-if="activeTab.result"
|
||||
class="flex-1 min-h-0"
|
||||
:key="activeTab.id"
|
||||
:result="activeTab.result"
|
||||
:sql="activeTab.sql"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
type CellPosition,
|
||||
type CellSelectionRange,
|
||||
} from "@/lib/gridSelection";
|
||||
import { buildTableSelectSql, normalizeWhereInput } from "@/lib/tableSelectSql";
|
||||
|
||||
import { useToast } from "@/composables/useToast";
|
||||
|
||||
|
|
@ -59,8 +60,8 @@ const props = defineProps<{
|
|||
|
||||
const emit = defineEmits<{
|
||||
reload: [];
|
||||
paginate: [offset: number, limit: number];
|
||||
sort: [column: string, direction: "asc" | "desc" | null];
|
||||
paginate: [offset: number, limit: number, whereInput?: string];
|
||||
sort: [column: string, direction: "asc" | "desc" | null, whereInput?: string];
|
||||
}>();
|
||||
|
||||
const hasData = computed(() => props.result.columns.length > 0);
|
||||
|
|
@ -133,6 +134,8 @@ const showTranspose = ref(false);
|
|||
const sortCol = ref<string | null>(null);
|
||||
const sortDir = ref<"asc" | "desc">("asc");
|
||||
const searchText = ref("");
|
||||
const saveError = ref("");
|
||||
const isApplyingWhere = ref(false);
|
||||
const columnWidths = ref<number[]>([]);
|
||||
const gridRef = ref<HTMLDivElement>();
|
||||
const headerRef = ref<HTMLDivElement>();
|
||||
|
|
@ -203,21 +206,26 @@ watch(() => props.result.columns.length, initColumnWidths);
|
|||
const pageSize = ref(100);
|
||||
const currentPage = ref(1);
|
||||
const isFullPage = computed(() => props.result.rows.length >= pageSize.value);
|
||||
const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql);
|
||||
const isWhereSearch = computed(() => canUseWhereSearch.value && /^\s*where\b/i.test(searchText.value));
|
||||
const wherePredicate = computed(() => normalizeWhereInput(searchText.value));
|
||||
const activeWhereInput = computed(() => isWhereSearch.value && wherePredicate.value ? searchText.value : undefined);
|
||||
const clientSearchText = computed(() => isWhereSearch.value ? "" : searchText.value);
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage.value <= 1) return;
|
||||
currentPage.value--;
|
||||
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value);
|
||||
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value, activeWhereInput.value);
|
||||
}
|
||||
function nextPage() {
|
||||
if (!isFullPage.value) return;
|
||||
currentPage.value++;
|
||||
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value);
|
||||
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value, activeWhereInput.value);
|
||||
}
|
||||
function changePageSize(size: number) {
|
||||
pageSize.value = size;
|
||||
currentPage.value = 1;
|
||||
emit("paginate", 0, size);
|
||||
emit("paginate", 0, size, activeWhereInput.value);
|
||||
}
|
||||
|
||||
// --- Editing ---
|
||||
|
|
@ -240,8 +248,8 @@ const hasPendingChanges = computed(() =>
|
|||
|
||||
const sortedRows = computed(() => {
|
||||
let rows = props.result.rows.map((row, sourceIndex) => ({ row, sourceIndex }));
|
||||
if (searchText.value) {
|
||||
const q = searchText.value.toLowerCase();
|
||||
if (clientSearchText.value) {
|
||||
const q = clientSearchText.value.toLowerCase();
|
||||
rows = rows.filter(({ row, sourceIndex }) => {
|
||||
const data = rowDataWithChanges(row, sourceIndex);
|
||||
return data.some((cell) => cell !== null && String(cell).toLowerCase().includes(q));
|
||||
|
|
@ -282,8 +290,8 @@ const displayItems = computed<RowItem[]>(() => {
|
|||
return items;
|
||||
});
|
||||
const hasVisibleRows = computed(() => displayItems.value.length > 0);
|
||||
const emptyTitle = computed(() => searchText.value ? t('grid.noSearchResults') : t('grid.noRows'));
|
||||
const emptyDescription = computed(() => searchText.value ? t('grid.noSearchResultsDescription') : t('grid.noRowsDescription'));
|
||||
const emptyTitle = computed(() => clientSearchText.value ? t('grid.noSearchResults') : t('grid.noRows'));
|
||||
const emptyDescription = computed(() => clientSearchText.value ? t('grid.noSearchResultsDescription') : t('grid.noRowsDescription'));
|
||||
const selectedRange = computed<CellSelectionRange | null>(() => {
|
||||
if (!selectionAnchor.value || !selectionFocus.value) return null;
|
||||
return normalizeSelectionRange(selectionAnchor.value, selectionFocus.value);
|
||||
|
|
@ -328,12 +336,41 @@ const activeCellDetail = computed(() => {
|
|||
function toggleSort(colName: string) {
|
||||
if (isResizing) return;
|
||||
if (sortCol.value === colName) {
|
||||
if (sortDir.value === "asc") { sortDir.value = "desc"; emit("sort", colName, "desc"); }
|
||||
else { sortCol.value = null; sortDir.value = "asc"; emit("sort", colName, null); }
|
||||
if (sortDir.value === "asc") { sortDir.value = "desc"; emit("sort", colName, "desc", activeWhereInput.value); }
|
||||
else { sortCol.value = null; sortDir.value = "asc"; emit("sort", colName, null, activeWhereInput.value); }
|
||||
} else {
|
||||
sortCol.value = colName;
|
||||
sortDir.value = "asc";
|
||||
emit("sort", colName, "asc");
|
||||
emit("sort", colName, "asc", activeWhereInput.value);
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchEnter(event: KeyboardEvent) {
|
||||
if (!isWhereSearch.value) return;
|
||||
event.preventDefault();
|
||||
void applyWhereSearch();
|
||||
}
|
||||
|
||||
async function applyWhereSearch() {
|
||||
if (!props.tableMeta || !props.onExecuteSql || !wherePredicate.value) return;
|
||||
isApplyingWhere.value = true;
|
||||
saveError.value = "";
|
||||
currentPage.value = 1;
|
||||
try {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: props.databaseType,
|
||||
schema: props.tableMeta.schema,
|
||||
tableName: props.tableMeta.tableName,
|
||||
primaryKeys: props.tableMeta.primaryKeys,
|
||||
orderBy: sortCol.value ? `${quoteIdent(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined,
|
||||
limit: pageSize.value,
|
||||
whereInput: searchText.value,
|
||||
});
|
||||
await props.onExecuteSql(sql);
|
||||
} catch (e: any) {
|
||||
saveError.value = String(e?.message || e);
|
||||
} finally {
|
||||
isApplyingWhere.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -604,8 +641,6 @@ function generateSaveStatements(): string[] {
|
|||
return stmts;
|
||||
}
|
||||
|
||||
const saveError = ref("");
|
||||
|
||||
async function saveChanges() {
|
||||
const stmts = generateSaveStatements();
|
||||
if (stmts.length === 0) return;
|
||||
|
|
@ -956,11 +991,24 @@ function escapeAndHighlightKeywords(s: string): string {
|
|||
<input
|
||||
v-model="searchText"
|
||||
class="flex-1 h-5 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('grid.search')"
|
||||
:placeholder="canUseWhereSearch ? t('grid.searchOrWhere') : t('grid.search')"
|
||||
@keydown.enter="onSearchEnter"
|
||||
/>
|
||||
<span v-if="searchText" class="text-xs text-muted-foreground">
|
||||
<span v-if="clientSearchText" class="text-xs text-muted-foreground">
|
||||
{{ sortedRows.length }}/{{ result.rows.length }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="isWhereSearch"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 text-xs px-1.5 shrink-0"
|
||||
:disabled="isApplyingWhere || !wherePredicate"
|
||||
@click="applyWhereSearch"
|
||||
>
|
||||
<Loader2 v-if="isApplyingWhere" class="w-3 h-3 mr-1 animate-spin" />
|
||||
<Search v-else class="w-3 h-3 mr-1" />
|
||||
{{ t('grid.applyWhere') }}
|
||||
</Button>
|
||||
<Button v-if="editable && tableMeta" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" @click="addRow">
|
||||
<Plus class="w-3 h-3 mr-1" /> {{ t('grid.addRow') }}
|
||||
</Button>
|
||||
|
|
@ -1297,7 +1345,7 @@ function escapeAndHighlightKeywords(s: string): string {
|
|||
|
||||
<!-- Bottom status bar -->
|
||||
<div class="flex items-center gap-2 px-3 py-1 border-t text-xs text-muted-foreground bg-muted/30 shrink-0">
|
||||
<span v-if="hasData">{{ t('grid.rows', { count: result.rows.length }) }}</span>
|
||||
<span v-if="hasData">{{ t('grid.totalRows', { count: result.rows.length }) }}</span>
|
||||
<span v-else>{{ t('grid.rowsAffected', { count: result.affected_rows }) }}</span>
|
||||
<span>{{ result.execution_time_ms }}ms</span>
|
||||
<span v-if="hasCellSelection" class="text-foreground">{{ selectionSummary }}</span>
|
||||
|
|
|
|||
|
|
@ -78,8 +78,16 @@ export default {
|
|||
selectConnection: "Select connection",
|
||||
selectDatabase: "Select database",
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
table: "Table",
|
||||
tableData: "Table Data",
|
||||
redis: "Redis",
|
||||
mongo: "Mongo",
|
||||
},
|
||||
grid: {
|
||||
rows: "{count} rows",
|
||||
totalRows: "Total {count} rows",
|
||||
rowsAffected: "{count} rows affected",
|
||||
querySuccess: "Query executed successfully",
|
||||
noRows: "No data",
|
||||
|
|
@ -99,6 +107,8 @@ export default {
|
|||
exportMarkdown: "Export Markdown",
|
||||
copied: "Copied!",
|
||||
search: "Search...",
|
||||
searchOrWhere: "Search, or enter a WHERE clause...",
|
||||
applyWhere: "Apply WHERE",
|
||||
page: "Page {page}",
|
||||
rowsPerPage: "Rows per page",
|
||||
save: "Save",
|
||||
|
|
|
|||
|
|
@ -80,8 +80,16 @@ export default {
|
|||
selectConnection: "选择连接",
|
||||
selectDatabase: "选择数据库",
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
table: "表",
|
||||
tableData: "数据表",
|
||||
redis: "Redis",
|
||||
mongo: "Mongo",
|
||||
},
|
||||
grid: {
|
||||
rows: "{count} 行",
|
||||
totalRows: "共 {count} 行",
|
||||
rowsAffected: "影响 {count} 行",
|
||||
querySuccess: "查询执行成功",
|
||||
noRows: "暂无数据",
|
||||
|
|
@ -101,6 +109,8 @@ export default {
|
|||
exportMarkdown: "导出 Markdown",
|
||||
copied: "已复制!",
|
||||
search: "搜索...",
|
||||
searchOrWhere: "搜索,或输入 WHERE 条件...",
|
||||
applyWhere: "应用 WHERE",
|
||||
page: "第 {page} 页",
|
||||
rowsPerPage: "每页行数",
|
||||
save: "保存",
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export function buildSqlCompletionItemsFromContext(
|
|||
items.push(...buildTableItems(context.prefix, input.tables));
|
||||
}
|
||||
|
||||
if (!context.qualifier) {
|
||||
if (!context.qualifier && !context.suggestTables) {
|
||||
items.push(...buildKeywordItems(context.prefix));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import type { DatabaseType } from "../types/database.ts";
|
||||
|
||||
export interface BuildTableSelectSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
primaryKeys?: string[];
|
||||
orderBy?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
whereInput?: string;
|
||||
}
|
||||
|
||||
export function quoteTableIdentifier(databaseType: DatabaseType | undefined, name: string): string {
|
||||
if (databaseType === "mysql") return `\`${name.replace(/`/g, "``")}\``;
|
||||
return `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
export function qualifiedTableName(options: Pick<BuildTableSelectSqlOptions, "databaseType" | "schema" | "tableName">): string {
|
||||
const { databaseType, schema, tableName } = options;
|
||||
if ((databaseType === "postgres" || databaseType === "oracle" || databaseType === "sqlserver") && schema) {
|
||||
return `${quoteTableIdentifier(databaseType, schema)}.${quoteTableIdentifier(databaseType, tableName)}`;
|
||||
}
|
||||
return quoteTableIdentifier(databaseType, tableName);
|
||||
}
|
||||
|
||||
export function normalizeWhereInput(whereInput?: string): string {
|
||||
const withoutSemicolon = whereInput?.trim().replace(/;+$/, "").trim() ?? "";
|
||||
return withoutSemicolon.replace(/^where\b/i, "").trim();
|
||||
}
|
||||
|
||||
export function buildTableSelectSql(options: BuildTableSelectSqlOptions): string {
|
||||
const databaseType = options.databaseType;
|
||||
const limit = options.limit ?? 100;
|
||||
const table = qualifiedTableName(options);
|
||||
const predicate = normalizeWhereInput(options.whereInput);
|
||||
const where = predicate ? ` WHERE (${predicate})` : "";
|
||||
const defaultOrderBy = options.primaryKeys?.length
|
||||
? options.primaryKeys.map((pk) => `${quoteTableIdentifier(databaseType, pk)} ASC`).join(", ")
|
||||
: undefined;
|
||||
const orderBy = options.orderBy ?? defaultOrderBy;
|
||||
const order = orderBy ? ` ORDER BY ${orderBy}` : "";
|
||||
|
||||
if (databaseType === "oracle") {
|
||||
const offset = options.offset ? ` OFFSET ${options.offset} ROWS` : "";
|
||||
return `SELECT * FROM ${table}${where}${order}${offset} FETCH FIRST ${limit} ROWS ONLY`;
|
||||
}
|
||||
|
||||
if (databaseType === "sqlserver") {
|
||||
return `SELECT TOP ${limit} * FROM ${table}${where}${order}`;
|
||||
}
|
||||
|
||||
const offset = options.offset ? ` OFFSET ${options.offset}` : "";
|
||||
return `SELECT * FROM ${table}${where}${order} LIMIT ${limit}${offset};`;
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildTableSelectSql } from "../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 SQL Server WHERE query with TOP", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "accounts",
|
||||
whereInput: "where id = 1",
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
assert.equal(sql, 'SELECT TOP 25 * FROM "dbo"."accounts" WHERE (id = 1)');
|
||||
});
|
||||
Loading…
Reference in New Issue