A new super admin settings page, /admin/settings/database, lists every table with its record count and size, and one page of the open table's records newest key first. Values are cut inside SQL (400 characters, with a flag saying so) and a record opens whole in a modal, so a table of long texts reads without shipping it all. A search matches every column's text, word by word or as a phrase, which is the one thing that also works on a virtual table. Nothing in it writes: the catalog, the records and the single record are all reads, GET only.
86 lines
2.4 KiB
TypeScript
86 lines
2.4 KiB
TypeScript
import quoteSqlIdentifier from "./quote-sql-identifier";
|
|
import type { DatabaseSearchMode } from "@/src/types";
|
|
|
|
type Params = {
|
|
/**
|
|
* The table's columns, in their declared order.
|
|
*/
|
|
columns: string[];
|
|
search: string;
|
|
mode: DatabaseSearchMode;
|
|
/**
|
|
* How many words at most are matched, so a long line can't build a
|
|
* query of a hundred conditions.
|
|
*/
|
|
max_words: number;
|
|
};
|
|
|
|
/**
|
|
* Splits a search into its words, in the case they were typed (SQLite's
|
|
* `lower` folds ASCII case).
|
|
*/
|
|
export function searchWords({ search, max_words }: { search: string; max_words: number }): string[] {
|
|
return search
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.slice(0, max_words);
|
|
}
|
|
|
|
type WordParams = {
|
|
/**
|
|
* The SQL that reads every column of a row as one text blob.
|
|
*/
|
|
text: string;
|
|
search: string;
|
|
mode: DatabaseSearchMode;
|
|
max_words: number;
|
|
};
|
|
|
|
/**
|
|
* A search as a `WHERE` condition, with the parameters its placeholders
|
|
* take, in order.
|
|
*/
|
|
export function searchCondition({ text, search, mode, max_words }: WordParams): { sql: string; values: string[] } {
|
|
if (mode == "phrase") {
|
|
return {
|
|
sql: `instr(lower(${text}), lower(?)) > 0`,
|
|
values: [search.trim()],
|
|
};
|
|
}
|
|
|
|
const words = searchWords({ search, max_words });
|
|
|
|
if (!words.length) {
|
|
return { sql: `1`, values: [] };
|
|
}
|
|
|
|
return {
|
|
sql: words.map(() => `instr(lower(${text}), lower(?)) > 0`).join(` AND `),
|
|
values: words,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A search over every column of a table, as a condition the records query
|
|
* puts in its `WHERE` (and the count query puts in its own), with the
|
|
* parameters its placeholders take, in order.
|
|
*
|
|
* The columns are joined into one text blob per row, so a value is found
|
|
* wherever it lives: an id, a name, a message's words. Reading every row
|
|
* is the only honest way to find a value in a table with no index for it;
|
|
* the page sizes keep what comes back small, not the cost of the search.
|
|
*/
|
|
export default function buildSearchClause({ columns, search, mode, max_words }: Params): {
|
|
sql: string;
|
|
values: string[];
|
|
} {
|
|
const text = columns.length
|
|
? columns
|
|
.map((name) => `COALESCE(CAST(${quoteSqlIdentifier({ name })} AS TEXT), '')`)
|
|
.join(` || ' ' || `)
|
|
: `''`;
|
|
|
|
return searchCondition({ text, search, mode, max_words });
|
|
}
|