feat: v0.7.0 参数化查询 + 事务增量 flush + 复合主键语义硬化 + EXPLAIN 真实索引信息
- 参数化查询 db.query(sql, params):词法层 ? 绑定 + SQL 字面量安全编码 ('' 转义/注入防护);参数计数不匹配 PARAM_ERROR;对象参数显式拒绝 - KVStoreEngine 事务增量 flush:行级变更追踪,commit 仅写改动行 (1000 行表改 1 行:日志 1 条目 vs 整表 1000 条目);级联影响表漏写修复 (txFullTables 同步加入 txDirtyTables);移除每次 commit 全量 checkpoint (阈值自动 checkpoint + close 统一截断) - 复合主键显式拒绝:createSchema 校验期 SCHEMA_ERROR + ALTER ADD 主键列防护 (此前静默取第一个主键,其余标记失效) - EXPLAIN usingIndex 真实命中信息:pk / index:col / none 测试 1126 → 1147(72 套件);行覆盖率 89.8%;版本 0.7.0
This commit is contained in:
+1
-1
@@ -214,4 +214,4 @@ export class DatabaseError extends Error {
|
||||
// 版本
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VERSION = '0.6.3';
|
||||
export const VERSION = '0.7.0';
|
||||
|
||||
+10
-3
@@ -16,6 +16,7 @@ import { Table } from './table/table';
|
||||
import { createSchema } from './table/schema';
|
||||
import { QueryExecutor } from './query/executor';
|
||||
import { parseAll } from './sql/parser';
|
||||
import { bindParameters } from './sql/params';
|
||||
import { TransactionManager } from './transaction/index';
|
||||
import { PluginManager } from './plugin/index';
|
||||
import type { Statement } from './query/ast';
|
||||
@@ -186,8 +187,12 @@ export class MetonaSqlark {
|
||||
|
||||
// ---- SQL 查询 ----
|
||||
|
||||
/** 执行 SQL 字符串查询 */
|
||||
async query(sql: string): Promise<unknown> {
|
||||
/**
|
||||
* 执行 SQL 字符串查询。
|
||||
* v0.7.0: 支持位置参数(`?`)—— `db.query('SELECT * FROM t WHERE id = ?', ['1'])`。
|
||||
* 参数按 SQL 字面量安全编码(字符串 '' 转义),杜绝 SQL 注入。
|
||||
*/
|
||||
async query(sql: string, params?: unknown[]): Promise<unknown> {
|
||||
this.ensureReady();
|
||||
const startTime = this.debug ? Date.now() : 0;
|
||||
|
||||
@@ -195,8 +200,10 @@ export class MetonaSqlark {
|
||||
|
||||
let result: unknown;
|
||||
try {
|
||||
// v0.7.0: 参数绑定(仅替换字符串字面量之外的 ?)
|
||||
const boundSql = bindParameters(sql, params);
|
||||
// v0.3.0: 支持分号分隔的多语句,逐条顺序执行,返回最后一条的结果
|
||||
const statements: Statement[] = parseAll(sql);
|
||||
const statements: Statement[] = parseAll(boundSql);
|
||||
for (const stmt of statements) {
|
||||
// v0.5.1: SQL 写语句触发 CRUD 生命周期钩子(与 Table API 路径一致)
|
||||
await this.triggerStatementHooks(stmt, 'before');
|
||||
|
||||
@@ -46,6 +46,15 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
private txDirtyTables: Set<string> = new Set();
|
||||
/** 事务中发生 schema 变更(DDL)—— commit 时持久化 schema */
|
||||
private txSchemaChanged = false;
|
||||
/**
|
||||
* v0.7.0: 事务行级变更记录(table → pk → put/delete)。
|
||||
* commit 时按行增量 flush(此前整表 diff:大表事务改 1 行也重写全表)。
|
||||
*/
|
||||
private txChanges: Map<string, Map<string, 'put' | 'delete'>> = new Map();
|
||||
/** v0.7.0: 无法行级追踪的表(主键变更/级联影响表)→ commit 时整表 diff */
|
||||
private txFullTables: Set<string> = new Set();
|
||||
/** v0.7.0: 事务内 clear 的表 → commit 时清空 KV 行 */
|
||||
private txClearedTables: Set<string> = new Set();
|
||||
|
||||
constructor(medium?: IStorageBackend, checkpointThreshold?: number) {
|
||||
this.kv = new KVStore(medium, checkpointThreshold);
|
||||
@@ -119,6 +128,9 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
if (this.txActive) {
|
||||
try { await this.rollbackTransaction(); } catch { /* ignore */ }
|
||||
}
|
||||
// v0.7.0: 关闭前 checkpoint(截断日志,重开更快)。
|
||||
// 失败不阻塞关闭(日志已持久,重开可全量重放)。
|
||||
try { await this.kv.checkpoint(); } catch { /* 数据在日志中,重开放心重放 */ }
|
||||
await this.kv.close();
|
||||
await this.memory.close();
|
||||
this.opened = false;
|
||||
@@ -239,6 +251,19 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
const pks = await this.memory.insert(tableName, rows);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
// v0.7.0: 事务内 clear 后又写入 → 清空语义被覆盖,整表 diff 兜底
|
||||
if (this.txClearedTables.has(tableName)) {
|
||||
this.txClearedTables.delete(tableName);
|
||||
this.txFullTables.add(tableName);
|
||||
return pks;
|
||||
}
|
||||
// v0.7.0: 行级变更记录(增量 flush)
|
||||
let changes = this.txChanges.get(tableName);
|
||||
if (!changes) {
|
||||
changes = new Map();
|
||||
this.txChanges.set(tableName, changes);
|
||||
}
|
||||
for (const pk of pks) changes.set(pk, 'put');
|
||||
return pks;
|
||||
}
|
||||
// 增量持久化(原子 putMany)
|
||||
@@ -279,6 +304,28 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
const count = await this.memory.update(tableName, query, updates);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
// v0.7.0: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
|
||||
// 相关表整表 diff 兜底;普通更新记录受影响行
|
||||
if (pkChanged) {
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
this.txFullTables.add(t);
|
||||
this.txDirtyTables.add(t);
|
||||
}
|
||||
} else {
|
||||
let changes = this.txChanges.get(tableName);
|
||||
if (!changes) {
|
||||
changes = new Map();
|
||||
this.txChanges.set(tableName, changes);
|
||||
}
|
||||
for (const pk of affected) changes.set(pk, 'put');
|
||||
// 级联影响表(理论上非主键更新不级联,防御性兜底)
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t !== tableName) {
|
||||
this.txFullTables.add(t);
|
||||
this.txDirtyTables.add(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -328,6 +375,19 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
const count = await this.memory.delete(tableName, query);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
// v0.7.0: 行级变更记录 —— 删除行记录 delete;级联影响表整表 diff 兜底
|
||||
let changes = this.txChanges.get(tableName);
|
||||
if (!changes) {
|
||||
changes = new Map();
|
||||
this.txChanges.set(tableName, changes);
|
||||
}
|
||||
for (const pk of pks) changes.set(pk, 'delete');
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t !== tableName) {
|
||||
this.txFullTables.add(t);
|
||||
this.txDirtyTables.add(t);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
const puts: Record<string, ArrayBuffer> = {};
|
||||
@@ -353,6 +413,9 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
await this.memory.clear(tableName);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
// v0.7.0: 事务内清空 → commit 时删除全部 KV 行(比整表 diff 更高效)
|
||||
this.txClearedTables.add(tableName);
|
||||
this.txChanges.delete(tableName);
|
||||
return;
|
||||
}
|
||||
const diff = await this.collectTableDiff(tableName);
|
||||
@@ -391,27 +454,67 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
this.txActive = true;
|
||||
this.txDirtyTables = new Set();
|
||||
this.txSchemaChanged = false;
|
||||
this.txChanges = new Map();
|
||||
this.txFullTables = new Set();
|
||||
this.txClearedTables = new Set();
|
||||
}
|
||||
|
||||
async commitTransaction(): Promise<void> {
|
||||
this.ensureOpen();
|
||||
if (!this.txActive) throw new DatabaseError('No active transaction', 'TX_NONE');
|
||||
// v0.6.1: 全部 dirty 表合并为单次原子 flush(一条日志记录 = 真原子,
|
||||
// 多表事务中途崩溃/失败不会出现"部分表已提交")
|
||||
// 多表事务中途崩溃/失败不会出现"部分表已提交")。
|
||||
// v0.7.0: 行级增量 flush —— 普通 insert/update/delete 仅写事务内改动的行
|
||||
// (此前 collectTableDiff 整表重写:大表事务改 1 行也 O(表大小));
|
||||
// 主键变更/级联影响表整表 diff 兜底;clear/drop 表只删 KV 行。
|
||||
const puts: Record<string, ArrayBuffer> = {};
|
||||
const deletes: string[] = [];
|
||||
for (const table of this.txDirtyTables) {
|
||||
if (await this.memory.hasTable(table)) {
|
||||
const diff = await this.collectTableDiff(table);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
} else {
|
||||
if (!(await this.memory.hasTable(table))) {
|
||||
// 事务内 drop 的表:清理 KV 残留行
|
||||
const all = await this.kv.getAll();
|
||||
const prefix = this.rowPrefix(table);
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix)) deletes.push(key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (this.txClearedTables.has(table)) {
|
||||
// 事务内 clear 的表:删除全部 KV 行
|
||||
const all = await this.kv.getAll();
|
||||
const prefix = this.rowPrefix(table);
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix)) deletes.push(key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (this.txFullTables.has(table)) {
|
||||
const diff = await this.collectTableDiff(table);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
continue;
|
||||
}
|
||||
const changes = this.txChanges.get(table);
|
||||
if (changes && changes.size > 0) {
|
||||
// v0.7.0: 增量 flush —— 单次全表扫描 + 变更集合过滤
|
||||
const schema = await this.memory.getTableSchema(table);
|
||||
if (!schema) continue;
|
||||
const pkCol = this.getPK(schema);
|
||||
const pending = new Map(changes);
|
||||
const rows = await this.memory.find(table, { table: table });
|
||||
for (const row of rows) {
|
||||
const pkStr = String(row[pkCol]);
|
||||
const kind = pending.get(pkStr);
|
||||
if (kind !== undefined) {
|
||||
if (kind === 'put') puts[this.rowKey(table, pkStr)] = enc(JSON.stringify(row));
|
||||
else deletes.push(this.rowKey(table, pkStr));
|
||||
pending.delete(pkStr);
|
||||
}
|
||||
}
|
||||
// 内存中已不存在的行(后续操作删除)→ KV 行删除
|
||||
for (const [pk, kind] of pending) {
|
||||
if (kind === 'delete') deletes.push(this.rowKey(table, pk));
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.kv.writeBatch(puts, deletes);
|
||||
@@ -419,10 +522,15 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
if (this.txSchemaChanged) {
|
||||
await this.persistSchema();
|
||||
}
|
||||
await this.kv.checkpoint();
|
||||
// v0.7.0-perf: 移除每次 commit 的强制全量 checkpoint —— KVStore 按日志阈值
|
||||
// 自动 checkpoint(日志重放保证崩溃恢复正确),大库高频事务不再 O(库大小)。
|
||||
// close() 时统一 checkpoint(截断日志,重开更快)。
|
||||
await this.memory.commitTransaction();
|
||||
this.txActive = false;
|
||||
this.txDirtyTables = new Set();
|
||||
this.txChanges = new Map();
|
||||
this.txFullTables = new Set();
|
||||
this.txClearedTables = new Set();
|
||||
}
|
||||
|
||||
async rollbackTransaction(): Promise<void> {
|
||||
@@ -432,6 +540,9 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
this.txActive = false;
|
||||
this.txDirtyTables = new Set();
|
||||
this.txSchemaChanged = false;
|
||||
this.txChanges = new Map();
|
||||
this.txFullTables = new Set();
|
||||
this.txClearedTables = new Set();
|
||||
}
|
||||
|
||||
// ---- 内部 ----
|
||||
|
||||
+31
-1
@@ -207,6 +207,24 @@ export class QueryExecutor {
|
||||
plan = compileStatement(stmt.query);
|
||||
} catch { /* 非查询语句无 QueryPlan */ }
|
||||
|
||||
// v0.7.0: 真实索引命中信息(此前 usingIndex 恒为 'auto' 占位)。
|
||||
// 引擎无关启发式:WHERE 中存在主键/索引/唯一列条件 → 对应引擎索引路径。
|
||||
let usingIndex: string = plan?.table ? 'none' : 'none';
|
||||
if (plan && plan.table && plan.where && Object.keys(plan.where).length > 0) {
|
||||
try {
|
||||
const schema = await this.engine.getTableSchema(plan.table);
|
||||
if (schema) {
|
||||
for (const col of Object.keys(plan.where)) {
|
||||
if (col.startsWith('$')) continue;
|
||||
const colDef = schema.columns[col];
|
||||
if (!colDef) continue;
|
||||
if (colDef.primaryKey) { usingIndex = 'pk'; break; }
|
||||
if (colDef.index || colDef.unique) { usingIndex = `index:${col}`; break; }
|
||||
}
|
||||
}
|
||||
} catch { /* schema 读取失败保持 none */ }
|
||||
}
|
||||
|
||||
return {
|
||||
type: stmt.query.type,
|
||||
table: plan?.table,
|
||||
@@ -215,7 +233,7 @@ export class QueryExecutor {
|
||||
orderBy: plan?.orderBy || [],
|
||||
limit: plan?.limit,
|
||||
offset: plan?.offset,
|
||||
usingIndex: plan?.table ? 'auto' : 'none',
|
||||
usingIndex,
|
||||
estimatedRows: rows,
|
||||
actualTimeMs: elapsed,
|
||||
};
|
||||
@@ -713,6 +731,18 @@ export class QueryExecutor {
|
||||
const schema = await this.engine.getTableSchema(stmt.name);
|
||||
if (!schema) return;
|
||||
|
||||
// v0.7.0: ALTER ADD 主键列防护 —— 复合主键不支持(与 createSchema 校验对齐),
|
||||
// 避免绕过建表校验添加第二个主键列导致语义陷阱
|
||||
if (stmt.action === 'ADD' && stmt.column.primaryKey) {
|
||||
const hasPk = Object.values(schema.columns).some((c) => c.primaryKey);
|
||||
if (hasPk) {
|
||||
throw new DatabaseError(
|
||||
`Composite primary keys are not supported yet: table "${stmt.name}" already has a primary key column`,
|
||||
'SCHEMA_ERROR',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// v0.4.1: 引擎级 alterTable(Aria 需重写存储行 + 持久化 schema;其余引擎走通用引用路径)
|
||||
if (typeof this.engine.alterTable === 'function') {
|
||||
return this.engine.alterTable(stmt.name, stmt.action, { ...astColumnToColumnDef(stmt.column), name: stmt.column.name });
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* metona-sqlark SQL Parameters — 参数化查询绑定
|
||||
* @module sql/params
|
||||
*
|
||||
* v0.7.0: `db.query(sql, params)` 位置参数(`?`)支持。
|
||||
* 绑定在词法层面完成:仅替换字符串字面量之外的 `?`,
|
||||
* 值按 SQL 字面量编码(字符串 `''` 转义、数字/布尔/JSON 直出),
|
||||
* 从根上规避 SQL 注入(不经过字符串拼接由用户自行转义)。
|
||||
*/
|
||||
|
||||
import { DatabaseError } from '../constants';
|
||||
|
||||
/** 将单个参数值编码为 SQL 字面量 */
|
||||
function encodeParam(value: unknown): string {
|
||||
if (value === null || value === undefined) return 'NULL';
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isFinite(value)) return String(value);
|
||||
return 'NULL'; // NaN/Infinity 无 SQL 字面量 → NULL
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE';
|
||||
if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`;
|
||||
// 对象/数组无 SQL 字面量(SQL 方言不支持 json 字面量),显式拒绝而非静默错配
|
||||
throw new DatabaseError(
|
||||
'Object/array query parameters are not supported by SQL binding (pass JSON strings explicitly)',
|
||||
'PARAM_ERROR',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 SQL 中的位置参数 `?`(字符串字面量之外)替换为编码后的字面量。
|
||||
* @param sql 含 `?` 占位符的 SQL
|
||||
* @param params 位置参数数组
|
||||
* @throws PARAM_ERROR 参数数量不匹配
|
||||
*/
|
||||
export function bindParameters(sql: string, params?: unknown[]): string {
|
||||
// undefined = 不启用绑定;[] + 含 ? 的 SQL 由循环内报 PARAM_ERROR
|
||||
if (!params) return sql;
|
||||
|
||||
let out = '';
|
||||
let i = 0;
|
||||
let pIdx = 0;
|
||||
let quote: string | null = null;
|
||||
|
||||
while (i < sql.length) {
|
||||
const ch = sql[i];
|
||||
|
||||
if (quote !== null) {
|
||||
out += ch;
|
||||
if (ch === quote) {
|
||||
// SQL 标准 '' 转义:两个连续引号 = 一个引号(原样保留)
|
||||
if (sql[i + 1] === quote) {
|
||||
out += sql[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
quote = null;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" || ch === '"') {
|
||||
quote = ch;
|
||||
out += ch;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '?') {
|
||||
if (pIdx >= params.length) {
|
||||
throw new DatabaseError(
|
||||
`Too few query parameters: placeholder #${pIdx + 1} has no value (got ${params.length} total)`,
|
||||
'PARAM_ERROR',
|
||||
);
|
||||
}
|
||||
out += encodeParam(params[pIdx]);
|
||||
pIdx++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (quote !== null) {
|
||||
throw new DatabaseError('Unterminated string literal in SQL', 'PARSE_ERROR');
|
||||
}
|
||||
if (pIdx < params.length) {
|
||||
throw new DatabaseError(
|
||||
`Too many query parameters: ${params.length} provided but only ${pIdx} placeholders`,
|
||||
'PARAM_ERROR',
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -44,6 +44,16 @@ export function validateColumns(columns: Record<string, ColumnDef>): void {
|
||||
if (primaryKeyCount === 0) {
|
||||
throw new DatabaseError('Table must have at least one primary key column', 'SCHEMA_ERROR');
|
||||
}
|
||||
// v0.7.0: 复合主键(多列 primaryKey)当前不支持 —— 所有引擎的存储布局与
|
||||
// 外键引用均为单主键假设(此前静默取第一个主键,其余标记被忽略 → 语义陷阱)。
|
||||
// 显式拒绝避免用户误用;复合主键列入 v0.8 路线图。
|
||||
if (primaryKeyCount > 1) {
|
||||
throw new DatabaseError(
|
||||
`Composite primary keys are not supported yet: table has ${primaryKeyCount} primary key columns. ` +
|
||||
'Use a single primary key column (or a unique column combination) instead.',
|
||||
'SCHEMA_ERROR',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取主键列名 */
|
||||
|
||||
Reference in New Issue
Block a user