Grok 5 updates
This commit is contained in:
@@ -5,6 +5,7 @@ import type {
|
||||
SQLInsertGenReturn,
|
||||
} from "../../types";
|
||||
import sqlInsertGenerator from "../../utils/sql-insert-generator";
|
||||
import { sanitizeHtmlFieldsBatch } from "../../utils/sanitize-html-fields";
|
||||
import grabDuplicateSafeInsertSql from "../grab-duplicate-safe-insert-sql";
|
||||
|
||||
type Params<
|
||||
@@ -29,7 +30,13 @@ export default async function DbInsert<
|
||||
let sqlObj: SQLInsertGenReturn | null = null;
|
||||
|
||||
try {
|
||||
const finalData: { [k: string]: any }[] = data.map((d) => ({
|
||||
const sanitizedData = sanitizeHtmlFieldsBatch({
|
||||
table,
|
||||
data,
|
||||
config,
|
||||
});
|
||||
|
||||
const finalData: { [k: string]: any }[] = sanitizedData.map((d) => ({
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
...d,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ServerQueryParam,
|
||||
} from "../../types";
|
||||
import sqlGenerator from "../../utils/sql-generator";
|
||||
import sanitizeHtmlFields from "../../utils/sanitize-html-fields";
|
||||
|
||||
type Params<
|
||||
Schema extends { [k: string]: any } = { [k: string]: any },
|
||||
@@ -69,9 +70,15 @@ export default async function DbUpdate<
|
||||
let sql = ``;
|
||||
sql += `UPDATE ${quoteIdentifier(table)} SET`;
|
||||
|
||||
const sanitizedData = sanitizeHtmlFields({
|
||||
table,
|
||||
data,
|
||||
config,
|
||||
});
|
||||
|
||||
const finalData: { [k: string]: SQLInsertGenValueType } = {
|
||||
updated_at: Date.now(),
|
||||
...data,
|
||||
...sanitizedData,
|
||||
};
|
||||
|
||||
const keys = Object.keys(finalData);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { BUN_MARIADB_FieldSchemaType } from "../../types";
|
||||
import MariaDBQuoteGen from "./mariadb-quote-gen";
|
||||
|
||||
export default function buildForeignKeyConstraint(
|
||||
field: BUN_MARIADB_FieldSchemaType,
|
||||
): string {
|
||||
const fk = field.foreignKey!;
|
||||
const constraintName = fk.foreignKeyName
|
||||
? `CONSTRAINT ${MariaDBQuoteGen(fk.foreignKeyName)} `
|
||||
: "";
|
||||
|
||||
let constraint = `${constraintName}FOREIGN KEY (${MariaDBQuoteGen(field.fieldName!)}) REFERENCES ${MariaDBQuoteGen(fk.destinationTableName!)}(${MariaDBQuoteGen(fk.destinationTableColumnName!)})`;
|
||||
|
||||
if (fk.cascadeDelete) {
|
||||
constraint += " ON DELETE CASCADE";
|
||||
}
|
||||
|
||||
if (fk.cascadeUpdate) {
|
||||
constraint += " ON UPDATE CASCADE";
|
||||
}
|
||||
|
||||
return constraint;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { BUN_MARIADB_TableSchemaType } from "../../types";
|
||||
|
||||
export default function buildTableOptions(
|
||||
table: BUN_MARIADB_TableSchemaType,
|
||||
): string {
|
||||
const options = ["ENGINE=InnoDB"];
|
||||
|
||||
if (table.collation) {
|
||||
options.push("DEFAULT CHARSET=utf8mb4", `COLLATE ${table.collation}`);
|
||||
}
|
||||
|
||||
return ` ${options.join(" ")}`;
|
||||
}
|
||||
@@ -10,14 +10,12 @@ export default async function createDBSchema(params: CreateDBSchemaParams) {
|
||||
await createDBManagerTable(params);
|
||||
|
||||
/**
|
||||
* Reorder Tables
|
||||
* Reorder Tables (parents before children with FKs)
|
||||
*/
|
||||
const ordered_db_schema = await orderDBSchema(params);
|
||||
|
||||
/**
|
||||
* Handle Tables
|
||||
* Handle Tables (create / update / drop)
|
||||
*/
|
||||
await handleDBSchemaTables({ ...params, db_schema: ordered_db_schema });
|
||||
|
||||
process.exit();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
BUN_MARIADB_TableSchemaType,
|
||||
BunMariaDBConfig,
|
||||
} from "../../types";
|
||||
import buildColumnDefinition from "./build-column-definition";
|
||||
import buildForeignKeyConstraint from "./build-foreign-key-constraint";
|
||||
import buildTableOptions from "./build-table-options";
|
||||
import MariaDBQuoteGen from "./mariadb-quote-gen";
|
||||
import runSchemaQuery from "./run-schema-query";
|
||||
|
||||
export default async function createTable({
|
||||
table,
|
||||
config,
|
||||
}: {
|
||||
table: BUN_MARIADB_TableSchemaType;
|
||||
config?: BunMariaDBConfig;
|
||||
}): Promise<void> {
|
||||
if (!table.tableName.match(/_temp_\d+$/)) {
|
||||
console.log(`Creating table: ${table.tableName}`);
|
||||
}
|
||||
|
||||
const columnDefinitions: string[] = [];
|
||||
const foreignKeys: string[] = [];
|
||||
const primaryKeys: string[] = [];
|
||||
|
||||
for (const field of table.fields || []) {
|
||||
columnDefinitions.push(buildColumnDefinition(field));
|
||||
|
||||
if (field.primaryKey && field.fieldName) {
|
||||
primaryKeys.push(field.fieldName);
|
||||
}
|
||||
|
||||
if (field.foreignKey && !table.isVector) {
|
||||
foreignKeys.push(buildForeignKeyConstraint(field));
|
||||
}
|
||||
}
|
||||
|
||||
if (primaryKeys.length > 0) {
|
||||
const pkCols = primaryKeys.map((k) => MariaDBQuoteGen(k)).join(", ");
|
||||
columnDefinitions.push(`PRIMARY KEY (${pkCols})`);
|
||||
}
|
||||
|
||||
if (table.uniqueConstraints) {
|
||||
for (const constraint of table.uniqueConstraints) {
|
||||
if (
|
||||
constraint.constraintTableFields &&
|
||||
constraint.constraintTableFields.length > 0
|
||||
) {
|
||||
const fields = constraint.constraintTableFields
|
||||
.map((field) => MariaDBQuoteGen(field.value))
|
||||
.join(", ");
|
||||
const constraintName =
|
||||
constraint.constraintName ||
|
||||
`unique_${fields.replace(/`/g, "")}`;
|
||||
|
||||
columnDefinitions.push(
|
||||
`CONSTRAINT ${MariaDBQuoteGen(constraintName)} UNIQUE (${fields})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sql = `CREATE TABLE IF NOT EXISTS ${MariaDBQuoteGen(table.tableName)} (${[...columnDefinitions, ...foreignKeys].join(", ")})${buildTableOptions(table)}`;
|
||||
|
||||
await runSchemaQuery({ query: sql, config });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { BunMariaDBConfig } from "../../types";
|
||||
import { querySchemaRows } from "./run-schema-query";
|
||||
import schemaCondition from "./schema-condition";
|
||||
|
||||
export type ColumnInfoRow = {
|
||||
name: string;
|
||||
type: string;
|
||||
comment?: string;
|
||||
};
|
||||
|
||||
export default async function getTableColumns({
|
||||
tableName,
|
||||
config,
|
||||
}: {
|
||||
tableName: string;
|
||||
config?: BunMariaDBConfig;
|
||||
}): Promise<ColumnInfoRow[]> {
|
||||
const schemaCond = schemaCondition(config);
|
||||
const rows = await querySchemaRows<{
|
||||
COLUMN_NAME: string;
|
||||
COLUMN_TYPE: string;
|
||||
COLUMN_COMMENT: string;
|
||||
}>({
|
||||
query: `SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE ${schemaCond.where} AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`,
|
||||
values: [...schemaCond.values, tableName],
|
||||
config,
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
name: row.COLUMN_NAME,
|
||||
type: row.COLUMN_TYPE,
|
||||
comment: row.COLUMN_COMMENT,
|
||||
}));
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { AppData } from "../../data/app-data";
|
||||
import type {
|
||||
BUN_MARIADB_DB_TABLE_MANAGER_TABLE,
|
||||
BUN_MARIADB_INFORMATION_SCHEMA_TABLES,
|
||||
CreateDBSchemaTableHandlerParams,
|
||||
} from "../../types";
|
||||
import dbHandler from "../db-handler";
|
||||
import DbInsert from "../mariadb/db-insert";
|
||||
import buildColumnDefinition from "./build-column-definition";
|
||||
import type { CreateDBSchemaTableHandlerParams } from "../../types";
|
||||
import createTable from "./create-table";
|
||||
import MariaDBQuoteGen from "./mariadb-quote-gen";
|
||||
import resolveTable from "./resolve-table";
|
||||
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
|
||||
import schemaCondition from "./schema-condition";
|
||||
import syncIndexes from "./sync-indexes";
|
||||
import updateTable from "./update-table";
|
||||
import upsertDbManagerTable, {
|
||||
removeDbManagerTable,
|
||||
} from "./upsert-db-manager-table";
|
||||
|
||||
export default async function handleDBSchemaTable({
|
||||
db_schema,
|
||||
@@ -16,31 +17,64 @@ export default async function handleDBSchemaTable({
|
||||
db_manager_table_name,
|
||||
existing_live_table,
|
||||
}: CreateDBSchemaTableHandlerParams) {
|
||||
if (!db_manager_table_name || !existing_live_table?.TABLE_NAME) {
|
||||
const insert_table = await DbInsert<BUN_MARIADB_DB_TABLE_MANAGER_TABLE>(
|
||||
{
|
||||
data: [
|
||||
{
|
||||
table_name: table.tableName,
|
||||
},
|
||||
],
|
||||
table: AppData["DbSchemaManagerTableName"],
|
||||
const resolvedTable = resolveTable(table, db_schema);
|
||||
|
||||
const schemaCond = schemaCondition(config);
|
||||
const liveTables = await querySchemaRows<{ TABLE_NAME: string }>({
|
||||
query: `SELECT TABLE_NAME FROM information_schema.TABLES WHERE ${schemaCond.where} AND TABLE_TYPE = 'BASE TABLE'`,
|
||||
values: schemaCond.values,
|
||||
config,
|
||||
});
|
||||
const liveTableNames = liveTables.map((t) => t.TABLE_NAME);
|
||||
|
||||
let tableExistsTracked = Boolean(db_manager_table_name);
|
||||
let tableExistsLive = Boolean(
|
||||
existing_live_table?.TABLE_NAME ||
|
||||
liveTableNames.includes(resolvedTable.tableName),
|
||||
);
|
||||
let wasRenamed = false;
|
||||
|
||||
if (
|
||||
resolvedTable.tableNameOld &&
|
||||
resolvedTable.tableNameOld !== resolvedTable.tableName
|
||||
) {
|
||||
if (liveTableNames.includes(resolvedTable.tableNameOld)) {
|
||||
console.log(
|
||||
`Renaming table: ${resolvedTable.tableNameOld} -> ${resolvedTable.tableName}`,
|
||||
);
|
||||
await runSchemaQuery({
|
||||
query: `RENAME TABLE ${MariaDBQuoteGen(resolvedTable.tableNameOld)} TO ${MariaDBQuoteGen(resolvedTable.tableName)}`,
|
||||
config,
|
||||
},
|
||||
);
|
||||
|
||||
let sql = ``;
|
||||
|
||||
sql += `CREATE TABLE IF NOT EXISTS ${MariaDBQuoteGen(table.tableName)}`;
|
||||
sql += ` (`;
|
||||
|
||||
for (let i = 0; i < table.fields.length; i++) {
|
||||
const field = table.fields[i];
|
||||
if (!field) continue;
|
||||
const col_def = buildColumnDefinition(field);
|
||||
sql += ` ${col_def}`;
|
||||
});
|
||||
await upsertDbManagerTable({
|
||||
tableName: resolvedTable.tableName,
|
||||
config,
|
||||
});
|
||||
await removeDbManagerTable({
|
||||
tableName: resolvedTable.tableNameOld,
|
||||
config,
|
||||
});
|
||||
tableExistsTracked = true;
|
||||
tableExistsLive = true;
|
||||
wasRenamed = true;
|
||||
}
|
||||
|
||||
sql += ` )`;
|
||||
}
|
||||
|
||||
if (!tableExistsTracked && !tableExistsLive) {
|
||||
await createTable({ table: resolvedTable, config });
|
||||
await upsertDbManagerTable({
|
||||
tableName: resolvedTable.tableName,
|
||||
config,
|
||||
});
|
||||
} else {
|
||||
if (!wasRenamed) {
|
||||
await updateTable({ table: resolvedTable, config });
|
||||
}
|
||||
await upsertDbManagerTable({
|
||||
tableName: resolvedTable.tableName,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
await syncIndexes({ table: resolvedTable, config });
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import _ from "lodash";
|
||||
import { AppData } from "../../data/app-data";
|
||||
import type {
|
||||
BUN_MARIADB_INFORMATION_SCHEMA_TABLES,
|
||||
CreateDBSchemaParams,
|
||||
} from "../../types";
|
||||
import dbHandler from "../db-handler";
|
||||
import getExistingTablesFromTablesManagerTable from "./get-existing-tables-from-tables-manager-table";
|
||||
import handleDBSchemaTable from "./handle-db-schema-table";
|
||||
import getExistingTablesFromTablesManagerTable from "./get-existing-tables-from-tables-manager-table";
|
||||
import MariaDBQuoteGen from "./mariadb-quote-gen";
|
||||
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
|
||||
import schemaCondition from "./schema-condition";
|
||||
import { removeDbManagerTable } from "./upsert-db-manager-table";
|
||||
|
||||
export default async function handleDBSchemaTables(
|
||||
params: CreateDBSchemaParams,
|
||||
@@ -19,34 +23,26 @@ export default async function handleDBSchemaTables(
|
||||
const existing_schema_tables =
|
||||
await getExistingTablesFromTablesManagerTable(params);
|
||||
|
||||
const schemaCond = schemaCondition(config);
|
||||
const existing_live_tables =
|
||||
await dbHandler<BUN_MARIADB_INFORMATION_SCHEMA_TABLES>({
|
||||
query: `SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME != ?`,
|
||||
config: params.config,
|
||||
values: [AppData["DbSchemaManagerTableName"]],
|
||||
await querySchemaRows<BUN_MARIADB_INFORMATION_SCHEMA_TABLES>({
|
||||
query: `SELECT TABLE_NAME FROM information_schema.TABLES WHERE ${schemaCond.where} AND TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME != ?`,
|
||||
values: [...schemaCond.values, AppData["DbSchemaManagerTableName"]],
|
||||
config,
|
||||
});
|
||||
|
||||
for (let i = 0; i < db_schema.tables.length; i++) {
|
||||
const table = db_schema.tables[i];
|
||||
if (!table) continue;
|
||||
|
||||
const existing_table = existing_schema_tables.find(
|
||||
(t) => t == table?.tableName,
|
||||
(t) => t == table.tableName,
|
||||
);
|
||||
|
||||
const existing_live_table = existing_live_tables.payload?.find(
|
||||
(t) => t.TABLE_NAME == table?.tableName,
|
||||
const existing_live_table = existing_live_tables.find(
|
||||
(t) => t.TABLE_NAME == table.tableName,
|
||||
);
|
||||
|
||||
if (!table) {
|
||||
if (existing_live_table?.TABLE_NAME) {
|
||||
await dbHandler<BUN_MARIADB_INFORMATION_SCHEMA_TABLES>({
|
||||
query: `DROP TABLE ${existing_live_table.TABLE_NAME}`,
|
||||
config: params.config,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await handleDBSchemaTable({
|
||||
...params,
|
||||
table,
|
||||
@@ -54,4 +50,84 @@ export default async function handleDBSchemaTables(
|
||||
existing_live_table,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop tables tracked by the manager but no longer in the schema.
|
||||
* Skip drops when remaining schema tables still reference the table via FK
|
||||
* (e.g. external tables like `users` that are referenced but not managed).
|
||||
*/
|
||||
const schemaTableNames = db_schema.tables.map((t) => t.tableName);
|
||||
const tablesToDrop = _.uniq(
|
||||
existing_schema_tables.filter(
|
||||
(tableName): tableName is string =>
|
||||
Boolean(tableName) && !schemaTableNames.includes(tableName!),
|
||||
),
|
||||
);
|
||||
|
||||
if (tablesToDrop.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fkRows = await querySchemaRows<{
|
||||
TABLE_NAME: string;
|
||||
REFERENCED_TABLE_NAME: string;
|
||||
}>({
|
||||
query: `SELECT TABLE_NAME, REFERENCED_TABLE_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE ${schemaCond.where} AND REFERENCED_TABLE_NAME IS NOT NULL`,
|
||||
values: schemaCond.values,
|
||||
config,
|
||||
});
|
||||
|
||||
const referencedByRemaining = new Map<string, string[]>();
|
||||
for (const row of fkRows) {
|
||||
if (
|
||||
!row.REFERENCED_TABLE_NAME ||
|
||||
!tablesToDrop.includes(row.REFERENCED_TABLE_NAME)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Referenced by a table we are keeping
|
||||
if (
|
||||
schemaTableNames.includes(row.TABLE_NAME) ||
|
||||
!tablesToDrop.includes(row.TABLE_NAME)
|
||||
) {
|
||||
const list = referencedByRemaining.get(row.REFERENCED_TABLE_NAME) || [];
|
||||
if (!list.includes(row.TABLE_NAME)) {
|
||||
list.push(row.TABLE_NAME);
|
||||
}
|
||||
referencedByRemaining.set(row.REFERENCED_TABLE_NAME, list);
|
||||
}
|
||||
}
|
||||
|
||||
const safeToDrop: string[] = [];
|
||||
|
||||
for (const tableName of tablesToDrop) {
|
||||
const dependents = referencedByRemaining.get(tableName);
|
||||
if (dependents && dependents.length > 0) {
|
||||
console.warn(
|
||||
`Skipping drop of table \`${tableName}\`: still referenced by ${dependents.join(", ")}. Removing from schema manager tracking only.`,
|
||||
);
|
||||
await removeDbManagerTable({ tableName, config });
|
||||
continue;
|
||||
}
|
||||
safeToDrop.push(tableName);
|
||||
}
|
||||
|
||||
if (safeToDrop.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runSchemaQuery({ query: `SET FOREIGN_KEY_CHECKS = 0`, config });
|
||||
try {
|
||||
for (const tableName of safeToDrop) {
|
||||
console.log(`Dropping table: ${tableName}`);
|
||||
await runSchemaQuery({
|
||||
query: `DROP TABLE IF EXISTS ${MariaDBQuoteGen(tableName)}`,
|
||||
config,
|
||||
});
|
||||
await removeDbManagerTable({ tableName, config });
|
||||
}
|
||||
} finally {
|
||||
await runSchemaQuery({ query: `SET FOREIGN_KEY_CHECKS = 1`, config });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
BUN_MARIADB_TableSchemaType,
|
||||
BunMariaDBConfig,
|
||||
} from "../../types";
|
||||
import createTable from "./create-table";
|
||||
import getTableColumns from "./get-table-columns";
|
||||
import MariaDBQuoteGen from "./mariadb-quote-gen";
|
||||
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
|
||||
import schemaCondition from "./schema-condition";
|
||||
|
||||
async function checkIfTableExists({
|
||||
tableName,
|
||||
config,
|
||||
}: {
|
||||
tableName: string;
|
||||
config?: BunMariaDBConfig;
|
||||
}): Promise<boolean> {
|
||||
const schemaCond = schemaCondition(config);
|
||||
const rows = await querySchemaRows<{ table_exists: number }>({
|
||||
query: `SELECT 1 AS \`table_exists\` FROM information_schema.TABLES WHERE ${schemaCond.where} AND TABLE_NAME = ? LIMIT 1`,
|
||||
values: [...schemaCond.values, tableName],
|
||||
config,
|
||||
});
|
||||
|
||||
return Boolean(rows[0]?.table_exists);
|
||||
}
|
||||
|
||||
export default async function recreateTable({
|
||||
table,
|
||||
config,
|
||||
}: {
|
||||
table: BUN_MARIADB_TableSchemaType;
|
||||
config?: BunMariaDBConfig;
|
||||
}): Promise<void> {
|
||||
const doesTableExist = await checkIfTableExists({
|
||||
tableName: table.tableName,
|
||||
config,
|
||||
});
|
||||
|
||||
if (!doesTableExist) {
|
||||
await createTable({ table, config });
|
||||
return;
|
||||
}
|
||||
|
||||
const tempTableName = `${table.tableName}_temp_${Date.now()}`;
|
||||
const backupOldTableName = `${table.tableName}_old_${Date.now()}`;
|
||||
const existingColumns = await getTableColumns({
|
||||
tableName: table.tableName,
|
||||
config,
|
||||
});
|
||||
|
||||
const columnsToKeep = (table.fields || [])
|
||||
.filter((field) =>
|
||||
existingColumns.some((column) => column.name === field.fieldName),
|
||||
)
|
||||
.map((field) => field.fieldName)
|
||||
.filter((fieldName): fieldName is string => Boolean(fieldName));
|
||||
|
||||
await createTable({
|
||||
table: { ...table, tableName: tempTableName },
|
||||
config,
|
||||
});
|
||||
|
||||
if (columnsToKeep.length > 0) {
|
||||
const columnList = columnsToKeep
|
||||
.map((column) => MariaDBQuoteGen(column))
|
||||
.join(", ");
|
||||
|
||||
await runSchemaQuery({
|
||||
query: `INSERT INTO ${MariaDBQuoteGen(tempTableName)} (${columnList}) SELECT ${columnList} FROM ${MariaDBQuoteGen(table.tableName)}`,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
await runSchemaQuery({ query: `SET FOREIGN_KEY_CHECKS = 0`, config });
|
||||
try {
|
||||
await runSchemaQuery({
|
||||
query: `RENAME TABLE ${MariaDBQuoteGen(table.tableName)} TO ${MariaDBQuoteGen(backupOldTableName)}`,
|
||||
config,
|
||||
});
|
||||
await runSchemaQuery({
|
||||
query: `RENAME TABLE ${MariaDBQuoteGen(tempTableName)} TO ${MariaDBQuoteGen(table.tableName)}`,
|
||||
config,
|
||||
});
|
||||
await runSchemaQuery({
|
||||
query: `DROP TABLE ${MariaDBQuoteGen(backupOldTableName)}`,
|
||||
config,
|
||||
});
|
||||
} finally {
|
||||
await runSchemaQuery({ query: `SET FOREIGN_KEY_CHECKS = 1`, config });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import _ from "lodash";
|
||||
import type {
|
||||
BUN_MARIADB_DatabaseSchemaType,
|
||||
BUN_MARIADB_FieldSchemaType,
|
||||
BUN_MARIADB_TableSchemaType,
|
||||
} from "../../types";
|
||||
|
||||
export default function resolveTable(
|
||||
table: BUN_MARIADB_TableSchemaType,
|
||||
db_schema: BUN_MARIADB_DatabaseSchemaType,
|
||||
): BUN_MARIADB_TableSchemaType {
|
||||
if (!table.parentTableName) {
|
||||
return _.cloneDeep(table);
|
||||
}
|
||||
|
||||
const parentTable = db_schema.tables.find(
|
||||
(schemaTable) => schemaTable.tableName === table.parentTableName,
|
||||
);
|
||||
|
||||
if (!parentTable) {
|
||||
throw new Error(
|
||||
`Parent table \`${table.parentTableName}\` not found for \`${table.tableName}\``,
|
||||
);
|
||||
}
|
||||
|
||||
const mergedFieldsMap = new Map<string, BUN_MARIADB_FieldSchemaType>();
|
||||
|
||||
(parentTable.fields || []).forEach((f) => {
|
||||
if (f.fieldName) mergedFieldsMap.set(f.fieldName, _.cloneDeep(f));
|
||||
});
|
||||
|
||||
(table.fields || []).forEach((f) => {
|
||||
if (f.fieldName) {
|
||||
const existing = mergedFieldsMap.get(f.fieldName) || {};
|
||||
mergedFieldsMap.set(f.fieldName, _.merge({}, existing, f));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
..._.cloneDeep(parentTable),
|
||||
tableName: table.tableName,
|
||||
tableDescription: table.tableDescription || parentTable.tableDescription,
|
||||
collation: table.collation || parentTable.collation,
|
||||
isVector:
|
||||
table.isVector !== undefined ? table.isVector : parentTable.isVector,
|
||||
fields: Array.from(mergedFieldsMap.values()),
|
||||
indexes: _.uniqBy(
|
||||
[...(parentTable.indexes || []), ...(table.indexes || [])],
|
||||
"indexName",
|
||||
),
|
||||
uniqueConstraints: [
|
||||
...(parentTable.uniqueConstraints || []),
|
||||
...(table.uniqueConstraints || []),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { BunMariaDBConfig } from "../../types";
|
||||
import dbHandler from "../db-handler";
|
||||
|
||||
export default async function runSchemaQuery({
|
||||
query,
|
||||
values,
|
||||
config,
|
||||
}: {
|
||||
query: string;
|
||||
values?: any[];
|
||||
config?: BunMariaDBConfig;
|
||||
}): Promise<void> {
|
||||
const res = await dbHandler({
|
||||
query,
|
||||
values,
|
||||
config,
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(
|
||||
`Database query failed: ${query} ... ERROR: ${res.error || res.msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function querySchemaRows<T extends Record<string, any>>({
|
||||
query,
|
||||
values,
|
||||
config,
|
||||
}: {
|
||||
query: string;
|
||||
values?: any[];
|
||||
config?: BunMariaDBConfig;
|
||||
}): Promise<T[]> {
|
||||
const res = await dbHandler<T>({
|
||||
query,
|
||||
values,
|
||||
config,
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(
|
||||
`Database query failed: ${query} ... ERROR: ${res.error || res.msg}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (res.payload || []) as T[];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { BunMariaDBConfig } from "../../types";
|
||||
|
||||
export default function schemaCondition(config?: BunMariaDBConfig): {
|
||||
where: string;
|
||||
values: string[];
|
||||
} {
|
||||
const databaseName = config?.db_name || global.CONFIG?.db_name;
|
||||
|
||||
if (databaseName) {
|
||||
return {
|
||||
where: "TABLE_SCHEMA = ?",
|
||||
values: [databaseName],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
where: "TABLE_SCHEMA = DATABASE()",
|
||||
values: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type {
|
||||
BUN_MARIADB_TableSchemaType,
|
||||
BunMariaDBConfig,
|
||||
} from "../../types";
|
||||
import MariaDBQuoteGen from "./mariadb-quote-gen";
|
||||
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
|
||||
import schemaCondition from "./schema-condition";
|
||||
|
||||
export default async function syncIndexes({
|
||||
table,
|
||||
config,
|
||||
}: {
|
||||
table: BUN_MARIADB_TableSchemaType;
|
||||
config?: BunMariaDBConfig;
|
||||
}): Promise<void> {
|
||||
const schemaCond = schemaCondition(config);
|
||||
const rows = await querySchemaRows<{
|
||||
INDEX_NAME: string;
|
||||
COLUMN_NAME: string;
|
||||
INDEX_TYPE: string;
|
||||
}>({
|
||||
query: `SELECT INDEX_NAME, COLUMN_NAME, INDEX_TYPE FROM information_schema.STATISTICS WHERE ${schemaCond.where} AND TABLE_NAME = ? AND INDEX_NAME <> 'PRIMARY' ORDER BY INDEX_NAME, SEQ_IN_INDEX`,
|
||||
values: [...schemaCond.values, table.tableName],
|
||||
config,
|
||||
});
|
||||
|
||||
/**
|
||||
* Indexes required by foreign keys / unique constraints cannot be dropped
|
||||
* freely. Skip those when cleaning up schema indexes.
|
||||
*/
|
||||
const protectedConstraintRows = await querySchemaRows<{
|
||||
CONSTRAINT_NAME: string;
|
||||
CONSTRAINT_TYPE: string;
|
||||
}>({
|
||||
query: `SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE FROM information_schema.TABLE_CONSTRAINTS WHERE ${schemaCond.where} AND TABLE_NAME = ? AND CONSTRAINT_TYPE IN ('FOREIGN KEY', 'UNIQUE')`,
|
||||
values: [...schemaCond.values, table.tableName],
|
||||
config,
|
||||
});
|
||||
const protectedIndexNames = new Set(
|
||||
protectedConstraintRows.map((r) => r.CONSTRAINT_NAME),
|
||||
);
|
||||
|
||||
// Column-level UNIQUE creates an index often named after the column
|
||||
for (const field of table.fields || []) {
|
||||
if (field.unique && field.fieldName) {
|
||||
protectedIndexNames.add(field.fieldName);
|
||||
}
|
||||
}
|
||||
for (const constraint of table.uniqueConstraints || []) {
|
||||
if (constraint.constraintName) {
|
||||
protectedIndexNames.add(constraint.constraintName);
|
||||
}
|
||||
}
|
||||
|
||||
const existingIndexesMap = new Map<
|
||||
string,
|
||||
{ columns: string[]; type: string }
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!existingIndexesMap.has(row.INDEX_NAME)) {
|
||||
existingIndexesMap.set(row.INDEX_NAME, {
|
||||
columns: [],
|
||||
type: row.INDEX_TYPE,
|
||||
});
|
||||
}
|
||||
existingIndexesMap.get(row.INDEX_NAME)!.columns.push(row.COLUMN_NAME);
|
||||
}
|
||||
|
||||
for (const [indexName, details] of existingIndexesMap.entries()) {
|
||||
if (protectedIndexNames.has(indexName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const schemaIndex = table.indexes?.find((i) => i.indexName === indexName);
|
||||
|
||||
if (!schemaIndex) {
|
||||
console.log(`Dropping index: ${indexName}`);
|
||||
try {
|
||||
await runSchemaQuery({
|
||||
query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`,
|
||||
config,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (
|
||||
String(err?.message || "").includes(
|
||||
"needed in a foreign key constraint",
|
||||
)
|
||||
) {
|
||||
console.warn(
|
||||
`Skipping drop of index ${indexName}: required by a foreign key constraint`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
const schemaColumns = schemaIndex.indexTableFields || [];
|
||||
const columnsMatch =
|
||||
details.columns.length === schemaColumns.length &&
|
||||
details.columns.every((col, idx) => col === schemaColumns[idx]);
|
||||
|
||||
if (!columnsMatch) {
|
||||
console.log(`Recreating changed index: ${indexName}`);
|
||||
await runSchemaQuery({
|
||||
query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`,
|
||||
config,
|
||||
});
|
||||
existingIndexesMap.delete(indexName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const index of table.indexes || []) {
|
||||
if (
|
||||
!index.indexName ||
|
||||
!index.indexTableFields ||
|
||||
index.indexTableFields.length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!existingIndexesMap.has(index.indexName)) {
|
||||
const isVectorIndex =
|
||||
table.isVector ||
|
||||
table.fields?.some(
|
||||
(f) =>
|
||||
f.fieldName === index.indexTableFields![0] && f.isVector,
|
||||
);
|
||||
|
||||
if (isVectorIndex) {
|
||||
console.log(`Creating Vector index: ${index.indexName}`);
|
||||
const targetField = MariaDBQuoteGen(index.indexTableFields[0]!);
|
||||
const distanceMetric =
|
||||
(index.indexType as string)?.toLowerCase() === "cosine"
|
||||
? "cosine"
|
||||
: "euclidean";
|
||||
|
||||
await runSchemaQuery({
|
||||
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD VECTOR INDEX ${MariaDBQuoteGen(index.indexName)} (${targetField}) M=8 DISTANCE=${distanceMetric}`,
|
||||
config,
|
||||
});
|
||||
} else {
|
||||
console.log(`Creating standard index: ${index.indexName}`);
|
||||
const fields = index.indexTableFields
|
||||
.map((field) => MariaDBQuoteGen(field))
|
||||
.join(", ");
|
||||
const typeUpper = index.indexType?.toUpperCase();
|
||||
const isSpecialType =
|
||||
typeUpper === "FULLTEXT" || typeUpper === "SPATIAL";
|
||||
const indexPrefix = isSpecialType ? `${typeUpper} ` : "";
|
||||
const indexSuffix =
|
||||
!isSpecialType &&
|
||||
(typeUpper === "BTREE" || typeUpper === "HASH")
|
||||
? ` USING ${typeUpper}`
|
||||
: "";
|
||||
|
||||
await runSchemaQuery({
|
||||
query: `CREATE ${indexPrefix}INDEX ${MariaDBQuoteGen(index.indexName)} ON ${MariaDBQuoteGen(table.tableName)} (${fields})${indexSuffix}`,
|
||||
config,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import type {
|
||||
BUN_MARIADB_FieldSchemaType,
|
||||
BUN_MARIADB_TableSchemaType,
|
||||
BunMariaDBConfig,
|
||||
} from "../../types";
|
||||
import buildColumnDefinition from "./build-column-definition";
|
||||
import createTable from "./create-table";
|
||||
import getTableColumns from "./get-table-columns";
|
||||
import mapDataType from "./map-data-types";
|
||||
import MariaDBQuoteGen from "./mariadb-quote-gen";
|
||||
import recreateTable from "./recreate-table";
|
||||
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
|
||||
import schemaCondition from "./schema-condition";
|
||||
|
||||
/**
|
||||
* Compare live COLUMN_TYPE with schema-mapped type.
|
||||
* Live types often include display widths (e.g. bigint(20) vs BIGINT).
|
||||
*/
|
||||
function columnTypesMatch(liveType: string, expectedType: string): boolean {
|
||||
const live = liveType.toLowerCase().replace(/\s+/g, "");
|
||||
const expected = expectedType.toLowerCase().replace(/\s+/g, "");
|
||||
|
||||
if (live === expected) return true;
|
||||
// live may include display width: bigint(20) vs bigint
|
||||
if (live.startsWith(`${expected}(`)) return true;
|
||||
// expected may include length live omits in some versions
|
||||
if (expected.startsWith(`${live}(`)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function addColumn({
|
||||
tableName,
|
||||
field,
|
||||
config,
|
||||
}: {
|
||||
tableName: string;
|
||||
field: BUN_MARIADB_FieldSchemaType;
|
||||
config?: BunMariaDBConfig;
|
||||
}): Promise<void> {
|
||||
console.log(`Adding column: ${tableName}.${field.fieldName}`);
|
||||
const columnDef = buildColumnDefinition(field).trim();
|
||||
await runSchemaQuery({
|
||||
query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} ADD COLUMN IF NOT EXISTS ${columnDef}`,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
async function modifyColumn({
|
||||
tableName,
|
||||
field,
|
||||
config,
|
||||
}: {
|
||||
tableName: string;
|
||||
field: BUN_MARIADB_FieldSchemaType;
|
||||
config?: BunMariaDBConfig;
|
||||
}): Promise<void> {
|
||||
console.log(`Modifying column: ${tableName}.${field.fieldName}`);
|
||||
const columnDef = buildColumnDefinition(field).trim();
|
||||
await runSchemaQuery({
|
||||
query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} MODIFY COLUMN ${columnDef}`,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
async function dropColumn({
|
||||
tableName,
|
||||
fieldName,
|
||||
config,
|
||||
}: {
|
||||
tableName: string;
|
||||
fieldName: string;
|
||||
config?: BunMariaDBConfig;
|
||||
}): Promise<void> {
|
||||
console.log(`Dropping column: ${tableName}.${fieldName}`);
|
||||
await runSchemaQuery({
|
||||
query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} DROP COLUMN ${MariaDBQuoteGen(fieldName)}`,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function updateTable({
|
||||
table,
|
||||
config,
|
||||
}: {
|
||||
table: BUN_MARIADB_TableSchemaType;
|
||||
config?: BunMariaDBConfig;
|
||||
}): Promise<void> {
|
||||
const existingColumns = await getTableColumns({
|
||||
tableName: table.tableName,
|
||||
config,
|
||||
});
|
||||
|
||||
if (existingColumns.length === 0) {
|
||||
await createTable({ table, config });
|
||||
return;
|
||||
}
|
||||
|
||||
const liveFieldsMap = new Map(
|
||||
existingColumns.map((col) => [
|
||||
col.name,
|
||||
{ type: col.type.toLowerCase(), comment: col.comment || "" },
|
||||
]),
|
||||
);
|
||||
const codeFieldsMap = new Map(
|
||||
(table.fields || []).map((f) => [f.fieldName, f]),
|
||||
);
|
||||
|
||||
const fieldsToAdd: BUN_MARIADB_FieldSchemaType[] = [];
|
||||
const fieldsToModify: BUN_MARIADB_FieldSchemaType[] = [];
|
||||
const fieldsToDrop: string[] = [];
|
||||
|
||||
for (const field of table.fields || []) {
|
||||
if (!field.fieldName) continue;
|
||||
|
||||
const liveField = liveFieldsMap.get(field.fieldName);
|
||||
|
||||
if (!liveField) {
|
||||
fieldsToAdd.push(field);
|
||||
} else {
|
||||
let typeDiverged = !columnTypesMatch(
|
||||
liveField.type,
|
||||
mapDataType(field),
|
||||
);
|
||||
|
||||
if (field.isVector || field.dataType === "VECTOR") {
|
||||
const dimensions = field.vectorSize || 1536;
|
||||
const expectedNativeToken = `vector(${dimensions})`;
|
||||
typeDiverged = liveField.type.toLowerCase() !== expectedNativeToken;
|
||||
}
|
||||
|
||||
if (typeDiverged) {
|
||||
fieldsToModify.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const col of existingColumns) {
|
||||
if (!codeFieldsMap.has(col.name)) {
|
||||
fieldsToDrop.push(col.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
fieldsToAdd.length === 0 &&
|
||||
fieldsToModify.length === 0 &&
|
||||
fieldsToDrop.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Surgically updating table structure from database layout: ${table.tableName}`,
|
||||
);
|
||||
|
||||
if (fieldsToDrop.length > 0) {
|
||||
const schemaCond = schemaCondition(config);
|
||||
|
||||
const pkRows = await querySchemaRows<{ COLUMN_NAME: string }>({
|
||||
query: `SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE ${schemaCond.where} AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'`,
|
||||
values: [...schemaCond.values, table.tableName],
|
||||
config,
|
||||
});
|
||||
const pkColumnNames = pkRows.map((r) => r.COLUMN_NAME);
|
||||
|
||||
if (fieldsToDrop.some((f) => pkColumnNames.includes(f))) {
|
||||
console.log(
|
||||
`Dropping primary key because a PK column is being dropped`,
|
||||
);
|
||||
await runSchemaQuery({
|
||||
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} DROP PRIMARY KEY`,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
const indexRows = await querySchemaRows<{
|
||||
INDEX_NAME: string;
|
||||
COLUMN_NAME: string;
|
||||
}>({
|
||||
query: `SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS WHERE ${schemaCond.where} AND TABLE_NAME = ? AND INDEX_NAME <> 'PRIMARY'`,
|
||||
values: [...schemaCond.values, table.tableName],
|
||||
config,
|
||||
});
|
||||
|
||||
const indexesToDrop = new Set<string>();
|
||||
for (const row of indexRows) {
|
||||
if (fieldsToDrop.includes(row.COLUMN_NAME)) {
|
||||
indexesToDrop.add(row.INDEX_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
for (const indexName of indexesToDrop) {
|
||||
console.log(
|
||||
`Dropping index ${indexName} because it contains a dropped column`,
|
||||
);
|
||||
await runSchemaQuery({
|
||||
query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`,
|
||||
config,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of fieldsToAdd) {
|
||||
await addColumn({ tableName: table.tableName, field, config });
|
||||
}
|
||||
|
||||
for (const field of fieldsToModify) {
|
||||
try {
|
||||
await modifyColumn({ tableName: table.tableName, field, config });
|
||||
} catch (err: any) {
|
||||
if (field.isVector || field.dataType === "VECTOR") {
|
||||
console.warn(
|
||||
`[Vector Resize] Re-aligning dimension spaces natively via safe migration schema rebuild.`,
|
||||
);
|
||||
await recreateTable({ table, config });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
for (const fieldName of fieldsToDrop) {
|
||||
await dropColumn({ tableName: table.tableName, fieldName, config });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { AppData } from "../../data/app-data";
|
||||
import type { BunMariaDBConfig } from "../../types";
|
||||
import MariaDBQuoteGen from "./mariadb-quote-gen";
|
||||
import runSchemaQuery from "./run-schema-query";
|
||||
|
||||
export default async function upsertDbManagerTable({
|
||||
tableName,
|
||||
config,
|
||||
}: {
|
||||
tableName: string;
|
||||
config?: BunMariaDBConfig;
|
||||
}): Promise<void> {
|
||||
const now = Date.now();
|
||||
await runSchemaQuery({
|
||||
query: `INSERT INTO ${MariaDBQuoteGen(AppData["DbSchemaManagerTableName"])} (table_name, created_at, updated_at) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at)`,
|
||||
values: [tableName, now, now],
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeDbManagerTable({
|
||||
tableName,
|
||||
config,
|
||||
}: {
|
||||
tableName: string;
|
||||
config?: BunMariaDBConfig;
|
||||
}): Promise<void> {
|
||||
await runSchemaQuery({
|
||||
query: `DELETE FROM ${MariaDBQuoteGen(AppData["DbSchemaManagerTableName"])} WHERE table_name = ?`,
|
||||
values: [tableName],
|
||||
config,
|
||||
});
|
||||
}
|
||||
+15
-1
@@ -120,7 +120,6 @@ export interface BUN_MARIADB_ChildrenTablesType {
|
||||
*/
|
||||
export const TextFieldTypesArray = [
|
||||
{ title: "Plain Text", value: "plain" },
|
||||
{ title: "Rich Text", value: "richText" },
|
||||
{ title: "Markdown", value: "markdown" },
|
||||
{ title: "JSON", value: "json" },
|
||||
{ title: "YAML", value: "yaml" },
|
||||
@@ -1544,6 +1543,21 @@ export type BunMariaDBConfig = {
|
||||
* File path to the SSL certificate
|
||||
*/
|
||||
ssl_ca?: string;
|
||||
/**
|
||||
* Extra HTML sanitization allowlists appended to the defaults
|
||||
* when inserting/updating fields marked `html: true`.
|
||||
*/
|
||||
html_sanitize?: {
|
||||
/**
|
||||
* Additional tags to allow (merged with built-in defaults).
|
||||
*/
|
||||
allowed_tags?: string[];
|
||||
/**
|
||||
* Additional attributes to allow per tag (merged with built-in defaults).
|
||||
* Values for each tag are appended to any existing allowed attributes.
|
||||
*/
|
||||
allowed_attributes?: Record<string, string[]>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import type {
|
||||
BUN_MARIADB_DatabaseSchemaType,
|
||||
BUN_MARIADB_FieldSchemaType,
|
||||
BunMariaDBConfig,
|
||||
} from "../types";
|
||||
import { readLiveSchema } from "../functions/live-schema";
|
||||
import getSanitizeHtmlOptions from "./sanitize-html-options";
|
||||
|
||||
function grabTableFields(tableName: string): BUN_MARIADB_FieldSchemaType[] {
|
||||
const dbSchema: BUN_MARIADB_DatabaseSchemaType | undefined =
|
||||
global.DB_SCHEMA || readLiveSchema();
|
||||
|
||||
const tableSchema = dbSchema?.tables?.find((t) => t.tableName === tableName);
|
||||
|
||||
return tableSchema?.fields || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Only fields with an explicit `html: true` flag are sanitized.
|
||||
* Missing / falsy / non-true values are never sanitized.
|
||||
*/
|
||||
function isExplicitHtmlField(field?: BUN_MARIADB_FieldSchemaType): boolean {
|
||||
return field?.html === true;
|
||||
}
|
||||
|
||||
function sanitizeValue(
|
||||
value: unknown,
|
||||
config?: BunMariaDBConfig,
|
||||
): unknown {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return sanitizeHtml(value, getSanitizeHtmlOptions(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize string values ONLY for schema fields with explicit `html: true`.
|
||||
* Other fields (including plain text that happens to contain HTML) are left untouched.
|
||||
*/
|
||||
export default function sanitizeHtmlFields<
|
||||
T extends Record<string, any> = Record<string, any>,
|
||||
>({
|
||||
table,
|
||||
data,
|
||||
config,
|
||||
}: {
|
||||
table: string;
|
||||
data: T;
|
||||
config?: BunMariaDBConfig;
|
||||
}): T {
|
||||
const fields = grabTableFields(table);
|
||||
if (fields.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const htmlFieldNames = new Set(
|
||||
fields
|
||||
.filter(isExplicitHtmlField)
|
||||
.map((f) => f.fieldName)
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
);
|
||||
|
||||
// No explicitly marked html fields on this table — skip entirely
|
||||
if (htmlFieldNames.size === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const sanitized = { ...data } as Record<string, any>;
|
||||
const resolvedConfig = config || global.CONFIG;
|
||||
|
||||
for (const key of Object.keys(sanitized)) {
|
||||
// Only sanitize keys that map to fields with html: true
|
||||
if (!htmlFieldNames.has(key)) continue;
|
||||
sanitized[key] = sanitizeValue(sanitized[key], resolvedConfig);
|
||||
}
|
||||
|
||||
return sanitized as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize an array of row objects for insert.
|
||||
*/
|
||||
export function sanitizeHtmlFieldsBatch<
|
||||
T extends Record<string, any> = Record<string, any>,
|
||||
>({
|
||||
table,
|
||||
data,
|
||||
config,
|
||||
}: {
|
||||
table: string;
|
||||
data: T[];
|
||||
config?: BunMariaDBConfig;
|
||||
}): T[] {
|
||||
return data.map((row) =>
|
||||
sanitizeHtmlFields({ table, data: row, config }),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { IOptions } from "sanitize-html";
|
||||
import type { BunMariaDBConfig } from "../types";
|
||||
|
||||
export const defaultSanitizeHtmlOptions: IOptions = {
|
||||
allowedTags: [
|
||||
"b",
|
||||
"i",
|
||||
"em",
|
||||
"strong",
|
||||
"a",
|
||||
"p",
|
||||
"span",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"img",
|
||||
"div",
|
||||
"button",
|
||||
"pre",
|
||||
"code",
|
||||
"br",
|
||||
"hr",
|
||||
"blockquote",
|
||||
"table",
|
||||
"tr",
|
||||
"td",
|
||||
"th",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tfoot",
|
||||
"caption",
|
||||
"colgroup",
|
||||
"col",
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ["href", "title", "class", "style", "target", "rel"],
|
||||
img: ["src", "alt", "width", "height", "class", "style"],
|
||||
"*": ["style", "class", "title", "id"],
|
||||
},
|
||||
};
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return Array.from(new Set(values));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build sanitize-html options, appending any tags/attributes from config.
|
||||
*/
|
||||
export default function getSanitizeHtmlOptions(
|
||||
config?: BunMariaDBConfig,
|
||||
): IOptions {
|
||||
const cfg = config || global.CONFIG;
|
||||
const extra = cfg?.html_sanitize;
|
||||
|
||||
const baseTags = defaultSanitizeHtmlOptions.allowedTags || [];
|
||||
const baseAttrs = {
|
||||
...(defaultSanitizeHtmlOptions.allowedAttributes || {}),
|
||||
} as Record<string, string[]>;
|
||||
|
||||
const allowedTags = uniqueStrings([
|
||||
...(Array.isArray(baseTags) ? baseTags : []),
|
||||
...(extra?.allowed_tags || []),
|
||||
]);
|
||||
|
||||
const allowedAttributes: Record<string, string[]> = { ...baseAttrs };
|
||||
|
||||
for (const [tag, attrs] of Object.entries(
|
||||
extra?.allowed_attributes || {},
|
||||
)) {
|
||||
allowedAttributes[tag] = uniqueStrings([
|
||||
...(allowedAttributes[tag] || []),
|
||||
...attrs,
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
allowedTags,
|
||||
allowedAttributes,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user