mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-30 16:21:39 +00:00
Extract all inline database type checks (schema-aware, pagination style, feature support sets, etc.) into a single source of truth. Adding a new database type now requires updating only one file instead of 15+.
208 lines
7.0 KiB
TypeScript
208 lines
7.0 KiB
TypeScript
import type { ColumnInfo, DatabaseType } from "../types/database.ts";
|
|
import { qualifiedTableName, quoteTableIdentifier } from "./tableSelectSql.ts";
|
|
import { usesFetchFirst } from "@/lib/databaseCapabilities";
|
|
|
|
export interface DatabaseSearchSqlOptions {
|
|
databaseType?: DatabaseType;
|
|
schema?: string;
|
|
tableName: string;
|
|
columns: ColumnInfo[];
|
|
term: string;
|
|
limit?: number;
|
|
}
|
|
|
|
export interface DatabaseSearchSql {
|
|
sql: string;
|
|
searchableColumns: string[];
|
|
}
|
|
|
|
export interface SearchResultWhereOptions {
|
|
databaseType?: DatabaseType;
|
|
columns: ColumnInfo[];
|
|
resultColumns: string[];
|
|
row: unknown[];
|
|
matchedColumns?: string[];
|
|
}
|
|
|
|
const TEXT_TYPES = ["char", "text", "clob", "varchar", "nvarchar", "nchar", "uuid", "uniqueidentifier", "enum"];
|
|
|
|
const NUMBER_TYPES = ["int", "serial", "number", "numeric", "decimal", "float", "double", "real", "money"];
|
|
|
|
const SKIPPED_TYPES = ["blob", "binary", "bytea", "image", "geometry", "geography"];
|
|
|
|
function normalizedType(column: ColumnInfo): string {
|
|
return column.data_type.toLowerCase();
|
|
}
|
|
|
|
export function isTextSearchColumn(column: ColumnInfo): 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 {
|
|
const type = normalizedType(column);
|
|
if (SKIPPED_TYPES.some((skipped) => type.includes(skipped))) return false;
|
|
return NUMBER_TYPES.some((numberType) => type.includes(numberType));
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
function columnByName(columns: ColumnInfo[], name: string): ColumnInfo | undefined {
|
|
return columns.find((column) => column.name.toLowerCase() === name.toLowerCase());
|
|
}
|
|
|
|
export function findMatchedSearchColumns(
|
|
resultColumns: string[],
|
|
row: unknown[],
|
|
columns: ColumnInfo[],
|
|
term: string,
|
|
): string[] {
|
|
const query = cleanTerm(term).toLowerCase();
|
|
const numericTerm = parseNumericTerm(term);
|
|
if (!query) return [];
|
|
|
|
const matches: string[] = [];
|
|
resultColumns.forEach((columnName, index) => {
|
|
const value = row[index];
|
|
if (value === null || value === undefined) return;
|
|
const column = columnByName(columns, columnName);
|
|
if (!column || (!isTextSearchColumn(column) && !isNumericSearchColumn(column))) return;
|
|
|
|
if (isNumericSearchColumn(column) && numericTerm && String(value).trim() === numericTerm) {
|
|
matches.push(columnName);
|
|
return;
|
|
}
|
|
|
|
if (isTextSearchColumn(column) && String(value).toLowerCase().includes(query)) {
|
|
matches.push(columnName);
|
|
}
|
|
});
|
|
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 ");
|
|
}
|