Files
MetonaSqlark/tests/v080-query-layer.test.ts
T
thzxx 841db2e049 fix(A22/A23/A25/A26/A27/A29/A30/A36): 查询层 8 项缺陷根治 + 单一语义收敛
每项都先用可执行探针复现出**错误的实际输出**,再修根因、补永久回归套件
(tests/v080-query-layer.test.ts,8 项 × 4 引擎 + 跨引擎项,共 111 断言)。

A22 GROUP BY 引用 SELECT 别名
  修复前:`SELECT g AS grp, COUNT(*) FROM t GROUP BY grp` 抛
  `COLUMN_NOT_FOUND Unknown column "g" in SELECT list` —— 错误信息与真正原因
  (GROUP BY 用了别名)无关,因为 grp 取到 undefined 使全表并成一组,
  投影阶段又发现 g 不在输出行里。
  修复:GROUP BY 项先解析回**基列**(别名 → 源表达式)再分组,输出键与
  "按基列分组"完全一致。

A23 HAVING 引用未出现在 SELECT 里的聚合
  修复前:`SELECT g FROM t GROUP BY g HAVING SUM(n) > 25` → `[]`
  (SUM(n) 从未被求值 → HAVING 的键在分组行里不存在 → UNKNOWN)。
  修复:需要计算的聚合 = SELECT 列 ∪ HAVING 中的聚合(并集),
  并在 HAVING **之后**才把行收缩为 SELECT 输出键(否则又变回 [];
  顺序错了会双向出错:先投影 → 空结果,不投影 → 泄漏内部聚合列)。

A25 带表前缀的聚合参数恒 0
  修复前:`COUNT(t.n)` → 0、`SUM(t.n)` → null(行键是 n,直接取 row['t.n']
  得 undefined 再被"过滤 NULL"剔除,**且不报错**)。
  修复:新增唯一列引用解析 resolveColumnValue(前缀剥离 → 精确 → 唯一后缀),
  聚合识别统一为 parseAggregateExpression —— 此前"是否聚合"与"如何求值"
  用两条不同的正则。取不到列改为抛 COLUMN_NOT_FOUND,不再静默计 0。

A26 UNION 尾部 ORDER BY/LIMIT 归属错误
  修复前:`A UNION B ORDER BY id DESC` 只排 B;`... LIMIT 3` 返回 4 行
  (parser 把子句挂在右侧 SELECT 上,AST 没有复合查询级字段)。
  修复:SelectUnionStatement 增加 orderBy/limit/offset,parser 把子句**上移**
  (移动而非复制,否则 LIMIT 应用两次),executor 在合并+去重后统一排序/切片。

A27 DISTINCT 作用在投影前
  修复前:`SELECT DISTINCT g AS d FROM t` 返回 4 行 a,a,b,b
  (对 {id,g,n} 原始行去重),而 `SELECT DISTINCT g` 返回 2 行。
  修复:DISTINCT 移到投影后(作用于输出列);ORDER BY 的应用时机随之拆成
  "引用输出列 → 投影后" / "引用非输出列 → 投影前",两者互为因果必须一起改。

A29 maxRowsPerQuery 静默截断写入
  修复前:maxRowsPerQuery=2 时 `INSERT INTO dst SELECT id FROM src`(4 行源)
  只写 2 行并报成功 —— 不是"限制查询规模"而是**静默丢数据**。
  修复:行源不截断(executeSelect 增加 purpose='source'),写路径显式报错。

A30 INSERT 值多于目标列静默丢弃
  修复前:`INSERT INTO t (id,g) VALUES ('9','z','LOST')` 报成功、'LOST' 消失。
  修复:显式列名时解析期拦截(PARSE_ERROR),未给列名时 executor 对照 schema
  拦截(VALIDATION_ERROR)——两种情况都需要,因为前者无需 schema。

A36 派生表别名引用
  修复前:`SELECT d.id FROM (SELECT id, g FROM t) AS d` 返回 `[]`,
  而同义的 `SELECT id FROM (...) AS d` 正确。
  修复:抽出 normalizeUnprefixedReferences(WHERE/ORDER BY/GROUP BY/SELECT
  四类引用统一剥离别名前缀),非 JOIN 单表路径与派生表路径共用同一规则。

连带根治(修复过程中发现的两个更底层问题):

1. **同步抛错穿过 async 边界**:`executor.execute()` 里 `return this.executeXxx(stmt)`
   的同步前导段若抛错(arity/校验),异常成为**同步抛出** ——
   `await expect(db.query(...)).rejects...` 的断言不生效、`.catch()` 永不执行。
   现统一包一层 try/catch,保证任何错误都是 rejected promise。

2. **缺列的行形状不一致**:validateRow 此前"值为 undefined 就不落键",
   于是 `INSERT INTO t (id,g) VALUES ('9','z')` 的行里没有 n 键 →
   `SELECT id,g,n FROM t` 抛 COLUMN_NOT_FOUND: n,而 `SELECT * FROM t` 正常。
   现在缺列且无 default → 显式补 null(SQL 语义),行始终含全部 schema 列;
   ALTER ADD 同步在已有行上物化 null,使"内存视图"与"重启后视图"一致。

验证:全量 83 套件 / 1589 测试通过(含 Aria 5 万行索引竞态、KVStore 持久化);
typecheck(src+tests) 与 lint 零错误。
2026-09-15 00:00:08 +08:00

364 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v0.8.0 回归套件 —— 查询层缺陷根治(A22/A23/A25/A26/A27/A29/A30/A36
* ============================================================================
* 本套件锁定 PLAN-v0.7.5.md §5 缺陷总账中查询层的 8 项修复。每一项都先给出
* **修复前的实测错误输出**,再断言正确结果 —— 这样即使将来重构执行器,
* 失败的断言能直接告诉后来者"当初错在哪里"。
*
* 参考数据(表 t):
* id=1 g='a' n=10 id=3 g='b' n=30
* id=2 g='a' n=20 id=4 g='b' n=40
*/
import { MetonaSqlark } from '../src/core';
import { rows as rowsOf } from './helpers/assertions';
import type { DatabaseConfig } from '../src/constants';
const ENGINES: Array<[string, DatabaseConfig['mode'], Partial<DatabaseConfig>]> = [
['memory', 'memory', {}],
['disk', 'disk', {}],
['hybrid', 'hybrid', {}],
['aria', 'aria', { diskEngine: 'memory' }],
];
describe('[v0.8.0] 查询层缺陷根治', () => {
describe.each(ENGINES)('%s 引擎', (label, mode, extra) => {
let db: MetonaSqlark;
beforeEach(async () => {
db = await MetonaSqlark.create({
name: `ql-${label}-${Math.random().toString(36).slice(2)}`,
mode,
...extra,
});
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
g: { type: 'string' },
n: { type: 'number' },
});
await db.query("INSERT INTO t VALUES ('1','a',10),('2','a',20),('3','b',30),('4','b',40)");
});
afterEach(async () => {
await db.close();
});
// -------------------------------------------------------------------
// A22: GROUP BY 可引用 SELECT 别名
// -------------------------------------------------------------------
it('A22 GROUP BY 引用 SELECT 别名', async () => {
// 修复前:抛 `COLUMN_NOT_FOUND Unknown column "g" in SELECT list`
// GROUP BY grp 取 row['grp'] 得 undefined → 全部行并成一组 →
// 投影阶段又发现 g 不在输出行里,报出一个和真正原因无关的错误)
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT g AS grp, COUNT(*) AS c FROM t GROUP BY grp ORDER BY grp'),
);
expect(rows).toEqual([{ g: 'a', c: 2 }, { g: 'b', c: 2 }]);
// 输出键与"按基列分组"完全一致(同一查询的两种写法必须等价)
const byBase = rowsOf<Record<string, unknown>>(
await db.query('SELECT g, COUNT(*) AS c FROM t GROUP BY g ORDER BY g'),
);
expect(rows).toEqual(byBase);
});
it('A22 GROUP BY 引用别名时不泄漏别名键', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT g AS grp FROM t GROUP BY grp ORDER BY grp'),
);
// 输出的键必须是基列名 gSELECT 列表里 g AS grp 的投影结果),
// 而不是内部的分组键 grp —— 否则同一查询在"有别名/无别名"两种写法下
// 行形状不同。
expect(rows).toEqual([{ g: 'a' }, { g: 'b' }]);
});
// -------------------------------------------------------------------
// A23: HAVING 可引用未出现在 SELECT 里的聚合
// -------------------------------------------------------------------
it('A23 HAVING 引用未选中的聚合(SUM', async () => {
// 修复前:返回 []SUM(n) 从未被求值 → HAVING 的键在行里不存在 → UNKNOWN)
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT g FROM t GROUP BY g HAVING SUM(n) > 25 ORDER BY g'),
);
expect(rows).toEqual([{ g: 'a' }, { g: 'b' }]);
});
it('A23 HAVING 引用未选中的聚合(MAX)并正确过滤', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT g FROM t GROUP BY g HAVING MAX(n) > 25 ORDER BY g'),
);
expect(rows).toEqual([{ g: 'b' }]);
});
it('A23 HAVING 的辅助聚合不得出现在输出行里', async () => {
// 为 HAVING 计算的 SUM(n) 是内部键:输出必须只有 g 一列。
// (修复过程中曾泄漏成 {g, 'SUM(n)'} —— 多出用户没要求的输出列。)
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT g FROM t GROUP BY g HAVING SUM(n) > 0 ORDER BY g'),
);
expect(rows).toEqual([{ g: 'a' }, { g: 'b' }]);
expect(Object.keys(rows[0])).toEqual(['g']);
});
it('A23 HAVING 同时引用别名与未选中聚合', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT g AS grp, COUNT(*) AS c FROM t GROUP BY grp HAVING SUM(n) > 25 ORDER BY grp'),
);
expect(rows).toEqual([{ g: 'a', c: 2 }, { g: 'b', c: 2 }]);
});
// -------------------------------------------------------------------
// A25: 带表前缀的聚合参数
// -------------------------------------------------------------------
it('A25 COUNT(t.n) / SUM(t.n) 按列取值而非恒 0', async () => {
// 修复前:COUNT(t.n) → 0SUM(t.n) → null(行键是 n,直接取 row['t.n'] 得
// undefined,再被"过滤 NULL"剔除,且**不报错**
expect(rowsOf(await db.query('SELECT COUNT(t.n) AS c FROM t'))).toEqual([{ c: 4 }]);
expect(rowsOf(await db.query('SELECT SUM(t.n) AS s FROM t'))).toEqual([{ s: 100 }]);
expect(rowsOf(await db.query('SELECT AVG(t.n) AS a FROM t'))).toEqual([{ a: 25 }]);
expect(rowsOf(await db.query('SELECT MIN(t.n) AS lo FROM t'))).toEqual([{ lo: 10 }]);
expect(rowsOf(await db.query('SELECT MAX(t.n) AS hi FROM t'))).toEqual([{ hi: 40 }]);
});
it('A25 分组聚合的带前缀参数', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT g, SUM(t.n) AS s FROM t GROUP BY g ORDER BY g'),
);
expect(rows).toEqual([{ g: 'a', s: 30 }, { g: 'b', s: 70 }]);
});
it('A25 COUNT(DISTINCT t.n) 带前缀且类型安全去重', async () => {
expect(rowsOf(await db.query('SELECT COUNT(DISTINCT t.n) AS c FROM t'))).toEqual([{ c: 4 }]);
// 数值去重不得被编码串扰(曾把 encodeValueKey 的结果 Number() 回读成 NaN
expect(rowsOf(await db.query('SELECT SUM(DISTINCT t.n) AS s FROM t'))).toEqual([{ s: 100 }]);
});
it('A25 聚合参数引用不存在的列 → COLUMN_NOT_FOUND(不静默计 0', async () => {
await expect(db.query('SELECT COUNT(t.nope) AS c FROM t')).rejects.toMatchObject({
code: 'COLUMN_NOT_FOUND',
});
});
// -------------------------------------------------------------------
// A26: UNION 尾部 ORDER BY / LIMIT 作用于整个复合结果
// -------------------------------------------------------------------
it('A26 UNION 尾部 ORDER BY 作用于复合结果', async () => {
// 修复前:只对右侧 SELECT 排序 → [{1},{2},{4},{3}]
const rows = rowsOf<Record<string, unknown>>(
await db.query("SELECT id FROM t WHERE g = 'a' UNION SELECT id FROM t WHERE g = 'b' ORDER BY id DESC"),
);
expect(rows).toEqual([{ id: '4' }, { id: '3' }, { id: '2' }, { id: '1' }]);
});
it('A26 UNION 尾部 LIMIT 作用于复合结果', async () => {
// 修复前:只截断右侧 → 返回 4 行
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT id FROM t UNION SELECT id FROM t LIMIT 3'),
);
expect(rows).toHaveLength(3);
});
it('A26 UNION LIMIT 只应用一次(不得双重截断)', async () => {
const limited = rowsOf<Record<string, unknown>>(
await db.query('SELECT id FROM t UNION ALL SELECT id FROM t LIMIT 5'),
);
expect(limited).toHaveLength(5);
});
it('A26 UNION 去重 + OFFSET', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT id FROM t UNION SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1'),
);
expect(rows).toEqual([{ id: '2' }, { id: '3' }]);
});
it('A26 UNION ORDER BY 引用不存在的列 → COLUMN_NOT_FOUND', async () => {
await expect(db.query('SELECT id FROM t UNION SELECT id FROM t ORDER BY nope')).rejects.toMatchObject({
code: 'COLUMN_NOT_FOUND',
});
});
// -------------------------------------------------------------------
// A27: DISTINCT 作用于输出列(投影之后)
// -------------------------------------------------------------------
it('A27 DISTINCT 列别名与裸列结果一致', async () => {
// 修复前:`SELECT DISTINCT g AS d` 返回 4 行 a,a,b,b
//DISTINCT 作用在投影前的 {id,g,n} 原始行上,四行互不相同)
const aliased = rowsOf<Record<string, unknown>>(await db.query('SELECT DISTINCT g AS d FROM t ORDER BY d'));
expect(aliased).toEqual([{ d: 'a' }, { d: 'b' }]);
const bare = rowsOf<Record<string, unknown>>(await db.query('SELECT DISTINCT g FROM t ORDER BY g'));
expect(bare).toEqual([{ g: 'a' }, { g: 'b' }]);
});
it('A27 DISTINCT 多列仍按输出列去重', async () => {
const rows = rowsOf<Record<string, unknown>>(await db.query('SELECT DISTINCT g, n FROM t ORDER BY g, n'));
expect(rows).toHaveLength(4);
});
it('A27 DISTINCT + ORDER BY 输出列(先去重再排序)', async () => {
const rows = rowsOf<Record<string, unknown>>(await db.query('SELECT DISTINCT g FROM t ORDER BY g DESC'));
expect(rows).toEqual([{ g: 'b' }, { g: 'a' }]);
});
// -------------------------------------------------------------------
// A30: INSERT 值个数与列个数不匹配 → 报错(此前静默丢弃多余值)
// -------------------------------------------------------------------
it('A30 显式列名时值多于列 → PARSE_ERROR(解析期即可判定)', async () => {
// 修复前:`INSERT INTO t (id, g) VALUES ('9','z','LOST')` 报成功、'LOST' 消失。
// 有了显式列名后 arity 无需 schema 即可判定,因此解析期就拦下
//v0.8.0 A16 的解析期校验;executor 侧对"未显式给列名"的情形兜底)。
await expect(
db.query("INSERT INTO t (id, g) VALUES ('9', 'z', 'LOST')"),
).rejects.toMatchObject({ code: 'PARSE_ERROR' });
expect(rowsOf(await db.query("SELECT id FROM t WHERE id = '9'"))).toHaveLength(0);
});
it('A30 显式列名时值少于列 → PARSE_ERROR(缺列必须显式写出)', async () => {
// `INSERT INTO t (id, g) VALUES ('9')` 是列/值个数不匹配的写法:
// 用户想写的是 `INSERT INTO t (id) VALUES ('9')`。静默补 NULL 会让
// 拼错列清单的语句"看起来成功",因此同样报错。
await expect(db.query("INSERT INTO t (id, g) VALUES ('9')")).rejects.toMatchObject({
code: 'PARSE_ERROR',
});
});
it('A30 未显式列名时值多于列 → VALIDATION_ERROR(需要 schema 才能判定)', async () => {
// 不带列清单时个数要对照 schema 才能判断,由 executor 在拿到 schema 后校验
await expect(db.query("INSERT INTO t VALUES ('9', 'z', 1, 'LOST')")).rejects.toMatchObject({
code: 'VALIDATION_ERROR',
});
expect(rowsOf(await db.query("SELECT id FROM t WHERE id = '9'"))).toHaveLength(0);
});
it('A30 值少于列仍合法(缺列走 default / NULL', async () => {
await db.query("INSERT INTO t (id, g) VALUES ('9', 'z')");
const rows = rowsOf<Record<string, unknown>>(await db.query("SELECT id, g, n FROM t WHERE id = '9'"));
expect(rows).toEqual([{ id: '9', g: 'z', n: null }]);
});
// -------------------------------------------------------------------
// A36: 派生表别名引用
// -------------------------------------------------------------------
it('A36 派生表别名引用与裸列引用结果一致', async () => {
// 修复前:`SELECT d.id FROM (SELECT ...) AS d` 返回 []d.id 未剥离前缀),
// 而同义的 `SELECT id FROM (...) AS d` 正确
const aliased = rowsOf<Record<string, unknown>>(
await db.query("SELECT d.id FROM (SELECT id, g FROM t) AS d WHERE d.g = 'a' ORDER BY d.id"),
);
expect(aliased).toEqual([{ id: '1' }, { id: '2' }]);
const bare = rowsOf<Record<string, unknown>>(
await db.query("SELECT id FROM (SELECT id, g FROM t) AS d WHERE g = 'a' ORDER BY id"),
);
expect(aliased).toEqual(bare);
});
it('A36 派生表别名用于聚合与排序', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT COUNT(d.id) AS c FROM (SELECT id FROM t) AS d'),
);
expect(rows).toEqual([{ c: 4 }]);
});
// -------------------------------------------------------------------
// A35: JOIN 的 NULL 键
// -------------------------------------------------------------------
it('A35 JOIN 的 NULL 键不成立,且与右表有无索引无关', async () => {
await db.defineTable('l', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
await db.defineTable('ri', { id: { type: 'string', primaryKey: true }, k: { type: 'string', index: true } });
await db.defineTable('rn', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
await db.query("INSERT INTO l VALUES ('l1','x'),('l2',NULL)");
await db.query("INSERT INTO ri VALUES ('r1','x'),('r2',NULL)");
await db.query("INSERT INTO rn VALUES ('n1','x'),('n2',NULL)");
const indexed = rowsOf<Record<string, unknown>>(
await db.query('SELECT l.id AS lid, ri.id AS rid FROM l JOIN ri ON l.k = ri.k'),
);
const unindexed = rowsOf<Record<string, unknown>>(
await db.query('SELECT l.id AS lid, rn.id AS rid FROM l JOIN rn ON l.k = rn.k'),
);
// NULL = NULL 是 UNKNOWN → 两个 NULL 行都不得匹配
expect(indexed).toEqual([{ lid: 'l1', rid: 'r1' }]);
expect(unindexed).toEqual([{ lid: 'l1', rid: 'n1' }]);
// 关键不变量:结果与"右表该列有没有索引"无关
expect(indexed.map((r) => r.lid)).toEqual(unindexed.map((r) => r.lid));
});
it('A35 LEFT JOIN 保留未匹配行(NULL 键行保留、右表列补 NULL', async () => {
await db.defineTable('l', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
await db.defineTable('r', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
await db.query("INSERT INTO l VALUES ('l1','x'),('l2',NULL)");
await db.query("INSERT INTO r VALUES ('r1','x')");
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT l.id AS lid, r.id AS rid FROM l LEFT JOIN r ON l.k = r.k ORDER BY lid'),
);
expect(rows).toEqual([{ lid: 'l1', rid: 'r1' }, { lid: 'l2', rid: null }]);
});
});
});
// ---------------------------------------------------------------------------
// maxRowsPerQuery 与 NaN 落盘(与引擎无关,用 memory 验证即可)
// ---------------------------------------------------------------------------
describe('[v0.8.0] A29 写路径的行数上限保护', () => {
it('INSERT ... SELECT 超过 maxRowsPerQuery → 显式报错(不静默截断)', async () => {
const db = await MetonaSqlark.create({ name: 'a29-limit', mode: 'memory', maxRowsPerQuery: 2 });
await db.defineTable('src', { id: { type: 'string', primaryKey: true } });
await db.defineTable('dst', { id: { type: 'string', primaryKey: true } });
// 注意:每条 INSERT 都受上限约束,因此分 4 条语句各写 1 行来堆积 4 行源数据
for (const id of ['1', '2', '3', '4']) {
await db.query(`INSERT INTO src VALUES ('${id}')`);
}
expect(rowsOf<{ c: number }>(await db.query('SELECT COUNT(*) AS c FROM src'))).toEqual([{ c: 4 }]);
// 修复前:行源被 maxRowsPerQuery 静默截断为 2 行 → 只写入 2 行并报成功。
// 现在:行源不截断,写路径显式报错,dst 保持空。
await expect(db.query('INSERT INTO dst SELECT id FROM src')).rejects.toMatchObject({
code: 'QUERY_ERROR',
});
expect(rowsOf(await db.query('SELECT id FROM dst'))).toHaveLength(0);
await db.close();
});
it('INSERT ... SELECT 未超上限时正常写入全部行源', async () => {
const db = await MetonaSqlark.create({ name: 'a29-within', mode: 'memory', maxRowsPerQuery: 2 });
await db.defineTable('src', { id: { type: 'string', primaryKey: true } });
await db.defineTable('dst', { id: { type: 'string', primaryKey: true } });
await db.query("INSERT INTO src VALUES ('1'),('2')");
await db.query('INSERT INTO dst SELECT id FROM src');
expect(rowsOf(await db.query('SELECT id FROM dst ORDER BY id'))).toEqual([{ id: '1' }, { id: '2' }]);
await db.close();
});
it('INSERT ... VALUES 同样受上限保护', async () => {
const db = await MetonaSqlark.create({ name: 'a29-limit-values', mode: 'memory', maxRowsPerQuery: 2 });
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await expect(db.query("INSERT INTO t VALUES ('1'),('2'),('3')")).rejects.toMatchObject({
code: 'QUERY_ERROR',
});
expect(rowsOf(await db.query('SELECT id FROM t'))).toHaveLength(0);
await db.close();
});
});
describe('[v0.8.0] NaN / Infinity 拒绝落盘(B-1 规范化契约)', () => {
const ENGINES2 = ENGINES;
it.each(ENGINES2)('%s: NaN 写入被拒绝(否则重启后变 null)', async (label, mode, extra) => {
const db = await MetonaSqlark.create({ name: `nan-${label}`, mode, ...extra });
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
v: { type: 'number' },
});
await expect(db.table('t').insert({ id: '1', v: Number.NaN } as never)).rejects.toMatchObject({
code: 'VALIDATION_ERROR',
});
await expect(db.table('t').insert({ id: '2', v: Number.POSITIVE_INFINITY } as never)).rejects.toMatchObject({
code: 'VALIDATION_ERROR',
});
// 正常数值不受影响
await db.table('t').insert({ id: '3', v: 1.5 } as never);
expect(rowsOf<{ v: number }>(await db.query('SELECT v FROM t'))).toEqual([{ v: 1.5 }]);
await db.close();
});
});