feat: metona-sqlark v0.1.12 — 前端TypeScript关系型数据库

- 4种存储引擎:Memory / IndexedDB / OPFS / Hybrid
- 完整SQL支持:SELECT/INSERT/UPDATE/DELETE/JOIN/GROUP BY/HAVING/DISTINCT
- Query Builder链式API + TypeScript泛型支持
- 聚合函数:COUNT/SUM/AVG/MIN/MAX
- 事务、插件系统(14 hooks)、发布订阅、数据迁移、导入导出
- React/Vue框架集成
- 264个测试用例,93.46%覆盖率
- 零运行时依赖
This commit is contained in:
thzxx
2026-07-26 15:00:01 +08:00
commit e2a590c5b1
60 changed files with 16359 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
/**
* metona-sqlark Query AST — 查询抽象语法树类型定义
* @module query/ast
*
* QueryBuilder 和 SQL Parser 统一输出此 AST
* Executor 只认 AST,保证两种查询接口行为一致。
*/
import type { WhereCondition, OrderBy } from '../constants';
// ---------------------------------------------------------------------------
// AST 语句类型枚举
// ---------------------------------------------------------------------------
export type StatementType =
| 'SELECT'
| 'INSERT'
| 'UPDATE'
| 'DELETE'
| 'CREATE_TABLE'
| 'DROP_TABLE';
// ---------------------------------------------------------------------------
// 列引用
// ---------------------------------------------------------------------------
/** 列引用,'*' 表示所有列;支持 'table.column' 格式 */
export type ColumnRef = string;
// ---------------------------------------------------------------------------
// JOIN
// ---------------------------------------------------------------------------
/** JOIN 类型 */
export type JoinType = 'INNER' | 'LEFT' | 'RIGHT' | 'CROSS';
/** JOIN 子句 */
export interface JoinClause {
type: JoinType;
table: string;
alias?: string;
on: WhereCondition;
}
// ---------------------------------------------------------------------------
// 聚合函数
// ---------------------------------------------------------------------------
/** 聚合函数类型 */
export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX';
/** 聚合表达式 */
export interface AggregateExpression {
type: 'AGGREGATE';
func: AggregateFunc;
column: string; // '*' for COUNT(*)
alias?: string;
}
// ---------------------------------------------------------------------------
// DDL: CREATE TABLE
// ---------------------------------------------------------------------------
export interface ASTColumnDef {
name: string;
type: string;
primaryKey?: boolean;
unique?: boolean;
required?: boolean;
default?: unknown;
index?: boolean;
maxLength?: number;
min?: number;
max?: number;
}
export interface CreateTableStatement {
type: 'CREATE_TABLE';
name: string;
columns: ASTColumnDef[];
}
// ---------------------------------------------------------------------------
// DDL: DROP TABLE
// ---------------------------------------------------------------------------
export interface DropTableStatement {
type: 'DROP_TABLE';
name: string;
}
// ---------------------------------------------------------------------------
// DML: INSERT
// ---------------------------------------------------------------------------
export interface InsertStatement {
type: 'INSERT';
into: string;
columns?: string[];
values: unknown[][];
}
// ---------------------------------------------------------------------------
// DML: UPDATE
// ---------------------------------------------------------------------------
export interface UpdateStatement {
type: 'UPDATE';
table: string;
sets: Record<string, unknown>;
where: WhereCondition;
}
// ---------------------------------------------------------------------------
// DML: DELETE
// ---------------------------------------------------------------------------
export interface DeleteStatement {
type: 'DELETE';
from: string;
where: WhereCondition;
}
// ---------------------------------------------------------------------------
// DQL: SELECT
// ---------------------------------------------------------------------------
export interface SelectStatement {
type: 'SELECT';
columns: ColumnRef[];
distinct?: boolean;
from: string;
/** 主表别名 */
alias?: string;
/** JOIN 子句列表 */
joins?: JoinClause[];
where: WhereCondition;
/** GROUP BY */
groupBy?: string[];
/** HAVING */
having?: WhereCondition;
orderBy?: OrderBy[];
limit?: number;
offset?: number;
}
// ---------------------------------------------------------------------------
// AST 联合类型
// ---------------------------------------------------------------------------
export type Statement =
| SelectStatement
| InsertStatement
| UpdateStatement
| DeleteStatement
| CreateTableStatement
| DropTableStatement;
+182
View File
@@ -0,0 +1,182 @@
/**
* metona-sqlark Query Builder — 链式查询构建器
* @module query/builder
*
* 链式调用 → 构建 AST → 执行引擎操作。
* 支持 JOIN(需要 Executor)。
*/
import type { IStorageEngine } from '../engine/interface';
import type { WhereCondition, OrderBy, SortDirection } from '../constants';
import type { SelectStatement, UpdateStatement, DeleteStatement, JoinType, JoinClause } from './ast';
import { QueryExecutor } from './executor';
// ---------------------------------------------------------------------------
// SelectQueryBuilder
// ---------------------------------------------------------------------------
export class SelectQueryBuilder {
private _where: WhereCondition = {};
private _orderBy: OrderBy[] = [];
private _limit?: number;
private _offset?: number;
private _joins: JoinClause[] = [];
private _alias?: string;
private _executor?: QueryExecutor;
constructor(
private engine: IStorageEngine,
private tableName: string,
private _columns: string[] = ['*'],
executor?: QueryExecutor,
) {
this._executor = executor;
}
/** 主表别名 */
as(alias: string): this {
this._alias = alias;
return this;
}
/** INNER JOIN */
innerJoin(table: string, on: WhereCondition, alias?: string): this {
return this._addJoin('INNER', table, on, alias);
}
/** LEFT JOIN */
leftJoin(table: string, on: WhereCondition, alias?: string): this {
return this._addJoin('LEFT', table, on, alias);
}
/** RIGHT JOIN */
rightJoin(table: string, on: WhereCondition, alias?: string): this {
return this._addJoin('RIGHT', table, on, alias);
}
/** CROSS JOIN */
crossJoin(table: string, alias?: string): this {
return this._addJoin('CROSS', table, {}, alias);
}
/** 通用 JOIN */
join(table: string, on: WhereCondition, alias?: string): this {
return this._addJoin('INNER', table, on, alias);
}
private _addJoin(type: JoinType, table: string, on: WhereCondition, alias?: string): this {
this._joins.push({ type, table, on, alias });
return this;
}
/** 添加过滤条件 */
where(condition: WhereCondition): this {
this._where = { ...this._where, ...condition };
return this;
}
/** 排序 */
orderBy(column: string, direction: SortDirection = 'asc'): this {
this._orderBy.push({ column, direction });
return this;
}
/** 限制返回条数 */
limit(n: number): this {
this._limit = n;
return this;
}
/** 偏移量 */
offset(n: number): this {
this._offset = n;
return this;
}
/** 执行查询 */
async execute(): Promise<Record<string, unknown>[]> {
// 有 JOIN → 通过 Executor 执行
if (this._joins.length > 0 && this._executor) {
const ast = this.toAST();
return this._executor.execute(ast) as Promise<Record<string, unknown>[]>;
}
// 无 JOIN → 直接调用引擎
return this.engine.find(this.tableName, {
table: this.tableName,
columns: this._columns,
where: this._where,
orderBy: this._orderBy.length > 0 ? this._orderBy : undefined,
limit: this._limit,
offset: this._offset,
});
}
/** 获取 AST */
toAST(): SelectStatement {
return {
type: 'SELECT',
columns: this._columns,
from: this.tableName,
alias: this._alias,
joins: this._joins.length > 0 ? [...this._joins] : undefined,
where: this._where,
orderBy: this._orderBy.length > 0 ? this._orderBy : undefined,
limit: this._limit,
offset: this._offset,
};
}
}
// ---------------------------------------------------------------------------
// UpdateQueryBuilder
// ---------------------------------------------------------------------------
export class UpdateQueryBuilder {
private _where: WhereCondition = {};
constructor(
private engine: IStorageEngine,
private tableName: string,
private _updates: Record<string, unknown>,
) {}
where(condition: WhereCondition): this {
this._where = { ...this._where, ...condition };
return this;
}
async execute(): Promise<number> {
return this.engine.update(this.tableName, { table: this.tableName, where: this._where }, this._updates);
}
toAST(): UpdateStatement {
return { type: 'UPDATE', table: this.tableName, sets: this._updates, where: this._where };
}
}
// ---------------------------------------------------------------------------
// DeleteQueryBuilder
// ---------------------------------------------------------------------------
export class DeleteQueryBuilder {
private _where: WhereCondition = {};
constructor(
private engine: IStorageEngine,
private tableName: string,
) {}
where(condition: WhereCondition): this {
this._where = { ...this._where, ...condition };
return this;
}
async execute(): Promise<number> {
return this.engine.delete(this.tableName, { table: this.tableName, where: this._where });
}
toAST(): DeleteStatement {
return { type: 'DELETE', from: this.tableName, where: this._where };
}
}
+57
View File
@@ -0,0 +1,57 @@
/**
* metona-sqlark Query Compiler — AST → 查询计划
* @module query/compiler
*
* 将 AST 语句编译为引擎可执行的 QueryPlan。
* v0.0.1: 简单直接映射,未来可加入索引选择、过滤下推等优化。
*/
import type { QueryPlan } from '../constants';
import { DatabaseError } from '../constants';
import type { Statement, SelectStatement, DeleteStatement, UpdateStatement } from './ast';
// ---------------------------------------------------------------------------
// 编译 AST → QueryPlan
// ---------------------------------------------------------------------------
/**
* 编译 SELECT / DELETE / UPDATE 语句为 QueryPlan。
* INSERT 和 DDL 语句不需要 QueryPlan。
*/
export function compileStatement(stmt: Statement): QueryPlan {
switch (stmt.type) {
case 'SELECT':
return compileSelect(stmt);
case 'DELETE':
return compileDelete(stmt);
case 'UPDATE':
return compileUpdate(stmt);
default:
throw new DatabaseError(`Cannot compile statement type "${stmt.type}" to QueryPlan`, 'COMPILE_ERROR');
}
}
function compileSelect(stmt: SelectStatement): QueryPlan {
return {
table: stmt.from,
columns: stmt.columns,
where: stmt.where,
orderBy: stmt.orderBy?.length ? stmt.orderBy : undefined,
limit: stmt.limit,
offset: stmt.offset,
};
}
function compileDelete(stmt: DeleteStatement): QueryPlan {
return {
table: stmt.from,
where: stmt.where,
};
}
function compileUpdate(stmt: UpdateStatement): QueryPlan {
return {
table: stmt.table,
where: stmt.where,
};
}
+229
View File
@@ -0,0 +1,229 @@
/**
* metona-sqlark Query Executor — AST 执行器
* @module query/executor
*
* JOIN / GROUP BY / DISTINCT 逻辑在此层处理。
*/
import type { IStorageEngine } from '../engine/interface';
import type {
Statement, SelectStatement, InsertStatement, UpdateStatement,
DeleteStatement, CreateTableStatement, DropTableStatement, JoinClause,
} from './ast';
import { DatabaseError } from '../constants';
import { compileStatement } from './compiler';
import { createSchema, astColumnToColumnDef } from '../table/schema';
import { matchWhere, applyOrderBy, projectColumns } from './where-matcher';
// ---------------------------------------------------------------------------
// Executor
// ---------------------------------------------------------------------------
export class QueryExecutor {
constructor(private engine: IStorageEngine) {}
async execute(stmt: Statement): Promise<unknown> {
switch (stmt.type) {
case 'SELECT': return this.executeSelect(stmt);
case 'INSERT': return this.executeInsert(stmt);
case 'UPDATE': return this.executeUpdate(stmt);
case 'DELETE': return this.executeDelete(stmt);
case 'CREATE_TABLE': return this.executeCreateTable(stmt);
case 'DROP_TABLE': return this.executeDropTable(stmt);
default: throw new DatabaseError('Unknown statement type', 'UNKNOWN_STATEMENT');
}
}
// ===================================================================
// SELECT
// ===================================================================
private async executeSelect(stmt: SelectStatement): Promise<Record<string, unknown>[]> {
const hasGroupBy = !!(stmt.groupBy && stmt.groupBy.length > 0);
let rows: Record<string, unknown>[];
if (!stmt.joins || stmt.joins.length === 0) {
const plan = compileStatement(hasGroupBy ? { ...stmt, columns: ['*'] } : stmt);
rows = await this.engine.find(plan.table, plan);
} else {
rows = await this.executeJoinSelect(stmt);
}
if (hasGroupBy) rows = this.executeGroupBy(rows, stmt);
if (stmt.distinct) rows = this.executeDistinct(rows);
if (stmt.having && Object.keys(stmt.having).length > 0) {
rows = rows.filter((row) => matchWhere(row, stmt.having!));
}
if (stmt.orderBy && stmt.orderBy.length > 0) rows = applyOrderBy(rows, stmt.orderBy);
const offset = stmt.offset ?? 0;
const limit = stmt.limit ?? rows.length;
rows = rows.slice(offset, offset + limit);
if (!hasGroupBy && stmt.columns.length > 0 && stmt.columns[0] !== '*') {
rows = rows.map((row) => projectColumns(row, stmt.columns));
}
return rows;
}
// ---- JOIN ----
private async executeJoinSelect(stmt: SelectStatement): Promise<Record<string, unknown>[]> {
const mainAlias = stmt.alias ?? stmt.from;
const mainRows = (await this.engine.find(stmt.from, { table: stmt.from }))
.map((row) => this.prefixRow(row, mainAlias));
let resultRows = mainRows;
for (const join of stmt.joins!) {
const joinAlias = join.alias ?? join.table;
const joinRows = (await this.engine.find(join.table, { table: join.table }))
.map((row) => this.prefixRow(row, joinAlias));
resultRows = this.joinRows(resultRows, joinRows, join);
}
if (stmt.where && Object.keys(stmt.where).length > 0) {
resultRows = resultRows.filter((row) => matchWhere(row, stmt.where));
}
return resultRows;
}
private prefixRow(row: Record<string, unknown>, alias: string): Record<string, unknown> {
const prefixed: Record<string, unknown> = {};
for (const [key, value] of Object.entries(row)) prefixed[`${alias}.${key}`] = value;
return prefixed;
}
/** 嵌套循环连接(优化:避免 ON 时对象扩散) */
private joinRows(
leftRows: Record<string, unknown>[],
rightRows: Record<string, unknown>[],
join: JoinClause,
): Record<string, unknown>[] {
if (join.type === 'CROSS') {
const result: Record<string, unknown>[] = [];
for (const l of leftRows) for (const r of rightRows) result.push({ ...l, ...r });
return result;
}
const result: Record<string, unknown>[] = [];
for (const l of leftRows) {
let matched = false;
for (const r of rightRows) {
// 合并后匹配 ON(避免创建临时对象再丢弃)
const merged = { ...l, ...r };
if (matchWhere(merged, join.on, { $col: true })) {
result.push(merged);
matched = true;
}
}
if (!matched && join.type === 'LEFT') {
const nullRight: Record<string, unknown> = {};
for (const key of Object.keys(rightRows[0] ?? {})) nullRight[key] = null;
result.push({ ...l, ...nullRight });
}
}
if (join.type === 'RIGHT') {
for (const r of rightRows) {
const isMatched = leftRows.some((l) => {
const merged = { ...l, ...r };
return matchWhere(merged, join.on, { $col: true });
});
if (!isMatched) {
const nullLeft: Record<string, unknown> = {};
for (const key of Object.keys(leftRows[0] ?? {})) nullLeft[key] = null;
result.push({ ...nullLeft, ...r });
}
}
}
return result;
}
// ---- GROUP BY ----
private executeGroupBy(rows: Record<string, unknown>[], stmt: SelectStatement): Record<string, unknown>[] {
const groups = new Map<string, Record<string, unknown>[]>();
for (const row of rows) {
const key = stmt.groupBy!.map((col) => String(row[col] ?? 'null')).join('|');
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(row);
}
const result: Record<string, unknown>[] = [];
for (const groupRows of groups.values()) {
const aggregated: Record<string, unknown> = {};
for (const col of stmt.groupBy!) aggregated[col] = groupRows[0][col];
for (const colExpr of stmt.columns) {
if (colExpr === '*') continue;
const m = colExpr.match(/^(COUNT|SUM|AVG|MIN|MAX)\((.+?)\)(?:\s+AS\s+(\w+))?$/i);
if (m) {
const [, func, arg, alias] = m;
aggregated[alias || colExpr] = this.computeAggregate(func.toUpperCase(), groupRows, arg.trim());
} else if (!stmt.groupBy!.includes(colExpr)) {
aggregated[colExpr] = groupRows[0][colExpr];
}
}
result.push(aggregated);
}
return result;
}
private computeAggregate(func: string, rows: Record<string, unknown>[], col: string): number {
const nums = rows.map((r) => r[col]).filter((v) => v !== null && v !== undefined).map(Number);
switch (func) {
case 'COUNT': return col === '*' ? rows.length : nums.length;
case 'SUM': return nums.reduce((a: number, b) => a + b, 0);
case 'AVG': return nums.length === 0 ? 0 : nums.reduce((a: number, b) => a + b, 0) / nums.length;
case 'MIN': return nums.length === 0 ? 0 : Math.min(...nums);
case 'MAX': return nums.length === 0 ? 0 : Math.max(...nums);
default: return 0;
}
}
// ---- DISTINCT(优化:列值拼接代替 JSON.stringify ----
private executeDistinct(rows: Record<string, unknown>[]): Record<string, unknown>[] {
const seen = new Set<string>();
return rows.filter((row) => {
const key = Object.values(row).map((v) => String(v ?? '\0')).join('\x1f');
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// ===================================================================
// 其他语句
// ===================================================================
private async executeInsert(stmt: InsertStatement): Promise<string[]> {
const schema = await this.engine.getTableSchema(stmt.into);
if (!schema) throw new DatabaseError(`Table "${stmt.into}" does not exist`, 'TABLE_NOT_FOUND');
const colNames = stmt.columns ?? Object.keys(schema.columns);
const rows: Record<string, unknown>[] = stmt.values.map((vals: unknown[]) => {
const row: Record<string, unknown> = {};
for (let i = 0; i < colNames.length; i++) { if (i < vals.length) row[colNames[i]] = vals[i]; }
return row;
});
return this.engine.insert(stmt.into, rows);
}
private async executeUpdate(stmt: UpdateStatement): Promise<number> {
const plan = compileStatement(stmt);
return this.engine.update(plan.table, plan, stmt.sets);
}
private async executeDelete(stmt: DeleteStatement): Promise<number> {
const plan = compileStatement(stmt);
return this.engine.delete(plan.table, plan);
}
private async executeCreateTable(stmt: CreateTableStatement): Promise<void> {
const columns: Record<string, any> = {};
for (const col of stmt.columns) columns[col.name] = astColumnToColumnDef(col);
return this.engine.createTable(createSchema(stmt.name, columns));
}
private async executeDropTable(stmt: DropTableStatement): Promise<void> {
return this.engine.dropTable(stmt.name);
}
getEngine(): IStorageEngine { return this.engine; }
}
+9
View File
@@ -0,0 +1,9 @@
/**
* metona-sqlark Query — 查询系统
* @module query
*/
export type * from './ast';
export { SelectQueryBuilder, UpdateQueryBuilder, DeleteQueryBuilder } from './builder';
export { compileStatement } from './compiler';
export { QueryExecutor } from './executor';
+173
View File
@@ -0,0 +1,173 @@
/**
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
* @module query/where-matcher
*
* MemoryEngine / IndexedDBEngine / QueryExecutor 共享此模块,
* 消除 220+ 行重复代码,统一 $and/$or/$not/$col 行为。
*/
import type { WhereCondition, OrderBy } from '../constants';
// ---------------------------------------------------------------------------
// LIKE 正则缓存
// ---------------------------------------------------------------------------
const likeCache = new Map<string, RegExp>();
function compileLikeRegex(pattern: string): RegExp {
const cached = likeCache.get(pattern);
if (cached) return cached;
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/%/g, '.*')
.replace(/_/g, '.');
const regex = new RegExp(`^${escaped}$`, 'i');
likeCache.set(pattern, regex);
return regex;
}
// ---------------------------------------------------------------------------
// WHERE 匹配(顶层入口)
// ---------------------------------------------------------------------------
/**
* 匹配完整 WHERE 条件
* @param row 当前数据行
* @param where WHERE 条件对象
* @param options.$col 是否启用 $col 列引用解析
*/
export function matchWhere(
row: Record<string, unknown>,
where: WhereCondition,
options: { $col?: boolean } = {},
): boolean {
for (const [field, condition] of Object.entries(where)) {
// 顶层 $and
if (field === '$and') {
const subs = condition as WhereCondition[];
if (!subs.every((sub) => matchWhere(row, sub, options))) return false;
continue;
}
// 顶层 $or
if (field === '$or') {
const subs = condition as WhereCondition[];
if (!subs.some((sub) => matchWhere(row, sub, options))) return false;
continue;
}
if (!matchField(row[field], condition, row, options)) return false;
}
return true;
}
// ---------------------------------------------------------------------------
// 字段匹配
// ---------------------------------------------------------------------------
function matchField(
value: unknown,
condition: unknown,
row: Record<string, unknown>,
options: { $col?: boolean },
): boolean {
// 嵌套 $and
if (typeof condition === 'object' && condition !== null && '$and' in (condition as Record<string, unknown>)) {
const subs = (condition as Record<string, unknown>).$and as WhereCondition[];
return subs.every((sub) => matchWhere(row, sub, options));
}
// 嵌套 $or
if (typeof condition === 'object' && condition !== null && '$or' in (condition as Record<string, unknown>)) {
const subs = (condition as Record<string, unknown>).$or as WhereCondition[];
return subs.some((sub) => matchWhere(row, sub, options));
}
// $not
if (typeof condition === 'object' && condition !== null && '$not' in (condition as Record<string, unknown>)) {
return !matchField(value, (condition as Record<string, unknown>).$not, row, options);
}
// 简单值 => $eq
if (typeof condition !== 'object' || condition === null || Array.isArray(condition)) {
return value === condition;
}
const ops = condition as Record<string, unknown>;
// $col 简写: { $col: name } === { $eq: { $col: name } }(仅 JOIN ON 场景)
if (options.$col && '$col' in ops && Object.keys(ops).length === 1) {
return value === row[ops.$col as string];
}
// 遍历操作符
for (const [op, operand] of Object.entries(ops)) {
let actualOperand = operand;
// $col 列引用解析
if (options.$col && typeof operand === 'object' && operand !== null && '$col' in (operand as Record<string, unknown>)) {
actualOperand = row[(operand as Record<string, unknown>).$col as string];
}
if (!matchOperator(value, op, actualOperand)) return false;
}
return true;
}
// ---------------------------------------------------------------------------
// 操作符匹配
// ---------------------------------------------------------------------------
function matchOperator(value: unknown, op: string, operand: unknown): boolean {
switch (op) {
case '$eq': return value === operand;
case '$ne': return value !== operand;
case '$gt': return (value as number) > (operand as number);
case '$gte': return (value as number) >= (operand as number);
case '$lt': return (value as number) < (operand as number);
case '$lte': return (value as number) <= (operand as number);
case '$in': return Array.isArray(operand) && operand.includes(value);
case '$nin': return Array.isArray(operand) && !operand.includes(value);
case '$like': return compileLikeRegex(String(operand)).test(String(value));
default: return true;
}
}
// ---------------------------------------------------------------------------
// 排序
// ---------------------------------------------------------------------------
export function applyOrderBy(rows: Record<string, unknown>[], orderBy: OrderBy[]): Record<string, unknown>[] {
return [...rows].sort((a, b) => {
for (const { column, direction } of orderBy) {
const cmp = compare(a[column], b[column]);
if (cmp !== 0) return direction === 'desc' ? -cmp : cmp;
}
return 0;
});
}
function compare(a: unknown, b: unknown): number {
if (a === b) return 0;
if (a === null || a === undefined) return 1;
if (b === null || b === undefined) return -1;
if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b);
if (typeof a === 'number' && typeof b === 'number') return a - b;
return String(a).localeCompare(String(b));
}
// ---------------------------------------------------------------------------
// 列投影
// ---------------------------------------------------------------------------
export function projectColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
const projected: Record<string, unknown> = {};
for (const col of columns) {
if (col in row) {
projected[col] = row[col];
} else {
for (const key of Object.keys(row)) {
if (key.endsWith(`.${col}`) || key === col) {
projected[col] = row[key];
break;
}
}
}
}
return projected;
}