perf(search): batch column fetch and multi-table probe

This commit is contained in:
Lan-zk
2026-10-19 15:17:58 +08:00
committed by GitHub
parent 105da7dfea
commit c7c266c309
23 changed files with 1533 additions and 32 deletions
@@ -12,7 +12,10 @@ import { useConnectionStore } from "@/stores/connectionStore";
import * as api from "@/lib/backend/api";
import { buildDatabaseSearchSql, buildSearchResultWhere, findMatchedSearchColumns } from "@/lib/database/databaseSearch";
import { databaseSearchBatchRange, databaseSearchNextBatchSize } from "@/lib/database/databaseSearchBatch";
import type { DatabaseSearchResultItem, DatabaseSearchScope, DatabaseSearchTableTask, DatabaseSearchTabState, DatabaseType, TableInfo } from "@/types/database";
import { fetchDatabaseSearchColumns, findSearchColumns, type DatabaseSearchColumnLite, type SearchColumnsByTable } from "@/lib/database/databaseSearchColumns";
import { nextProbeChunkEnd, probeTables } from "@/lib/database/databaseSearchProbe";
import { supportsBulkDatabaseSearch } from "@/lib/database/databaseSearchCapabilities";
import type { ColumnInfo, DatabaseSearchResultItem, DatabaseSearchScope, DatabaseSearchTableTask, DatabaseSearchTabState, DatabaseType, TableInfo } from "@/types/database";
import { buildDatabaseStructureSearchSql, effectiveDatabaseSearchScope, runDatabaseStructureSearch, structureHitPreview, supportsDatabaseStructureSearchFromManifest } from "@/lib/database/databaseStructureSearch";
import { usesDocumentStructureSearch, usesSqlCatalogStructureSearch } from "@/lib/database/databaseStructureSearchCapabilities";
import type { NavigationTarget } from "@/composables/useNavigationTargets";
@@ -60,6 +63,8 @@ const generalError = ref(props.initialState?.generalError ?? "");
const currentExecutionId = ref("");
const tableTasks = ref<DatabaseSearchTableTask[]>(props.initialState?.tableTasks ? [...props.initialState.tableTasks] : []);
const nextTableIndex = ref(props.initialState?.nextTableIndex ?? 0);
// 批量取列结果(键小写)。不进持久化 tab state:组件存活期间复用,重建后为空走逐表回退。
const columnsByTable = ref<SearchColumnsByTable>(new Map());
const activeKeyword = ref(props.initialState?.activeKeyword ?? "");
const activePerTableLimit = ref(props.initialState?.activePerTableLimit ?? 20);
let runId = 0;
@@ -132,6 +137,7 @@ function resetSearchState() {
currentExecutionId.value = "";
tableTasks.value = [];
nextTableIndex.value = 0;
columnsByTable.value = new Map();
activeKeyword.value = "";
activePerTableLimit.value = 20;
emitState();
@@ -226,6 +232,8 @@ async function startSearch() {
progressTotal.value = tableTasks.value.length;
loadingTables.value = false;
emitState();
await loadBulkColumns(currentRun);
if (currentRun !== runId || cancelled.value) return;
await scanTableRange(currentRun, false);
} catch (error: any) {
if (currentRun === runId && !cancelled.value) {
@@ -333,25 +341,125 @@ async function runStructureSearch(currentRun: number) {
}
}
/** 批量取列:按任务表涉及的 schema 各发一条目录查询,失败静默(空 Map → 逐表回退)。 */
async function loadBulkColumns(currentRun: number) {
const databaseType = connection.value?.db_type;
if (!databaseType || !supportsBulkDatabaseSearch(databaseType)) return;
const schemas = [...new Set(tableTasks.value.map((task) => task.schema))];
const merged: SearchColumnsByTable = new Map();
for (const schema of schemas) {
if (currentRun !== runId || cancelled.value) return;
const executionId = makeExecutionId();
currentExecutionId.value = executionId;
const partial = await fetchDatabaseSearchColumns({
databaseType,
database: props.database,
schema,
executeQuery: (sql, runExecutionId) => api.executeQuery(props.connectionId, props.database, sql, undefined, runExecutionId),
executionId,
});
if (currentExecutionId.value === executionId) currentExecutionId.value = "";
if (currentRun !== runId || cancelled.value) return;
partial.forEach((columns, key) => merged.set(key, columns));
}
columnsByTable.value = merged;
}
async function scanTableRange(currentRun: number, scanAllRemaining: boolean) {
const range = databaseSearchBatchRange(nextTableIndex.value, tableTasks.value.length, scanAllRemaining);
const databaseType = connection.value!.db_type;
const probeReady = supportsBulkDatabaseSearch(databaseType) && columnsByTable.value.size > 0;
// 每张任务的预取列(可能为 undefined——该表无目录信息,批内走逐表回退)
const taskColumns = tableTasks.value.map((task) => findSearchColumns(columnsByTable.value, task.schema, props.database, task.table.name));
while (nextTableIndex.value < range.end) {
if (currentRun !== runId || cancelled.value) break;
const task = tableTasks.value[nextTableIndex.value];
if (!task) break;
await searchTable(task, connection.value!.db_type, currentRun);
const chunkStart = nextTableIndex.value;
const chunkEnd = probeReady ? nextProbeChunkEnd(chunkStart, range.end, taskColumns, activeKeyword.value) : Math.min(chunkStart + 1, range.end);
const chunk = tableTasks.value.slice(chunkStart, chunkEnd);
if (!chunk.length) break;
let hitChunkIndexes: Set<number> | undefined;
if (probeReady) {
hitChunkIndexes = await probeChunk(chunk, taskColumns.slice(chunkStart, chunkEnd), databaseType, currentRun);
}
if (currentRun !== runId || cancelled.value) break;
nextTableIndex.value += 1;
for (let offset = 0; offset < chunk.length; offset++) {
if (currentRun !== runId || cancelled.value) break;
const taskIndex = chunkStart + offset;
const task = chunk[offset];
const prefetched = taskColumns[taskIndex];
// 探测路径下:未命中的表跳过取数;无目录信息的表恒走逐表
if (hitChunkIndexes && prefetched && !hitChunkIndexes.has(taskIndex)) {
// skip: 探测未命中
} else {
await searchTable(task, databaseType, currentRun, prefetched);
}
nextTableIndex.value = taskIndex + 1;
progressDone.value = nextTableIndex.value;
emitState();
}
}
}
async function searchTable(task: DatabaseSearchTableTask, databaseType: DatabaseType, currentRun: number) {
/** 对一个探测批执行多表探测,返回命中表的全局下标集合;语句失败整批回退(全部视为待取数)。 */
async function probeChunk(chunk: DatabaseSearchTableTask[], chunkColumns: Array<DatabaseSearchColumnLite[] | undefined>, databaseType: DatabaseType, currentRun: number): Promise<Set<number>> {
const chunkStart = nextTableIndex.value;
const probeable: number[] = [];
chunkColumns.forEach((columns, offset) => {
if (columns?.length) probeable.push(offset);
});
if (!probeable.length) return new Set();
const executionId = makeExecutionId();
currentExecutionId.value = executionId;
let hitProbeIndexes: Set<number>;
try {
hitProbeIndexes = await probeTables({
databaseType,
driverProfile: connection.value?.driver_profile,
term: activeKeyword.value,
limit: activePerTableLimit.value,
tables: probeable.map((offset) => ({
schema: chunk[offset].schema,
tableName: chunk[offset].table.name,
columns: chunkColumns[offset]!,
})),
executeQuery: (sql, runExecutionId) => api.executeQuery(props.connectionId, props.database, sql, undefined, runExecutionId),
executionId,
});
} catch {
// 探测批失败(单表权限/中途 DDL 等):整批回退逐表,错误重新隔离到单表
hitProbeIndexes = new Set(probeable.map((_, index) => index));
}
if (currentExecutionId.value === executionId) currentExecutionId.value = "";
if (currentRun !== runId || cancelled.value) return new Set();
const hitTaskIndexes = new Set<number>();
hitProbeIndexes.forEach((probeIndex) => {
const offset = probeable[probeIndex];
if (offset !== undefined) hitTaskIndexes.add(chunkStart + offset);
});
return hitTaskIndexes;
}
async function searchTable(task: DatabaseSearchTableTask, databaseType: DatabaseType, currentRun: number, prefetchedColumns?: DatabaseSearchColumnLite[]) {
const tableLabel = task.schema ? `${task.schema}.${task.table.name}` : task.table.name;
try {
const schema = task.schema || props.database;
const columns = await api.getColumns(props.connectionId, props.database, schema, task.table.name);
// 批量取列命中时跳过逐表 getColumns;数据搜索单表查询只消费这三个字段
const columns: ColumnInfo[] = prefetchedColumns?.length
? prefetchedColumns.map((column) => ({
name: column.name,
data_type: column.data_type,
is_primary_key: column.is_primary_key,
is_nullable: false,
column_default: null,
extra: null,
}))
: await api.getColumns(props.connectionId, props.database, schema, task.table.name);
const query = await buildDatabaseSearchSql({
databaseType,
driverProfile: connection.value?.driver_profile,
@@ -0,0 +1,261 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import i18n from "@/i18n";
import * as api from "@/lib/backend/api";
import DatabaseSearchPanel from "@/components/search/DatabaseSearchPanel.vue";
vi.mock("@/lib/backend/api", () => ({
listSchemas: vi.fn(),
listTables: vi.fn(),
getColumns: vi.fn(),
executeQuery: vi.fn(),
buildDatabaseSearchSql: vi.fn(),
buildDatabaseSearchColumnsSql: vi.fn(),
buildDatabaseSearchProbeSql: vi.fn(),
buildSearchResultWhere: vi.fn(),
cancelQuery: vi.fn(),
}));
const connectionConfig: { id: string; name: string; db_type: string; driver_profile: string | null } = {
id: "conn-1",
name: "test-conn",
db_type: "dameng",
driver_profile: null,
};
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
getConfig: (id: string) => (connectionConfig.id === id ? connectionConfig : undefined),
ensureConnected: vi.fn(async () => {}),
}),
}));
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
props: { disabled: Boolean, variant: String, size: String },
emits: ["click"],
setup(props, { slots, emit }) {
return () => h("button", { disabled: props.disabled, onClick: (e: MouseEvent) => emit("click", e) }, slots.default?.());
},
}),
};
});
vi.mock("@/components/ui/input", async () => {
const { defineComponent, h } = await import("vue");
return {
Input: defineComponent({
props: { modelValue: [String, Number], disabled: Boolean, placeholder: String, type: String },
emits: ["update:modelValue", "keydown"],
setup(props, { emit }) {
return () =>
h("input", {
value: props.modelValue,
disabled: props.disabled,
placeholder: props.placeholder,
type: props.type || "text",
onInput: (e: any) => emit("update:modelValue", e.target.value),
onKeydown: (e: any) => emit("keydown", e),
});
},
}),
};
});
vi.mock("@/components/ui/badge", async () => {
const { defineComponent, h } = await import("vue");
return {
Badge: defineComponent({
setup(_props, { slots }) {
return () => h("span", slots.default?.());
},
}),
};
});
vi.mock("@/components/ui/label", async () => {
const { defineComponent, h } = await import("vue");
return {
Label: defineComponent({
setup(_props, { slots }) {
return () => h("label", slots.default?.());
},
}),
};
});
vi.mock("@/components/ui/select", async () => {
const { defineComponent, h } = await import("vue");
const passthrough = defineComponent({
setup(_props, { slots }) {
return () => h("div", slots.default?.());
},
});
return { Select: passthrough, SelectContent: passthrough, SelectItem: passthrough, SelectTrigger: passthrough, SelectValue: passthrough };
});
const mountedApps: App[] = [];
beforeEach(() => {
setActivePinia(createPinia());
connectionConfig.db_type = "dameng";
});
afterEach(() => {
// 模块工厂里的 vi.fn 不受 restoreAllMocks 影响,必须显式清调用历史与实现
vi.clearAllMocks();
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
const TABLES = [
{ name: "USERS", table_type: "TABLE" },
{ name: "FILES", table_type: "TABLE" },
{ name: "V_ORDERS", table_type: "VIEW" },
];
const BULK_COLUMN_ROWS = {
columns: ["table_name", "column_name", "data_type", "is_primary_key"],
rows: [
["USERS", "ID", "NUMBER", 1],
["USERS", "EMAIL", "VARCHAR2", 0],
["FILES", "DATA", "BLOB", 0],
],
};
async function mountPanel() {
vi.mocked(api.listTables).mockResolvedValue(TABLES as any);
vi.mocked(api.getColumns).mockResolvedValue([]);
vi.mocked(api.buildSearchResultWhere).mockResolvedValue('"ID" = 1');
vi.mocked(api.buildDatabaseSearchSql).mockResolvedValue({ sql: "SELECT_USERS", searchableColumns: ["EMAIL"] });
vi.mocked(api.buildDatabaseSearchColumnsSql).mockResolvedValue({ sql: "COLS_SQL" });
vi.mocked(api.buildDatabaseSearchProbeSql).mockResolvedValue({ sql: "PROBE_SQL", includedIndices: [0] });
const states: any[] = [];
const panelRef = ref<any>(null);
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup() {
return () =>
h(DatabaseSearchPanel, {
ref: panelRef,
connectionId: "conn-1",
database: "shop",
schema: "DLWEB",
initialState: { keyword: "admin" },
inDialog: true,
"onUpdate:state": (state: any) => states.push(state),
});
},
}),
);
app.use(i18n);
mountedApps.push(app);
app.mount(container);
await nextTick();
return { panelRef, states };
}
describe("DatabaseSearchPanel scan flow", () => {
it("bulk path: one columns query + one probe, fetch rows only for hit tables", async () => {
vi.mocked(api.executeQuery).mockImplementation(async (_conn: string, _db: string, sql: string) => {
if (sql === "COLS_SQL") return BULK_COLUMN_ROWS as any;
if (sql === "PROBE_SQL") return { columns: ["TABLE_LABEL", "HITS"], rows: [["t0", 2]] } as any;
if (sql === "SELECT_USERS") return { columns: ["ID", "EMAIL"], rows: [[1, "admin@x.com"]] } as any;
throw new Error(`unexpected sql: ${sql}`);
});
const { panelRef, states } = await mountPanel();
await panelRef.value.startSearch();
await vi.waitFor(() => {
expect(states.at(-1)?.progressDone).toBe(2);
});
// 批量取列:一条目录查询,零次逐表 getColumns
expect(api.buildDatabaseSearchColumnsSql).toHaveBeenCalledTimes(1);
expect(api.getColumns).not.toHaveBeenCalled();
// 探测:两个任务表(视图已被排除),只有 USERS 命中并取数
expect(api.buildDatabaseSearchProbeSql).toHaveBeenCalledTimes(1);
const probeArg = vi.mocked(api.buildDatabaseSearchProbeSql).mock.calls[0][0];
expect(probeArg.tables.map((t: any) => t.tableName)).toEqual(["USERS", "FILES"]);
expect(api.buildDatabaseSearchSql).toHaveBeenCalledTimes(1);
expect(vi.mocked(api.buildDatabaseSearchSql).mock.calls[0][0].tableName).toBe("USERS");
// 预取列被传入单表查询(证明不再走 getColumns)
expect(vi.mocked(api.buildDatabaseSearchSql).mock.calls[0][0].columns.map((c: any) => c.name)).toEqual(["ID", "EMAIL"]);
// 结果与回表条件一致
const finalState = states.at(-1);
expect(finalState.results).toHaveLength(1);
expect(finalState.results[0].tableName).toBe("USERS");
expect(finalState.results[0].whereInput).toBe('"ID" = 1');
expect(finalState.progressTotal).toBe(2);
});
it("probe failure falls back to per-table scan for the whole batch", async () => {
vi.mocked(api.executeQuery).mockImplementation(async (_conn: string, _db: string, sql: string) => {
if (sql === "COLS_SQL") return BULK_COLUMN_ROWS as any;
if (sql === "PROBE_SQL") throw new Error("ORA-00942: table or view does not exist");
if (sql === "SELECT_USERS") return { columns: ["ID", "EMAIL"], rows: [[1, "admin@x.com"]] } as any;
throw new Error(`unexpected sql: ${sql}`);
});
const { panelRef, states } = await mountPanel();
await panelRef.value.startSearch();
await vi.waitFor(() => {
expect(states.at(-1)?.progressDone).toBe(2);
});
// 整批回退:两张表都执行了逐表取数查询,列信息仍来自批量 Map
expect(api.buildDatabaseSearchSql).toHaveBeenCalledTimes(2);
expect(vi.mocked(api.buildDatabaseSearchSql).mock.calls.map((c) => c[0].tableName)).toEqual(["USERS", "FILES"]);
expect(api.getColumns).not.toHaveBeenCalled();
// 失败被吞掉,不产生 generalError
expect(states.at(-1)?.generalError).toBe("");
});
it("unsupported dialect silently falls back to per-table getColumns", async () => {
connectionConfig.db_type = "sqlite";
vi.mocked(api.getColumns).mockResolvedValue([{ name: "EMAIL", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, extra: null }] as any);
vi.mocked(api.executeQuery).mockImplementation(async (_conn: string, _db: string, sql: string) => {
if (sql === "SELECT_USERS") return { columns: ["EMAIL"], rows: [["admin@x.com"]] } as any;
throw new Error(`unexpected sql: ${sql}`);
});
const { panelRef, states } = await mountPanel();
await panelRef.value.startSearch();
await vi.waitFor(() => {
expect(states.at(-1)?.progressDone).toBe(2);
});
expect(api.buildDatabaseSearchColumnsSql).not.toHaveBeenCalled();
expect(api.buildDatabaseSearchProbeSql).not.toHaveBeenCalled();
expect(api.getColumns).toHaveBeenCalledTimes(2);
expect(api.buildDatabaseSearchSql).toHaveBeenCalledTimes(2);
});
it("tables without probe hits advance progress without any fetch query", async () => {
vi.mocked(api.executeQuery).mockImplementation(async (_conn: string, _db: string, sql: string) => {
if (sql === "COLS_SQL") return BULK_COLUMN_ROWS as any;
throw new Error(`unexpected sql: ${sql}`);
});
const { panelRef, states } = await mountPanel();
// mountPanel 会设置默认探测 mock,这里在启动前覆盖为 null(全部无可搜列等价场景)
vi.mocked(api.buildDatabaseSearchProbeSql).mockResolvedValue(null);
await panelRef.value.startSearch();
await vi.waitFor(() => {
expect(states.at(-1)?.progressDone).toBe(2);
});
expect(api.buildDatabaseSearchSql).not.toHaveBeenCalled();
expect(states.at(-1)?.results).toHaveLength(0);
});
});
+2
View File
@@ -304,6 +304,8 @@ export const buildCreateUserSql = forward("buildCreateUserSql");
export const buildDroppedFilePreviewSql = forward("buildDroppedFilePreviewSql");
export const buildTableSelectSql = forward("buildTableSelectSql");
export const buildDatabaseSearchSql = forward("buildDatabaseSearchSql");
export const buildDatabaseSearchColumnsSql = forward("buildDatabaseSearchColumnsSql");
export const buildDatabaseSearchProbeSql = forward("buildDatabaseSearchProbeSql");
export const buildDatabaseStructureSearchSql = forward("buildDatabaseStructureSearchSql");
export const supportsDatabaseStructureSearch = forward("supportsDatabaseStructureSearch");
export const buildSearchResultWhere = forward("buildSearchResultWhere");
+8
View File
@@ -1873,6 +1873,14 @@ export async function buildDatabaseSearchSql(options: DatabaseSearchSqlOptions):
return post("/api/query/build-database-search-sql", { options });
}
export async function buildDatabaseSearchColumnsSql(options: import("@/lib/database/databaseSearchColumns").DatabaseSearchColumnsSqlOptions): Promise<{ sql: string } | null> {
return post("/api/query/build-database-search-columns-sql", { options });
}
export async function buildDatabaseSearchProbeSql(options: import("@/lib/database/databaseSearchProbe").DatabaseSearchProbeSqlOptions): Promise<{ sql: string; includedIndices: number[] } | null> {
return post("/api/query/build-database-search-probe-sql", { options });
}
export async function buildDatabaseStructureSearchSql(options: DatabaseStructureSearchSqlOptions): Promise<DatabaseStructureSearchSql | null> {
return post("/api/query/build-database-structure-search-sql", { options });
}
+8
View File
@@ -2310,6 +2310,14 @@ export async function buildDatabaseSearchSql(options: DatabaseSearchSqlOptions):
return invoke("build_database_search_sql", { options });
}
export async function buildDatabaseSearchColumnsSql(options: import("@/lib/database/databaseSearchColumns").DatabaseSearchColumnsSqlOptions): Promise<{ sql: string } | null> {
return invoke("build_database_search_columns_sql", { options });
}
export async function buildDatabaseSearchProbeSql(options: import("@/lib/database/databaseSearchProbe").DatabaseSearchProbeSqlOptions): Promise<{ sql: string; includedIndices: number[] } | null> {
return invoke("build_database_search_probe_sql", { options });
}
export async function buildDatabaseStructureSearchSql(options: DatabaseStructureSearchSqlOptions): Promise<DatabaseStructureSearchSql | null> {
return invoke("build_database_structure_search_sql", { options });
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { BULK_DATABASE_SEARCH_TYPES, supportsBulkDatabaseSearch } from "@/lib/database/databaseSearchCapabilities";
// 镜像 Rust database_search_columns_sql.rs / database_search_probe_sql.rs 的家族 match 臂:
// 两边同步修改时必须一并更新此快照,防止前端预判与后端判定漂移。
const RUST_BULK_SEARCH_FAMILIES = ["oracle", "oceanbase-oracle", "dameng", "mysql", "doris", "starrocks", "manticoresearch", "goldendb", "postgres", "kingbase", "highgo", "uxdb", "vastbase", "opengauss", "gaussdb", "kwdb", "yashandb", "oscar", "redshift", "sqlserver"];
describe("databaseSearchCapabilities", () => {
it("mirrors the Rust bulk search family dispatch", () => {
expect([...BULK_DATABASE_SEARCH_TYPES].sort()).toEqual([...RUST_BULK_SEARCH_FAMILIES].sort());
});
it("gates unsupported dialects", () => {
expect(supportsBulkDatabaseSearch("dameng")).toBe(true);
expect(supportsBulkDatabaseSearch("sqlite")).toBe(false);
expect(supportsBulkDatabaseSearch(undefined)).toBe(false);
});
});
@@ -0,0 +1,119 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as api from "@/lib/backend/api.ts";
import { fetchDatabaseSearchColumns, findSearchColumns, searchColumnsTableKey } from "@/lib/database/databaseSearchColumns";
afterEach(() => {
vi.restoreAllMocks();
});
function rowsResult(columns: string[], rows: unknown[][]) {
return { columns, rows };
}
describe("databaseSearchColumns", () => {
it("groups catalog rows by table with lowercase keys", async () => {
const buildSpy = vi.spyOn(api, "buildDatabaseSearchColumnsSql").mockResolvedValue({ sql: "SELECT 1" });
const executeQuery = vi.fn().mockResolvedValue(
rowsResult(
["TABLE_NAME", "COLUMN_NAME", "DATA_TYPE", "IS_PRIMARY_KEY"],
[
["USERS", "ID", "NUMBER", 1],
["USERS", "EMAIL", "VARCHAR2", 0],
["ORDERS", "ID", "NUMBER", 1],
],
),
);
const map = await fetchDatabaseSearchColumns({
databaseType: "dameng",
database: "app",
schema: "DLWEB",
executeQuery,
executionId: "exec-1",
});
expect(buildSpy).toHaveBeenCalledWith({ databaseType: "dameng", database: "app", schema: "DLWEB" });
const users = findSearchColumns(map, "DLWEB", "app", "USERS");
expect(users).toHaveLength(2);
expect(users?.[0]).toEqual({ name: "ID", data_type: "NUMBER", is_primary_key: true });
expect(users?.[1]?.is_primary_key).toBe(false);
expect(findSearchColumns(map, "dlweb", "app", "users")).toHaveLength(2);
expect(findSearchColumns(map, "DLWEB", "app", "ORDERS")).toHaveLength(1);
});
it("normalizes primary key flag variants", async () => {
vi.spyOn(api, "buildDatabaseSearchColumnsSql").mockResolvedValue({ sql: "SELECT 1" });
const executeQuery = vi.fn().mockResolvedValue(
rowsResult(
["table_name", "column_name", "data_type", "is_primary_key"],
[
["t1", "a", "int", true],
["t1", "b", "int", false],
["t2", "a", "int", "1"],
["t2", "b", "int", "0"],
["t3", "a", "int", 1],
["t3", "b", "int", 0],
],
),
);
const map = await fetchDatabaseSearchColumns({ databaseType: "postgres", database: "app", executeQuery, executionId: "e" });
const t1 = findSearchColumns(map, undefined, "app", "t1")!;
const t2 = findSearchColumns(map, undefined, "app", "t2")!;
const t3 = findSearchColumns(map, undefined, "app", "t3")!;
expect(t1.map((c) => c.is_primary_key)).toEqual([true, false]);
expect(t2.map((c) => c.is_primary_key)).toEqual([true, false]);
expect(t3.map((c) => c.is_primary_key)).toEqual([true, false]);
});
it("returns empty map when builder yields no sql", async () => {
const buildSpy = vi.spyOn(api, "buildDatabaseSearchColumnsSql").mockResolvedValue(null);
const executeQuery = vi.fn();
const map = await fetchDatabaseSearchColumns({ databaseType: "dameng", database: "app", executeQuery, executionId: "e" });
expect(map.size).toBe(0);
expect(buildSpy).toHaveBeenCalled();
expect(executeQuery).not.toHaveBeenCalled();
});
it("returns empty map when the catalog result is truncated so callers fall back per-table", async () => {
vi.spyOn(api, "buildDatabaseSearchColumnsSql").mockResolvedValue({ sql: "SELECT 1" });
const executeQuery = vi.fn().mockResolvedValue({
columns: ["TABLE_NAME", "COLUMN_NAME", "DATA_TYPE", "IS_PRIMARY_KEY"],
rows: [
["USERS", "ID", "NUMBER", 1],
["USERS", "EMAIL", "VARCHAR2", 0],
],
truncated: true,
});
const map = await fetchDatabaseSearchColumns({
databaseType: "dameng",
database: "app",
schema: "DLWEB",
executeQuery,
executionId: "exec-1",
});
expect(map.size).toBe(0);
});
it("returns empty map when the catalog query fails", async () => {
vi.spyOn(api, "buildDatabaseSearchColumnsSql").mockResolvedValue({ sql: "SELECT 1" });
const executeQuery = vi.fn().mockRejectedValue(new Error("permission denied"));
const map = await fetchDatabaseSearchColumns({ databaseType: "mysql", database: "app", executeQuery, executionId: "e" });
expect(map.size).toBe(0);
});
it("skips bulk fetch for unsupported dialects without calling the builder", async () => {
const buildSpy = vi.spyOn(api, "buildDatabaseSearchColumnsSql");
const map = await fetchDatabaseSearchColumns({ databaseType: "sqlite", database: "app", executeQuery: vi.fn(), executionId: "e" });
expect(map.size).toBe(0);
expect(buildSpy).not.toHaveBeenCalled();
});
it("keys use schema when present and database otherwise", () => {
expect(searchColumnsTableKey("public", "app", "Users")).toBe("public\nusers");
expect(searchColumnsTableKey(undefined, "app", "Users")).toBe("app\nusers");
expect(searchColumnsTableKey(" ", "app", "Users")).toBe("app\nusers");
});
});
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as api from "@/lib/backend/api.ts";
import type { DatabaseSearchColumnLite } from "@/lib/database/databaseSearchColumns";
import { countProbeConditions, DATABASE_SEARCH_PROBE_CONDITION_LIMIT, DATABASE_SEARCH_PROBE_TABLE_BATCH_SIZE, nextProbeChunkEnd, probeTables } from "@/lib/database/databaseSearchProbe";
afterEach(() => {
vi.restoreAllMocks();
});
function col(name: string, data_type: string): DatabaseSearchColumnLite {
return { name, data_type, is_primary_key: false };
}
describe("databaseSearchProbe", () => {
it("counts text columns and numeric columns only for numeric terms", () => {
const columns = [col("a", "varchar"), col("b", "int"), col("c", "text"), col("d", "decimal")];
expect(countProbeConditions(columns, "alice")).toBe(2);
expect(countProbeConditions(columns, "42")).toBe(4);
expect(countProbeConditions([], "alice")).toBe(0);
});
it("caps chunks at the table batch size", () => {
const tasks = Array.from({ length: 30 }, () => [col("a", "varchar")]);
expect(nextProbeChunkEnd(0, tasks.length, tasks, "x")).toBe(DATABASE_SEARCH_PROBE_TABLE_BATCH_SIZE);
expect(nextProbeChunkEnd(15, tasks.length, tasks, "x")).toBe(30);
});
it("splits before the table that would exceed the condition limit", () => {
// 每表 60 条件:第 8 张起累计 480,第 9 张会到 540 > 500 → 批在第 8 张后切分
const heavy = Array.from({ length: 60 }, (_, i) => col(`c${i}`, "varchar"));
const tasks = Array.from({ length: 12 }, () => heavy);
expect(nextProbeChunkEnd(0, tasks.length, tasks, "x")).toBe(8);
// 无列信息的表按 0 条件计,不触顶
const unknown = [heavy, undefined, heavy, undefined];
expect(nextProbeChunkEnd(0, 4, unknown, "x")).toBe(4);
});
it("always takes at least one table even beyond the condition limit", () => {
const huge = Array.from({ length: DATABASE_SEARCH_PROBE_CONDITION_LIMIT + 10 }, (_, i) => col(`c${i}`, "varchar"));
expect(nextProbeChunkEnd(0, 3, [huge, huge, huge], "x")).toBe(1);
});
it("maps label rows back to hit table indexes via includedIndices", async () => {
const buildSpy = vi.spyOn(api, "buildDatabaseSearchProbeSql").mockResolvedValue({
sql: "SELECT 1",
// 输入 3 张表,第 0、2 张进探测分支(t0、t1),第 1 张被剔除
includedIndices: [0, 2],
});
const executeQuery = vi.fn().mockResolvedValue({
columns: ["TABLE_LABEL", "HITS"],
rows: [
["t0", 3],
["t1", 0],
["tX", 9],
],
});
const hits = await probeTables({
databaseType: "dameng",
term: "alice",
limit: 20,
tables: [
{ tableName: "a", columns: [col("x", "varchar2")] },
{ tableName: "b", columns: [col("x", "blob")] },
{ tableName: "c", columns: [col("y", "text")] },
],
executeQuery,
executionId: "e",
});
expect(buildSpy).toHaveBeenCalled();
// t0 命中(hits=3)→ 输入下标 0;t1 未命中(hits=0);未知标签 tX 忽略;被剔除的输入下标 1 不出现
expect([...hits]).toEqual([0]);
expect(hits.has(1)).toBe(false);
expect(hits.has(2)).toBe(false);
});
it("returns empty set when the builder yields no sql", async () => {
vi.spyOn(api, "buildDatabaseSearchProbeSql").mockResolvedValue(null);
const hits = await probeTables({
databaseType: "mysql",
term: "alice",
limit: 20,
tables: [{ tableName: "a", columns: [col("x", "varchar")] }],
executeQuery: vi.fn(),
executionId: "e",
});
expect(hits.size).toBe(0);
});
it("propagates statement failures so callers fall back per table", async () => {
vi.spyOn(api, "buildDatabaseSearchProbeSql").mockResolvedValue({ sql: "SELECT 1", includedIndices: [0] });
const executeQuery = vi.fn().mockRejectedValue(new Error("table dropped"));
await expect(
probeTables({
databaseType: "mysql",
term: "alice",
limit: 20,
tables: [{ tableName: "a", columns: [col("x", "varchar")] }],
executeQuery,
executionId: "e",
}),
).rejects.toThrow("table dropped");
});
});
@@ -30,17 +30,20 @@ const NUMBER_TYPES = ["int", "serial", "number", "numeric", "decimal", "float",
const SKIPPED_TYPES = ["blob", "binary", "bytea", "image", "geometry", "geography"];
function normalizedType(column: ColumnInfo): string {
/** 文本/数值列判定只读 data_type,兼容批量取列的最小列结构。 */
type SearchableColumn = Pick<ColumnInfo, "data_type">;
function normalizedType(column: SearchableColumn): string {
return column.data_type.toLowerCase();
}
export function isTextSearchColumn(column: ColumnInfo): boolean {
export function isTextSearchColumn(column: SearchableColumn): boolean {
const type = normalizedType(column);
if (SKIPPED_TYPES.some((skipped) => type.includes(skipped))) return false;
return TEXT_TYPES.some((textType) => type.includes(textType));
}
export function isNumericSearchColumn(column: ColumnInfo): boolean {
export function isNumericSearchColumn(column: SearchableColumn): boolean {
const type = normalizedType(column);
if (SKIPPED_TYPES.some((skipped) => type.includes(skipped))) return false;
return NUMBER_TYPES.some((numberType) => type.includes(numberType));
@@ -50,7 +53,7 @@ function cleanTerm(term: string): string {
return term.trim();
}
function parseNumericTerm(term: string): string | null {
export function parseNumericTerm(term: string): string | null {
const trimmed = cleanTerm(term);
if (!/^[+-]?(?:\d+|\d+\.\d+|\.\d+)$/.test(trimmed)) return null;
return trimmed;
@@ -0,0 +1,14 @@
import type { DatabaseType } from "@/types/database";
/**
* 「在数据库中查找」批量路径(批量取列 + 多表探测)支持的方言,
* 镜像 Rust `database_search_columns_sql` / `database_search_probe_sql`
* 的家族判定;两处集合必须同步修改(有单测锁形状)。
*/
const BULK_DATABASE_SEARCH_TYPES = new Set<DatabaseType>(["oracle", "oceanbase-oracle", "dameng", "mysql", "doris", "starrocks", "manticoresearch", "goldendb", "postgres", "kingbase", "highgo", "uxdb", "vastbase", "opengauss", "gaussdb", "kwdb", "yashandb", "oscar", "redshift", "sqlserver"]);
export function supportsBulkDatabaseSearch(dbType?: DatabaseType): boolean {
return !!dbType && BULK_DATABASE_SEARCH_TYPES.has(dbType);
}
export { BULK_DATABASE_SEARCH_TYPES };
@@ -0,0 +1,97 @@
import type { DatabaseType } from "@/types/database.ts";
import * as api from "@/lib/backend/api.ts";
import { supportsBulkDatabaseSearch } from "@/lib/database/databaseSearchCapabilities.ts";
/** 批量取列的最小列形状:数据搜索路径只消费 name / data_type / is_primary_key。 */
export interface DatabaseSearchColumnLite {
name: string;
data_type: string;
is_primary_key: boolean;
}
export interface DatabaseSearchColumnsSqlOptions {
databaseType?: DatabaseType;
database: string;
schema?: string;
}
/** (schema, 表名) → 列列表。键统一小写兜底:listTables 与目录行大小写偶有差异。 */
export type SearchColumnsByTable = Map<string, DatabaseSearchColumnLite[]>;
export function searchColumnsTableKey(schema: string | undefined, database: string, tableName: string): string {
const owner = (schema && schema.trim()) || database;
return `${owner.toLowerCase()}\n${tableName.toLowerCase()}`;
}
export function findSearchColumns(map: SearchColumnsByTable, schema: string | undefined, database: string, tableName: string): DatabaseSearchColumnLite[] | undefined {
return map.get(searchColumnsTableKey(schema, database, tableName));
}
function isTruthyPrimaryKey(value: unknown): boolean {
if (value === true) return true;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") return value.trim().toLowerCase() === "1" || value.trim().toLowerCase() === "true";
return false;
}
/**
* 一次目录查询取回整个 schema 的列信息并按表分组。
* 任何失败(方言不支持、目录视图不可见、执行出错)都返回空 Map,
* 调用方对缺列的表逐表回退 getColumns——绝不抛错中断搜索。
*/
export async function fetchDatabaseSearchColumns(options: {
databaseType?: DatabaseType;
database: string;
schema?: string;
buildSql?: typeof api.buildDatabaseSearchColumnsSql;
executeQuery: (sql: string, executionId: string) => Promise<{ columns: string[]; rows: unknown[][]; truncated?: boolean }>;
executionId: string;
}): Promise<SearchColumnsByTable> {
const map: SearchColumnsByTable = new Map();
if (!supportsBulkDatabaseSearch(options.databaseType)) return map;
try {
const buildSql = options.buildSql ?? api.buildDatabaseSearchColumnsSql;
const built = await buildSql({
databaseType: options.databaseType,
database: options.database,
schema: options.schema,
});
if (!built?.sql) return map;
const result = await options.executeQuery(built.sql, options.executionId);
// The catalog query rides the backend's default row cap (10k). A truncation
// can land mid-table, and a table seen with only part of its columns would
// make the probe miss keywords in the dropped columns — silently skipping
// that table. Drop the whole catalog instead: an empty map sends every
// table down the per-table getColumns fallback, which is always complete.
if (result.truncated) return map;
const headerIndex = new Map<string, number>();
result.columns.forEach((name, index) => headerIndex.set(name.toLowerCase(), index));
const tableIndex = headerIndex.get("table_name");
const columnIndex = headerIndex.get("column_name");
const typeIndex = headerIndex.get("data_type");
const primaryKeyIndex = headerIndex.get("is_primary_key");
if (tableIndex === undefined || columnIndex === undefined || typeIndex === undefined) return map;
for (const row of result.rows) {
const tableName = String(row[tableIndex] ?? "").trim();
const columnName = String(row[columnIndex] ?? "").trim();
if (!tableName || !columnName) continue;
const key = searchColumnsTableKey(options.schema, options.database, tableName);
let columns = map.get(key);
if (!columns) {
columns = [];
map.set(key, columns);
}
columns.push({
name: columnName,
data_type: String(row[typeIndex] ?? ""),
is_primary_key: primaryKeyIndex !== undefined && isTruthyPrimaryKey(row[primaryKeyIndex]),
});
}
} catch {
// 目录查询失败——返回已收集部分,未命中的表由调用方逐表回退
}
return map;
}
@@ -0,0 +1,95 @@
import type { DatabaseType } from "@/types/database.ts";
import * as api from "@/lib/backend/api.ts";
import { isNumericSearchColumn, isTextSearchColumn, parseNumericTerm } from "@/lib/database/databaseSearch.ts";
import type { DatabaseSearchColumnLite } from "@/lib/database/databaseSearchColumns.ts";
/** 单条探测语句最多合并的表数与条件数(双上限任一触顶切批,防巨型语句拖垮解析器)。 */
export const DATABASE_SEARCH_PROBE_TABLE_BATCH_SIZE = 20;
export const DATABASE_SEARCH_PROBE_CONDITION_LIMIT = 500;
export interface DatabaseSearchProbeSqlOptions {
databaseType?: DatabaseType;
driverProfile?: string;
tables: Array<{
schema?: string;
tableName: string;
columns: DatabaseSearchColumnLite[];
}>;
term: string;
limit?: number;
}
export function countProbeConditions(columns: DatabaseSearchColumnLite[], term: string): number {
const textCount = columns.filter(isTextSearchColumn).length;
const numericCount = parseNumericTerm(term) ? columns.filter(isNumericSearchColumn).length : 0;
return textCount + numericCount;
}
/**
* 计算下一个探测批的结束下标(不含):从 nextIndex 起,受表数/条件数双上限约束;
* 至少包含一张表,避免零条件批死循环(无列信息的表按 0 条件计,走批内逐表回退)。
* tasks 直接传每张表的预取列数组(undefined = 无目录信息)。
*/
export function nextProbeChunkEnd(nextIndex: number, endIndex: number, tasks: Array<DatabaseSearchColumnLite[] | undefined>, term: string, options?: { tableBatchSize?: number; conditionLimit?: number }): number {
const tableBatchSize = options?.tableBatchSize ?? DATABASE_SEARCH_PROBE_TABLE_BATCH_SIZE;
const conditionLimit = options?.conditionLimit ?? DATABASE_SEARCH_PROBE_CONDITION_LIMIT;
let end = Math.min(nextIndex, endIndex);
let conditions = 0;
let tables = 0;
while (end < endIndex && tables < tableBatchSize) {
const taskConditions = tasks[end] ? countProbeConditions(tasks[end]!, term) : 0;
if (tables > 0 && conditions + taskConditions > conditionLimit) break;
conditions += taskConditions;
tables += 1;
end += 1;
}
return Math.max(end, nextIndex + 1 <= endIndex ? nextIndex + 1 : nextIndex);
}
/**
* 执行一条多表探测语句,返回命中的表下标集合(相对传入 tables)。
* 语句失败向上抛出,由调用方将该批回退逐表路径。
*/
export async function probeTables(options: {
databaseType?: DatabaseType;
driverProfile?: string;
term: string;
limit: number;
tables: DatabaseSearchProbeSqlOptions["tables"];
buildSql?: typeof api.buildDatabaseSearchProbeSql;
executeQuery: (sql: string, executionId: string) => Promise<{ columns: string[]; rows: unknown[][] }>;
executionId: string;
}): Promise<Set<number>> {
const buildSql = options.buildSql ?? api.buildDatabaseSearchProbeSql;
const probe = await buildSql({
databaseType: options.databaseType,
driverProfile: options.driverProfile,
tables: options.tables,
term: options.term,
limit: options.limit,
});
if (!probe?.sql) return new Set();
const result = await options.executeQuery(probe.sql, options.executionId);
const headerIndex = new Map<string, number>();
result.columns.forEach((name, index) => headerIndex.set(name.toLowerCase(), index));
const labelIndex = headerIndex.get("table_label");
const hitsIndex = headerIndex.get("hits");
if (labelIndex === undefined) return new Set();
// includedIndices[k] 是第 k 个分支(t{k})对应的输入表下标
const labelToIndex = new Map<string, number>();
probe.includedIndices.forEach((tableIndex, branch) => {
labelToIndex.set(`t${branch}`, tableIndex);
});
const hits = new Set<number>();
for (const row of result.rows) {
const label = String(row[labelIndex] ?? "").trim();
const tableIndex = labelToIndex.get(label);
if (tableIndex === undefined) continue;
const hitCount = hitsIndex === undefined ? 1 : Number(row[hitsIndex] ?? 0);
if (Number.isFinite(hitCount) && hitCount > 0) hits.add(tableIndex);
}
return hits;
}
+2
View File
@@ -44,6 +44,8 @@ pub use data::database_export;
pub use dbx_drivers::database_capabilities;
pub use dbx_sql::data_grid_extractors;
pub use dbx_sql::data_grid_sql;
pub use dbx_sql::database_search_columns_sql;
pub use dbx_sql::database_search_probe_sql;
pub use dbx_sql::database_search_sql;
pub use dbx_sql::database_structure_search_sql;
pub use dbx_types::database_manifest;
@@ -0,0 +1,248 @@
use serde::{Deserialize, Serialize};
use crate::models::connection::DatabaseType;
/// 「在数据库中查找」的批量列信息查询:每个 schema 一条目录 SQL,
/// 一次取回该 schema 全部普通表的列名/类型/主键标记,替代逐表 getColumns 往返。
/// 行形状统一为 table_name / column_name / data_type / is_primary_key,
/// 主键来源以 LEFT JOIN 挂接——权限不足时主键标记缺失但列信息仍可用。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DatabaseSearchColumnsSqlOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database_type: Option<DatabaseType>,
pub database: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DatabaseSearchColumnsSql {
pub sql: String,
}
pub fn build_database_search_columns_sql(options: DatabaseSearchColumnsSqlOptions) -> Option<DatabaseSearchColumnsSql> {
let database_type = options.database_type?;
let sql = match database_type {
DatabaseType::Oracle | DatabaseType::OceanbaseOracle | DatabaseType::Dameng => {
oracle_family_columns_sql(options.schema.as_deref(), &options.database)
}
DatabaseType::Mysql
| DatabaseType::Doris
| DatabaseType::StarRocks
| DatabaseType::ManticoreSearch
| DatabaseType::Goldendb => mysql_family_columns_sql(options.schema.as_deref(), &options.database),
DatabaseType::Postgres
| DatabaseType::Kingbase
| DatabaseType::Highgo
| DatabaseType::Uxdb
| DatabaseType::Gbase8c
| DatabaseType::Vastbase
| DatabaseType::OpenGauss
| DatabaseType::Gaussdb
| DatabaseType::Kwdb
| DatabaseType::Yashandb
| DatabaseType::Oscar
| DatabaseType::Redshift => postgres_family_columns_sql(options.schema.as_deref()),
DatabaseType::SqlServer => sqlserver_columns_sql(options.schema.as_deref()),
// 其余方言(含 SQLite 本地家族)不支持批量取列,调用方逐表回退 getColumns。
_ => return None,
};
Some(DatabaseSearchColumnsSql { sql })
}
fn schema_or_database<'a>(schema: Option<&'a str>, database: &'a str) -> &'a str {
schema.filter(|value| !value.trim().is_empty()).unwrap_or(database)
}
// Oracle 家族用带引号的小写别名,否则无引号别名会被统一大写。
fn oracle_family_columns_sql(schema: Option<&str>, database: &str) -> String {
let owner = sql_string_literal(schema_or_database(schema, database));
format!(
"SELECT c.TABLE_NAME AS \"table_name\", c.COLUMN_NAME AS \"column_name\", c.DATA_TYPE AS \"data_type\" \
, CASE WHEN pc.COLUMN_NAME IS NULL THEN 0 ELSE 1 END AS \"is_primary_key\" \
FROM ALL_TAB_COLUMNS c \
LEFT JOIN ( \
SELECT cc.OWNER, cc.TABLE_NAME, cc.COLUMN_NAME \
FROM ALL_CONSTRAINTS cn \
JOIN ALL_CONS_COLUMNS cc ON cc.OWNER = cn.OWNER AND cc.TABLE_NAME = cn.TABLE_NAME AND cc.CONSTRAINT_NAME = cn.CONSTRAINT_NAME \
WHERE cn.CONSTRAINT_TYPE = 'P' ) pc \
ON pc.OWNER = c.OWNER AND pc.TABLE_NAME = c.TABLE_NAME AND pc.COLUMN_NAME = c.COLUMN_NAME \
WHERE c.OWNER = {owner} \
ORDER BY c.TABLE_NAME, c.COLUMN_ID"
)
}
fn mysql_family_columns_sql(schema: Option<&str>, database: &str) -> String {
let table_schema = sql_string_literal(schema_or_database(schema, database));
format!(
"SELECT c.TABLE_NAME AS table_name, c.COLUMN_NAME AS column_name, c.DATA_TYPE AS data_type \
, CASE WHEN k.COLUMN_NAME IS NULL THEN 0 ELSE 1 END AS is_primary_key \
FROM information_schema.COLUMNS c \
LEFT JOIN information_schema.KEY_COLUMN_USAGE k \
ON k.TABLE_SCHEMA = c.TABLE_SCHEMA AND k.TABLE_NAME = c.TABLE_NAME AND k.COLUMN_NAME = c.COLUMN_NAME \
AND k.CONSTRAINT_NAME = 'PRIMARY' \
WHERE c.TABLE_SCHEMA = {table_schema} \
ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION"
)
}
// PG 家族走 pg_catalog(与结构搜索同路线):对 PG 系分支(Kingbase/OpenGauss 等)兼容性
// 优于 information_schema;data_type 取 t.typname 裸类型名,与逐表 getColumns 返回的类型
// 字符串在文本/数值列子串匹配上等价(varchar/character varying 均命中 "char")。
fn postgres_family_columns_sql(schema: Option<&str>) -> String {
let schema_filter = match schema.filter(|value| !value.trim().is_empty()) {
Some(schema) => format!("n.nspname = {}", sql_string_literal(schema)),
None => "n.nspname NOT IN ('pg_catalog', 'information_schema')".to_string(),
};
format!(
"SELECT c.relname AS table_name, a.attname AS column_name, t.typname AS data_type \
, (pk.attnum IS NOT NULL) AS is_primary_key \
FROM pg_catalog.pg_attribute a \
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
JOIN pg_catalog.pg_type t ON t.oid = a.atttypid \
LEFT JOIN ( \
SELECT i.indrelid AS relid, a2.attnum AS attnum \
FROM pg_catalog.pg_index i \
JOIN pg_catalog.pg_attribute a2 ON a2.attrelid = i.indrelid AND a2.attnum = ANY(i.indkey) \
WHERE i.indisprimary ) pk ON pk.relid = c.oid AND pk.attnum = a.attnum \
WHERE a.attnum > 0 AND NOT a.attisdropped AND c.relkind IN ('r', 'p') \
AND {schema_filter} \
ORDER BY c.relname, a.attnum"
)
}
fn sqlserver_columns_sql(schema: Option<&str>) -> String {
let schema_filter = match schema.filter(|value| !value.trim().is_empty()) {
Some(schema) => format!("c.TABLE_SCHEMA = {}", sql_string_literal(schema)),
None => "c.TABLE_SCHEMA NOT IN ('sys', 'INFORMATION_SCHEMA')".to_string(),
};
format!(
"SELECT c.TABLE_NAME AS table_name, c.COLUMN_NAME AS column_name, c.DATA_TYPE AS data_type \
, CASE WHEN pk.COLUMN_NAME IS NULL THEN 0 ELSE 1 END AS is_primary_key \
FROM INFORMATION_SCHEMA.COLUMNS c \
LEFT JOIN ( \
SELECT kcu.TABLE_SCHEMA, kcu.TABLE_NAME, kcu.COLUMN_NAME \
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc \
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu \
ON kcu.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA AND kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME \
AND kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA \
WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' ) pk \
ON pk.TABLE_SCHEMA = c.TABLE_SCHEMA AND pk.TABLE_NAME = c.TABLE_NAME AND pk.COLUMN_NAME = c.COLUMN_NAME \
WHERE {schema_filter} \
ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION"
)
}
fn sql_string_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn oracle_family_uses_all_tab_columns_with_pk_join() {
let query = build_database_search_columns_sql(DatabaseSearchColumnsSqlOptions {
database_type: Some(DatabaseType::Dameng),
database: "dlweb".to_string(),
schema: Some("DLWEB".to_string()),
})
.unwrap();
assert!(query.sql.contains("FROM ALL_TAB_COLUMNS c"));
assert!(query.sql.contains("ALL_CONSTRAINTS cn"));
assert!(query.sql.contains("cn.CONSTRAINT_TYPE = 'P'"));
assert!(query.sql.contains("LEFT JOIN"));
assert!(query.sql.contains("WHERE c.OWNER = 'DLWEB'"));
// 带引号的小写别名保证 Oracle 驱动返回小写列头
assert!(query.sql.contains("AS \"table_name\""));
assert!(query.sql.contains("AS \"is_primary_key\""));
assert!(query.sql.contains("ORDER BY c.TABLE_NAME, c.COLUMN_ID"));
}
#[test]
fn oracle_family_falls_back_to_database_when_schema_missing() {
let query = build_database_search_columns_sql(DatabaseSearchColumnsSqlOptions {
database_type: Some(DatabaseType::Oracle),
database: "APP".to_string(),
schema: None,
})
.unwrap();
assert!(query.sql.contains("WHERE c.OWNER = 'APP'"));
}
#[test]
fn mysql_family_uses_information_schema_with_primary_key_usage() {
let query = build_database_search_columns_sql(DatabaseSearchColumnsSqlOptions {
database_type: Some(DatabaseType::Mysql),
database: "shop".to_string(),
schema: None,
})
.unwrap();
assert!(query.sql.contains("FROM information_schema.COLUMNS c"));
assert!(query.sql.contains("information_schema.KEY_COLUMN_USAGE k"));
assert!(query.sql.contains("k.CONSTRAINT_NAME = 'PRIMARY'"));
assert!(query.sql.contains("WHERE c.TABLE_SCHEMA = 'shop'"));
// 复合主键:join 按列名对齐,多行主键列各自命中
assert!(query.sql.contains("k.COLUMN_NAME = c.COLUMN_NAME"));
assert!(query.sql.contains("ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION"));
}
#[test]
fn postgres_family_uses_pg_catalog_and_excludes_system_schemas_by_default() {
let with_schema = build_database_search_columns_sql(DatabaseSearchColumnsSqlOptions {
database_type: Some(DatabaseType::Postgres),
database: "app".to_string(),
schema: Some("public".to_string()),
})
.unwrap();
assert!(with_schema.sql.contains("pg_catalog.pg_attribute a"));
assert!(with_schema.sql.contains("i.indisprimary"));
assert!(with_schema.sql.contains("n.nspname = 'public'"));
assert!(with_schema.sql.contains("c.relkind IN ('r', 'p')"));
let without_schema = build_database_search_columns_sql(DatabaseSearchColumnsSqlOptions {
database_type: Some(DatabaseType::Postgres),
database: "app".to_string(),
schema: None,
})
.unwrap();
assert!(without_schema.sql.contains("n.nspname NOT IN ('pg_catalog', 'information_schema')"));
}
#[test]
fn sqlserver_excludes_system_schemas_when_schema_missing() {
let query = build_database_search_columns_sql(DatabaseSearchColumnsSqlOptions {
database_type: Some(DatabaseType::SqlServer),
database: "master".to_string(),
schema: None,
})
.unwrap();
assert!(query.sql.contains("INFORMATION_SCHEMA.COLUMNS c"));
assert!(query.sql.contains("tc.CONSTRAINT_TYPE = 'PRIMARY KEY'"));
assert!(query.sql.contains("c.TABLE_SCHEMA NOT IN ('sys', 'INFORMATION_SCHEMA')"));
let with_schema = build_database_search_columns_sql(DatabaseSearchColumnsSqlOptions {
database_type: Some(DatabaseType::SqlServer),
database: "master".to_string(),
schema: Some("dbo".to_string()),
})
.unwrap();
assert!(with_schema.sql.contains("c.TABLE_SCHEMA = 'dbo'"));
}
#[test]
fn unsupported_dialects_return_none() {
let options = DatabaseSearchColumnsSqlOptions {
database_type: Some(DatabaseType::Sqlite),
database: "local".to_string(),
schema: None,
};
assert_eq!(build_database_search_columns_sql(options), None);
}
}
@@ -0,0 +1,267 @@
use serde::{Deserialize, Serialize};
use crate::models::connection::DatabaseType;
use crate::sql_dialect::{
firebird_rows_clause, pagination_strategy, qualified_table_name, PaginationContext, TablePaginationStrategy,
};
use crate::database_search_sql::{build_table_search_conditions, clamp_limit, DatabaseSearchColumn};
/// 多表探测查询:把一批表的搜索条件合并为一条 UNION ALL 语句,
/// 每个分支返回 (序号标签, 命中数)——分支内套与单表查询一致的 WHERE 条件和
/// 该方言的行数限额,COUNT 结果即"该表最多有 limit 条命中"。
/// 标签用分支序号 t0..tN 而非表名字面量,彻底绕开标识符转义;
/// included_indices[k] 是第 k 个分支对应的输入表下标。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DatabaseSearchProbeTable {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
pub table_name: String,
#[serde(default)]
pub columns: Vec<DatabaseSearchColumn>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DatabaseSearchProbeSqlOptions {
#[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>,
#[serde(default)]
pub tables: Vec<DatabaseSearchProbeTable>,
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 DatabaseSearchProbeSql {
pub sql: String,
pub included_indices: Vec<usize>,
}
pub fn build_database_search_probe_sql(options: DatabaseSearchProbeSqlOptions) -> Option<DatabaseSearchProbeSql> {
let database_type = options.database_type;
let driver_profile = options.driver_profile.as_deref();
let limit = clamp_limit(options.limit);
let mut branches = Vec::new();
let mut included_indices = Vec::new();
for (index, table) in options.tables.iter().enumerate() {
// 无可搜列的表剔除出探测批,不中断其余表(进度由调用方按已处理推进)。
let Some(conditions) =
build_table_search_conditions(database_type, driver_profile, &table.columns, &options.term)
else {
continue;
};
let qualified = qualified_table_name(database_type, table.schema.as_deref(), &table.table_name);
let label = format!("t{}", branches.len());
branches.push(probe_branch_sql(database_type, &qualified, &conditions.where_clause, &label, limit));
included_indices.push(index);
}
if branches.is_empty() {
return None;
}
Some(DatabaseSearchProbeSql { sql: branches.join("\nUNION ALL\n"), included_indices })
}
fn probe_branch_sql(
database_type: Option<DatabaseType>,
qualified_table: &str,
where_clause: &str,
label: &str,
limit: usize,
) -> String {
let limited_inner = match pagination_strategy(database_type, PaginationContext::BoundedRead) {
TablePaginationStrategy::SqlServerTop | TablePaginationStrategy::IrisTop => {
format!("SELECT TOP {limit} 1 FROM {qualified_table} WHERE ({where_clause})")
}
TablePaginationStrategy::InformixFirst => {
format!("SELECT FIRST {limit} 1 FROM {qualified_table} WHERE ({where_clause})")
}
TablePaginationStrategy::FirebirdRows => {
format!("SELECT 1 FROM {qualified_table} WHERE ({where_clause}) {}", firebird_rows_clause(limit, 0))
}
TablePaginationStrategy::Db2FetchFirst | TablePaginationStrategy::FetchFirst => {
format!("SELECT 1 FROM {qualified_table} WHERE ({where_clause}) FETCH FIRST {limit} ROWS ONLY")
}
TablePaginationStrategy::Rownum => {
format!("SELECT 1 FROM {qualified_table} WHERE ({where_clause}) AND ROWNUM <= {limit}")
}
// 这些策略没有服务端限额可用,COUNT 会数完所有命中行——正确性不变,代价交给调用方批次控制。
TablePaginationStrategy::AgentMaxRows
| TablePaginationStrategy::QuestDbLimit
| TablePaginationStrategy::LimitOffset
| TablePaginationStrategy::Unbounded => {
format!("SELECT 1 FROM {qualified_table} WHERE ({where_clause}) LIMIT {limit}")
}
};
format!("SELECT '{label}' AS table_label, COUNT(*) AS hits FROM ({limited_inner}) probe_branch")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database_search_sql::build_database_search_sql;
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 }
}
fn probe_table(schema: Option<&str>, table: &str, columns: Vec<DatabaseSearchColumn>) -> DatabaseSearchProbeTable {
DatabaseSearchProbeTable { schema: schema.map(str::to_string), table_name: table.to_string(), columns }
}
fn probe_options(
database_type: DatabaseType,
tables: Vec<DatabaseSearchProbeTable>,
) -> DatabaseSearchProbeSqlOptions {
DatabaseSearchProbeSqlOptions {
database_type: Some(database_type),
driver_profile: None,
tables,
term: "alice".to_string(),
limit: Some(20),
}
}
#[test]
fn oracle_family_branch_uses_rownum_limited_subquery() {
let probe = build_database_search_probe_sql(probe_options(
DatabaseType::Dameng,
vec![probe_table(Some("DLWEB"), "USERS", vec![col("NAME", "varchar2", false), col("PIC", "blob", false)])],
))
.unwrap();
assert_eq!(probe.included_indices, vec![0]);
assert_eq!(
probe.sql,
"SELECT 't0' AS table_label, COUNT(*) AS hits FROM \
(SELECT 1 FROM \"DLWEB\".\"USERS\" WHERE (LOWER(CAST(\"NAME\" AS TEXT)) LIKE '%alice%' ESCAPE '~') AND ROWNUM <= 20) probe_branch"
);
}
#[test]
fn mysql_family_branch_uses_limit_subquery() {
let probe = build_database_search_probe_sql(probe_options(
DatabaseType::Mysql,
vec![
probe_table(None, "users", vec![col("email", "varchar", false)]),
probe_table(None, "orders", vec![col("note", "text", false)]),
],
))
.unwrap();
assert_eq!(probe.included_indices, vec![0, 1]);
assert!(probe.sql.contains("FROM `users` WHERE"));
assert!(probe.sql.contains("LIMIT 20"));
assert_eq!(probe.sql.matches("UNION ALL").count(), 1);
assert!(probe.sql.contains("'t0' AS table_label"));
assert!(probe.sql.contains("'t1' AS table_label"));
}
#[test]
fn sqlserver_branch_uses_top_subquery() {
let probe = build_database_search_probe_sql(probe_options(
DatabaseType::SqlServer,
vec![probe_table(Some("dbo"), "users", vec![col("name", "nvarchar", false)])],
))
.unwrap();
assert!(probe.sql.contains("SELECT TOP 20 1 FROM [dbo].[users]"));
}
#[test]
fn postgres_branch_uses_limit_subquery() {
let probe = build_database_search_probe_sql(probe_options(
DatabaseType::Postgres,
vec![probe_table(Some("public"), "orders", vec![col("note", "text", false)])],
))
.unwrap();
assert!(probe.sql.contains("FROM \"public\".\"orders\""));
assert!(probe.sql.contains("LIMIT 20) probe_branch"));
}
#[test]
fn tables_without_searchable_columns_are_excluded_and_indices_preserved() {
let probe = build_database_search_probe_sql(probe_options(
DatabaseType::Mysql,
vec![
probe_table(None, "files", vec![col("payload", "blob", false)]),
probe_table(None, "users", vec![col("email", "varchar", false)]),
probe_table(None, "empty", vec![]),
],
))
.unwrap();
// 无可搜列表被剔除,included_indices 指向原输入下标
assert_eq!(probe.included_indices, vec![1]);
assert!(probe.sql.contains("'t0' AS table_label"));
assert!(probe.sql.contains("FROM `users`"));
assert!(!probe.sql.contains("`files`"));
assert!(!probe.sql.contains("`empty`"));
}
#[test]
fn empty_or_all_excluded_input_returns_none() {
assert_eq!(
build_database_search_probe_sql(DatabaseSearchProbeSqlOptions {
database_type: Some(DatabaseType::Mysql),
driver_profile: None,
tables: vec![],
term: "alice".to_string(),
limit: Some(20),
}),
None
);
assert_eq!(
build_database_search_probe_sql(probe_options(
DatabaseType::Mysql,
vec![probe_table(None, "files", vec![col("payload", "blob", false)])],
)),
None
);
}
#[test]
fn probe_branch_conditions_match_single_table_query_exactly() {
let columns = vec![col("email", "varchar", false), col("id", "bigint", true)];
let single = build_database_search_sql(crate::database_search_sql::DatabaseSearchSqlOptions {
database_type: Some(DatabaseType::Dameng),
driver_profile: None,
schema: Some("DLWEB".to_string()),
table_name: "USERS".to_string(),
columns: columns.clone(),
term: "alice".to_string(),
limit: Some(20),
})
.unwrap();
let probe = build_database_search_probe_sql(probe_options(
DatabaseType::Dameng,
vec![probe_table(Some("DLWEB"), "USERS", columns)],
))
.unwrap();
// 探测分支内的 WHERE 条件与单表查询完全一致(含数值词场景由共享拼装函数保证)
let single_conds = single.sql.split(" WHERE (").nth(1).unwrap().split(")) WHERE ROWNUM").next().unwrap();
assert!(probe.sql.contains(&format!("WHERE ({single_conds}) AND ROWNUM <= 20) probe_branch")));
}
#[test]
fn numeric_term_adds_equality_predicates_in_probe_branches() {
let probe = build_database_search_probe_sql(DatabaseSearchProbeSqlOptions {
database_type: Some(DatabaseType::Mysql),
driver_profile: None,
tables: vec![probe_table(None, "orders", vec![col("id", "bigint", true), col("note", "text", false)])],
term: "42".to_string(),
limit: Some(20),
})
.unwrap();
assert!(probe.sql.contains("`note`"));
assert!(probe.sql.contains("`id` = 42"));
}
}
+115 -15
View File
@@ -75,42 +75,68 @@ pub fn is_numeric_search_column(column: &DatabaseSearchColumn) -> bool {
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);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TableSearchConditions {
pub where_clause: String,
pub searchable_columns: Vec<String>,
}
/// 单表搜索条件拼装:数据搜索单表查询与多表探测分支共用,
/// 保证两条路径对同一表同一关键词产出完全一致的 WHERE 条件。
pub fn build_table_search_conditions(
database_type: Option<DatabaseType>,
driver_profile: Option<&str>,
columns: &[DatabaseSearchColumn],
term: &str,
) -> Option<TableSearchConditions> {
let term = clean_term(term);
if term.is_empty() {
return None;
}
let text_columns: Vec<_> = options.columns.iter().filter(|column| is_text_search_column(column)).collect();
let text_columns: Vec<_> = 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()
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, options.driver_profile.as_deref(), &identifier),
sql_string_literal(&like_pattern(&term))
));
let identifier = quote_table_identifier(database_type, &column.name);
conditions.push(text_search_condition(database_type, driver_profile, column, &identifier, &term));
}
for column in &numeric_columns {
let identifier = quote_table_identifier(options.database_type, &column.name);
let identifier = quote_table_identifier(database_type, &column.name);
conditions.push(format!("{identifier} = {}", numeric_term.as_deref().unwrap_or_default()));
}
if conditions.is_empty() {
return None;
}
Some(TableSearchConditions {
where_clause: conditions.join(" OR "),
searchable_columns: text_columns
.iter()
.chain(numeric_columns.iter())
.map(|column| column.name.clone())
.collect(),
})
}
pub fn build_database_search_sql(options: DatabaseSearchSqlOptions) -> Option<DatabaseSearchSql> {
let conditions = build_table_search_conditions(
options.database_type,
options.driver_profile.as_deref(),
&options.columns,
&options.term,
)?;
let table = qualified_table_name(options.database_type, options.schema.as_deref(), &options.table_name);
let where_clause = conditions.join(" OR ");
let where_clause = conditions.where_clause;
let limit = clamp_limit(options.limit);
let searchable_columns =
text_columns.iter().chain(numeric_columns.iter()).map(|column| column.name.clone()).collect();
let searchable_columns = conditions.searchable_columns;
let sql = match pagination_strategy(options.database_type, PaginationContext::BoundedRead) {
TablePaginationStrategy::SqlServerTop => format!("SELECT TOP {limit} * FROM {table} WHERE ({where_clause})"),
@@ -179,7 +205,7 @@ fn clean_term(term: &str) -> String {
term.trim().to_string()
}
fn clamp_limit(limit: Option<usize>) -> usize {
pub(crate) fn clamp_limit(limit: Option<usize>) -> usize {
limit.unwrap_or(20).clamp(1, 100)
}
@@ -248,6 +274,35 @@ fn text_cast_expression(database_type: Option<DatabaseType>, driver_profile: Opt
}
}
/// Oracle 家族的 CLOB 列不能走 CAST AS VARCHAR2(4000):内容超 4000 字符时直接抛
/// ORA-22835(整表不可搜)。改用 DBMS_LOB.INSTR 全量搜索——不截断、不报错;
/// LOWER(CLOB) 返回 CLOB,INSTR 对 CLOB 直接可用。DM8/OB Oracle 模式均提供 DBMS_LOB。
/// INSTR 按字面子串匹配,与 LIKE '%term%' ESCAPE(通配符已被转义为字面量)语义一致。
fn is_oracle_family_clob(database_type: Option<DatabaseType>, data_type: &str) -> bool {
matches!(database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle | DatabaseType::Dameng))
&& data_type.to_ascii_lowercase().contains("clob")
}
fn text_search_condition(
database_type: Option<DatabaseType>,
driver_profile: Option<&str>,
column: &DatabaseSearchColumn,
identifier: &str,
term: &str,
) -> String {
if is_oracle_family_clob(database_type, &column.data_type) {
return format!(
"DBMS_LOB.INSTR(LOWER({identifier}), {}) > 0",
sql_string_literal(&clean_term(term).to_ascii_lowercase())
);
}
format!(
"{} LIKE {} ESCAPE '~'",
text_cast_expression(database_type, driver_profile, identifier),
sql_string_literal(&like_pattern(term))
)
}
fn sql_value_literal(database_type: Option<DatabaseType>, column: &DatabaseSearchColumn, value: &Value) -> String {
match value {
Value::Null => "NULL".to_string(),
@@ -281,6 +336,51 @@ mod tests {
DatabaseSearchColumn { name: name.to_string(), data_type: data_type.to_string(), is_primary_key }
}
#[test]
fn oracle_family_clob_columns_use_dbms_lob_instr() {
let query = build_database_search_sql(DatabaseSearchSqlOptions {
database_type: Some(DatabaseType::Oracle),
driver_profile: None,
schema: Some("ZOEDM".to_string()),
table_name: "CLOSED_LOOP_VIEW_LOG".to_string(),
columns: vec![col("ID", "number", true), col("TITLE", "varchar2", false), col("CONTENT", "clob", false)],
term: "closed_loop".to_string(),
limit: Some(20),
})
.unwrap();
// CLOB 列走 DBMS_LOB.INSTR(全量、不抛 ORA-22835),varchar2 列保持 CAST+LIKE
assert!(query.sql.contains("DBMS_LOB.INSTR(LOWER(\"CONTENT\"), 'closed_loop') > 0"));
// LIKE 侧通配符照常转义(_ → ~_)
assert!(query.sql.contains("LOWER(CAST(\"TITLE\" AS VARCHAR2(4000))) LIKE '%closed~_loop%' ESCAPE '~'"));
// Dameng / OceanBase Oracle 同为 Oracle 家族
let dameng = build_database_search_sql(DatabaseSearchSqlOptions {
database_type: Some(DatabaseType::Dameng),
driver_profile: None,
schema: None,
table_name: "t".to_string(),
columns: vec![col("BODY", "clob", false)],
term: "x".to_string(),
limit: None,
})
.unwrap();
assert!(dameng.sql.contains("DBMS_LOB.INSTR(LOWER(\"BODY\"), 'x') > 0"));
// 其他方言的 clob/text 列不受影响,仍走 CAST+LIKE
let mysql = build_database_search_sql(DatabaseSearchSqlOptions {
database_type: Some(DatabaseType::Mysql),
driver_profile: None,
schema: None,
table_name: "t".to_string(),
columns: vec![col("body", "longtext", false)],
term: "x".to_string(),
limit: None,
})
.unwrap();
assert!(mysql.sql.contains("LIKE '%x%'"));
}
#[test]
fn builds_table_search_query_over_text_columns() {
let query = build_database_search_sql(DatabaseSearchSqlOptions {
@@ -285,7 +285,7 @@ fn oracle_family_structure_search_sql(
LEFT JOIN ALL_COL_COMMENTS m ON c.OWNER = m.OWNER AND c.TABLE_NAME = m.TABLE_NAME AND c.COLUMN_NAME = m.COLUMN_NAME \
WHERE c.OWNER = {owner} \
AND ({name_expr} LIKE LOWER({pattern}) ESCAPE '~' OR LOWER(COALESCE(m.COMMENTS, '')) LIKE LOWER({pattern}) ESCAPE '~') \
ORDER BY object_schema, object_name, column_name) WHERE ROWNUM <= {limit} \
ORDER BY 1, 2, 3) WHERE ROWNUM <= {limit} \
UNION ALL \
SELECT * FROM ( \
SELECT t.OWNER AS object_schema, t.TABLE_NAME AS object_name, CAST(NULL AS VARCHAR2(128)) AS column_name \
@@ -293,8 +293,8 @@ fn oracle_family_structure_search_sql(
FROM ALL_TAB_COMMENTS t \
WHERE t.OWNER = {owner} \
AND LOWER(COALESCE(t.COMMENTS, '')) LIKE LOWER({pattern}) ESCAPE '~' \
ORDER BY object_schema, object_name) WHERE ROWNUM <= {table_limit} \
ORDER BY object_schema, object_name, column_name) WHERE ROWNUM <= {total_limit}",
ORDER BY 1, 2) WHERE ROWNUM <= {table_limit} \
ORDER BY 1, 2, 3) WHERE ROWNUM <= {total_limit}",
owner = owner_literal,
name_expr = name_expr,
pattern = pattern_literal,
+2
View File
@@ -6,6 +6,8 @@ pub use dbx_types::{database_manifest, models, types};
pub mod data_grid_extractors;
pub mod data_grid_sql;
pub mod database_search_columns_sql;
pub mod database_search_probe_sql;
pub mod database_search_sql;
pub mod database_structure_search_sql;
pub mod query_result_sql;
+2 -1
View File
@@ -6,7 +6,8 @@ pub use dbx_sql_core::{
value_literals,
};
pub use dbx_sql_data::{
data_grid_extractors, data_grid_sql, database_search_sql, database_structure_search_sql, query_result_sql,
data_grid_extractors, data_grid_sql, database_search_columns_sql, database_search_probe_sql, database_search_sql,
database_structure_search_sql, query_result_sql,
};
pub use dbx_sql_dialect::{dml_binding, postgres_index_key, sql_dialect};
pub use dbx_sql_schema::{db_admin_sql, schema_diff, sql_parser, table_structure_sql};
+2
View File
@@ -867,6 +867,8 @@ async fn serve() -> Result<(), String> {
.route("/query/build-create-user-sql", post(routes::query::build_create_user_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-database-search-columns-sql", post(routes::query::build_database_search_columns_sql))
.route("/query/build-database-search-probe-sql", post(routes::query::build_database_search_probe_sql))
.route("/query/build-database-structure-search-sql", post(routes::query::build_database_structure_search_sql))
.route("/query/supports-database-structure-search", post(routes::query::supports_database_structure_search))
.route("/query/build-search-result-where", post(routes::query::build_search_result_where))
+24
View File
@@ -152,6 +152,18 @@ pub struct BuildDatabaseStructureSearchSqlRequest {
pub options: dbx_core::database_structure_search_sql::DatabaseStructureSearchSqlOptions,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildDatabaseSearchColumnsSqlRequest {
pub options: dbx_core::database_search_columns_sql::DatabaseSearchColumnsSqlOptions,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildDatabaseSearchProbeSqlRequest {
pub options: dbx_core::database_search_probe_sql::DatabaseSearchProbeSqlOptions,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SupportsDatabaseStructureSearchRequest {
@@ -1154,6 +1166,18 @@ pub async fn build_database_structure_search_sql(
Json(dbx_core::database_structure_search_sql::build_database_structure_search_sql(req.options))
}
pub async fn build_database_search_columns_sql(
Json(req): Json<BuildDatabaseSearchColumnsSqlRequest>,
) -> Json<Option<dbx_core::database_search_columns_sql::DatabaseSearchColumnsSql>> {
Json(dbx_core::database_search_columns_sql::build_database_search_columns_sql(req.options))
}
pub async fn build_database_search_probe_sql(
Json(req): Json<BuildDatabaseSearchProbeSqlRequest>,
) -> Json<Option<dbx_core::database_search_probe_sql::DatabaseSearchProbeSql>> {
Json(dbx_core::database_search_probe_sql::build_database_search_probe_sql(req.options))
}
pub async fn supports_database_structure_search(Json(req): Json<SupportsDatabaseStructureSearchRequest>) -> Json<bool> {
Json(dbx_core::database_structure_search_sql::supports_database_structure_search(
req.database_type,
+14
View File
@@ -704,6 +704,20 @@ pub fn build_database_structure_search_sql(
Ok(dbx_core::database_structure_search_sql::build_database_structure_search_sql(options))
}
#[tauri::command]
pub fn build_database_search_columns_sql(
options: dbx_core::database_search_columns_sql::DatabaseSearchColumnsSqlOptions,
) -> Result<Option<dbx_core::database_search_columns_sql::DatabaseSearchColumnsSql>, String> {
Ok(dbx_core::database_search_columns_sql::build_database_search_columns_sql(options))
}
#[tauri::command]
pub fn build_database_search_probe_sql(
options: dbx_core::database_search_probe_sql::DatabaseSearchProbeSqlOptions,
) -> Result<Option<dbx_core::database_search_probe_sql::DatabaseSearchProbeSql>, String> {
Ok(dbx_core::database_search_probe_sql::build_database_search_probe_sql(options))
}
#[tauri::command]
pub fn supports_database_structure_search(
database_type: Option<dbx_core::models::connection::DatabaseType>,
+2
View File
@@ -2365,6 +2365,8 @@ pub fn run() {
commands::query::build_dropped_file_preview_sql,
commands::query::build_table_select_sql,
commands::query::build_database_search_sql,
commands::query::build_database_search_columns_sql,
commands::query::build_database_search_probe_sql,
commands::query::build_database_structure_search_sql,
commands::query::supports_database_structure_search,
commands::query::build_search_result_where,