@variablesoftware/mock-d1
Version:
🎛️🗂️🧠 Mock D1 Database implementation for testing Cloudflare Workers
315 lines (314 loc) • 12.9 kB
JavaScript
import { z } from "zod";
import { findTableKey, summarizeValue } from "../../helpers/index.js";
import { log } from "@variablesoftware/logface";
import { extractTableName, normalizeTableName } from '../tableUtils/tableNameUtils.js';
import { d1Error } from '../errors.js';
import { validateSqlOrThrow } from '../sqlValidation.js';
// Accept any JSON-serializable value except function, symbol, bigint, undefined
const bindArgsSchema = z.record(z.string(), z.any()).superRefine((args, ctx) => {
for (const [key, value] of Object.entries(args)) {
if (typeof value === 'function' ||
typeof value === 'symbol' ||
typeof value === 'bigint' ||
typeof value === 'undefined') {
ctx.addIssue({
code: 'custom',
path: [key],
message: `Unsupported data type`
});
}
// Only allow JSON-serializable objects/arrays
if (typeof value === 'object' && value !== null) {
try {
JSON.stringify(value);
}
catch {
ctx.addIssue({
code: 'custom',
path: [key],
message: `Unsupported data type`
});
}
}
}
});
/**
* Handles INSERT INTO <table> (...) VALUES (...) statements for the mock D1 engine.
* Inserts a new row into the specified table using the provided bind arguments.
*
* @param sql - The SQL INSERT statement string.
* @param db - The in-memory database map.
* @param bindArgs - The named bind arguments for the statement.
* @returns An object representing the result of the INSERT operation.
* @throws If the SQL statement is malformed or the column/bind count does not match.
*/
export function handleInsert(sql, db, bindArgs = {} // Default to an empty object
) {
// Validate SQL (including malformed) at run-time
validateSqlOrThrow(sql);
// Only log debug info if not in stress mode
const isStress = process.env.D1_STRESS === '1';
const isDebug = process.env.DEBUG === '1';
if (!isStress || !isDebug) {
log.debug("called", { sql, bindArgs: summarizeValue(bindArgs) });
}
// Parse columns and values from SQL
const colMatch = sql.match(/insert into\s+([`"])?(\w+)\1?(?:\s*\(([^)]*)\))?/i);
const valuesMatch = sql.match(/values\s*\(([^)]+)\)/i);
if (!colMatch || !valuesMatch) {
if (isDebug)
log.error("malformed INSERT", { sql });
throw d1Error('MALFORMED_INSERT');
}
if (sql.indexOf(colMatch[0]) > sql.indexOf(valuesMatch[0])) {
if (isDebug)
log.error("malformed INSERT: columns after values", { sql });
throw d1Error('MALFORMED_INSERT');
}
const columns = colMatch[3]
? colMatch[3].split(",").map(s => {
const trimmed = s.trim();
const quotedMatch = trimmed.match(/^([`"[])(.+)\1/);
if (quotedMatch) {
return { name: quotedMatch[2], quoted: true, original: quotedMatch[0] };
}
else {
return { name: trimmed, quoted: false, original: trimmed };
}
}).filter(c => c.name)
: [];
const values = valuesMatch[1] ? valuesMatch[1].split(",").map(s => s.trim()) : [];
// --- PRIORITIZE: Check for missing bind arguments BEFORE any column/value count mismatch ---
for (const valueExpr of values) {
const bindMatch = valueExpr.match(/^:(.+)$/);
if (bindMatch) {
const bindName = bindMatch[1];
if (!(bindName in bindArgs)) {
throw new Error('Missing bind argument');
}
}
}
// 1. Throw if column/value count mismatch
if (columns.length !== values.length || columns.length === 0) {
throw d1Error('MALFORMED_INSERT', 'Column/value count mismatch in INSERT');
}
// 2. Throw if duplicate column names
// Only declare here, and do not redeclare above
const seenUnquotedInsert = new Set();
const seenQuotedInsert = new Set();
for (const col of columns) {
if (col.quoted) {
if (seenQuotedInsert.has(col.name)) {
throw d1Error('MALFORMED_INSERT', 'Duplicate column name in INSERT');
}
seenQuotedInsert.add(col.name);
}
else {
const lower = col.name.toLowerCase();
if (seenUnquotedInsert.has(lower)) {
throw d1Error('MALFORMED_INSERT', 'Duplicate column name in INSERT');
}
seenUnquotedInsert.add(lower);
}
}
// 3. Skip insert if all values are undefined/null (including bind values)
if (values.every((v, _i) => {
const bindMatch = v && typeof v === 'string' && v.match(/^:(.+)$/);
if (bindMatch) {
const bindName = bindMatch[1];
// Only treat as undefined if the key is present and value is undefined/null
return (bindName in bindArgs) ? (bindArgs[bindName] === undefined || bindArgs[bindName] === null) : false;
}
return v === undefined || v === null || v === 'undefined' || v === 'null';
})) {
// Check if any bind placeholder is missing as a key in bindArgs
const missingBind = values.some((v, _i) => {
const bindMatch = v && typeof v === 'string' && v.match(/^:(.+)$/);
if (bindMatch) {
const bindName = bindMatch[1];
// Only throw if the key is not present at all
return !(bindName in bindArgs);
}
return false;
});
if (missingBind) {
throw new Error('Missing bind argument');
}
let tableName = '';
try {
tableName = extractTableName(sql, 'INSERT');
}
catch { /* */ }
let tableKey = tableName ? findTableKey(db, tableName) : undefined;
let tableData = (tableKey && db.has(tableKey)) ? db.get(tableKey) : undefined;
// D1: no-op insert should return success: false
return { success: false, results: [], meta: { changes: 0, rows_written: 0, last_row_id: tableData && tableData.rows ? tableData.rows.length : 0 } };
}
// Validate bind arguments
try {
bindArgsSchema.parse(bindArgs);
}
catch (e) {
log.error("bindArgs validation failed", { bindArgs: summarizeValue(bindArgs), error: e });
throw new Error("Unsupported data type");
}
let tableName;
try {
tableName = extractTableName(sql, 'INSERT');
}
catch {
throw new Error("Malformed INSERT statement");
}
let tableKey = findTableKey(db, tableName);
let tableData = tableKey ? db.get(tableKey) : undefined;
// If table does not exist, auto-create with D1-accurate key logic
if (!tableKey || !tableData) {
const colMatch = sql.match(/insert into\s+([`"])?(\w+)\1?(?:\s*\(([^)]*)\))?/i);
const columns = colMatch && colMatch[3]
? colMatch[3].split(",").map(s => {
const trimmed = s.trim();
const quotedMatch = trimmed.match(/^([`"[])(.+)\1/);
if (quotedMatch) {
return { name: quotedMatch[2], quoted: true, original: quotedMatch[0] };
}
else {
return { name: trimmed, quoted: false, original: trimmed };
}
}).filter(c => c.name)
: [];
const newTableKey = normalizeTableName(tableName);
db.set(newTableKey, { columns, rows: [] });
tableKey = newTableKey;
tableData = db.get(tableKey);
if (!isStress) {
log.debug("auto-creating table", { tableName, newTableKey, columns });
}
}
if (!tableData)
throw d1Error('TABLE_NOT_FOUND', tableName);
// --- Normalize columns to array (compatibility with object schema) ---
let columnsArr;
if (Array.isArray(tableData.columns)) {
columnsArr = tableData.columns.map(c => ({
name: c.name,
quoted: c.quoted,
original: c.original ?? c.name
}));
}
else {
columnsArr = Object.keys(tableData.columns).map(k => ({
name: k,
quoted: false,
original: k
}));
}
// Throw if column/value count mismatch
if (columnsArr.length !== values.length || columnsArr.length === 0) {
throw d1Error('MALFORMED_INSERT', 'Column/value count mismatch in INSERT');
}
// Throw if duplicate column names (only check once here)
const seenUnquoted = new Set();
const seenQuoted = new Set();
for (const col of columnsArr) {
if (col.quoted) {
if (seenQuoted.has(col.name)) {
throw d1Error('MALFORMED_INSERT', 'Duplicate column name in INSERT');
}
seenQuoted.add(col.name);
}
else {
const lower = col.name.toLowerCase();
if (seenUnquoted.has(lower)) {
throw d1Error('MALFORMED_INSERT', 'Duplicate column name in INSERT');
}
seenUnquoted.add(lower);
}
}
// If all values are undefined/null, skip insert and return success
if (values.every(v => v === undefined || v === null || v === 'undefined' || v === 'null')) {
return { success: true, results: [], meta: { changes: 0, rows_written: 0, last_row_id: tableData.rows.length } };
}
// Accept bind arg names and column names case-insensitively, and allow SQL keywords as names
const bindKeys = Object.keys(bindArgs);
const normBindArgs = Object.fromEntries(bindKeys.map(k => [k.toLowerCase(), bindArgs[k]]));
// Build row using bind parameter names from VALUES clause
const row = Object.create(null);
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const valueExpr = values[i];
const bindMatch = valueExpr.match(/^:(.+)$/);
if (!bindMatch) {
log.error("Non-bind value in VALUES clause (not supported)", { valueExpr, sql });
throw d1Error('MALFORMED_INSERT');
}
const bindName = bindMatch[1].toLowerCase();
let value = normBindArgs[bindName];
if (typeof value === 'object' && value !== null) {
if (Object.getPrototypeOf(value) !== Object.prototype && !Array.isArray(value)) {
log.error("unsupported object type", { value: summarizeValue(value) });
throw new Error("Unsupported data type");
}
try {
value = JSON.stringify(value);
}
catch (err) {
log.error("JSON.stringify failed", { value: summarizeValue(value), err });
throw new Error("Unsupported data type");
}
}
row[col.quoted ? col.name : col.name.toLowerCase()] = value;
if (!isStress) {
log.debug("assigned value", { col: col.name, value: summarizeValue(value) });
}
}
// Guard: do not insert a row if all values are undefined
const allUndefined = columns.every(col => row[col.quoted ? col.name : col.name.toLowerCase()] === undefined);
if (allUndefined) {
// Check if any value is undefined due to missing bind argument
const missingBind = columns.some((col, i) => {
const valueExpr = values[i];
const bindMatch = valueExpr && typeof valueExpr === 'string' && valueExpr.match(/^:(.+)$/);
if (bindMatch) {
const bindName = bindMatch[1].toLowerCase();
return !(bindName in normBindArgs);
}
return false;
});
if (missingBind) {
// Always throw the specific error for missing bind argument
throw new Error('Missing bind argument');
}
log.warn("Skipping insert of all-undefined row", { tableKey, row });
return {
success: false,
results: [],
meta: {
duration: 0,
size_after: tableData.rows.length,
rows_read: 0,
rows_written: 0,
last_row_id: tableData.rows.length,
changed_db: false,
changes: 0,
},
};
}
tableData.rows.push(row);
if (!isStress) {
log.debug("final table rows", { tableKey, rowCount: tableData.rows.length });
log.info("row inserted", { tableKey, rowCount: tableData.rows.length });
}
return {
success: true,
results: [],
meta: {
duration: 0,
size_after: tableData.rows.length,
rows_read: 0,
rows_written: 1,
last_row_id: tableData.rows.length,
changed_db: true,
changes: 1,
},
};
}