Compare commits

...

38 Commits
3.3.11 ... dev

Author SHA1 Message Date
Pan Qiancheng c080b15078 fix: 完善DbStore类型,在init时自动创建扩展 2026-01-23 10:53:10 +08:00
Xu Chang ec085ddfd1 3.3.14-dev 2026-01-21 09:55:09 +08:00
Xu Chang 411f4db18f 3.3.13-pub 2026-01-21 09:53:56 +08:00
Pan Qiancheng 62bc866606 Merge branch 'dev' of https://gitea.51mars.com/Oak-Team/oak-db into dev 2026-01-21 09:50:21 +08:00
Pan Qiancheng eee2f7c874 fix: 去除了多余的添加deleteAt字段的逻辑 2026-01-21 09:50:14 +08:00
Xu Chang cb8b0428b4 Merge branch 'dev' of gitea.51mars.com:Oak-Team/oak-db into dev 2026-01-21 09:42:31 +08:00
Xu Chang be10547065 漏了一根索引上的deleteAt 2026-01-21 09:42:23 +08:00
Pan Qiancheng 49dc9141de feat: 完善类型支持,在pg中提供部分ddl支持 2026-01-20 18:03:33 +08:00
Pan Qiancheng 5a5ac5c194 feat: 支持在init时自动创建地理和paser插件,以及将初始化包裹在事务中 2026-01-20 12:04:57 +08:00
Pan Qiancheng b4e0b08ba7 fix: 统一datatime类型的处理行为 2026-01-20 11:00:51 +08:00
Pan Qiancheng 195e97b3d9 fix: 修复了部分内置字段查询结果为string导致的上层转换问题 2026-01-20 10:52:53 +08:00
Pan Qiancheng c5456b3fcb test: 新增测试用例:嵌套跨节点表达式 2026-01-13 15:04:08 +08:00
Pan Qiancheng a5cb652468 Merge branch 'dev' of https://gitea.51mars.com/Oak-Team/oak-db into dev 2026-01-13 15:02:59 +08:00
Pan Qiancheng 8f0319c648 fix: 修复一处mysql的filter深层调用节点表达式时会出现的bug 2026-01-13 15:02:37 +08:00
Xu Chang 5ab7e9e43b 3.3.13-dev 2026-01-09 15:44:32 +08:00
Xu Chang 6d7d3dea1f 3.3.12-pub 2026-01-09 15:43:34 +08:00
Pan Qiancheng 66215811fc 测试 2026-01-09 15:22:59 +08:00
Pan Qiancheng 3b8892d3e1 fix: 修复#sqp测试用例的一些问题 2026-01-04 10:01:17 +08:00
Pan Qiancheng 5858dd7db0 fix: 修复了$contains的问题。
feat: 拆分了所有的测试用例
fix: 修复了部分测试用例的问题
2026-01-01 22:07:18 +08:00
Xu Chang e9059b8514 merge 2026-01-01 13:44:30 +08:00
Xu Chang 1a3e3cb005 部分ddl的实现 2026-01-01 13:25:38 +08:00
Pan Qiancheng 9c0437a025 更新ignore 2026-01-01 11:29:58 +08:00
Pan Qiancheng 789580fd82 fix: 统一count的结果类型,postgres需要转换为number 2026-01-01 11:25:46 +08:00
Pan Qiancheng 5035f0d520 fix: 添加一处assert 2026-01-01 11:25:22 +08:00
Pan Qiancheng 3a17335937 fix: 修复mysql中dateDiff、dateCeil、dateFloor的部分问题 2026-01-01 11:25:06 +08:00
Pan Qiancheng f6322dffff fix: 修复postgres中布尔运算表达式问题,dateDiff、dateCeil、dateFloor部分问题 2026-01-01 11:24:28 +08:00
Pan Qiancheng aa49a12cb0 feat: 添加测试用例 2026-01-01 11:23:16 +08:00
Pan Qiancheng e0a447adc0 feat: 修复索引创建时的问题,适配新的全文检索方式和$ts参数 2025-12-31 15:01:49 +08:00
Pan Qiancheng b5286fea5d feat: postgres允许全文检索的索引使用多种语言配置 2025-12-31 12:03:24 +08:00
Pan Qiancheng ef44cc4dc4 fix: 修复测试用例问题,postgres测试用例基本通过 2025-12-30 18:39:49 +08:00
Pan Qiancheng 846cccb325 fix: 对于in和not in 需要使用quoteIdentifier进行包裹,避免id中的-号被识别为运算符 2025-12-30 18:38:59 +08:00
Pan Qiancheng 1d464a38a9 fix: 修复在json判断的几个问题 2025-12-30 18:37:36 +08:00
Pan Qiancheng 6ec189001f fix: 修复postgres中jsonb格式查询时的别名问题 2025-12-30 16:32:56 +08:00
Pan Qiancheng 03391db61a fix: 去除一处多余的console.log 2025-12-30 16:22:06 +08:00
Pan Qiancheng 27f8fb16c5 fix: postgres的char类型在查询结果中会自动填充空格,需要特殊处理,以保证行为和mysql一致 2025-12-30 16:19:08 +08:00
Pan Qiancheng 05f58945c4 feat: 实现postgres的数据库支持 2025-12-30 15:44:43 +08:00
QCQCQC@Debian 66cf652646 fix: 新增一处外键检查的assert错误信息 2025-12-05 13:57:50 +08:00
Xu Chang aade97762f 3.3.12-dev 2025-10-16 09:16:59 +08:00
61 changed files with 13581 additions and 2523 deletions

5
.gitignore vendored
View File

@ -115,4 +115,7 @@ dist
.yarn/install-state.gz
.pnp.*
package-lock.json
test/test-app-domain
test/test-app-domain
test-dist

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules\\typescript\\lib"
}

View File

@ -44,7 +44,7 @@ class MySqlConnector {
}
async exec(sql, txn) {
if (process.env.NODE_ENV === 'development') {
// console.log(sql);
// console.log(`${sql}; \n`);
}
if (txn) {
const connection = this.txnDict[txn];

19
lib/MySQL/store.d.ts vendored
View File

@ -4,10 +4,11 @@ import { CascadeStore } from 'oak-domain/lib/store/CascadeStore';
import { MySQLConfiguration } from './types/Configuration';
import { MySqlConnector } from './connector';
import { MySqlTranslator, MySqlSelectOption, MysqlOperateOption } from './translator';
import { AsyncContext, AsyncRowStore } from 'oak-domain/lib/store/AsyncRowStore';
import { AsyncContext } from 'oak-domain/lib/store/AsyncRowStore';
import { SyncContext } from 'oak-domain/lib/store/SyncRowStore';
import { CreateEntityOption } from '../types/Translator';
export declare class MysqlStore<ED extends EntityDict & BaseEntityDict, Cxt extends AsyncContext<ED>> extends CascadeStore<ED> implements AsyncRowStore<ED, Cxt> {
import { DbStore, Plan } from '../types/dbStore';
export declare class MysqlStore<ED extends EntityDict & BaseEntityDict, Cxt extends AsyncContext<ED>> extends CascadeStore<ED> implements DbStore<ED, Cxt> {
protected countAbjointRow<T extends keyof ED, OP extends SelectOption, Cxt extends SyncContext<ED>>(entity: T, selection: Pick<ED[T]['Selection'], 'filter' | 'count'>, context: Cxt, option: OP): number;
protected aggregateAbjointRowSync<T extends keyof ED, OP extends SelectOption, Cxt extends SyncContext<ED>>(entity: T, aggregation: ED[T]['Aggregation'], context: Cxt, option: OP): AggregationResult<ED[T]['Schema']>;
protected selectAbjointRow<T extends keyof ED, OP extends SelectOption>(entity: T, selection: ED[T]['Selection'], context: SyncContext<ED>, option: OP): Partial<ED[T]['Schema']>[];
@ -31,7 +32,19 @@ export declare class MysqlStore<ED extends EntityDict & BaseEntityDict, Cxt exte
begin(option?: TxnOption): Promise<string>;
commit(txnId: string): Promise<void>;
rollback(txnId: string): Promise<void>;
connect(): void;
connect(): Promise<void>;
disconnect(): Promise<void>;
initialize(option: CreateEntityOption): Promise<void>;
readSchema(): Promise<StorageSchema<ED>>;
/**
* dataSchemaschemaupgrade
* plan分为两阶段
*/
makeUpgradePlan(): Promise<Plan>;
/**
* schema的不同new对old的增量
* @param schemaOld
* @param SchemaNew
*/
diffSchema(schemaOld: StorageSchema<any>, schemaNew: StorageSchema<any>): Plan;
}

View File

@ -209,7 +209,7 @@ class MysqlStore extends CascadeStore_1.CascadeStore {
// 边界如果是toModi的对象这里的外键确实有可能为空
// assert(schema[e].toModi || r[`${attr}Id`] === r[attr].id, `对象${<string>e}取数据时发现其外键与连接的对象的主键不一致rowId是${r.id},其${attr}Id值为${r[`${attr}Id`]},连接的对象的主键为${r[attr].id}`);
if (r[attr].id === null) {
(0, assert_1.default)(schema[e].toModi || r[`${attr}Id`] === null);
(0, assert_1.default)(schema[e].toModi || r[`${attr}Id`] === null, `对象${String(e)}取数据时发现其外键找不到目标对象rowId是${r.id},其外键${attr}Id值为${r[`${attr}Id`]}`);
delete r[attr];
continue;
}
@ -289,8 +289,8 @@ class MysqlStore extends CascadeStore_1.CascadeStore {
async rollback(txnId) {
await this.connector.rollbackTransaction(txnId);
}
connect() {
this.connector.connect();
async connect() {
await this.connector.connect();
}
async disconnect() {
await this.connector.disconnect();
@ -304,5 +304,127 @@ class MysqlStore extends CascadeStore_1.CascadeStore {
}
}
}
// 从数据库中读取当前schema
readSchema() {
return this.translator.readSchema((sql) => this.connector.exec(sql));
}
/**
* 根据载入的dataSchema和数据库中原来的schema决定如何来upgrade
* 制订出来的plan分为两阶段增加阶段和削减阶段在两个阶段之间由用户来修正数据
*/
async makeUpgradePlan() {
const originSchema = await this.readSchema();
const plan = this.diffSchema(originSchema, this.translator.schema);
return plan;
}
/**
* 比较两个schema的不同这里计算的是new对old的增量
* @param schemaOld
* @param SchemaNew
*/
diffSchema(schemaOld, schemaNew) {
const plan = {
newTables: {},
newIndexes: {},
updatedIndexes: {},
updatedTables: {},
};
for (const table in schemaNew) {
// mysql数据字典不分大小写的
if (schemaOld[table] || schemaOld[table.toLowerCase()]) {
const { attributes, indexes } = schemaOld[table] || schemaOld[table.toLowerCase()];
const { attributes: attributesNew, indexes: indexesNew } = schemaNew[table];
const assignToUpdateTables = (attr, isNew) => {
if (!plan.updatedTables[table]) {
plan.updatedTables[table] = {
attributes: {
[attr]: {
...attributesNew[attr],
isNew,
}
}
};
}
else {
plan.updatedTables[table].attributes[attr] = {
...attributesNew[attr],
isNew,
};
}
};
for (const attr in attributesNew) {
if (attributes[attr]) {
// 因为反向无法复原原来定义的attribute类型这里就比较两次创建的sql是不是一致不是太好的设计。
const sql1 = this.translator.translateAttributeDef(attr, attributesNew[attr]);
const sql2 = this.translator.translateAttributeDef(attr, attributes[attr]);
if (!this.translator.compareSql(sql1, sql2)) {
assignToUpdateTables(attr, false);
}
}
else {
assignToUpdateTables(attr, true);
}
}
if (indexesNew) {
const assignToIndexes = (index, isNew) => {
if (isNew) {
if (plan.newIndexes[table]) {
plan.newIndexes[table].push(index);
}
else {
plan.newIndexes[table] = [index];
}
}
else {
if (plan.updatedIndexes[table]) {
plan.updatedIndexes[table].push(index);
}
else {
plan.updatedIndexes[table] = [index];
}
}
};
const compareConfig = (config1, config2) => {
const unique1 = config1?.unique || false;
const unique2 = config2?.unique || false;
if (unique1 !== unique2) {
return false;
}
const type1 = config1?.type || 'btree';
const type2 = config2?.type || 'btree';
// parser目前无法从mysql中读出来所以不比了
return type1 === type2;
};
for (const index of indexesNew) {
const { name, config, attributes } = index;
const origin = indexes?.find(ele => ele.name === name);
if (origin) {
if (JSON.stringify(attributes) !== JSON.stringify(origin.attributes)) {
// todo这里要细致比较不能用json.stringify
assignToIndexes(index, false);
}
else {
if (!compareConfig(config, origin.config)) {
assignToIndexes(index, false);
}
}
}
else {
assignToIndexes(index, true);
}
}
}
}
else {
plan.newTables[table] = {
attributes: schemaNew[table].attributes,
};
if (schemaNew[table].indexes) {
plan.newIndexes[table] = schemaNew[table].indexes;
}
}
}
return plan;
}
}
exports.MysqlStore = MysqlStore;

View File

@ -1,4 +1,4 @@
import { EntityDict, Q_FullTextValue, RefOrExpression, Ref, StorageSchema } from "oak-domain/lib/types";
import { EntityDict, Q_FullTextValue, RefOrExpression, Ref, StorageSchema, Attribute } from "oak-domain/lib/types";
import { EntityDict as BaseEntityDict } from 'oak-domain/lib/base-app-domain';
import { DataType } from "oak-domain/lib/types/schema/DataTypes";
import { SqlOperateOption, SqlSelectOption, SqlTranslator } from "../sqlTranslator";
@ -97,6 +97,7 @@ export declare class MySqlTranslator<ED extends EntityDict & BaseEntityDict> ext
protected translateObjectProjection(projection: Record<string, any>, alias: string, attr: string, prefix: string): string;
protected translateAttrValue(dataType: DataType | Ref, value: any): string;
protected translateFullTextSearch<T extends keyof ED>(value: Q_FullTextValue, entity: T, alias: string): string;
translateAttributeDef(attr: string, attrDef: Attribute): string;
translateCreateEntity<T extends keyof ED>(entity: T, options?: CreateEntityOption): string[];
private translateFnName;
private translateAttrInExpression;
@ -104,4 +105,10 @@ export declare class MySqlTranslator<ED extends EntityDict & BaseEntityDict> ext
protected populateSelectStmt<T extends keyof ED>(projectionText: string, fromText: string, aliasDict: Record<string, string>, filterText: string, sorterText?: string, groupByText?: string, indexFrom?: number, count?: number, option?: MySqlSelectOption): string;
protected populateUpdateStmt(updateText: string, fromText: string, aliasDict: Record<string, string>, filterText: string, sorterText?: string, indexFrom?: number, count?: number, option?: MysqlOperateOption): string;
protected populateRemoveStmt(updateText: string, fromText: string, aliasDict: Record<string, string>, filterText: string, sorterText?: string, indexFrom?: number, count?: number, option?: MysqlOperateOption): string;
/**
* MySQL返回的Type回译成oak的类型 populateDataTypeDef
* @param type
*/
private reTranslateToAttribute;
readSchema(execFn: (sql: string) => Promise<any>): Promise<StorageSchema<ED>>;
}

View File

@ -119,21 +119,21 @@ class MySqlTranslator extends sqlTranslator_1.SqlTranslator {
// numeric types
"bit",
"int",
"integer",
"integer", // synonym for int
"tinyint",
"smallint",
"mediumint",
"bigint",
"float",
"double",
"double precision",
"real",
"double precision", // synonym for double
"real", // synonym for double
"decimal",
"dec",
"numeric",
"fixed",
"bool",
"boolean",
"dec", // synonym for decimal
"numeric", // synonym for decimal
"fixed", // synonym for decimal
"bool", // synonym for tinyint
"boolean", // synonym for tinyint
// date and time types
"date",
"datetime",
@ -142,10 +142,10 @@ class MySqlTranslator extends sqlTranslator_1.SqlTranslator {
"year",
// string types
"char",
"nchar",
"nchar", // synonym for national char
"national char",
"varchar",
"nvarchar",
"nvarchar", // synonym for national varchar
"national varchar",
"blob",
"text",
@ -266,14 +266,18 @@ class MySqlTranslator extends sqlTranslator_1.SqlTranslator {
return 'text ';
}
if (type === 'ref') {
return 'char(36)';
return 'char(36) ';
}
if (['bool', 'boolean'].includes(type)) {
// MySQL读出来就是tinyint(1)
return 'tinyint(1) ';
}
if (type === 'money') {
return 'bigint';
return 'bigint ';
}
if (type === 'enum') {
(0, assert_1.default)(enumeration);
return `enum(${enumeration.map(ele => `'${ele}'`).join(',')})`;
return `enum(${enumeration.map(ele => `'${ele}'`).join(',')}) `;
}
if (MySqlTranslator.withLengthDataTypes.includes(type)) {
if (params) {
@ -291,34 +295,34 @@ class MySqlTranslator extends sqlTranslator_1.SqlTranslator {
if (typeof scale === 'number') {
return `${type}(${precision}, ${scale}) `;
}
return `${type}(${precision})`;
return `${type}(${precision}) `;
}
else {
const { precision, scale } = MySqlTranslator.dataTypeDefaults[type];
if (typeof scale === 'number') {
return `${type}(${precision}, ${scale}) `;
}
return `${type}(${precision})`;
return `${type}(${precision}) `;
}
}
if (MySqlTranslator.withWidthDataTypes.includes(type)) {
(0, assert_1.default)(type === 'int');
const { width } = params;
const { width } = params || { width: 4 };
switch (width) {
case 1: {
return 'tinyint';
return 'tinyint ';
}
case 2: {
return 'smallint';
return 'smallint ';
}
case 3: {
return 'mediumint';
return 'mediumint ';
}
case 4: {
return 'int';
return 'int ';
}
default: {
return 'bigint';
return 'bigint ';
}
}
}
@ -553,6 +557,28 @@ class MySqlTranslator extends sqlTranslator_1.SqlTranslator {
const columns2 = attributes.map(({ name }) => `${alias}.${name}`);
return ` match(${columns2.join(',')}) against ('${$search}' in natural language mode)`;
}
translateAttributeDef(attr, attrDef) {
let sql = `\`${attr}\` `;
const { type, params, default: defaultValue, unique, notNull, sequenceStart, enumeration, } = attrDef;
sql += this.populateDataTypeDef(type, params, enumeration);
if (notNull || type === 'geometry') {
sql += ' not null ';
}
if (unique) {
sql += ' unique ';
}
if (typeof sequenceStart === 'number') {
sql += ' auto_increment unique ';
}
if (defaultValue !== undefined) {
(0, assert_1.default)(type !== 'ref');
sql += ` default ${this.translateAttrValue(type, defaultValue)}`;
}
if (attr === types_1.PrimaryKeyAttribute) {
sql += ' primary key';
}
return sql;
}
translateCreateEntity(entity, options) {
const ifExists = options?.ifExists || 'drop';
const { schema } = this;
@ -578,32 +604,14 @@ class MySqlTranslator extends sqlTranslator_1.SqlTranslator {
sql += '(';
// 翻译所有的属性
Object.keys(attributes).forEach((attr, idx) => {
const attrDef = attributes[attr];
const { type, params, default: defaultValue, unique, notNull, sequenceStart, enumeration, } = attrDef;
sql += `\`${attr}\` `;
sql += this.populateDataTypeDef(type, params, enumeration);
if (notNull || type === 'geometry') {
sql += ' not null ';
const attrSql = this.translateAttributeDef(attr, attributes[attr]);
if (idx !== 0) {
sql += ', ';
}
if (unique) {
sql += ' unique ';
}
if (sequenceStart) {
if (hasSequence) {
throw new Error(`${entity}」只能有一个sequence列`);
}
hasSequence = sequenceStart;
sql += ' auto_increment unique ';
}
if (defaultValue !== undefined) {
(0, assert_1.default)(type !== 'ref');
sql += ` default ${this.translateAttrValue(type, defaultValue)}`;
}
if (attr === 'id') {
sql += ' primary key';
}
if (idx < Object.keys(attributes).length - 1) {
sql += ',\n';
sql += attrSql;
if (typeof attributes[attr].sequenceStart === 'number') {
(0, assert_1.default)(hasSequence === false, 'Entity can only have one auto increment attribute.');
hasSequence = attributes[attr].sequenceStart;
}
});
// 翻译索引信息
@ -621,12 +629,11 @@ class MySqlTranslator extends sqlTranslator_1.SqlTranslator {
else if (type === 'spatial') {
sql += ' spatial ';
}
sql += `index ${name} `;
sql += `index \`${name}\` `;
if (type === 'hash') {
sql += ` using hash `;
}
sql += '(';
let includeDeleteAt = false;
attributes.forEach(({ name, size, direction }, idx2) => {
sql += `\`${name}\``;
if (size) {
@ -636,15 +643,9 @@ class MySqlTranslator extends sqlTranslator_1.SqlTranslator {
sql += ` ${direction}`;
}
if (idx2 < attributes.length - 1) {
sql += ',';
}
if (name === '$$deleteAt$$') {
includeDeleteAt = true;
sql += ', ';
}
});
if (!includeDeleteAt && !type) {
sql += ', `$$deleteAt$$`'; // 在mysql80+之后,需要给属性加上``包裹,否则会报错
}
sql += ')';
if (parser) {
sql += ` with parser ${parser}`;
@ -796,10 +797,11 @@ class MySqlTranslator extends sqlTranslator_1.SqlTranslator {
case '$dayOfYear': {
return 'DAYOFYEAR(%s)';
}
case '$dateDiff': {
(0, assert_1.default)(argumentNumber === 3);
return 'DATEDIFF(%s, %s, %s)';
}
// 这个实现有问题DATEDIFF只是计算两个日期之间的天数差只接受两个参数放在translateExperession里实现
// case '$dateDiff': {
// assert(argumentNumber === 3);
// return 'DATEDIFF(%s, %s, %s)';
// }
case '$contains': {
(0, assert_1.default)(argumentNumber === 2);
return 'ST_CONTAINS(%s, %s)';
@ -816,6 +818,22 @@ class MySqlTranslator extends sqlTranslator_1.SqlTranslator {
result += ')';
return result;
}
// ========== 聚合函数 ==========
case '$$count': {
return 'COUNT(%s)';
}
case '$$sum': {
return 'SUM(%s)';
}
case '$$max': {
return 'MAX(%s)';
}
case '$$min': {
return 'MIN(%s)';
}
case '$$avg': {
return 'AVG(%s)';
}
default: {
throw new Error(`unrecoganized function ${fnName}`);
}
@ -854,15 +872,142 @@ class MySqlTranslator extends sqlTranslator_1.SqlTranslator {
const refId = (expr)['#refId'];
const refAttr = (expr)['#refAttr'];
(0, assert_1.default)(refDict[refId]);
const attrText = `\`${refDict[refId][0]}\`.\`${refAttr}\``;
result = this.translateAttrInExpression(entity, (expr)['#refAttr'], attrText);
const [refAlias, refEntity] = refDict[refId];
const attrText = `\`${refAlias}\`.\`${refAttr}\``;
// 这里必须使用refEntity否则在filter深层嵌套节点表达式时会出现entity不对应
result = this.translateAttrInExpression(refEntity, (expr)['#refAttr'], attrText);
}
else {
(0, assert_1.default)(k.length === 1);
if ((expr)[k[0]] instanceof Array) {
const fnName = this.translateFnName(k[0], (expr)[k[0]].length);
const fnKey = k[0];
const fnArgs = (expr)[fnKey];
// 特殊处理日期相关函数
if (fnKey === '$dateDiff') {
// $dateDiff: [date1, date2, unit]
(0, assert_1.default)(fnArgs instanceof Array && fnArgs.length === 3);
const [date1Expr, date2Expr, unit] = fnArgs;
// 转换日期表达式
const translateDateArg = (arg) => {
if (arg instanceof Date) {
return `FROM_UNIXTIME(${arg.valueOf()} / 1000)`;
}
else if (typeof arg === 'number') {
return `FROM_UNIXTIME(${arg} / 1000)`;
}
else {
return translateInner(arg);
}
};
const date1Str = translateDateArg(date1Expr);
const date2Str = translateDateArg(date2Expr);
// MySQL TIMESTAMPDIFF 单位映射
const unitMap = {
's': 'SECOND',
'm': 'MINUTE',
'h': 'HOUR',
'd': 'DAY',
'M': 'MONTH',
'y': 'YEAR'
};
const mysqlUnit = unitMap[unit];
if (!mysqlUnit) {
throw new Error(`Unsupported date diff unit: ${unit}`);
}
// TIMESTAMPDIFF(unit, date1, date2) 返回 date2 - date1
// 但类型定义是 date1 - date2所以参数顺序要反过来
result = `TIMESTAMPDIFF(${mysqlUnit}, ${date2Str}, ${date1Str})`;
}
else if (fnKey === '$dateCeil') {
// $dateCeil: [date, unit]
(0, assert_1.default)(fnArgs instanceof Array && fnArgs.length === 2);
const [dateExpr, unit] = fnArgs;
const getTimestampExpr = (arg) => {
if (arg instanceof Date) {
return `${arg.valueOf()}`;
}
else if (typeof arg === 'number') {
return `${arg}`;
}
else {
const k = Object.keys(arg);
if (k.includes('#attr')) {
return `\`${alias}\`.\`${arg['#attr']}\``;
}
else if (k.includes('#refId')) {
const refId = arg['#refId'];
const refAttr = arg['#refAttr'];
return `\`${refDict[refId][0]}\`.\`${refAttr}\``;
}
return translateInner(arg);
}
};
const tsExpr = getTimestampExpr(dateExpr);
const msPerUnit = {
's': 1000,
'm': 60000,
'h': 3600000,
'd': 86400000,
};
if (msPerUnit[unit]) {
// CEIL 向上取整
result = `CEIL(${tsExpr} / ${msPerUnit[unit]}) * ${msPerUnit[unit]}`;
}
else {
(0, assert_1.default)(typeof unit === 'string', 'unit should be string');
console.warn('暂不支持 $dateCeil 对月年单位的处理');
throw new Error(`Unsupported date ceil unit: ${unit}`);
}
}
else if (fnKey === '$dateFloor') {
// $dateFloor: [date, unit]
(0, assert_1.default)(fnArgs instanceof Array && fnArgs.length === 2);
const [dateExpr, unit] = fnArgs;
// 获取毫秒时间戳表达式
const getTimestampExpr = (arg) => {
if (arg instanceof Date) {
return `${arg.valueOf()}`;
}
else if (typeof arg === 'number') {
return `${arg}`;
}
else {
// 属性引用,直接返回属性(存储本身就是毫秒时间戳)
const k = Object.keys(arg);
if (k.includes('#attr')) {
return `\`${alias}\`.\`${arg['#attr']}\``;
}
else if (k.includes('#refId')) {
const refId = arg['#refId'];
const refAttr = arg['#refAttr'];
return `\`${refDict[refId][0]}\`.\`${refAttr}\``;
}
// 其他表达式递归处理
return translateInner(arg);
}
};
const tsExpr = getTimestampExpr(dateExpr);
// 固定间隔单位:直接用时间戳数学运算
const msPerUnit = {
's': 1000,
'm': 60000,
'h': 3600000,
'd': 86400000,
};
if (msPerUnit[unit]) {
// FLOOR(timestamp / interval) * interval
result = `FLOOR(${tsExpr} / ${msPerUnit[unit]}) * ${msPerUnit[unit]}`;
}
else {
(0, assert_1.default)(typeof unit === 'string', 'unit should be string');
console.warn('暂不支持 $dateFloor 对月年单位的处理');
throw new Error(`Unsupported date floor unit: ${unit}`);
}
}
else if (fnArgs instanceof Array) {
// 原有的数组参数处理逻辑
const fnName = this.translateFnName(fnKey, fnArgs.length);
const args = [fnName];
args.push(...(expr)[k[0]].map((ele) => {
args.push(...fnArgs.map((ele) => {
if (['string', 'number'].includes(typeof ele) || ele instanceof Date) {
return translateConstant(ele);
}
@ -873,9 +1018,10 @@ class MySqlTranslator extends sqlTranslator_1.SqlTranslator {
result = util_1.format.apply(null, args);
}
else {
const fnName = this.translateFnName(k[0], 1);
// 原有的单参数处理逻辑
const fnName = this.translateFnName(fnKey, 1);
const args = [fnName];
const arg = (expr)[k[0]];
const arg = fnArgs;
if (['string', 'number'].includes(typeof arg) || arg instanceof Date) {
args.push(translateConstant(arg));
}
@ -949,7 +1095,7 @@ class MySqlTranslator extends sqlTranslator_1.SqlTranslator {
}
// 新的remove应该包含$$deleteAt$$的值了
/* const now = Date.now();
const updateText2 = updateText ? `${updateText}, \`${alias}\`.\`$$deleteAt$$\` = '${now}'` : `\`${alias}\`.\`$$deleteAt$$\` = '${now}'`; */
(0, assert_1.default)(updateText.includes(types_1.DeleteAtAttribute));
let sql = `update ${fromText} set ${updateText}`;
@ -965,5 +1111,99 @@ class MySqlTranslator extends sqlTranslator_1.SqlTranslator {
}
return sql;
}
/**
* 将MySQL返回的Type回译成oak的类型 populateDataTypeDef 的反函数
* @param type
*/
reTranslateToAttribute(type) {
const withLengthDataTypes = MySqlTranslator.withLengthDataTypes.join('|');
let result = (new RegExp(`^(${withLengthDataTypes})\\((\\d+)\\)$`)).exec(type);
if (result) {
return {
type: result[1],
params: {
length: parseInt(result[2]),
}
};
}
const withPrecisionDataTypes = MySqlTranslator.withPrecisionDataTypes.join('|');
result = (new RegExp(`^(${withPrecisionDataTypes})\\((\\d+),(d+)\\)$`)).exec(type);
if (result) {
return {
type: result[1],
params: {
precision: parseInt(result[2]),
scale: parseInt(result[3]),
},
};
}
result = (/^enum\((\S+)\)$/).exec(type);
if (result) {
const enumeration = result[1].split(',').map(ele => ele.slice(1, -1));
return {
type: 'enum',
enumeration
};
}
return {
type: type,
};
}
// 分析当前数据库结构图
async readSchema(execFn) {
const result = {};
const sql = 'show tables;';
const [tables] = await execFn(sql);
for (const tableItem of tables) {
const table = Object.values(tableItem)[0];
const [tableResult] = await execFn(`desc \`${table}\``);
const attributes = {};
for (const attrItem of tableResult) {
const { Field: attrName, Null: isNull, Type: type, Key: key } = attrItem;
attributes[attrName] = {
...this.reTranslateToAttribute(type),
notNull: isNull.toUpperCase() === 'NO',
unique: key.toUpperCase() === 'UNI',
};
// 自增列只可能是seq
if (attrName === '$$seq$$') {
attributes[attrName].sequenceStart = 10000;
}
}
Object.assign(result, {
[table]: {
attributes,
}
});
const [indexedColumns] = (await execFn(`show index from \`${table}\``));
if (indexedColumns.length) {
const groupedColumns = (0, lodash_1.groupBy)(indexedColumns.sort((ele1, ele2) => ele1.Key_name.localeCompare(ele2.Key_name) || ele1.Seq_in_index - ele2.Seq_in_index), 'Key_name');
const indexes = Object.values(groupedColumns).map((ele) => {
const index = {
name: ele[0].Key_name,
attributes: ele.map(ele2 => ({
name: ele2.Column_name,
direction: ele2.Collation === 'D' ? 'DESC' : (ele2.Collation === 'A' ? 'ASC' : undefined),
size: ele2.Sub_part || undefined,
})),
};
if (ele[0].Non_unique === 0 || ele[0].Index_type.toUpperCase() !== 'BTREE') {
index.config = {};
if (ele[0].Non_unique === 0) {
index.config.unique = true;
}
if (ele[0].Index_type.toUpperCase() !== 'BTREE') {
index.config.type = ele[0].Index_type.toLowerCase();
}
}
return index;
});
Object.assign(result[table], {
indexes,
});
}
}
return result;
}
}
exports.MySqlTranslator = MySqlTranslator;

27
lib/PostgreSQL/connector.d.ts vendored Normal file
View File

@ -0,0 +1,27 @@
import { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg';
import { TxnOption } from 'oak-domain/lib/types';
import { PostgreSQLConfiguration } from './types/Configuration';
export declare class PostgreSQLConnector {
pool: Pool;
configuration: PostgreSQLConfiguration;
txnDict: Record<string, PoolClient>;
constructor(configuration: PostgreSQLConfiguration);
connect(): void;
disconnect(): Promise<void>;
startTransaction(option?: TxnOption): Promise<string>;
/**
* PostgreSQL
*/
private mapIsolationLevel;
exec(sql: string, txn?: string): Promise<[QueryResultRow[], QueryResult]>;
commitTransaction(txn: string): Promise<void>;
rollbackTransaction(txn: string): Promise<void>;
/**
*
*/
getPoolStatus(): {
total: number;
idle: number;
waiting: number;
};
}

147
lib/PostgreSQL/connector.js Normal file
View File

@ -0,0 +1,147 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PostgreSQLConnector = void 0;
const tslib_1 = require("tslib");
const pg_1 = require("pg");
const uuid_1 = require("uuid");
const assert_1 = tslib_1.__importDefault(require("assert"));
class PostgreSQLConnector {
pool;
configuration;
txnDict;
constructor(configuration) {
this.configuration = configuration;
this.txnDict = {};
this.pool = new pg_1.Pool(configuration);
// 错误处理
this.pool.on('error', (err) => {
console.error('PostgreSQL pool error:', err);
});
}
connect() {
// pg Pool 是惰性连接,不需要显式连接
// 但可以在这里进行连接测试
}
async disconnect() {
// 先获取所有事务ID的快照
const txnIds = Object.keys(this.txnDict);
for (const txnId of txnIds) {
try {
await this.rollbackTransaction(txnId);
}
catch (e) {
console.error(`Failed to rollback transaction ${txnId}:`, e);
}
}
await this.pool.end();
}
async startTransaction(option) {
const startInner = async () => {
const connection = await this.pool.connect();
// 添加:检测连接是否已被其他事务占用
for (const txn2 in this.txnDict) {
if (this.txnDict[txn2] === connection) {
return new Promise((resolve) => {
this.pool.on('release', () => resolve(startInner()));
});
}
}
try {
let beginStmt = 'BEGIN';
if (option?.isolationLevel) {
// PostgreSQL 隔离级别:
// READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE
// 注意: PostgreSQL 的 READ UNCOMMITTED 行为等同于 READ COMMITTED
const level = this.mapIsolationLevel(option.isolationLevel);
beginStmt = `BEGIN ISOLATION LEVEL ${level}`;
}
await connection.query(beginStmt);
const id = (0, uuid_1.v4)();
this.txnDict[id] = connection;
return id;
}
catch (error) {
// 如果启动事务失败,释放连接
connection.release();
throw error;
}
};
return startInner();
}
/**
* 映射隔离级别到 PostgreSQL 语法
*/
mapIsolationLevel(level) {
const levelUpper = level.toUpperCase().replace(/[-_]/g, ' '); // 同时处理 - 和 _
const validLevels = [
'READ UNCOMMITTED',
'READ COMMITTED',
'REPEATABLE READ',
'SERIALIZABLE'
];
if (validLevels.includes(levelUpper)) {
return levelUpper;
}
// 默认使用 READ COMMITTED
console.warn(`Unknown isolation level "${level}", using READ COMMITTED`);
return 'READ COMMITTED';
}
async exec(sql, txn) {
if (process.env.NODE_ENV === 'development') {
// console.log(`SQL: ${sql}; \n`);
}
try {
let result;
if (txn) {
const connection = this.txnDict[txn];
(0, assert_1.default)(connection, `Transaction ${txn} not found`);
result = await connection.query(sql);
}
else {
result = await this.pool.query(sql);
}
// 返回格式与 mysql2 兼容: [rows, fields/result]
return [result.rows, result];
}
catch (error) {
// 增强错误信息
const enhancedError = new Error(`PostgreSQL query failed: ${error.message}\nSQL: ${sql.slice(0, 500)}${sql.length > 500 ? '...' : ''}`);
enhancedError.originalError = error;
enhancedError.sql = sql;
throw enhancedError;
}
}
async commitTransaction(txn) {
const connection = this.txnDict[txn];
(0, assert_1.default)(connection, `Transaction ${txn} not found`);
try {
await connection.query('COMMIT');
}
finally {
delete this.txnDict[txn];
connection.release();
}
}
async rollbackTransaction(txn) {
const connection = this.txnDict[txn];
(0, assert_1.default)(connection, `Transaction ${txn} not found`);
try {
await connection.query('ROLLBACK');
}
finally {
delete this.txnDict[txn];
connection.release();
}
}
/**
* 获取连接池状态
*/
getPoolStatus() {
return {
total: this.pool.totalCount,
idle: this.pool.idleCount,
waiting: this.pool.waitingCount
};
}
}
exports.PostgreSQLConnector = PostgreSQLConnector;

50
lib/PostgreSQL/store.d.ts vendored Normal file
View File

@ -0,0 +1,50 @@
import { EntityDict, OperateOption, OperationResult, TxnOption, StorageSchema, SelectOption, AggregationResult } from 'oak-domain/lib/types';
import { EntityDict as BaseEntityDict } from 'oak-domain/lib/base-app-domain';
import { CascadeStore } from 'oak-domain/lib/store/CascadeStore';
import { PostgreSQLConfiguration } from './types/Configuration';
import { PostgreSQLConnector } from './connector';
import { PostgreSQLTranslator, PostgreSQLSelectOption, PostgreSQLOperateOption } from './translator';
import { AsyncContext } from 'oak-domain/lib/store/AsyncRowStore';
import { SyncContext } from 'oak-domain/lib/store/SyncRowStore';
import { CreateEntityOption } from '../types/Translator';
import { DbStore, Plan } from '../types/dbStore';
export declare class PostgreSQLStore<ED extends EntityDict & BaseEntityDict, Cxt extends AsyncContext<ED>> extends CascadeStore<ED> implements DbStore<ED, Cxt> {
protected countAbjointRow<T extends keyof ED, OP extends SelectOption, Cxt extends SyncContext<ED>>(entity: T, selection: Pick<ED[T]['Selection'], 'filter' | 'count'>, context: Cxt, option: OP): number;
protected aggregateAbjointRowSync<T extends keyof ED, OP extends SelectOption, Cxt extends SyncContext<ED>>(entity: T, aggregation: ED[T]['Aggregation'], context: Cxt, option: OP): AggregationResult<ED[T]['Schema']>;
protected selectAbjointRow<T extends keyof ED, OP extends SelectOption>(entity: T, selection: ED[T]['Selection'], context: SyncContext<ED>, option: OP): Partial<ED[T]['Schema']>[];
protected updateAbjointRow<T extends keyof ED, OP extends OperateOption>(entity: T, operation: ED[T]['Operation'], context: SyncContext<ED>, option: OP): number;
exec(script: string, txnId?: string): Promise<void>;
connector: PostgreSQLConnector;
translator: PostgreSQLTranslator<ED>;
constructor(storageSchema: StorageSchema<ED>, configuration: PostgreSQLConfiguration);
checkRelationAsync<T extends keyof ED, Cxt extends AsyncContext<ED>>(entity: T, operation: Omit<ED[T]['Operation'] | ED[T]['Selection'], 'id'>, context: Cxt): Promise<void>;
protected aggregateAbjointRowAsync<T extends keyof ED, OP extends SelectOption, Cxt extends AsyncContext<ED>>(entity: T, aggregation: ED[T]['Aggregation'], context: Cxt, option: OP): Promise<AggregationResult<ED[T]['Schema']>>;
aggregate<T extends keyof ED, OP extends SelectOption>(entity: T, aggregation: ED[T]['Aggregation'], context: Cxt, option: OP): Promise<AggregationResult<ED[T]['Schema']>>;
protected supportManyToOneJoin(): boolean;
protected supportMultipleCreate(): boolean;
private formResult;
protected selectAbjointRowAsync<T extends keyof ED>(entity: T, selection: ED[T]['Selection'], context: AsyncContext<ED>, option?: PostgreSQLSelectOption): Promise<Partial<ED[T]['Schema']>[]>;
protected updateAbjointRowAsync<T extends keyof ED>(entity: T, operation: ED[T]['Operation'], context: AsyncContext<ED>, option?: PostgreSQLOperateOption): Promise<number>;
operate<T extends keyof ED>(entity: T, operation: ED[T]['Operation'], context: Cxt, option: OperateOption): Promise<OperationResult<ED>>;
select<T extends keyof ED>(entity: T, selection: ED[T]['Selection'], context: Cxt, option: SelectOption): Promise<Partial<ED[T]['Schema']>[]>;
protected countAbjointRowAsync<T extends keyof ED>(entity: T, selection: Pick<ED[T]['Selection'], 'filter' | 'count'>, context: AsyncContext<ED>, option: SelectOption): Promise<number>;
count<T extends keyof ED>(entity: T, selection: Pick<ED[T]['Selection'], 'filter' | 'count'>, context: Cxt, option: SelectOption): Promise<number>;
begin(option?: TxnOption): Promise<string>;
commit(txnId: string): Promise<void>;
rollback(txnId: string): Promise<void>;
connect(): Promise<void>;
disconnect(): Promise<void>;
initialize(option: CreateEntityOption): Promise<void>;
readSchema(): Promise<StorageSchema<ED>>;
/**
* dataSchemaschemaupgrade
* plan分为两阶段
*/
makeUpgradePlan(): Promise<Plan>;
/**
* schema的不同new对old的增量
* @param schemaOld
* @param schemaNew
*/
diffSchema(schemaOld: StorageSchema<any>, schemaNew: StorageSchema<any>): Plan;
}

539
lib/PostgreSQL/store.js Normal file
View File

@ -0,0 +1,539 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PostgreSQLStore = void 0;
const tslib_1 = require("tslib");
const CascadeStore_1 = require("oak-domain/lib/store/CascadeStore");
const connector_1 = require("./connector");
const translator_1 = require("./translator");
const lodash_1 = require("lodash");
const assert_1 = tslib_1.__importDefault(require("assert"));
const relation_1 = require("oak-domain/lib/store/relation");
const ToNumberAttrs = new Set([
'$$seq$$',
'$$createAt$$',
'$$updateAt$$',
'$$deleteAt$$',
]);
function convertGeoTextToObject(geoText) {
if (geoText.startsWith('POINT')) {
const coord = geoText.match(/(-?\d+\.?\d*)/g);
(0, assert_1.default)(coord && coord.length === 2);
return {
type: 'point',
coordinate: coord.map(ele => parseFloat(ele)),
};
}
else if (geoText.startsWith('LINESTRING')) {
const coordsMatch = geoText.match(/\(([^)]+)\)/);
if (coordsMatch) {
const points = coordsMatch[1].split(',').map(p => {
const [x, y] = p.trim().split(/\s+/).map(parseFloat);
return [x, y];
});
return {
type: 'path',
coordinate: points,
};
}
}
else if (geoText.startsWith('POLYGON')) {
const ringsMatch = geoText.match(/\(\(([^)]+)\)\)/g);
if (ringsMatch) {
const rings = ringsMatch.map(ring => {
const coordStr = ring.replace(/[()]/g, '');
return coordStr.split(',').map(p => {
const [x, y] = p.trim().split(/\s+/).map(parseFloat);
return [x, y];
});
});
return {
type: 'polygon',
coordinate: rings,
};
}
}
throw new Error(`Unsupported geometry type: ${geoText.slice(0, 50)}`);
}
class PostgreSQLStore extends CascadeStore_1.CascadeStore {
countAbjointRow(entity, selection, context, option) {
throw new Error('PostgreSQL store 不支持同步取数据');
}
aggregateAbjointRowSync(entity, aggregation, context, option) {
throw new Error('PostgreSQL store 不支持同步取数据');
}
selectAbjointRow(entity, selection, context, option) {
throw new Error('PostgreSQL store 不支持同步取数据');
}
updateAbjointRow(entity, operation, context, option) {
throw new Error('PostgreSQL store 不支持同步更新数据');
}
async exec(script, txnId) {
await this.connector.exec(script, txnId);
}
connector;
translator;
constructor(storageSchema, configuration) {
super(storageSchema);
this.connector = new connector_1.PostgreSQLConnector(configuration);
this.translator = new translator_1.PostgreSQLTranslator(storageSchema);
}
checkRelationAsync(entity, operation, context) {
throw new Error('Method not implemented.');
}
async aggregateAbjointRowAsync(entity, aggregation, context, option) {
const sql = this.translator.translateAggregate(entity, aggregation, option);
const result = await this.connector.exec(sql, context.getCurrentTxnId());
return this.formResult(entity, result[0]);
}
aggregate(entity, aggregation, context, option) {
return this.aggregateAsync(entity, aggregation, context, option);
}
supportManyToOneJoin() {
return true;
}
supportMultipleCreate() {
return true;
}
formResult(entity, result) {
const schema = this.getSchema();
function resolveAttribute(entity2, r, attr, value) {
const { attributes, view } = schema[entity2];
if (!view) {
const i = attr.indexOf(".");
if (i !== -1) {
const attrHead = attr.slice(0, i);
const attrTail = attr.slice(i + 1);
const rel = (0, relation_1.judgeRelation)(schema, entity2, attrHead);
if (rel === 1) {
(0, lodash_1.set)(r, attr, value);
}
else {
if (!r[attrHead]) {
r[attrHead] = {};
}
if (rel === 0) {
resolveAttribute(entity2, r[attrHead], attrTail, value);
}
else if (rel === 2) {
resolveAttribute(attrHead, r[attrHead], attrTail, value);
}
else {
(0, assert_1.default)(typeof rel === 'string');
resolveAttribute(rel, r[attrHead], attrTail, value);
}
}
}
else if (attributes[attr]) {
const { type } = attributes[attr];
switch (type) {
case 'date':
case 'time':
case 'datetime': {
if (value instanceof Date) {
r[attr] = value.valueOf();
}
else {
if (typeof value === 'string') {
r[attr] = parseInt(value, 10);
}
else {
(0, assert_1.default)(typeof value === 'number' || value === null);
r[attr] = value;
}
}
break;
}
case 'geometry': {
if (typeof value === 'string') {
r[attr] = convertGeoTextToObject(value);
}
else {
r[attr] = value;
}
break;
}
case 'object':
case 'array': {
// PostgreSQL jsonb 直接返回对象,不需要 parse
if (typeof value === 'string') {
r[attr] = JSON.parse(value);
}
else {
r[attr] = value;
}
break;
}
case 'function': {
if (typeof value === 'string') {
r[attr] = `return ${Buffer.from(value, 'base64').toString()}`;
}
else {
r[attr] = value;
}
break;
}
case 'bool':
case 'boolean': {
// PostgreSQL 直接返回 boolean 类型
r[attr] = value;
break;
}
case 'decimal': {
// PostgreSQL numeric 类型可能返回字符串
if (typeof value === 'string') {
r[attr] = parseFloat(value);
}
else {
(0, assert_1.default)(value === null || typeof value === 'number');
r[attr] = value;
}
break;
}
// TODO: 这里和mysql统一行为ref类型的字符串去除前后空格
case "char":
case "ref": {
if (value) {
(0, assert_1.default)(typeof value === 'string');
r[attr] = value.trim();
}
else {
r[attr] = value;
}
break;
}
default: {
r[attr] = value;
}
}
}
else {
// TODO: 这里和mysql统一行为id字段为char类型时去除后面的空格
if (value && typeof value === 'string') {
if (attr === 'id') {
r[attr] = value.trim();
}
else if (attr.startsWith("#count")) {
// PostgreSQL count 返回字符串
r[attr] = parseInt(value, 10);
}
else if (attr.startsWith("#sum") || attr.startsWith("#avg") || attr.startsWith("#min") || attr.startsWith("#max")) {
// PostgreSQL sum/avg/min/max 返回字符串
r[attr] = parseFloat(value);
}
else if (ToNumberAttrs.has(attr)) {
// PostgreSQL sum/avg/min/max 返回字符串
r[attr] = parseInt(value, 10);
}
else {
r[attr] = value;
}
}
else {
r[attr] = value;
}
}
}
else {
(0, lodash_1.assign)(r, { [attr]: value });
}
}
function removeNullObjects(r, e) {
for (let attr in r) {
const rel = (0, relation_1.judgeRelation)(schema, e, attr);
if (rel === 2) {
if (r[attr].id === null) {
(0, assert_1.default)(schema[e].toModi || r.entity !== attr);
delete r[attr];
continue;
}
removeNullObjects(r[attr], attr);
}
else if (typeof rel === 'string') {
if (r[attr].id === null) {
(0, assert_1.default)(schema[e].toModi || r[`${attr}Id`] === null, `对象${String(e)}取数据时发现其外键找不到目标对象rowId是${r.id},其外键${attr}Id值为${r[`${attr}Id`]}`);
delete r[attr];
continue;
}
removeNullObjects(r[attr], rel);
}
}
}
function formSingleRow(r) {
let result2 = {};
for (let attr in r) {
const value = r[attr];
resolveAttribute(entity, result2, attr, value);
}
removeNullObjects(result2, entity);
return result2;
}
if (result instanceof Array) {
return result.map(r => formSingleRow(r));
}
return formSingleRow(result);
}
async selectAbjointRowAsync(entity, selection, context, option) {
const sql = this.translator.translateSelect(entity, selection, option);
const result = await this.connector.exec(sql, context.getCurrentTxnId());
return this.formResult(entity, result[0]);
}
async updateAbjointRowAsync(entity, operation, context, option) {
const { translator, connector } = this;
const { action } = operation;
const txn = context.getCurrentTxnId();
switch (action) {
case 'create': {
const { data } = operation;
const sql = translator.translateInsert(entity, data instanceof Array ? data : [data]);
const result = await connector.exec(sql, txn);
// PostgreSQL QueryResult.rowCount
return result[1].rowCount || 0;
}
case 'remove': {
const sql = translator.translateRemove(entity, operation, option);
const result = await connector.exec(sql, txn);
return result[1].rowCount || 0;
}
default: {
(0, assert_1.default)(!['select', 'download', 'stat'].includes(action));
const sql = translator.translateUpdate(entity, operation, option);
const result = await connector.exec(sql, txn);
return result[1].rowCount || 0;
}
}
}
async operate(entity, operation, context, option) {
const { action } = operation;
(0, assert_1.default)(!['select', 'download', 'stat'].includes(action), '不支持使用 select operation');
return await super.operateAsync(entity, operation, context, option);
}
async select(entity, selection, context, option) {
return await super.selectAsync(entity, selection, context, option);
}
async countAbjointRowAsync(entity, selection, context, option) {
const sql = this.translator.translateCount(entity, selection, option);
const result = await this.connector.exec(sql, context.getCurrentTxnId());
// PostgreSQL 返回的 count 是 string 类型bigint
const cnt = result[0][0]?.cnt;
return typeof cnt === 'string' ? parseInt(cnt, 10) : (cnt || 0);
}
async count(entity, selection, context, option) {
return this.countAsync(entity, selection, context, option);
}
async begin(option) {
return await this.connector.startTransaction(option);
}
async commit(txnId) {
await this.connector.commitTransaction(txnId);
}
async rollback(txnId) {
await this.connector.rollbackTransaction(txnId);
}
async connect() {
await this.connector.connect();
}
async disconnect() {
await this.connector.disconnect();
}
async initialize(option) {
const schema = this.getSchema();
// ===== 第一阶段:事务外创建扩展 =====
let hasGeoType = false;
let hasChineseTsConfig = false;
let chineseParser = null;
// 扫描 schema
for (const entity in schema) {
const { attributes, indexes } = schema[entity];
for (const attr in attributes) {
if (attributes[attr].type === 'geometry') {
hasGeoType = true;
}
}
for (const index of indexes || []) {
if (index.config?.tsConfig === 'chinese' || index.config?.tsConfig?.includes('chinese')) {
hasChineseTsConfig = true;
}
if (index.config?.chineseParser) {
(0, assert_1.default)(!chineseParser || chineseParser === index.config.chineseParser, '当前定义了多个中文分词器,请保持一致');
chineseParser = index.config.chineseParser;
}
}
}
// 在事务外创建扩展
if (hasGeoType) {
console.log('Initializing PostGIS extension for geometry support...');
await this.connector.exec('CREATE EXTENSION IF NOT EXISTS postgis;');
}
if (hasChineseTsConfig) {
console.log('Initializing Chinese parser extension...');
await this.connector.exec(`CREATE EXTENSION IF NOT EXISTS ${chineseParser || 'zhparser'};`);
}
// ===== 第二阶段:事务内创建配置和表 =====
const txn = await this.connector.startTransaction({
isolationLevel: 'serializable',
});
try {
// 创建中文文本搜索配置
if (hasChineseTsConfig) {
console.log('Initializing Chinese text search configuration...');
const checkChineseConfigSql = `
SELECT COUNT(*) as cnt
FROM pg_catalog.pg_ts_config
WHERE cfgname = 'chinese';
`;
const result = await this.connector.exec(checkChineseConfigSql, txn);
const count = parseInt(result[0][0]?.cnt || '0', 10);
if (count === 0) {
const createChineseConfigSql = `
CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = ${chineseParser || 'zhparser'});
ALTER TEXT SEARCH CONFIGURATION chinese ADD MAPPING FOR n,v,a,i,e,l WITH simple;
`;
await this.connector.exec(createChineseConfigSql, txn);
}
}
// 创建实体表
for (const entity in schema) {
const sqls = this.translator.translateCreateEntity(entity, option);
for (const sql of sqls) {
await this.connector.exec(sql, txn);
}
}
await this.connector.commitTransaction(txn);
}
catch (error) {
await this.connector.rollbackTransaction(txn);
throw error;
}
}
// 从数据库中读取当前schema
readSchema() {
return this.translator.readSchema((sql) => this.connector.exec(sql));
}
/**
* 根据载入的dataSchema和数据库中原来的schema决定如何来upgrade
* 制订出来的plan分为两阶段增加阶段和削减阶段在两个阶段之间由用户来修正数据
*/
async makeUpgradePlan() {
const originSchema = await this.readSchema();
const plan = this.diffSchema(originSchema, this.translator.schema);
return plan;
}
/**
* 比较两个schema的不同这里计算的是new对old的增量
* @param schemaOld
* @param schemaNew
*/
diffSchema(schemaOld, schemaNew) {
const plan = {
newTables: {},
newIndexes: {},
updatedIndexes: {},
updatedTables: {},
};
for (const table in schemaNew) {
// PostgreSQL 表名区分大小写(使用双引号时)
if (schemaOld[table]) {
const { attributes, indexes } = schemaOld[table];
const { attributes: attributesNew, indexes: indexesNew } = schemaNew[table];
const assignToUpdateTables = (attr, isNew) => {
const skipAttrs = ['$$seq$$', '$$createAt$$', '$$updateAt$$', '$$deleteAt$$', 'id'];
if (skipAttrs.includes(attr)) {
return;
}
if (!plan.updatedTables[table]) {
plan.updatedTables[table] = {
attributes: {
[attr]: {
...attributesNew[attr],
isNew,
}
}
};
}
else {
plan.updatedTables[table].attributes[attr] = {
...attributesNew[attr],
isNew,
};
}
};
for (const attr in attributesNew) {
if (attributes[attr]) {
// 比较两次创建的属性定义是否一致
const sql1 = this.translator.translateAttributeDef(attr, attributesNew[attr]);
const sql2 = this.translator.translateAttributeDef(attr, attributes[attr]);
if (!this.translator.compareSql(sql1, sql2)) {
assignToUpdateTables(attr, false);
}
}
else {
assignToUpdateTables(attr, true);
}
}
if (indexesNew) {
const assignToIndexes = (index, isNew) => {
if (isNew) {
if (plan.newIndexes[table]) {
plan.newIndexes[table].push(index);
}
else {
plan.newIndexes[table] = [index];
}
}
else {
if (plan.updatedIndexes[table]) {
plan.updatedIndexes[table].push(index);
}
else {
plan.updatedIndexes[table] = [index];
}
}
};
const compareConfig = (config1, config2) => {
const unique1 = config1?.unique || false;
const unique2 = config2?.unique || false;
if (unique1 !== unique2) {
return false;
}
const type1 = config1?.type || 'btree';
const type2 = config2?.type || 'btree';
// tsConfig 比较
const tsConfig1 = config1?.tsConfig;
const tsConfig2 = config2?.tsConfig;
if (JSON.stringify(tsConfig1) !== JSON.stringify(tsConfig2)) {
return false;
}
return type1 === type2;
};
for (const index of indexesNew) {
const { name, config, attributes: indexAttrs } = index;
const origin = indexes?.find(ele => ele.name === name);
if (origin) {
if (JSON.stringify(indexAttrs) !== JSON.stringify(origin.attributes)) {
assignToIndexes(index, false);
}
else {
if (!compareConfig(config, origin.config)) {
assignToIndexes(index, false);
}
}
}
else {
assignToIndexes(index, true);
}
}
}
}
else {
plan.newTables[table] = {
attributes: schemaNew[table].attributes,
};
if (schemaNew[table].indexes) {
plan.newIndexes[table] = schemaNew[table].indexes;
}
}
}
return plan;
}
}
exports.PostgreSQLStore = PostgreSQLStore;

103
lib/PostgreSQL/translator.d.ts vendored Normal file
View File

@ -0,0 +1,103 @@
import { EntityDict, Q_FullTextValue, RefOrExpression, Ref, StorageSchema, Attribute } from "oak-domain/lib/types";
import { EntityDict as BaseEntityDict } from 'oak-domain/lib/base-app-domain';
import { DataType } from "oak-domain/lib/types/schema/DataTypes";
import { SqlOperateOption, SqlSelectOption, SqlTranslator } from "../sqlTranslator";
import { CreateEntityOption } from '../types/Translator';
export interface PostgreSQLSelectOption extends SqlSelectOption {
}
export interface PostgreSQLOperateOption extends SqlOperateOption {
}
export declare class PostgreSQLTranslator<ED extends EntityDict & BaseEntityDict> extends SqlTranslator<ED> {
private getEnumTypeName;
/**
* MySQL JSON PostgreSQL
* : ".foo.bar[0].baz" -> '{foo,bar,0,baz}'
*/
private convertJsonPath;
/**
* PostgreSQL JSON 访
* @param column
* @param path JSON
* @param asText 使 #>> #>
*/
private buildJsonAccessor;
protected getDefaultSelectFilter(alias: string, option?: PostgreSQLSelectOption): string;
private makeUpSchema;
constructor(schema: StorageSchema<ED>);
static supportedDataTypes: DataType[];
static spatialTypes: DataType[];
static withLengthDataTypes: DataType[];
static withPrecisionDataTypes: DataType[];
static withScaleDataTypes: DataType[];
static dataTypeDefaults: Record<string, any>;
maxAliasLength: number;
private populateDataTypeDef;
/**
* PostgreSQL
* SQL注入
*/
escapeStringValue(value: string): string;
/**
* LIKE
* LIKE
*/
escapeLikePattern(value: string): string;
/**
* PostgreSQL
*
*/
escapeIdentifier(identifier: string): string;
/**
* tsquery
*/
escapeTsQueryValue(value: string): string;
quoteIdentifier(identifier: string): string;
protected translateAttrProjection(dataType: DataType, alias: string, attr: string): string;
protected translateObjectPredicate(predicate: Record<string, any>, alias: string, attr: string): string;
protected translateObjectProjection(projection: Record<string, any>, alias: string, attr: string, prefix: string): string;
protected translateAttrValue(dataType: DataType | Ref, value: any): string;
protected translateFullTextSearch<T extends keyof ED>(value: Q_FullTextValue, entity: T, alias: string): string;
translateCreateEntity<T extends keyof ED>(entity: T, options?: CreateEntityOption): string[];
private translateFnName;
private translateAttrInExpression;
protected translateExpression<T extends keyof ED>(entity: T, alias: string, expression: RefOrExpression<keyof ED[T]["OpSchema"]>, refDict: Record<string, [string, keyof ED]>): string;
protected populateSelectStmt<T extends keyof ED>(projectionText: string, fromText: string, aliasDict: Record<string, string>, filterText: string, sorterText?: string, groupByText?: string, indexFrom?: number, count?: number, option?: PostgreSQLSelectOption): string;
translateUpdate<T extends keyof ED, OP extends SqlOperateOption>(entity: T, operation: ED[T]['Update'], option?: OP): string;
translateRemove<T extends keyof ED, OP extends SqlOperateOption>(entity: T, operation: ED[T]['Remove'], option?: OP): string;
/**
* PostgreSQL专用的结构化JOIN分析
* JOIN信息FROM字符串
*/
private analyzeJoinStructured;
/**
* JOIN条件UPDATE/DELETE的WHERE子句
*/
private buildJoinConditions;
/**
* PostgreSQL UPSERT
* INSERT ... ON CONFLICT (key) DO UPDATE SET ...
*/
translateUpsert<T extends keyof ED>(entity: T, data: ED[T]['CreateMulti']['data'], conflictKeys: string[], updateAttrs?: string[]): string;
protected populateUpdateStmt(updateText: string, fromText: string, aliasDict: Record<string, string>, filterText: string, sorterText?: string, indexFrom?: number, count?: number, option?: PostgreSQLOperateOption): string;
protected populateRemoveStmt(updateText: string, fromText: string, aliasDict: Record<string, string>, filterText: string, sorterText?: string, indexFrom?: number, count?: number, option?: PostgreSQLOperateOption): string;
/**
* PostgreSQL Type oak populateDataTypeDef
* @param type PostgreSQL
*/
private reTranslateToAttribute;
/**
* PostgreSQL schema
*/
readSchema(execFn: (sql: string) => Promise<any>): Promise<StorageSchema<ED>>;
/**
* PostgreSQL DDL
* @param attr
* @param attrDef
*/
translateAttributeDef(attr: string, attrDef: Attribute): string;
/**
* SQL schema diff
*
*/
compareSql(sql1: string, sql2: string): boolean;
}

2159
lib/PostgreSQL/translator.js Normal file

File diff suppressed because it is too large Load Diff

13
lib/PostgreSQL/types/Configuration.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
export type PostgreSQLConfiguration = {
host: string;
user: string;
password: string;
database: string;
port?: number;
max?: number;
idleTimeoutMillis?: number;
connectionTimeoutMillis?: number;
};
export type Configuration = {
postgresql: PostgreSQLConfiguration;
};

View File

@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

2
lib/index.d.ts vendored
View File

@ -1,2 +1,4 @@
export * from './MySQL/store';
export { MySqlSelectOption, MysqlOperateOption } from './MySQL/translator';
export * from './PostgreSQL/store';
export { PostgreSQLSelectOption, PostgreSQLOperateOption } from './PostgreSQL/translator';

View File

@ -2,3 +2,4 @@
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
tslib_1.__exportStar(require("./MySQL/store"), exports);
tslib_1.__exportStar(require("./PostgreSQL/store"), exports);

View File

@ -21,7 +21,7 @@ export declare abstract class SqlTranslator<ED extends EntityDict & BaseEntityDi
protected abstract populateUpdateStmt<OP extends SqlOperateOption>(updateText: string, fromText: string, aliasDict: Record<string, string>, filterText: string, sorterText?: string, indexFrom?: number, count?: number, option?: OP): string;
protected abstract populateRemoveStmt<OP extends SqlOperateOption>(updateText: string, fromText: string, aliasDict: Record<string, string>, filterText: string, sorterText?: string, indexFrom?: number, count?: number, option?: OP): string;
protected abstract translateExpression<T extends keyof ED>(entity: T, alias: string, expression: RefOrExpression<keyof ED[T]['OpSchema']>, refDict: Record<string, [string, keyof ED]>): string;
private getStorageName;
protected getStorageName<T extends keyof ED>(entity: T): string;
translateInsert<T extends keyof ED>(entity: T, data: ED[T]['CreateMulti']['data']): string;
/**
* analyze the join relations in projection/query/sort
@ -37,10 +37,20 @@ export declare abstract class SqlTranslator<ED extends EntityDict & BaseEntityDi
* @param param0
*/
private analyzeJoin;
/**
* like模式中的特殊字符进行转义
* % _
* @param pattern like模式字符串
* @returns
*/
escapeLikePattern(pattern: string): string;
private translateComparison;
private translateEvaluation;
protected translatePredicate(predicate: string, value: any, type?: DataType | Ref): string;
private translateFilter;
protected translateFilter<T extends keyof ED, OP extends SqlSelectOption>(entity: T, filter: ED[T]['Selection']['filter'], aliasDict: Record<string, string>, filterRefAlias: Record<string, [string, keyof ED]>, initialNumber: number, option?: OP): {
stmt: string;
currentNumber: number;
};
private translateSorter;
private translateProjection;
private translateSelectInner;
@ -52,4 +62,7 @@ export declare abstract class SqlTranslator<ED extends EntityDict & BaseEntityDi
translateUpdate<T extends keyof ED, OP extends SqlOperateOption>(entity: T, operation: ED[T]['Update'], option?: OP): string;
translateDestroyEntity(entity: string, truncate?: boolean): string;
escapeStringValue(value: string): string;
/**比较两段sql是否完全一致这里是把所有的空格去掉了 */
compareSql(sql1: string, sql2: string): boolean;
quoteIdentifier(name: string): string;
}

View File

@ -21,7 +21,7 @@ class SqlTranslator {
const { attributes, indexes } = schema[entity];
// 增加默认的属性
(0, lodash_1.assign)(attributes, {
id: {
[types_1.PrimaryKeyAttribute]: {
type: 'char',
params: {
length: 36,
@ -69,9 +69,11 @@ class SqlTranslator {
name: types_1.DeleteAtAttribute,
}],
}, {
name: `${entity}_trigger_uuid`,
name: `${entity}_trigger_uuid_auto_create`,
attributes: [{
name: types_1.TriggerUuidAttribute,
}, {
name: types_1.DeleteAtAttribute,
}]
},
];
@ -84,7 +86,7 @@ class SqlTranslator {
attributes: [{
name: attr,
}, {
name: '$$deleteAt$$',
name: types_1.DeleteAtAttribute,
}]
});
}
@ -100,7 +102,7 @@ class SqlTranslator {
}, {
name: 'entityId',
}, {
name: '$$deleteAt$$',
name: types_1.DeleteAtAttribute,
}]
});
}
@ -113,7 +115,7 @@ class SqlTranslator {
attributes: [{
name: attr,
}, {
name: '$$deleteAt$$',
name: types_1.DeleteAtAttribute,
}]
});
}
@ -129,7 +131,7 @@ class SqlTranslator {
}, {
name: 'expiresAt',
}, {
name: '$$deleteAt$$',
name: types_1.DeleteAtAttribute,
}]
});
}
@ -137,6 +139,16 @@ class SqlTranslator {
}
}
if (indexes) {
for (const index of indexes) {
const { attributes, config } = index;
if (!config?.type || config.type === 'btree') {
if (!attributes.find((ele) => ele.name === types_1.DeleteAtAttribute)) {
attributes.push({
name: types_1.DeleteAtAttribute,
});
}
}
}
indexes.push(...intrinsticIndexes);
}
else {
@ -154,14 +166,14 @@ class SqlTranslator {
translateInsert(entity, data) {
const { schema } = this;
const { attributes, storageName = entity } = schema[entity];
let sql = `insert into \`${storageName}\`(`;
let sql = `insert into ${this.quoteIdentifier(storageName)}(`;
/**
* 这里的attrs要用所有行的union集合
*/
const dataFull = data.reduce((prev, cur) => Object.assign({}, cur, prev), {});
const attrs = Object.keys(dataFull).filter(ele => attributes.hasOwnProperty(ele));
attrs.forEach((attr, idx) => {
sql += ` \`${attr}\``;
sql += ` ${this.quoteIdentifier(attr)}`;
if (idx < attrs.length - 1) {
sql += ',';
}
@ -209,7 +221,7 @@ class SqlTranslator {
const projectionRefAlias = {};
const filterRefAlias = {};
const alias = `${entity}_${number++}`;
let from = ` \`${this.getStorageName(entity)}\` \`${alias}\` `;
let from = ` ${this.quoteIdentifier(this.getStorageName(entity))} ${this.quoteIdentifier(alias)} `;
const aliasDict = {
'./': alias,
};
@ -243,7 +255,7 @@ class SqlTranslator {
(0, lodash_1.assign)(aliasDict, {
[pathAttr]: alias2,
});
from += ` left join \`${this.getStorageName(rel)}\` \`${alias2}\` on \`${alias}\`.\`${op}Id\` = \`${alias2}\`.\`id\``;
from += ` left join ${this.quoteIdentifier(this.getStorageName(rel))} ${this.quoteIdentifier(alias2)} on ${this.quoteIdentifier(alias)}.${this.quoteIdentifier(op + 'Id')} = ${this.quoteIdentifier(alias2)}.${this.quoteIdentifier('id')}`;
}
else {
alias2 = aliasDict[pathAttr];
@ -263,7 +275,7 @@ class SqlTranslator {
(0, lodash_1.assign)(aliasDict, {
[pathAttr]: alias2,
});
from += ` left join \`${this.getStorageName(op)}\` \`${alias2}\` on \`${alias}\`.\`entityId\` = \`${alias2}\`.\`id\` and \`${alias}\`.\`entity\` = '${op}'`;
from += ` left join ${this.quoteIdentifier(this.getStorageName(op))} ${this.quoteIdentifier(alias2)} on ${this.quoteIdentifier(alias)}.${this.quoteIdentifier('entityId')} = ${this.quoteIdentifier(alias2)}.${this.quoteIdentifier('id')} and ${this.quoteIdentifier(alias)}.${this.quoteIdentifier('entity')} = '${op}'`;
}
else {
alias2 = aliasDict[pathAttr];
@ -307,7 +319,7 @@ class SqlTranslator {
(0, lodash_1.assign)(aliasDict, {
[pathAttr]: alias2,
});
from += ` left join \`${this.getStorageName(rel)}\` \`${alias2}\` on \`${alias}\`.\`${attr}Id\` = \`${alias2}\`.\`id\``;
from += ` left join ${this.quoteIdentifier(this.getStorageName(rel))} ${this.quoteIdentifier(alias2)} on ${this.quoteIdentifier(alias)}.${this.quoteIdentifier(attr + 'Id')} = ${this.quoteIdentifier(alias2)}.${this.quoteIdentifier('id')}`;
}
else {
alias2 = aliasDict[pathAttr];
@ -327,7 +339,7 @@ class SqlTranslator {
(0, lodash_1.assign)(aliasDict, {
[pathAttr]: alias2,
});
from += ` left join \`${this.getStorageName(attr)}\` \`${alias2}\` on \`${alias}\`.\`entityId\` = \`${alias2}\`.\`id\` and \`${alias}\`.\`entity\` = '${attr}'`;
from += ` left join ${this.quoteIdentifier(this.getStorageName(attr))} ${this.quoteIdentifier(alias2)} on ${this.quoteIdentifier(alias)}.${this.quoteIdentifier('entityId')} = ${this.quoteIdentifier(alias2)}.${this.quoteIdentifier('id')} and ${this.quoteIdentifier(alias)}.${this.quoteIdentifier('entity')} = '${attr}'`;
}
else {
alias2 = aliasDict[pathAttr];
@ -365,7 +377,7 @@ class SqlTranslator {
(0, lodash_1.assign)(aliasDict, {
[pathAttr]: alias2,
});
from += ` left join \`${this.getStorageName(rel)}\` \`${alias2}\` on \`${alias}\`.\`${attr}Id\` = \`${alias2}\`.\`id\``;
from += ` left join ${this.quoteIdentifier(this.getStorageName(rel))} ${this.quoteIdentifier(alias2)} on ${this.quoteIdentifier(alias)}.${this.quoteIdentifier(attr + 'Id')} = ${this.quoteIdentifier(alias2)}.${this.quoteIdentifier('id')}`;
}
else {
alias2 = aliasDict[pathAttr];
@ -385,7 +397,7 @@ class SqlTranslator {
(0, lodash_1.assign)(aliasDict, {
[pathAttr]: alias2,
});
from += ` left join \`${this.getStorageName(attr)}\` \`${alias2}\` on \`${alias}\`.\`entityId\` = \`${alias2}\`.\`id\` and \`${alias}\`.\`entity\` = '${attr}'`;
from += ` left join ${this.quoteIdentifier(this.getStorageName(attr))} ${this.quoteIdentifier(alias2)} on ${this.quoteIdentifier(alias)}.${this.quoteIdentifier('entityId')} = ${this.quoteIdentifier(alias2)}.${this.quoteIdentifier('id')} and ${this.quoteIdentifier(alias)}.${this.quoteIdentifier('entity')} = '${attr}'`;
}
else {
alias2 = aliasDict[pathAttr];
@ -426,6 +438,15 @@ class SqlTranslator {
currentNumber: number,
};
}
/**
* 对like模式中的特殊字符进行转义
* 例如 % _以防止被当成通配符处理
* @param pattern like模式字符串
* @returns 转义后的字符串
*/
escapeLikePattern(pattern) {
return pattern.replace(/[%_]/g, (match) => `\\${match}`);
}
translateComparison(attr, value, type) {
const SQL_OP = {
$gt: '>',
@ -445,16 +466,19 @@ class SqlTranslator {
}
switch (attr) {
case '$startsWith': {
return ` like '${value}%'`;
const escaped = this.escapeLikePattern(value);
return ` LIKE '${escaped}%'`;
}
case '$endsWith': {
return ` like '%${value}'`;
const escaped = this.escapeLikePattern(value);
return ` LIKE '%${escaped}'`;
}
case '$includes': {
return ` like '%${value}%'`;
const escaped = this.escapeLikePattern(value);
return ` LIKE '%${escaped}%'`;
}
default: {
throw new Error(`unrecoganized comparison operator ${attr}`);
throw new Error(`unrecognized comparison operator ${attr}`);
}
}
}
@ -499,7 +523,7 @@ class SqlTranslator {
};
const values = value.map((v) => {
if (type && ['varchar', 'char', 'text', 'nvarchar', 'ref', 'enum'].includes(type) || typeof v === 'string') {
return `'${v}'`;
return this.escapeStringValue(String(v));
}
else {
return `${v}`;
@ -516,7 +540,7 @@ class SqlTranslator {
else if (predicate === '$between') {
const values = value.map((v) => {
if (type && ['varchar', 'char', 'text', 'nvarchar', 'ref', 'enum'].includes(type) || typeof v === 'string') {
return `'${v}'`;
return this.escapeStringValue(String(v));
}
else {
return `${v}`;
@ -626,7 +650,7 @@ class SqlTranslator {
filter: filter2[attr]
}, currentNumber, filterRefAlias, option);
currentNumber = ct2;
whereText += `(${refAlia2}.id ${predicate} (${stmt}))`;
whereText += `(${this.quoteIdentifier(alias)}.${this.quoteIdentifier('id')} ${predicate} (${stmt}))`;
}
else {
/**
@ -682,15 +706,18 @@ class SqlTranslator {
whereText += `(${this.translateObjectPredicate(filter2[attr], alias, attr)})`;
}
else {
if (!filter2[attr]) {
throw new Error(`属性${attr}的查询条件不能为null或undefined`);
}
(0, assert_1.default)(Object.keys(filter2[attr]).length === 1);
const predicate = Object.keys(filter2[attr])[0];
(0, assert_1.default)(predicate.startsWith('$'));
// 对属性上的谓词处理
whereText += ` (\`${alias}\`.\`${attr}\` ${this.translatePredicate(predicate, filter2[attr][predicate], type2)})`;
whereText += ` (${this.quoteIdentifier(alias)}.${this.quoteIdentifier(attr)} ${this.translatePredicate(predicate, filter2[attr][predicate], type2)})`;
}
}
else {
whereText += ` (\`${alias}\`.\`${attr}\` = ${this.translateAttrValue(type2, filter2[attr])})`;
whereText += ` (${this.quoteIdentifier(alias)}.${this.quoteIdentifier(attr)} = ${this.translateAttrValue(type2, filter2[attr])})`;
}
}
}
@ -716,7 +743,7 @@ class SqlTranslator {
return this.translateExpression(entity2, alias, sortAttr[attr], {});
}
else if (sortAttr[attr] === 1) {
return `\`${alias}\`.\`${attr}\``;
return `${this.quoteIdentifier(alias)}.${this.quoteIdentifier(attr)}`;
}
else {
const rel = (0, relation_1.judgeRelation)(this.schema, entity2, attr);
@ -765,12 +792,12 @@ class SqlTranslator {
projText += ` ${exprText}`;
}
else {
projText += ` ${exprText} as \`${prefix2}${attr}\``;
projText += ` ${exprText} as ${this.quoteIdentifier(prefix2 + attr)}`;
if (!as) {
as = `\`${prefix2}${attr}\``;
as = this.quoteIdentifier(prefix2 + attr);
}
else {
as += `, \`${prefix2}${attr}\``;
as += `, ${this.quoteIdentifier(prefix2 + attr)}`;
}
}
}
@ -789,12 +816,12 @@ class SqlTranslator {
projText += ` ${this.translateAttrProjection(type, alias, attr)}`;
}
else {
projText += ` ${this.translateAttrProjection(type, alias, attr)} as \`${prefix2}${attr}\``;
projText += ` ${this.translateAttrProjection(type, alias, attr)} as ${this.quoteIdentifier(prefix2 + attr)}`;
if (!as) {
as = `\`${prefix2}${attr}\``;
as = this.quoteIdentifier(prefix2 + attr);
}
else {
as += `, \`${prefix2}${attr}\``;
as += `, ${this.quoteIdentifier(prefix2 + attr)}`;
}
}
}
@ -810,12 +837,12 @@ class SqlTranslator {
projText += ` ${this.translateAttrProjection(type, alias, attr)}`;
}
else {
projText += ` ${this.translateAttrProjection(type, alias, attr)} as \`${prefix2}${projection2[attr]}\``;
projText += ` ${this.translateAttrProjection(type, alias, attr)} as ${this.quoteIdentifier(`${prefix2}${projection2[attr]}`)}`;
if (!as) {
as = `\`${prefix2}${projection2[attr]}\``;
as = this.quoteIdentifier(`${prefix2}${projection2[attr]}`);
}
else {
as += `\`${prefix2}${projection2[attr]}\``;
as += `, ${this.quoteIdentifier(`${prefix2}${projection2[attr]}`)}`;
}
}
}
@ -885,29 +912,29 @@ class SqlTranslator {
let { projText: projSubText } = this.translateProjection(entity, data[k], aliasDict, projectionRefAlias, undefined, true);
let projSubText2 = '';
if (k.startsWith('#max')) {
projSubText2 = `max(${projSubText}) as \`${k}\``;
projSubText2 = `max(${projSubText}) as ${this.quoteIdentifier(k)}`;
}
else if (k.startsWith('#min')) {
projSubText2 = `min(${projSubText}) as \`${k}\``;
projSubText2 = `min(${projSubText}) as ${this.quoteIdentifier(k)}`;
}
else if (k.startsWith('#count')) {
if (data.distinct) {
projSubText = `distinct ${projSubText}`;
}
projSubText2 = `count(${projSubText}) as \`${k}\``;
projSubText2 = `count(${projSubText}) as ${this.quoteIdentifier(k)}`;
}
else if (k.startsWith('#sum')) {
if (data.distinct) {
projSubText = `distinct ${projSubText}`;
}
projSubText2 = `sum(${projSubText}) as \`${k}\``;
projSubText2 = `sum(${projSubText}) as ${this.quoteIdentifier(k)}`;
}
else {
if (data.distinct) {
projSubText = `distinct ${projSubText}`;
}
(0, assert_1.default)(k.startsWith('#avg'));
projSubText2 = `avg(${projSubText}) as \`${k}\``;
projSubText2 = `avg(${projSubText}) as ${this.quoteIdentifier(k)}`;
}
if (!projText) {
projText = projSubText2;
@ -955,7 +982,7 @@ class SqlTranslator {
// delete只支持对volatile trigger的metadata域赋值
(0, assert_1.default)([types_1.TriggerDataAttribute, types_1.TriggerUuidAttribute, types_1.DeleteAtAttribute, types_1.UpdateAtAttribute].includes(attr));
const value = this.translateAttrValue(attributes[attr].type, data[attr]);
updateText += `\`${alias}\`.\`${attr}\` = ${value}`;
updateText += `${this.quoteIdentifier(alias)}.${this.quoteIdentifier(attr)} = ${value}`;
}
}
return this.populateRemoveStmt(updateText, fromText, aliasDict, filterText, /* sorterText */ undefined, indexFrom, count, option);
@ -973,7 +1000,7 @@ class SqlTranslator {
}
(0, assert_1.default)(attributes.hasOwnProperty(attr));
const value = this.translateAttrValue(attributes[attr].type, data[attr]);
updateText += `\`${alias}\`.\`${attr}\` = ${value}`;
updateText += `${this.quoteIdentifier(alias)}.${this.quoteIdentifier(attr)} = ${value}`;
}
const { stmt: filterText } = this.translateFilter(entity, filter, aliasDict, filterRefAlias, currentNumber, option);
// const sorterText = sorter && this.translateSorter(entity, sorter, aliasDict);
@ -984,10 +1011,10 @@ class SqlTranslator {
const { storageName = entity, view } = schema[entity];
let sql;
if (view) {
sql = `drop view if exists \`${storageName}\``;
sql = `drop view if exists ${this.quoteIdentifier(storageName)}`;
}
else {
sql = truncate ? `truncate table \`${storageName}\`` : `drop table if exists \`${storageName}\``;
sql = truncate ? `truncate table ${this.quoteIdentifier(storageName)}` : `drop table if exists ${this.quoteIdentifier(storageName)}`;
}
return sql;
}
@ -995,5 +1022,13 @@ class SqlTranslator {
const result = sqlstring_1.default.escape(value);
return result;
}
/**比较两段sql是否完全一致这里是把所有的空格去掉了 */
compareSql(sql1, sql2) {
const reg = /[\t\r\f\n\s]/g;
return sql1.replaceAll(reg, '') === sql2.replaceAll(reg, '');
}
quoteIdentifier(name) {
return `\`${name}\``; // MySQL 默认
}
}
exports.SqlTranslator = SqlTranslator;

7
lib/types/configuration.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
import { PostgreSQLConfiguration } from './../PostgreSQL/types/Configuration';
import { MySQLConfiguration } from './../MySQL/types/Configuration';
export type DbConfiguration = PostgreSQLConfiguration & {
type: 'postgresql';
} | MySQLConfiguration & {
type: 'mysql';
};

View File

@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

33
lib/types/dbStore.d.ts vendored Normal file
View File

@ -0,0 +1,33 @@
import { Attribute, EntityDict, Index, OperateOption, OperationResult, StorageSchema, TxnOption } from 'oak-domain/lib/types';
import { EntityDict as BaseEntityDict } from 'oak-domain/lib/base-app-domain';
import { AsyncContext, AsyncRowStore } from "oak-domain/lib/store/AsyncRowStore";
import { CreateEntityOption } from "./Translator";
import { AggregationResult, SelectOption } from "oak-domain/lib/types";
export type Plan = {
newTables: Record<string, {
attributes: Record<string, Attribute>;
}>;
newIndexes: Record<string, Index<any>[]>;
updatedTables: Record<string, {
attributes: Record<string, Attribute & {
isNew: boolean;
}>;
}>;
updatedIndexes: Record<string, Index<any>[]>;
};
export interface DbStore<ED extends EntityDict & BaseEntityDict, Cxt extends AsyncContext<ED>> extends AsyncRowStore<ED, Cxt> {
checkRelationAsync<T extends keyof ED, Cxt extends AsyncContext<ED>>(entity: T, operation: Omit<ED[T]['Operation'] | ED[T]['Selection'], 'id'>, context: Cxt): Promise<void>;
connect: () => Promise<void>;
disconnect: () => Promise<void>;
initialize(options: CreateEntityOption): Promise<void>;
aggregate<T extends keyof ED, OP extends SelectOption>(entity: T, aggregation: ED[T]['Aggregation'], context: Cxt, option: OP): Promise<AggregationResult<ED[T]['Schema']>>;
operate<T extends keyof ED>(entity: T, operation: ED[T]['Operation'], context: Cxt, option: OperateOption): Promise<OperationResult<ED>>;
select<T extends keyof ED>(entity: T, selection: ED[T]['Selection'], context: Cxt, option: SelectOption): Promise<Partial<ED[T]['Schema']>[]>;
count<T extends keyof ED>(entity: T, selection: Pick<ED[T]['Selection'], 'filter' | 'count'>, context: Cxt, option: SelectOption): Promise<number>;
begin(option?: TxnOption): Promise<string>;
commit(txnId: string): Promise<void>;
rollback(txnId: string): Promise<void>;
readSchema(): Promise<StorageSchema<ED>>;
makeUpgradePlan(): Promise<Plan>;
diffSchema(schemaOld: StorageSchema<any>, schemaNew: StorageSchema<any>): Plan;
}

3
lib/types/dbStore.js Normal file
View File

@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
;

View File

@ -1,6 +1,6 @@
{
"name": "oak-db",
"version": "3.3.11",
"version": "3.3.14",
"description": "oak-db",
"main": "lib/index",
"author": {
@ -10,15 +10,16 @@
"lib/**/*"
],
"scripts": {
"test": "mocha",
"test": "node --stack-size=65500 ./node_modules/mocha/bin/mocha",
"make:test:domain": "ts-node script/makeTestDomain.ts",
"build": "tsc"
"build": "node --stack-size=4096 ./script/build.js"
},
"dependencies": {
"lodash": "^4.17.21",
"mysql": "^2.18.1",
"mysql2": "^2.3.3",
"oak-domain": "^5.1.28",
"oak-domain": "file:../oak-domain",
"pg": "^8.16.3",
"uuid": "^8.3.2"
},
"license": "ISC",
@ -27,11 +28,11 @@
"@types/luxon": "^2.3.2",
"@types/mocha": "^9.1.1",
"@types/node": "^20.6.0",
"@types/pg": "^8.16.0",
"@types/sqlstring": "^2.3.0",
"@types/uuid": "^8.3.4",
"cross-env": "^7.0.3",
"mocha": "^10.2.0",
"oak-general-business": "~5.5.0",
"ts-node": "^10.9.1",
"tslib": "^2.4.0",
"typescript": "^5.2.2"

190
script/build.js Normal file
View File

@ -0,0 +1,190 @@
const ts = require('typescript');
const path = require('path');
const fs = require('fs');
const { cwd } = require('process');
// 解析命令行参数
function parseArgs() {
const args = process.argv.slice(2);
let configPath = 'tsconfig.json';
for (let i = 0; i < args.length; i++) {
if (args[i] === '-p' || args[i] === '--project') {
if (i + 1 < args.length) {
configPath = args[i + 1];
break;
} else {
console.error('error: option \'-p, --project\' argument missing');
process.exit(1);
}
}
}
return configPath;
}
// ANSI 颜色代码
const colors = {
reset: '\x1b[0m',
cyan: '\x1b[36m',
red: '\x1b[91m',
yellow: '\x1b[93m',
gray: '\x1b[90m'
};
function compile(configPath) {
// 读取 tsconfig.json
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
if (configFile.error) {
console.error(ts.formatDiagnostic(configFile.error, {
getCanonicalFileName: f => f,
getCurrentDirectory: process.cwd,
getNewLine: () => '\n'
}));
process.exit(1);
}
// 解析配置
const parsedConfig = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
path.dirname(configPath)
);
if (parsedConfig.errors.length > 0) {
parsedConfig.errors.forEach(diagnostic => {
console.error(ts.formatDiagnostic(diagnostic, {
getCanonicalFileName: f => f,
getCurrentDirectory: process.cwd,
getNewLine: () => '\n'
}));
});
process.exit(1);
}
// 创建编译程序
// 根据配置决定是否使用增量编译
let program;
if (parsedConfig.options.incremental || parsedConfig.options.composite) {
// 对于增量编译,使用 createIncrementalProgram
const host = ts.createIncrementalCompilerHost(parsedConfig.options);
program = ts.createIncrementalProgram({
rootNames: parsedConfig.fileNames,
options: parsedConfig.options,
host: host,
configFileParsingDiagnostics: ts.getConfigFileParsingDiagnostics(parsedConfig),
});
} else {
// 普通编译
program = ts.createProgram({
rootNames: parsedConfig.fileNames,
options: parsedConfig.options,
});
}
// 执行编译
const emitResult = program.emit();
// 获取诊断信息
const allDiagnostics = ts
.getPreEmitDiagnostics(program)
.concat(emitResult.diagnostics);
// 输出诊断信息
allDiagnostics.forEach(diagnostic => {
if (diagnostic.file) {
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(
diagnostic.start
);
const message = ts.flattenDiagnosticMessageText(
diagnostic.messageText,
'\n'
);
const isError = diagnostic.category === ts.DiagnosticCategory.Error;
const category = isError ? 'error' : 'warning';
const categoryColor = isError ? colors.red : colors.yellow;
console.log(
`${colors.cyan}${diagnostic.file.fileName}${colors.reset}:${colors.yellow}${line + 1}${colors.reset}:${colors.yellow}${character + 1}${colors.reset} - ${categoryColor}${category}${colors.reset} ${colors.gray}TS${diagnostic.code}${colors.reset}: ${message}`
);
} else {
const isError = diagnostic.category === ts.DiagnosticCategory.Error;
const category = isError ? 'error' : 'warning';
const categoryColor = isError ? colors.red : colors.yellow;
console.log(
`${categoryColor}${category}${colors.reset} ${colors.gray}TS${diagnostic.code}${colors.reset}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`
);
}
});
// 输出编译统计
const errorCount = allDiagnostics.filter(d => d.category === ts.DiagnosticCategory.Error).length;
const warningCount = allDiagnostics.filter(d => d.category === ts.DiagnosticCategory.Warning).length;
if (errorCount > 0 || warningCount > 0) {
if (allDiagnostics.length > 0) {
console.log('');
}
const parts = [];
if (errorCount > 0) {
parts.push(`${errorCount} error${errorCount !== 1 ? 's' : ''}`);
}
if (warningCount > 0) {
parts.push(`${warningCount} warning${warningCount !== 1 ? 's' : ''}`);
}
console.log(`Found ${parts.join(' and ')}.`);
}
// tsc 的行为:
// 1. 默认情况下(noEmitOnError: false):
// - 即使有类型错误也会生成 .js 文件
// - 但如果有错误,退出码是 1
// 2. noEmitOnError: true 时:
// - 有错误时不生成文件(emitSkipped 为 true)
// - 退出码是 1
// 3. 没有错误时:
// - 生成文件,退出码 0
// 无论 emitSkipped 与否,只要有错误就应该退出 1
if (errorCount > 0) {
process.exit(1);
}
console.log('Compilation completed successfully.');
}
// 执行编译
const configPathArg = parseArgs();
let configPath;
// 判断参数是目录还是文件
if (fs.existsSync(configPathArg)) {
const stat = fs.statSync(configPathArg);
if (stat.isDirectory()) {
// 如果是目录,拼接 tsconfig.json
configPath = path.resolve(configPathArg, 'tsconfig.json');
} else {
// 如果是文件,直接使用
configPath = path.resolve(configPathArg);
}
} else {
// 尝试作为相对路径解析
configPath = path.resolve(cwd(), configPathArg);
if (!fs.existsSync(configPath)) {
// 如果还是不存在,尝试添加 tsconfig.json
const dirPath = path.resolve(cwd(), configPathArg);
if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) {
configPath = path.join(dirPath, 'tsconfig.json');
}
}
}
if (!fs.existsSync(configPath)) {
console.error(`error TS5058: The specified path does not exist: '${configPath}'.`);
process.exit(1);
}
compile(configPath);

View File

@ -4,6 +4,6 @@ import {
} from 'oak-domain/lib/compiler/schemalBuilder';
analyzeEntities(`${process.cwd()}/node_modules/oak-domain/src/entities`, 'oak-domain/lib/entities')
analyzeEntities(`${process.cwd()}/node_modules/oak-general-business/src/entities`, 'oak-general-business/lib/entities');
// analyzeEntities(`${process.cwd()}/node_modules/oak-general-business/src/entities`, 'oak-general-business/lib/entities');
analyzeEntities(`${process.cwd()}/test/entities`);
buildSchema(`${process.cwd()}/test/test-app-domain`);

View File

@ -53,8 +53,9 @@ export class MySqlConnector {
async exec(sql: string, txn?: string) {
if (process.env.NODE_ENV === 'development') {
// console.log(sql);
// console.log(`${sql}; \n`);
}
if (txn) {
const connection = this.txnDict[txn];
assert(connection);

View File

@ -1,16 +1,17 @@
import { EntityDict, OperateOption, OperationResult, TxnOption, StorageSchema, SelectOption, AggregationResult, Geo } from 'oak-domain/lib/types';
import { EntityDict, OperateOption, OperationResult, TxnOption, StorageSchema, SelectOption, AggregationResult, Geo, Attributes, Attribute, Index, IndexConfig } from 'oak-domain/lib/types';
import { EntityDict as BaseEntityDict } from 'oak-domain/lib/base-app-domain';
import { CascadeStore } from 'oak-domain/lib/store/CascadeStore';
import { MySQLConfiguration } from './types/Configuration';
import { MySqlConnector } from './connector';
import { MySqlTranslator, MySqlSelectOption, MysqlOperateOption } from './translator';
import { assign, set } from 'lodash';
import { assign, difference, set, pick } from 'lodash';
import assert from 'assert';
import { judgeRelation } from 'oak-domain/lib/store/relation';
import { AsyncContext, AsyncRowStore } from 'oak-domain/lib/store/AsyncRowStore';
import { SyncContext } from 'oak-domain/lib/store/SyncRowStore';
import { CreateEntityOption } from '../types/Translator';
import { FieldPacket, ResultSetHeader, RowDataPacket } from 'mysql2';
import { DbStore, Plan } from '../types/dbStore';
function convertGeoTextToObject(geoText: string): Geo {
@ -30,7 +31,7 @@ function convertGeoTextToObject(geoText: string): Geo {
}
}
export class MysqlStore<ED extends EntityDict & BaseEntityDict, Cxt extends AsyncContext<ED>> extends CascadeStore<ED> implements AsyncRowStore<ED, Cxt>{
export class MysqlStore<ED extends EntityDict & BaseEntityDict, Cxt extends AsyncContext<ED>> extends CascadeStore<ED> implements DbStore<ED, Cxt>{
protected countAbjointRow<T extends keyof ED, OP extends SelectOption, Cxt extends SyncContext<ED>>(entity: T, selection: Pick<ED[T]['Selection'], 'filter' | 'count'>, context: Cxt, option: OP): number {
throw new Error('MySQL store不支持同步取数据不应该跑到这儿');
}
@ -72,25 +73,25 @@ export class MysqlStore<ED extends EntityDict & BaseEntityDict, Cxt extends Asyn
}
private formResult<T extends keyof ED>(entity: T, result: any): any {
const schema = this.getSchema();
/* function resolveObject(r: Record<string, any>, path: string, value: any) {
const i = path.indexOf(".");
const bs = path.indexOf('[');
const be = path.indexOf(']');
if (i === -1 && bs === -1) {
r[i] = value;
}
else if (i === -1) {
}
else if (bs === -1) {
const attrHead = path.slice(0, i);
const attrTail = path.slice(i + 1);
if (!r[attrHead]) {
r[attrHead] = {};
}
resolveObject(r[attrHead], attrTail, value);
}
} */
/* function resolveObject(r: Record<string, any>, path: string, value: any) {
const i = path.indexOf(".");
const bs = path.indexOf('[');
const be = path.indexOf(']');
if (i === -1 && bs === -1) {
r[i] = value;
}
else if (i === -1) {
}
else if (bs === -1) {
const attrHead = path.slice(0, i);
const attrTail = path.slice(i + 1);
if (!r[attrHead]) {
r[attrHead] = {};
}
resolveObject(r[attrHead], attrTail, value);
}
} */
function resolveAttribute<E extends keyof ED>(entity2: E, r: Record<string, any>, attr: string, value: any) {
const { attributes, view } = schema[entity2];
if (!view) {
@ -222,7 +223,7 @@ export class MysqlStore<ED extends EntityDict & BaseEntityDict, Cxt extends Asyn
// 边界如果是toModi的对象这里的外键确实有可能为空
// assert(schema[e].toModi || r[`${attr}Id`] === r[attr].id, `对象${<string>e}取数据时发现其外键与连接的对象的主键不一致rowId是${r.id},其${attr}Id值为${r[`${attr}Id`]},连接的对象的主键为${r[attr].id}`);
if (r[attr].id === null) {
assert(schema[e].toModi || r[`${attr}Id`] === null);
assert(schema[e].toModi || r[`${attr}Id`] === null, `对象${String(e)}取数据时发现其外键找不到目标对象rowId是${r.id},其外键${attr}Id值为${r[`${attr}Id`]}`);
delete r[attr];
continue;
}
@ -320,8 +321,8 @@ export class MysqlStore<ED extends EntityDict & BaseEntityDict, Cxt extends Asyn
async rollback(txnId: string): Promise<void> {
await this.connector.rollbackTransaction(txnId);
}
connect() {
this.connector.connect();
async connect() {
await this.connector.connect();
}
async disconnect() {
await this.connector.disconnect();
@ -335,4 +336,134 @@ export class MysqlStore<ED extends EntityDict & BaseEntityDict, Cxt extends Asyn
}
}
}
// 从数据库中读取当前schema
readSchema() {
return this.translator.readSchema((sql) => this.connector.exec(sql));
}
/**
* dataSchemaschemaupgrade
* plan分为两阶段
*/
async makeUpgradePlan() {
const originSchema = await this.readSchema();
const plan = this.diffSchema(originSchema, this.translator.schema);
return plan;
}
/**
* schema的不同new对old的增量
* @param schemaOld
* @param SchemaNew
*/
diffSchema(schemaOld: StorageSchema<any>, schemaNew: StorageSchema<any>) {
const plan: Plan = {
newTables: {},
newIndexes: {},
updatedIndexes: {},
updatedTables: {},
};
for (const table in schemaNew) {
// mysql数据字典不分大小写的
if (schemaOld[table] || schemaOld[table.toLowerCase()]) {
const { attributes, indexes } = schemaOld[table] || schemaOld[table.toLowerCase()];
const { attributes: attributesNew, indexes: indexesNew } = schemaNew[table];
const assignToUpdateTables = (attr: string, isNew: boolean) => {
if (!plan.updatedTables[table]) {
plan.updatedTables[table] = {
attributes: {
[attr]: {
...attributesNew[attr],
isNew,
}
}
};
}
else {
plan.updatedTables[table].attributes[attr] = {
...attributesNew[attr],
isNew,
};
}
};
for (const attr in attributesNew) {
if (attributes[attr]) {
// 因为反向无法复原原来定义的attribute类型这里就比较两次创建的sql是不是一致不是太好的设计。
const sql1 = this.translator.translateAttributeDef(attr, attributesNew[attr]);
const sql2 = this.translator.translateAttributeDef(attr, attributes[attr]);
if (!this.translator.compareSql(sql1, sql2)) {
assignToUpdateTables(attr, false);
}
}
else {
assignToUpdateTables(attr, true);
}
}
if (indexesNew) {
const assignToIndexes = (index: Index<any>, isNew: boolean) => {
if (isNew) {
if (plan.newIndexes[table]) {
plan.newIndexes[table].push(index);
}
else {
plan.newIndexes[table] = [index];
}
}
else {
if (plan.updatedIndexes[table]) {
plan.updatedIndexes[table].push(index);
}
else {
plan.updatedIndexes[table] = [index];
}
}
};
const compareConfig = (config1?: IndexConfig, config2?: IndexConfig) => {
const unique1 = config1?.unique || false;
const unique2 = config2?.unique || false;
if (unique1 !== unique2) {
return false;
}
const type1 = config1?.type || 'btree';
const type2 = config2?.type || 'btree';
// parser目前无法从mysql中读出来所以不比了
return type1 === type2;
};
for (const index of indexesNew) {
const { name, config, attributes } = index;
const origin = indexes?.find(ele => ele.name === name);
if (origin) {
if (JSON.stringify(attributes) !== JSON.stringify(origin.attributes)) {
// todo这里要细致比较不能用json.stringify
assignToIndexes(index, false);
}
else {
if (!compareConfig(config, origin.config)) {
assignToIndexes(index, false);
}
}
}
else {
assignToIndexes(index, true);
}
}
}
}
else {
plan.newTables[table] = {
attributes: schemaNew[table].attributes,
};
if (schemaNew[table].indexes) {
plan.newIndexes[table] = schemaNew[table].indexes!;
}
}
}
return plan;
}
}

View File

@ -1,7 +1,7 @@
import assert from 'assert';
import { format } from 'util';
import { assign } from 'lodash';
import { EntityDict, Geo, Q_FullTextValue, RefOrExpression, Ref, StorageSchema, Index, RefAttr, DeleteAtAttribute } from "oak-domain/lib/types";
import { assign, groupBy } from 'lodash';
import { EntityDict, Geo, Q_FullTextValue, RefOrExpression, Ref, StorageSchema, Index, RefAttr, DeleteAtAttribute, Attributes, Attribute, PrimaryKeyAttribute } from "oak-domain/lib/types";
import { EntityDict as BaseEntityDict } from 'oak-domain/lib/base-app-domain';
import { DataType, DataTypeParams } from "oak-domain/lib/types/schema/DataTypes";
import { SqlOperateOption, SqlSelectOption, SqlTranslator } from "../sqlTranslator";
@ -308,14 +308,18 @@ export class MySqlTranslator<ED extends EntityDict & BaseEntityDict> extends Sql
return 'text ';
}
if (type === 'ref') {
return 'char(36)';
return 'char(36) ';
}
if (['bool', 'boolean'].includes(type)) {
// MySQL读出来就是tinyint(1)
return 'tinyint(1) ';
}
if (type === 'money') {
return 'bigint';
return 'bigint ';
}
if (type === 'enum') {
assert(enumeration);
return `enum(${enumeration.map(ele => `'${ele}'`).join(',')})`;
return `enum(${enumeration.map(ele => `'${ele}'`).join(',')}) `;
}
if (MySqlTranslator.withLengthDataTypes.includes(type as DataType)) {
@ -335,35 +339,35 @@ export class MySqlTranslator<ED extends EntityDict & BaseEntityDict> extends Sql
if (typeof scale === 'number') {
return `${type}(${precision}, ${scale}) `;
}
return `${type}(${precision})`;
return `${type}(${precision}) `;
}
else {
const { precision, scale } = (MySqlTranslator.dataTypeDefaults as any)[type];
if (typeof scale === 'number') {
return `${type}(${precision}, ${scale}) `;
}
return `${type}(${precision})`;
return `${type}(${precision}) `;
}
}
if (MySqlTranslator.withWidthDataTypes.includes(type as DataType)) {
assert(type === 'int');
const { width } = params!;
const { width } = params || { width: 4 };
switch (width!) {
case 1: {
return 'tinyint';
return 'tinyint ';
}
case 2: {
return 'smallint';
return 'smallint ';
}
case 3: {
return 'mediumint';
return 'mediumint ';
}
case 4: {
return 'int';
return 'int ';
}
default: {
return 'bigint';
return 'bigint ';
}
}
}
@ -468,7 +472,7 @@ export class MySqlTranslator<ED extends EntityDict & BaseEntityDict> extends Sql
}
}
else {
assert (typeof length === 'object');
assert(typeof length === 'object');
const op = Object.keys(length)[0];
assert(op.startsWith('$'));
if (p) {
@ -617,6 +621,40 @@ export class MySqlTranslator<ED extends EntityDict & BaseEntityDict> extends Sql
);
return ` match(${columns2.join(',')}) against ('${$search}' in natural language mode)`;
}
translateAttributeDef(attr: string, attrDef: Attribute) {
let sql = `\`${attr}\` `;
const {
type,
params,
default: defaultValue,
unique,
notNull,
sequenceStart,
enumeration,
} = attrDef;
sql += this.populateDataTypeDef(type, params, enumeration) as string;
if (notNull || type === 'geometry') {
sql += ' not null ';
}
if (unique) {
sql += ' unique ';
}
if (typeof sequenceStart === 'number') {
sql += ' auto_increment unique ';
}
if (defaultValue !== undefined) {
assert(type !== 'ref');
sql += ` default ${this.translateAttrValue(type, defaultValue)}`;
}
if (attr === PrimaryKeyAttribute) {
sql += ' primary key'
}
return sql;
}
translateCreateEntity<T extends keyof ED>(entity: T, options?: CreateEntityOption): string[] {
const ifExists = options?.ifExists || 'drop';
const { schema } = this;
@ -647,41 +685,14 @@ export class MySqlTranslator<ED extends EntityDict & BaseEntityDict> extends Sql
// 翻译所有的属性
Object.keys(attributes).forEach(
(attr, idx) => {
const attrDef = attributes[attr];
const {
type,
params,
default: defaultValue,
unique,
notNull,
sequenceStart,
enumeration,
} = attrDef;
sql += `\`${attr}\` `
sql += this.populateDataTypeDef(type, params, enumeration) as string;
if (notNull || type === 'geometry') {
sql += ' not null ';
const attrSql = this.translateAttributeDef(attr, attributes[attr]);
if (idx !== 0) {
sql +=', ';
}
if (unique) {
sql += ' unique ';
}
if (sequenceStart) {
if (hasSequence) {
throw new Error(`${entity as string}」只能有一个sequence列`);
}
hasSequence = sequenceStart;
sql += ' auto_increment unique ';
}
if (defaultValue !== undefined) {
assert(type !== 'ref');
sql += ` default ${this.translateAttrValue(type, defaultValue)}`;
}
if (attr === 'id') {
sql += ' primary key'
}
if (idx < Object.keys(attributes).length - 1) {
sql += ',\n';
sql += attrSql;
if (typeof attributes[attr].sequenceStart === 'number') {
assert(hasSequence === false, 'Entity can only have one auto increment attribute.');
hasSequence = attributes[attr].sequenceStart!;
}
}
);
@ -702,13 +713,12 @@ export class MySqlTranslator<ED extends EntityDict & BaseEntityDict> extends Sql
else if (type === 'spatial') {
sql += ' spatial ';
}
sql += `index ${name} `;
sql += `index \`${name}\` `;
if (type === 'hash') {
sql += ` using hash `;
}
sql += '(';
let includeDeleteAt = false;
attributes.forEach(
({ name, size, direction }, idx2) => {
sql += `\`${name as string}\``;
@ -719,16 +729,10 @@ export class MySqlTranslator<ED extends EntityDict & BaseEntityDict> extends Sql
sql += ` ${direction}`;
}
if (idx2 < attributes.length - 1) {
sql += ','
}
if (name === '$$deleteAt$$') {
includeDeleteAt = true;
sql += ', ';
}
}
);
if (!includeDeleteAt && !type) {
sql += ', `$$deleteAt$$`'; // 在mysql80+之后,需要给属性加上``包裹,否则会报错
}
sql += ')';
if (parser) {
sql += ` with parser ${parser}`;
@ -885,10 +889,13 @@ export class MySqlTranslator<ED extends EntityDict & BaseEntityDict> extends Sql
case '$dayOfYear': {
return 'DAYOFYEAR(%s)';
}
case '$dateDiff': {
assert(argumentNumber === 3);
return 'DATEDIFF(%s, %s, %s)';
}
// 这个实现有问题DATEDIFF只是计算两个日期之间的天数差只接受两个参数放在translateExperession里实现
// case '$dateDiff': {
// assert(argumentNumber === 3);
// return 'DATEDIFF(%s, %s, %s)';
// }
case '$contains': {
assert(argumentNumber === 2);
return 'ST_CONTAINS(%s, %s)';
@ -905,6 +912,24 @@ export class MySqlTranslator<ED extends EntityDict & BaseEntityDict> extends Sql
result += ')';
return result;
}
// ========== 聚合函数 ==========
case '$$count': {
return 'COUNT(%s)';
}
case '$$sum': {
return 'SUM(%s)';
}
case '$$max': {
return 'MAX(%s)';
}
case '$$min': {
return 'MIN(%s)';
}
case '$$avg': {
return 'AVG(%s)';
}
default: {
throw new Error(`unrecoganized function ${fnName}`);
}
@ -946,15 +971,143 @@ export class MySqlTranslator<ED extends EntityDict & BaseEntityDict> extends Sql
const refAttr = (expr)['#refAttr'];
assert(refDict[refId]);
const attrText = `\`${refDict[refId][0]}\`.\`${refAttr}\``;
result = this.translateAttrInExpression(entity, (expr)['#refAttr'], attrText);
const [refAlias, refEntity] = refDict[refId];
const attrText = `\`${refAlias}\`.\`${refAttr}\``;
// 这里必须使用refEntity否则在filter深层嵌套节点表达式时会出现entity不对应
result = this.translateAttrInExpression(refEntity as T, (expr)['#refAttr'], attrText);
}
else {
assert(k.length === 1);
if ((expr)[k[0]] instanceof Array) {
const fnName = this.translateFnName(k[0], (expr)[k[0]].length);
const fnKey = k[0];
const fnArgs = (expr)[fnKey];
// 特殊处理日期相关函数
if (fnKey === '$dateDiff') {
// $dateDiff: [date1, date2, unit]
assert(fnArgs instanceof Array && fnArgs.length === 3);
const [date1Expr, date2Expr, unit] = fnArgs;
// 转换日期表达式
const translateDateArg = (arg: any): string => {
if (arg instanceof Date) {
return `FROM_UNIXTIME(${arg.valueOf()} / 1000)`;
} else if (typeof arg === 'number') {
return `FROM_UNIXTIME(${arg} / 1000)`;
} else {
return translateInner(arg);
}
};
const date1Str = translateDateArg(date1Expr);
const date2Str = translateDateArg(date2Expr);
// MySQL TIMESTAMPDIFF 单位映射
const unitMap: Record<string, string> = {
's': 'SECOND',
'm': 'MINUTE',
'h': 'HOUR',
'd': 'DAY',
'M': 'MONTH',
'y': 'YEAR'
};
const mysqlUnit = unitMap[unit];
if (!mysqlUnit) {
throw new Error(`Unsupported date diff unit: ${unit}`);
}
// TIMESTAMPDIFF(unit, date1, date2) 返回 date2 - date1
// 但类型定义是 date1 - date2所以参数顺序要反过来
result = `TIMESTAMPDIFF(${mysqlUnit}, ${date2Str}, ${date1Str})`;
} else if (fnKey === '$dateCeil') {
// $dateCeil: [date, unit]
assert(fnArgs instanceof Array && fnArgs.length === 2);
const [dateExpr, unit] = fnArgs;
const getTimestampExpr = (arg: any): string => {
if (arg instanceof Date) {
return `${arg.valueOf()}`;
} else if (typeof arg === 'number') {
return `${arg}`;
} else {
const k = Object.keys(arg);
if (k.includes('#attr')) {
return `\`${alias}\`.\`${arg['#attr']}\``;
} else if (k.includes('#refId')) {
const refId = arg['#refId'];
const refAttr = arg['#refAttr'];
return `\`${refDict[refId][0]}\`.\`${refAttr}\``;
}
return translateInner(arg);
}
};
const tsExpr = getTimestampExpr(dateExpr);
const msPerUnit: Record<string, number> = {
's': 1000,
'm': 60000,
'h': 3600000,
'd': 86400000,
};
if (msPerUnit[unit]) {
// CEIL 向上取整
result = `CEIL(${tsExpr} / ${msPerUnit[unit]}) * ${msPerUnit[unit]}`;
} else {
assert(typeof unit === 'string', 'unit should be string');
console.warn('暂不支持 $dateCeil 对月年单位的处理');
throw new Error(`Unsupported date ceil unit: ${unit}`);
}
} else if (fnKey === '$dateFloor') {
// $dateFloor: [date, unit]
assert(fnArgs instanceof Array && fnArgs.length === 2);
const [dateExpr, unit] = fnArgs;
// 获取毫秒时间戳表达式
const getTimestampExpr = (arg: any): string => {
if (arg instanceof Date) {
return `${arg.valueOf()}`;
} else if (typeof arg === 'number') {
return `${arg}`;
} else {
// 属性引用,直接返回属性(存储本身就是毫秒时间戳)
const k = Object.keys(arg);
if (k.includes('#attr')) {
return `\`${alias}\`.\`${arg['#attr']}\``;
} else if (k.includes('#refId')) {
const refId = arg['#refId'];
const refAttr = arg['#refAttr'];
return `\`${refDict[refId][0]}\`.\`${refAttr}\``;
}
// 其他表达式递归处理
return translateInner(arg);
}
};
const tsExpr = getTimestampExpr(dateExpr);
// 固定间隔单位:直接用时间戳数学运算
const msPerUnit: Record<string, number> = {
's': 1000,
'm': 60000,
'h': 3600000,
'd': 86400000,
};
if (msPerUnit[unit]) {
// FLOOR(timestamp / interval) * interval
result = `FLOOR(${tsExpr} / ${msPerUnit[unit]}) * ${msPerUnit[unit]}`;
} else {
assert(typeof unit === 'string', 'unit should be string');
console.warn('暂不支持 $dateFloor 对月年单位的处理');
throw new Error(`Unsupported date floor unit: ${unit}`);
}
} else if (fnArgs instanceof Array) {
// 原有的数组参数处理逻辑
const fnName = this.translateFnName(fnKey, fnArgs.length);
const args = [fnName];
args.push(...(expr)[k[0]].map(
args.push(...fnArgs.map(
(ele: any) => {
if (['string', 'number'].includes(typeof ele) || ele instanceof Date) {
return translateConstant(ele);
@ -968,9 +1121,10 @@ export class MySqlTranslator<ED extends EntityDict & BaseEntityDict> extends Sql
result = format.apply(null, args);
}
else {
const fnName = this.translateFnName(k[0], 1);
// 原有的单参数处理逻辑
const fnName = this.translateFnName(fnKey, 1);
const args = [fnName];
const arg = (expr)[k[0]];
const arg = fnArgs;
if (['string', 'number'].includes(typeof arg) || arg instanceof Date) {
args.push(translateConstant(arg));
}
@ -1060,7 +1214,7 @@ export class MySqlTranslator<ED extends EntityDict & BaseEntityDict> extends Sql
// 新的remove应该包含$$deleteAt$$的值了
/* const now = Date.now();
const updateText2 = updateText ? `${updateText}, \`${alias}\`.\`$$deleteAt$$\` = '${now}'` : `\`${alias}\`.\`$$deleteAt$$\` = '${now}'`; */
assert(updateText.includes(DeleteAtAttribute));
let sql = `update ${fromText} set ${updateText}`;
@ -1077,4 +1231,135 @@ export class MySqlTranslator<ED extends EntityDict & BaseEntityDict> extends Sql
return sql;
}
}
/**
* MySQL返回的Type回译成oak的类型 populateDataTypeDef
* @param type
*/
private reTranslateToAttribute(type: string): Attribute {
const withLengthDataTypes = MySqlTranslator.withLengthDataTypes.join('|')
let result = (new RegExp(`^(${withLengthDataTypes})\\((\\d+)\\)$`)).exec(type);
if (result) {
return {
type: result[1] as DataType,
params: {
length: parseInt(result[2]),
}
};
}
const withPrecisionDataTypes = MySqlTranslator.withPrecisionDataTypes.join('|')
result = (new RegExp(`^(${withPrecisionDataTypes})\\((\\d+),(d+)\\)$`)).exec(type);
if (result) {
return {
type: result[1] as DataType,
params: {
precision: parseInt(result[2]),
scale: parseInt(result[3]),
},
};
}
result = (/^enum\((\S+)\)$/).exec(type);
if (result) {
const enumeration = result[1].split(',').map(
ele => ele.slice(1, -1)
);
return {
type: 'enum',
enumeration
};
}
return {
type: type as DataType,
};
}
// 分析当前数据库结构图
async readSchema(execFn: (sql: string) => Promise<any>) {
const result: (typeof this.schema) = {} as typeof this.schema;
const sql = 'show tables;';
const [tables] = await execFn(sql);
for (const tableItem of tables) {
const table = Object.values(tableItem)[0] as string;
const [tableResult] = await execFn(`desc \`${table}\``);
const attributes: Attributes<any> = {};
for (const attrItem of tableResult) {
const { Field: attrName, Null: isNull, Type: type, Key: key } = attrItem as {
Field: string,
Null: 'YES' | 'NO',
Type: string,
Key: 'UNI' | 'MUL',
Extra: string;
};
attributes[attrName] = {
...this.reTranslateToAttribute(type),
notNull: isNull.toUpperCase() === 'NO',
unique: key.toUpperCase() === 'UNI',
};
// 自增列只可能是seq
if (attrName === '$$seq$$') {
attributes[attrName].sequenceStart = 10000;
}
}
Object.assign(result, {
[table]: {
attributes,
}
});
const [indexedColumns] = (await execFn(`show index from \`${table}\``)) as [
Array<{
Non_unique: 0 | 1;
Key_name: string;
Seq_in_index: number;
Column_name: string;
Index_type: string;
Null: string;
Collation: 'A' | 'D';
Sub_part: number;
}>
];
if (indexedColumns.length) {
const groupedColumns = groupBy(
indexedColumns.sort(
(ele1, ele2) => ele1.Key_name.localeCompare(ele2.Key_name) || ele1.Seq_in_index - ele2.Seq_in_index
),
'Key_name'
);
const indexes = Object.values(groupedColumns).map(
(ele) => {
const index: Index<any> = {
name: ele[0].Key_name,
attributes: ele.map(ele2 => ({
name: ele2.Column_name,
direction: ele2.Collation === 'D' ? 'DESC' : (ele2.Collation === 'A' ? 'ASC' : undefined),
size: ele2.Sub_part || undefined,
})),
};
if (ele[0].Non_unique === 0 || ele[0].Index_type.toUpperCase() !== 'BTREE') {
index.config = {};
if (ele[0].Non_unique === 0) {
index.config.unique = true;
}
if (ele[0].Index_type.toUpperCase() !== 'BTREE') {
index.config.type = ele[0].Index_type.toLowerCase() as any;
}
}
return index;
}
);
Object.assign(result[table], {
indexes,
});
}
}
return result;
}
}

168
src/PostgreSQL/connector.ts Normal file
View File

@ -0,0 +1,168 @@
import { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg';
import { v4 } from 'uuid';
import { TxnOption } from 'oak-domain/lib/types';
import { PostgreSQLConfiguration } from './types/Configuration';
import assert from 'assert';
export class PostgreSQLConnector {
pool: Pool;
configuration: PostgreSQLConfiguration;
txnDict: Record<string, PoolClient>;
constructor(configuration: PostgreSQLConfiguration) {
this.configuration = configuration;
this.txnDict = {};
this.pool = new Pool(configuration);
// 错误处理
this.pool.on('error', (err) => {
console.error('PostgreSQL pool error:', err);
});
}
connect() {
// pg Pool 是惰性连接,不需要显式连接
// 但可以在这里进行连接测试
}
async disconnect(): Promise<void> {
// 先获取所有事务ID的快照
const txnIds = Object.keys(this.txnDict);
for (const txnId of txnIds) {
try {
await this.rollbackTransaction(txnId);
} catch (e) {
console.error(`Failed to rollback transaction ${txnId}:`, e);
}
}
await this.pool.end();
}
async startTransaction(option?: TxnOption): Promise<string> {
const startInner = async (): Promise<string> => {
const connection = await this.pool.connect();
// 添加:检测连接是否已被其他事务占用
for (const txn2 in this.txnDict) {
if (this.txnDict[txn2] === connection) {
return new Promise((resolve) => {
this.pool.on('release', () => resolve(startInner()));
});
}
}
try {
let beginStmt = 'BEGIN';
if (option?.isolationLevel) {
// PostgreSQL 隔离级别:
// READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE
// 注意: PostgreSQL 的 READ UNCOMMITTED 行为等同于 READ COMMITTED
const level = this.mapIsolationLevel(option.isolationLevel);
beginStmt = `BEGIN ISOLATION LEVEL ${level}`;
}
await connection.query(beginStmt);
const id = v4();
this.txnDict[id] = connection;
return id;
} catch (error) {
// 如果启动事务失败,释放连接
connection.release();
throw error;
}
};
return startInner();
}
/**
* PostgreSQL
*/
private mapIsolationLevel(level: string): string {
const levelUpper = level.toUpperCase().replace(/[-_]/g, ' '); // 同时处理 - 和 _
const validLevels = [
'READ UNCOMMITTED',
'READ COMMITTED',
'REPEATABLE READ',
'SERIALIZABLE'
];
if (validLevels.includes(levelUpper)) {
return levelUpper;
}
// 默认使用 READ COMMITTED
console.warn(`Unknown isolation level "${level}", using READ COMMITTED`);
return 'READ COMMITTED';
}
async exec(sql: string, txn?: string): Promise<[QueryResultRow[], QueryResult]> {
if (process.env.NODE_ENV === 'development') {
// console.log(`SQL: ${sql}; \n`);
}
try {
let result: QueryResult;
if (txn) {
const connection = this.txnDict[txn];
assert(connection, `Transaction ${txn} not found`);
result = await connection.query(sql);
} else {
result = await this.pool.query(sql);
}
// 返回格式与 mysql2 兼容: [rows, fields/result]
return [result.rows, result];
} catch (error: any) {
// 增强错误信息
const enhancedError = new Error(
`PostgreSQL query failed: ${error.message}\nSQL: ${sql.slice(0, 500)}${sql.length > 500 ? '...' : ''}`
);
(enhancedError as any).originalError = error;
(enhancedError as any).sql = sql;
throw enhancedError;
}
}
async commitTransaction(txn: string): Promise<void> {
const connection = this.txnDict[txn];
assert(connection, `Transaction ${txn} not found`);
try {
await connection.query('COMMIT');
} finally {
delete this.txnDict[txn];
connection.release();
}
}
async rollbackTransaction(txn: string): Promise<void> {
const connection = this.txnDict[txn];
assert(connection, `Transaction ${txn} not found`);
try {
await connection.query('ROLLBACK');
} finally {
delete this.txnDict[txn];
connection.release();
}
}
/**
*
*/
getPoolStatus(): { total: number; idle: number; waiting: number } {
return {
total: this.pool.totalCount,
idle: this.pool.idleCount,
waiting: this.pool.waitingCount
};
}
}

668
src/PostgreSQL/store.ts Normal file
View File

@ -0,0 +1,668 @@
import {
EntityDict,
OperateOption,
OperationResult,
TxnOption,
StorageSchema,
SelectOption,
AggregationResult,
Geo,
IndexConfig,
Index,
Attribute
} from 'oak-domain/lib/types';
import { EntityDict as BaseEntityDict } from 'oak-domain/lib/base-app-domain';
import { CascadeStore } from 'oak-domain/lib/store/CascadeStore';
import { PostgreSQLConfiguration } from './types/Configuration';
import { PostgreSQLConnector } from './connector';
import { PostgreSQLTranslator, PostgreSQLSelectOption, PostgreSQLOperateOption } from './translator';
import { assign, set } from 'lodash';
import assert from 'assert';
import { judgeRelation } from 'oak-domain/lib/store/relation';
import { AsyncContext, AsyncRowStore } from 'oak-domain/lib/store/AsyncRowStore';
import { SyncContext } from 'oak-domain/lib/store/SyncRowStore';
import { CreateEntityOption } from '../types/Translator';
import { QueryResult } from 'pg';
import { DbStore, Plan } from '../types/dbStore';
const ToNumberAttrs = new Set([
'$$seq$$',
'$$createAt$$',
'$$updateAt$$',
'$$deleteAt$$',
]);
function convertGeoTextToObject(geoText: string): Geo {
if (geoText.startsWith('POINT')) {
const coord = geoText.match(/(-?\d+\.?\d*)/g) as string[];
assert(coord && coord.length === 2);
return {
type: 'point',
coordinate: coord.map(ele => parseFloat(ele)) as [number, number],
};
} else if (geoText.startsWith('LINESTRING')) {
const coordsMatch = geoText.match(/\(([^)]+)\)/);
if (coordsMatch) {
const points = coordsMatch[1].split(',').map(p => {
const [x, y] = p.trim().split(/\s+/).map(parseFloat);
return [x, y] as [number, number];
});
return {
type: 'path',
coordinate: points,
};
}
} else if (geoText.startsWith('POLYGON')) {
const ringsMatch = geoText.match(/\(\(([^)]+)\)\)/g);
if (ringsMatch) {
const rings = ringsMatch.map(ring => {
const coordStr = ring.replace(/[()]/g, '');
return coordStr.split(',').map(p => {
const [x, y] = p.trim().split(/\s+/).map(parseFloat);
return [x, y] as [number, number];
});
});
return {
type: 'polygon',
coordinate: rings,
};
}
}
throw new Error(`Unsupported geometry type: ${geoText.slice(0, 50)}`);
}
export class PostgreSQLStore<
ED extends EntityDict & BaseEntityDict,
Cxt extends AsyncContext<ED>
> extends CascadeStore<ED> implements DbStore<ED, Cxt> {
protected countAbjointRow<T extends keyof ED, OP extends SelectOption, Cxt extends SyncContext<ED>>(
entity: T,
selection: Pick<ED[T]['Selection'], 'filter' | 'count'>,
context: Cxt,
option: OP
): number {
throw new Error('PostgreSQL store 不支持同步取数据');
}
protected aggregateAbjointRowSync<T extends keyof ED, OP extends SelectOption, Cxt extends SyncContext<ED>>(
entity: T,
aggregation: ED[T]['Aggregation'],
context: Cxt,
option: OP
): AggregationResult<ED[T]['Schema']> {
throw new Error('PostgreSQL store 不支持同步取数据');
}
protected selectAbjointRow<T extends keyof ED, OP extends SelectOption>(
entity: T,
selection: ED[T]['Selection'],
context: SyncContext<ED>,
option: OP
): Partial<ED[T]['Schema']>[] {
throw new Error('PostgreSQL store 不支持同步取数据');
}
protected updateAbjointRow<T extends keyof ED, OP extends OperateOption>(
entity: T,
operation: ED[T]['Operation'],
context: SyncContext<ED>,
option: OP
): number {
throw new Error('PostgreSQL store 不支持同步更新数据');
}
async exec(script: string, txnId?: string) {
await this.connector.exec(script, txnId);
}
connector: PostgreSQLConnector;
translator: PostgreSQLTranslator<ED>;
constructor(storageSchema: StorageSchema<ED>, configuration: PostgreSQLConfiguration) {
super(storageSchema);
this.connector = new PostgreSQLConnector(configuration);
this.translator = new PostgreSQLTranslator(storageSchema);
}
checkRelationAsync<T extends keyof ED, Cxt extends AsyncContext<ED>>(
entity: T,
operation: Omit<ED[T]['Operation'] | ED[T]['Selection'], 'id'>,
context: Cxt
): Promise<void> {
throw new Error('Method not implemented.');
}
protected async aggregateAbjointRowAsync<T extends keyof ED, OP extends SelectOption, Cxt extends AsyncContext<ED>>(
entity: T,
aggregation: ED[T]['Aggregation'],
context: Cxt,
option: OP
): Promise<AggregationResult<ED[T]['Schema']>> {
const sql = this.translator.translateAggregate(entity, aggregation, option);
const result = await this.connector.exec(sql, context.getCurrentTxnId());
return this.formResult(entity, result[0]);
}
aggregate<T extends keyof ED, OP extends SelectOption>(
entity: T,
aggregation: ED[T]['Aggregation'],
context: Cxt,
option: OP
): Promise<AggregationResult<ED[T]['Schema']>> {
return this.aggregateAsync(entity, aggregation, context, option);
}
protected supportManyToOneJoin(): boolean {
return true;
}
protected supportMultipleCreate(): boolean {
return true;
}
private formResult<T extends keyof ED>(entity: T, result: any): any {
const schema = this.getSchema();
function resolveAttribute<E extends keyof ED>(
entity2: E,
r: Record<string, any>,
attr: string,
value: any
) {
const { attributes, view } = schema[entity2];
if (!view) {
const i = attr.indexOf(".");
if (i !== -1) {
const attrHead = attr.slice(0, i);
const attrTail = attr.slice(i + 1);
const rel = judgeRelation(schema, entity2, attrHead);
if (rel === 1) {
set(r, attr, value);
} else {
if (!r[attrHead]) {
r[attrHead] = {};
}
if (rel === 0) {
resolveAttribute(entity2, r[attrHead], attrTail, value);
} else if (rel === 2) {
resolveAttribute(attrHead, r[attrHead], attrTail, value);
} else {
assert(typeof rel === 'string');
resolveAttribute(rel, r[attrHead], attrTail, value);
}
}
} else if (attributes[attr]) {
const { type } = attributes[attr];
switch (type) {
case 'date':
case 'time':
case 'datetime': {
if (value instanceof Date) {
r[attr] = value.valueOf();
} else {
if (typeof value === 'string') {
r[attr] = parseInt(value, 10);
} else {
assert(typeof value === 'number' || value === null);
r[attr] = value;
}
}
break;
}
case 'geometry': {
if (typeof value === 'string') {
r[attr] = convertGeoTextToObject(value);
} else {
r[attr] = value;
}
break;
}
case 'object':
case 'array': {
// PostgreSQL jsonb 直接返回对象,不需要 parse
if (typeof value === 'string') {
r[attr] = JSON.parse(value);
} else {
r[attr] = value;
}
break;
}
case 'function': {
if (typeof value === 'string') {
r[attr] = `return ${Buffer.from(value, 'base64').toString()}`;
} else {
r[attr] = value;
}
break;
}
case 'bool':
case 'boolean': {
// PostgreSQL 直接返回 boolean 类型
r[attr] = value;
break;
}
case 'decimal': {
// PostgreSQL numeric 类型可能返回字符串
if (typeof value === 'string') {
r[attr] = parseFloat(value);
} else {
assert(value === null || typeof value === 'number');
r[attr] = value;
}
break;
}
// TODO: 这里和mysql统一行为ref类型的字符串去除前后空格
case "char":
case "ref": {
if (value) {
assert(typeof value === 'string');
r[attr] = value.trim();
} else {
r[attr] = value;
}
break;
}
default: {
r[attr] = value;
}
}
} else {
// TODO: 这里和mysql统一行为id字段为char类型时去除后面的空格
if (value && typeof value === 'string') {
if (attr === 'id') {
r[attr] = value.trim();
} else if (attr.startsWith("#count")) {
// PostgreSQL count 返回字符串
r[attr] = parseInt(value, 10);
}
else if (attr.startsWith("#sum") || attr.startsWith("#avg") || attr.startsWith("#min") || attr.startsWith("#max")) {
// PostgreSQL sum/avg/min/max 返回字符串
r[attr] = parseFloat(value);
}
else if (ToNumberAttrs.has(attr)) {
// PostgreSQL sum/avg/min/max 返回字符串
r[attr] = parseInt(value, 10);
} else {
r[attr] = value;
}
} else {
r[attr] = value;
}
}
} else {
assign(r, { [attr]: value });
}
}
function removeNullObjects<E extends keyof ED>(r: Record<string, any>, e: E) {
for (let attr in r) {
const rel = judgeRelation(schema, e, attr);
if (rel === 2) {
if (r[attr].id === null) {
assert(schema[e].toModi || r.entity !== attr);
delete r[attr];
continue;
}
removeNullObjects(r[attr], attr);
} else if (typeof rel === 'string') {
if (r[attr].id === null) {
assert(
schema[e].toModi || r[`${attr}Id`] === null,
`对象${String(e)}取数据时发现其外键找不到目标对象rowId是${r.id},其外键${attr}Id值为${r[`${attr}Id`]}`
);
delete r[attr];
continue;
}
removeNullObjects(r[attr], rel);
}
}
}
function formSingleRow(r: any): any {
let result2: Record<string, any> = {};
for (let attr in r) {
const value = r[attr];
resolveAttribute(entity, result2, attr, value);
}
removeNullObjects(result2, entity);
return result2;
}
if (result instanceof Array) {
return result.map(r => formSingleRow(r));
}
return formSingleRow(result);
}
protected async selectAbjointRowAsync<T extends keyof ED>(
entity: T,
selection: ED[T]['Selection'],
context: AsyncContext<ED>,
option?: PostgreSQLSelectOption
): Promise<Partial<ED[T]['Schema']>[]> {
const sql = this.translator.translateSelect(entity, selection, option);
const result = await this.connector.exec(sql, context.getCurrentTxnId());
return this.formResult(entity, result[0]);
}
protected async updateAbjointRowAsync<T extends keyof ED>(
entity: T,
operation: ED[T]['Operation'],
context: AsyncContext<ED>,
option?: PostgreSQLOperateOption
): Promise<number> {
const { translator, connector } = this;
const { action } = operation;
const txn = context.getCurrentTxnId();
switch (action) {
case 'create': {
const { data } = operation as ED[T]['Create'];
const sql = translator.translateInsert(entity, data instanceof Array ? data : [data]);
const result = await connector.exec(sql, txn);
// PostgreSQL QueryResult.rowCount
return (result[1] as QueryResult).rowCount || 0;
}
case 'remove': {
const sql = translator.translateRemove(entity, operation as ED[T]['Remove'], option);
const result = await connector.exec(sql, txn);
return (result[1] as QueryResult).rowCount || 0;
}
default: {
assert(!['select', 'download', 'stat'].includes(action));
const sql = translator.translateUpdate(entity, operation as ED[T]['Update'], option);
const result = await connector.exec(sql, txn);
return (result[1] as QueryResult).rowCount || 0;
}
}
}
async operate<T extends keyof ED>(
entity: T,
operation: ED[T]['Operation'],
context: Cxt,
option: OperateOption
): Promise<OperationResult<ED>> {
const { action } = operation;
assert(!['select', 'download', 'stat'].includes(action), '不支持使用 select operation');
return await super.operateAsync(entity, operation as any, context, option);
}
async select<T extends keyof ED>(
entity: T,
selection: ED[T]['Selection'],
context: Cxt,
option: SelectOption
): Promise<Partial<ED[T]['Schema']>[]> {
return await super.selectAsync(entity, selection, context, option);
}
protected async countAbjointRowAsync<T extends keyof ED>(
entity: T,
selection: Pick<ED[T]['Selection'], 'filter' | 'count'>,
context: AsyncContext<ED>,
option: SelectOption
): Promise<number> {
const sql = this.translator.translateCount(entity, selection, option);
const result = await this.connector.exec(sql, context.getCurrentTxnId());
// PostgreSQL 返回的 count 是 string 类型bigint
const cnt = result[0][0]?.cnt;
return typeof cnt === 'string' ? parseInt(cnt, 10) : (cnt || 0);
}
async count<T extends keyof ED>(
entity: T,
selection: Pick<ED[T]['Selection'], 'filter' | 'count'>,
context: Cxt,
option: SelectOption
) {
return this.countAsync(entity, selection, context, option);
}
async begin(option?: TxnOption): Promise<string> {
return await this.connector.startTransaction(option);
}
async commit(txnId: string): Promise<void> {
await this.connector.commitTransaction(txnId);
}
async rollback(txnId: string): Promise<void> {
await this.connector.rollbackTransaction(txnId);
}
async connect() {
await this.connector.connect();
}
async disconnect() {
await this.connector.disconnect();
}
async initialize(option: CreateEntityOption) {
const schema = this.getSchema();
// ===== 第一阶段:事务外创建扩展 =====
let hasGeoType = false;
let hasChineseTsConfig = false;
let chineseParser = null;
// 扫描 schema
for (const entity in schema) {
const { attributes, indexes } = schema[entity];
for (const attr in attributes) {
if (attributes[attr].type === 'geometry') {
hasGeoType = true;
}
}
for (const index of indexes || []) {
if (index.config?.tsConfig === 'chinese' || index.config?.tsConfig?.includes('chinese')) {
hasChineseTsConfig = true;
}
if (index.config?.chineseParser) {
assert(!chineseParser || chineseParser === index.config.chineseParser,
'当前定义了多个中文分词器,请保持一致');
chineseParser = index.config.chineseParser;
}
}
}
// 在事务外创建扩展
if (hasGeoType) {
console.log('Initializing PostGIS extension for geometry support...');
await this.connector.exec('CREATE EXTENSION IF NOT EXISTS postgis;');
}
if (hasChineseTsConfig) {
console.log('Initializing Chinese parser extension...');
await this.connector.exec(`CREATE EXTENSION IF NOT EXISTS ${chineseParser || 'zhparser'};`);
}
// ===== 第二阶段:事务内创建配置和表 =====
const txn = await this.connector.startTransaction({
isolationLevel: 'serializable',
});
try {
// 创建中文文本搜索配置
if (hasChineseTsConfig) {
console.log('Initializing Chinese text search configuration...');
const checkChineseConfigSql = `
SELECT COUNT(*) as cnt
FROM pg_catalog.pg_ts_config
WHERE cfgname = 'chinese';
`;
const result = await this.connector.exec(checkChineseConfigSql, txn);
const count = parseInt(result[0][0]?.cnt || '0', 10);
if (count === 0) {
const createChineseConfigSql = `
CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = ${chineseParser || 'zhparser'});
ALTER TEXT SEARCH CONFIGURATION chinese ADD MAPPING FOR n,v,a,i,e,l WITH simple;
`;
await this.connector.exec(createChineseConfigSql, txn);
}
}
// 创建实体表
for (const entity in schema) {
const sqls = this.translator.translateCreateEntity(entity, option);
for (const sql of sqls) {
await this.connector.exec(sql, txn);
}
}
await this.connector.commitTransaction(txn);
} catch (error) {
await this.connector.rollbackTransaction(txn);
throw error;
}
}
// 从数据库中读取当前schema
readSchema() {
return this.translator.readSchema((sql) => this.connector.exec(sql));
}
/**
* dataSchemaschemaupgrade
* plan分为两阶段
*/
async makeUpgradePlan() {
const originSchema = await this.readSchema();
const plan = this.diffSchema(originSchema, this.translator.schema);
return plan;
}
/**
* schema的不同new对old的增量
* @param schemaOld
* @param schemaNew
*/
diffSchema(schemaOld: StorageSchema<any>, schemaNew: StorageSchema<any>) {
const plan: Plan = {
newTables: {},
newIndexes: {},
updatedIndexes: {},
updatedTables: {},
};
for (const table in schemaNew) {
// PostgreSQL 表名区分大小写(使用双引号时)
if (schemaOld[table]) {
const { attributes, indexes } = schemaOld[table];
const { attributes: attributesNew, indexes: indexesNew } = schemaNew[table];
const assignToUpdateTables = (attr: string, isNew: boolean) => {
const skipAttrs = ['$$seq$$', '$$createAt$$', '$$updateAt$$', '$$deleteAt$$', 'id'];
if (skipAttrs.includes(attr)) {
return;
}
if (!plan.updatedTables[table]) {
plan.updatedTables[table] = {
attributes: {
[attr]: {
...attributesNew[attr],
isNew,
}
}
};
} else {
plan.updatedTables[table].attributes[attr] = {
...attributesNew[attr],
isNew,
};
}
};
for (const attr in attributesNew) {
if (attributes[attr]) {
// 比较两次创建的属性定义是否一致
const sql1 = this.translator.translateAttributeDef(attr, attributesNew[attr]);
const sql2 = this.translator.translateAttributeDef(attr, attributes[attr]);
if (!this.translator.compareSql(sql1, sql2)) {
assignToUpdateTables(attr, false);
}
} else {
assignToUpdateTables(attr, true);
}
}
if (indexesNew) {
const assignToIndexes = (index: Index<any>, isNew: boolean) => {
if (isNew) {
if (plan.newIndexes[table]) {
plan.newIndexes[table].push(index);
} else {
plan.newIndexes[table] = [index];
}
} else {
if (plan.updatedIndexes[table]) {
plan.updatedIndexes[table].push(index);
} else {
plan.updatedIndexes[table] = [index];
}
}
};
const compareConfig = (config1?: IndexConfig, config2?: IndexConfig) => {
const unique1 = config1?.unique || false;
const unique2 = config2?.unique || false;
if (unique1 !== unique2) {
return false;
}
const type1 = config1?.type || 'btree';
const type2 = config2?.type || 'btree';
// tsConfig 比较
const tsConfig1 = config1?.tsConfig;
const tsConfig2 = config2?.tsConfig;
if (JSON.stringify(tsConfig1) !== JSON.stringify(tsConfig2)) {
return false;
}
return type1 === type2;
};
for (const index of indexesNew) {
const { name, config, attributes: indexAttrs } = index;
const origin = indexes?.find(ele => ele.name === name);
if (origin) {
if (JSON.stringify(indexAttrs) !== JSON.stringify(origin.attributes)) {
assignToIndexes(index, false);
} else {
if (!compareConfig(config, origin.config)) {
assignToIndexes(index, false);
}
}
} else {
assignToIndexes(index, true);
}
}
}
} else {
plan.newTables[table] = {
attributes: schemaNew[table].attributes,
};
if (schemaNew[table].indexes) {
plan.newIndexes[table] = schemaNew[table].indexes!;
}
}
}
return plan;
}
}

2493
src/PostgreSQL/translator.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
export type PostgreSQLConfiguration = {
host: string;
user: string;
password: string;
database: string;
port?: number;
max?: number; // connection pool size
idleTimeoutMillis?: number;
connectionTimeoutMillis?: number;
}
export type Configuration = {
postgresql: PostgreSQLConfiguration;
}

View File

@ -1,2 +1,4 @@
export * from './MySQL/store';
export { MySqlSelectOption, MysqlOperateOption} from './MySQL/translator';
export { MySqlSelectOption, MysqlOperateOption} from './MySQL/translator';
export * from './PostgreSQL/store';
export { PostgreSQLSelectOption, PostgreSQLOperateOption } from './PostgreSQL/translator';

View File

@ -4,7 +4,8 @@ import { assign, cloneDeep, difference, identity, intersection, keys, set } from
import {
Attribute, EntityDict, EXPRESSION_PREFIX, Index, OperateOption,
Q_FullTextValue, Ref, RefOrExpression, SelectOption, StorageSchema, SubQueryPredicateMetadata,
TriggerDataAttribute, CreateAtAttribute, UpdateAtAttribute, DeleteAtAttribute, SeqAttribute, TriggerUuidAttribute
TriggerDataAttribute, CreateAtAttribute, UpdateAtAttribute, DeleteAtAttribute, SeqAttribute, TriggerUuidAttribute,
PrimaryKeyAttribute
} from "oak-domain/lib/types";
import { EntityDict as BaseEntityDict } from 'oak-domain/lib/base-app-domain';
import { DataType } from "oak-domain/lib/types/schema/DataTypes";
@ -29,7 +30,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
const { attributes, indexes } = schema[entity];
// 增加默认的属性
assign(attributes, {
id: {
[PrimaryKeyAttribute]: {
type: 'char',
params: {
length: 36,
@ -78,9 +79,11 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
name: DeleteAtAttribute,
}],
}, {
name: `${entity}_trigger_uuid`,
name: `${entity}_trigger_uuid_auto_create`,
attributes: [{
name: TriggerUuidAttribute,
}, {
name: DeleteAtAttribute,
}]
},
];
@ -96,7 +99,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
attributes: [{
name: attr,
}, {
name: '$$deleteAt$$',
name: DeleteAtAttribute,
}]
});
}
@ -115,7 +118,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
}, {
name: 'entityId',
}, {
name: '$$deleteAt$$',
name: DeleteAtAttribute,
}]
});
}
@ -131,7 +134,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
attributes: [{
name: attr,
}, {
name: '$$deleteAt$$',
name: DeleteAtAttribute,
}]
});
}
@ -150,7 +153,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
}, {
name: 'expiresAt',
}, {
name: '$$deleteAt$$',
name: DeleteAtAttribute,
}]
});
}
@ -159,6 +162,18 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
}
if (indexes) {
for (const index of indexes) {
const { attributes, config } = index;
if (!config?.type || config.type === 'btree') {
if (!attributes.find(
(ele) => ele.name === DeleteAtAttribute
)) {
attributes.push({
name: DeleteAtAttribute,
});
}
}
}
indexes.push(...intrinsticIndexes);
}
else {
@ -224,7 +239,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
expression: RefOrExpression<keyof ED[T]['OpSchema']>,
refDict: Record<string, [string, keyof ED]>): string;
private getStorageName<T extends keyof ED>(entity: T) {
protected getStorageName<T extends keyof ED>(entity: T) {
const { storageName } = this.schema[entity];
return (storageName || entity) as string;
}
@ -233,7 +248,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
const { schema } = this;
const { attributes, storageName = entity } = schema[entity];
let sql = `insert into \`${storageName as string}\`(`;
let sql = `insert into ${this.quoteIdentifier(storageName as string)}(`;
/**
* attrs要用所有行的union集合
@ -244,7 +259,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
);
attrs.forEach(
(attr, idx) => {
sql += ` \`${attr}\``;
sql += ` ${this.quoteIdentifier(attr)}`;
if (idx < attrs.length - 1) {
sql += ',';
}
@ -314,7 +329,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
const filterRefAlias: Record<string, [string, keyof ED]> = {};
const alias = `${entity as string}_${number++}`;
let from = ` \`${this.getStorageName(entity)}\` \`${alias}\` `;
let from = ` ${this.quoteIdentifier(this.getStorageName(entity))} ${this.quoteIdentifier(alias)} `;
const aliasDict: Record<string, string> = {
'./': alias,
};
@ -358,7 +373,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
assign(aliasDict, {
[pathAttr]: alias2,
});
from += ` left join \`${this.getStorageName(rel)}\` \`${alias2}\` on \`${alias}\`.\`${op}Id\` = \`${alias2}\`.\`id\``;
from += ` left join ${this.quoteIdentifier(this.getStorageName(rel))} ${this.quoteIdentifier(alias2)} on ${this.quoteIdentifier(alias)}.${this.quoteIdentifier(op + 'Id')} = ${this.quoteIdentifier(alias2)}.${this.quoteIdentifier('id')}`;
}
else {
alias2 = aliasDict[pathAttr];
@ -378,7 +393,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
assign(aliasDict, {
[pathAttr]: alias2,
});
from += ` left join \`${this.getStorageName(op)}\` \`${alias2}\` on \`${alias}\`.\`entityId\` = \`${alias2}\`.\`id\` and \`${alias}\`.\`entity\` = '${op}'`;
from += ` left join ${this.quoteIdentifier(this.getStorageName(op))} ${this.quoteIdentifier(alias2)} on ${this.quoteIdentifier(alias)}.${this.quoteIdentifier('entityId')} = ${this.quoteIdentifier(alias2)}.${this.quoteIdentifier('id')} and ${this.quoteIdentifier(alias)}.${this.quoteIdentifier('entity')} = '${op}'`;
}
else {
alias2 = aliasDict[pathAttr];
@ -430,7 +445,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
assign(aliasDict, {
[pathAttr]: alias2,
});
from += ` left join \`${this.getStorageName(rel)}\` \`${alias2}\` on \`${alias}\`.\`${attr}Id\` = \`${alias2}\`.\`id\``;
from += ` left join ${this.quoteIdentifier(this.getStorageName(rel))} ${this.quoteIdentifier(alias2)} on ${this.quoteIdentifier(alias)}.${this.quoteIdentifier(attr + 'Id')} = ${this.quoteIdentifier(alias2)}.${this.quoteIdentifier('id')}`;
}
else {
alias2 = aliasDict[pathAttr];
@ -450,7 +465,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
assign(aliasDict, {
[pathAttr]: alias2,
});
from += ` left join \`${this.getStorageName(attr)}\` \`${alias2}\` on \`${alias}\`.\`entityId\` = \`${alias2}\`.\`id\` and \`${alias}\`.\`entity\` = '${attr}'`;
from += ` left join ${this.quoteIdentifier(this.getStorageName(attr))} ${this.quoteIdentifier(alias2)} on ${this.quoteIdentifier(alias)}.${this.quoteIdentifier('entityId')} = ${this.quoteIdentifier(alias2)}.${this.quoteIdentifier('id')} and ${this.quoteIdentifier(alias)}.${this.quoteIdentifier('entity')} = '${attr}'`;
}
else {
alias2 = aliasDict[pathAttr];
@ -499,7 +514,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
assign(aliasDict, {
[pathAttr]: alias2,
});
from += ` left join \`${this.getStorageName(rel)}\` \`${alias2}\` on \`${alias}\`.\`${attr}Id\` = \`${alias2}\`.\`id\``;
from += ` left join ${this.quoteIdentifier(this.getStorageName(rel))} ${this.quoteIdentifier(alias2)} on ${this.quoteIdentifier(alias)}.${this.quoteIdentifier(attr + 'Id')} = ${this.quoteIdentifier(alias2)}.${this.quoteIdentifier('id')}`;
}
else {
alias2 = aliasDict[pathAttr];
@ -521,7 +536,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
assign(aliasDict, {
[pathAttr]: alias2,
});
from += ` left join \`${this.getStorageName(attr)}\` \`${alias2}\` on \`${alias}\`.\`entityId\` = \`${alias2}\`.\`id\` and \`${alias}\`.\`entity\` = '${attr}'`;
from += ` left join ${this.quoteIdentifier(this.getStorageName(attr))} ${this.quoteIdentifier(alias2)} on ${this.quoteIdentifier(alias)}.${this.quoteIdentifier('entityId')} = ${this.quoteIdentifier(alias2)}.${this.quoteIdentifier('id')} and ${this.quoteIdentifier(alias)}.${this.quoteIdentifier('entity')} = '${attr}'`;
}
else {
alias2 = aliasDict[pathAttr];
@ -567,10 +582,18 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
};
}
/**
* like模式中的特殊字符进行转义
* % _
* @param pattern like模式字符串
* @returns
*/
escapeLikePattern(pattern: string): string {
return pattern.replace(/[%_]/g, (match) => `\\${match}`);
}
private translateComparison(attr: string, value: any, type?: DataType | Ref): string {
const SQL_OP: {
[op: string]: string,
} = {
const SQL_OP: { [op: string]: string } = {
$gt: '>',
$lt: '<',
$gte: '>=',
@ -590,16 +613,19 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
switch (attr) {
case '$startsWith': {
return ` like '${value}%'`;
const escaped = this.escapeLikePattern(value);
return ` LIKE '${escaped}%'`;
}
case '$endsWith': {
return ` like '%${value}'`;
const escaped = this.escapeLikePattern(value);
return ` LIKE '%${escaped}'`;
}
case '$includes': {
return ` like '%${value}%'`;
const escaped = this.escapeLikePattern(value);
return ` LIKE '%${escaped}%'`;
}
default: {
throw new Error(`unrecoganized comparison operator ${attr}`);
throw new Error(`unrecognized comparison operator ${attr}`);
}
}
}
@ -650,7 +676,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
const values = value.map(
(v: string | number) => {
if (type && ['varchar', 'char', 'text', 'nvarchar', 'ref', 'enum'].includes(type as string) || typeof v === 'string') {
return `'${v}'`;
return this.escapeStringValue(String(v));
}
else {
return `${v}`;
@ -669,7 +695,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
const values = value.map(
(v: string | number) => {
if (type && ['varchar', 'char', 'text', 'nvarchar', 'ref', 'enum'].includes(type as string) || typeof v === 'string') {
return `'${v}'`;
return this.escapeStringValue(String(v));
}
else {
return `${v}`;
@ -692,7 +718,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
}
}
private translateFilter<T extends keyof ED, OP extends SqlSelectOption>(
protected translateFilter<T extends keyof ED, OP extends SqlSelectOption>(
entity: T,
filter: ED[T]['Selection']['filter'],
aliasDict: Record<string, string>,
@ -801,7 +827,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
}, currentNumber, filterRefAlias, option);
currentNumber = ct2;
whereText += `(${refAlia2}.id ${predicate} (${stmt}))`;
whereText += `(${this.quoteIdentifier(alias)}.${this.quoteIdentifier('id')} ${predicate} (${stmt}))`;
}
else {
/**
@ -859,15 +885,19 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
whereText += `(${this.translateObjectPredicate(filter2[attr], alias, attr)})`;
}
else {
if (!filter2[attr]) {
throw new Error(`属性${attr}的查询条件不能为null或undefined`);
}
assert(Object.keys(filter2[attr]).length === 1);
const predicate = Object.keys(filter2[attr])[0];
assert(predicate.startsWith('$'));
// 对属性上的谓词处理
whereText += ` (\`${alias}\`.\`${attr}\` ${this.translatePredicate(predicate, filter2[attr][predicate], type2)})`;
whereText += ` (${this.quoteIdentifier(alias)}.${this.quoteIdentifier(attr)} ${this.translatePredicate(predicate, filter2[attr][predicate], type2)})`;
}
}
else {
whereText += ` (\`${alias}\`.\`${attr}\` = ${this.translateAttrValue(type2, filter2[attr])})`;
whereText += ` (${this.quoteIdentifier(alias)}.${this.quoteIdentifier(attr)} = ${this.translateAttrValue(type2, filter2[attr])})`;
}
}
}
@ -898,7 +928,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
return this.translateExpression(entity2, alias, sortAttr[attr] as any, {});
}
else if (sortAttr[attr] === 1) {
return `\`${alias}\`.\`${attr}\``;
return `${this.quoteIdentifier(alias)}.${this.quoteIdentifier(attr)}`;
}
else {
const rel = judgeRelation(this.schema, entity2, attr);
@ -966,12 +996,12 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
projText += ` ${exprText}`;
}
else {
projText += ` ${exprText} as \`${prefix2}${attr}\``;
projText += ` ${exprText} as ${this.quoteIdentifier(prefix2 + attr)}`;
if (!as) {
as = `\`${prefix2}${attr}\``;
as = this.quoteIdentifier(prefix2 + attr);
}
else {
as += `, \`${prefix2}${attr}\``;
as += `, ${this.quoteIdentifier(prefix2 + attr)}`;
}
}
}
@ -990,12 +1020,12 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
projText += ` ${this.translateAttrProjection(type as DataType, alias, attr)}`;
}
else {
projText += ` ${this.translateAttrProjection(type as DataType, alias, attr)} as \`${prefix2}${attr}\``;
projText += ` ${this.translateAttrProjection(type as DataType, alias, attr)} as ${this.quoteIdentifier(prefix2 + attr)}`;
if (!as) {
as = `\`${prefix2}${attr}\``;
as = this.quoteIdentifier(prefix2 + attr);
}
else {
as += `, \`${prefix2}${attr}\``;
as += `, ${this.quoteIdentifier(prefix2 + attr)}`;
}
}
}
@ -1011,12 +1041,12 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
projText += ` ${this.translateAttrProjection(type as DataType, alias, attr)}`;
}
else {
projText += ` ${this.translateAttrProjection(type as DataType, alias, attr)} as \`${prefix2}${projection2[attr]}\``;
projText += ` ${this.translateAttrProjection(type as DataType, alias, attr)} as ${this.quoteIdentifier(`${prefix2}${projection2[attr]}`)}`;
if (!as) {
as = `\`${prefix2}${projection2[attr]}\``;
as = this.quoteIdentifier(`${prefix2}${projection2[attr]}`);
}
else {
as += `\`${prefix2}${projection2[attr]}\``;
as += `, ${this.quoteIdentifier(`${prefix2}${projection2[attr]}`)}`;
}
}
}
@ -1102,29 +1132,29 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
let { projText: projSubText } = this.translateProjection(entity, (data as any)[k]!, aliasDict, projectionRefAlias, undefined, true);
let projSubText2 = '';
if (k.startsWith('#max')) {
projSubText2 = `max(${projSubText}) as \`${k}\``;
projSubText2 = `max(${projSubText}) as ${this.quoteIdentifier(k)}`;
}
else if (k.startsWith('#min')) {
projSubText2 = `min(${projSubText}) as \`${k}\``;
projSubText2 = `min(${projSubText}) as ${this.quoteIdentifier(k)}`;
}
else if (k.startsWith('#count')) {
if (data.distinct) {
projSubText = `distinct ${projSubText}`;
}
projSubText2 = `count(${projSubText}) as \`${k}\``;
projSubText2 = `count(${projSubText}) as ${this.quoteIdentifier(k)}`;
}
else if (k.startsWith('#sum')) {
if (data.distinct) {
projSubText = `distinct ${projSubText}`;
}
projSubText2 = `sum(${projSubText}) as \`${k}\``;
projSubText2 = `sum(${projSubText}) as ${this.quoteIdentifier(k)}`;
}
else {
if (data.distinct) {
projSubText = `distinct ${projSubText}`;
}
assert(k.startsWith('#avg'));
projSubText2 = `avg(${projSubText}) as \`${k}\``;
projSubText2 = `avg(${projSubText}) as ${this.quoteIdentifier(k)}`;
}
if (!projText) {
projText = projSubText2;
@ -1185,7 +1215,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
// delete只支持对volatile trigger的metadata域赋值
assert([TriggerDataAttribute, TriggerUuidAttribute, DeleteAtAttribute, UpdateAtAttribute].includes(attr));
const value = this.translateAttrValue(attributes[attr].type as DataType, data[attr]);
updateText += `\`${alias}\`.\`${attr}\` = ${value}`;
updateText += `${this.quoteIdentifier(alias)}.${this.quoteIdentifier(attr)} = ${value}`;
}
}
return this.populateRemoveStmt(updateText, fromText, aliasDict, filterText, /* sorterText */ undefined, indexFrom, count, option);
@ -1206,7 +1236,7 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
}
assert(attributes.hasOwnProperty(attr));
const value = this.translateAttrValue(attributes[attr].type as DataType, data[attr]);
updateText += `\`${alias}\`.\`${attr}\` = ${value}`;
updateText += `${this.quoteIdentifier(alias)}.${this.quoteIdentifier(attr)} = ${value}`;
}
const { stmt: filterText } = this.translateFilter(entity, filter, aliasDict, filterRefAlias, currentNumber, option);
@ -1221,10 +1251,10 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
let sql: string;
if (view) {
sql = `drop view if exists \`${storageName}\``;
sql = `drop view if exists ${this.quoteIdentifier(storageName)}`;
}
else {
sql = truncate ? `truncate table \`${storageName}\`` : `drop table if exists \`${storageName}\``;
sql = truncate ? `truncate table ${this.quoteIdentifier(storageName)}` : `drop table if exists ${this.quoteIdentifier(storageName)}`;
}
return sql;
@ -1235,4 +1265,16 @@ export abstract class SqlTranslator<ED extends EntityDict & BaseEntityDict> {
const result = SqlString.escape(value);
return result;
}
}
/**比较两段sql是否完全一致这里是把所有的空格去掉了 */
compareSql(sql1: string, sql2: string) {
const reg = /[\t\r\f\n\s]/g;
return sql1.replaceAll(reg, '') === sql2.replaceAll(reg, '');
}
quoteIdentifier(name: string): string {
return `\`${name}\``; // MySQL 默认
}
}

View File

@ -0,0 +1,8 @@
import { PostgreSQLConfiguration } from './../PostgreSQL/types/Configuration';
import { MySQLConfiguration } from './../MySQL/types/Configuration';
export type DbConfiguration = PostgreSQLConfiguration & {
type: 'postgresql';
} | MySQLConfiguration & {
type: 'mysql';
}

61
src/types/dbStore.ts Normal file
View File

@ -0,0 +1,61 @@
import {
Attribute,
EntityDict,
Index,
OperateOption,
OperationResult,
StorageSchema,
TxnOption,
} from 'oak-domain/lib/types';
import { EntityDict as BaseEntityDict } from 'oak-domain/lib/base-app-domain';
import { AsyncContext,AsyncRowStore } from "oak-domain/lib/store/AsyncRowStore";
import { CreateEntityOption } from "./Translator";
import { AggregationResult, SelectOption } from "oak-domain/lib/types";
export type Plan = {
newTables: Record<string, {
attributes: Record<string, Attribute>;
}>;
newIndexes: Record<string, Index<any>[]>;
updatedTables: Record<string, {
attributes: Record<string, Attribute & { isNew: boolean }>;
}>;
updatedIndexes: Record<string, Index<any>[]>;
};
export interface DbStore<ED extends EntityDict & BaseEntityDict, Cxt extends AsyncContext<ED>> extends AsyncRowStore<ED, Cxt> {
checkRelationAsync<T extends keyof ED, Cxt extends AsyncContext<ED>>(entity: T, operation: Omit<ED[T]['Operation'] | ED[T]['Selection'], 'id'>, context: Cxt): Promise<void>;
connect: () => Promise<void>;
disconnect: () => Promise<void>;
initialize(options: CreateEntityOption): Promise<void>;
aggregate<T extends keyof ED, OP extends SelectOption>(
entity: T,
aggregation: ED[T]['Aggregation'],
context: Cxt,
option: OP
): Promise<AggregationResult<ED[T]['Schema']>>;
operate<T extends keyof ED>(
entity: T,
operation: ED[T]['Operation'],
context: Cxt,
option: OperateOption
): Promise<OperationResult<ED>>;
select<T extends keyof ED>(
entity: T,
selection: ED[T]['Selection'],
context: Cxt,
option: SelectOption
): Promise<Partial<ED[T]['Schema']>[]>;
count<T extends keyof ED>(
entity: T,
selection: Pick<ED[T]['Selection'], 'filter' | 'count'>,
context: Cxt,
option: SelectOption
): Promise<number>;
begin(option?: TxnOption): Promise<string>;
commit(txnId: string): Promise<void>;
rollback(txnId: string): Promise<void>;
readSchema(): Promise<StorageSchema<ED>>;
makeUpgradePlan(): Promise<Plan>;
diffSchema(schemaOld: StorageSchema<any>, schemaNew: StorageSchema<any>): Plan;
};

View File

@ -0,0 +1,53 @@
import { String, Int, Datetime, Image, Boolean, Text } from 'oak-domain/lib/types/DataType';
import { EntityShape } from 'oak-domain/lib/types/Entity';
import { Schema as System } from './System';
import { EntityDesc } from 'oak-domain/lib/types/EntityDesc';
export type AppType = 'web' | 'wechatMp' | 'wechatPublic' | 'native';
export interface Schema extends EntityShape {
name: String<32>;
description?: Text;
type: AppType;
system: System;
};
const entityDesc: EntityDesc<
Schema,
'',
'',
{
type: Schema['type'];
}
> = {
locales: {
zh_CN: {
name: '应用',
attr: {
description: '描述',
type: '类型',
system: '系统',
name: '名称',
},
v: {
type: {
web: '网站',
wechatPublic: '微信公众号',
wechatMp: '微信小程序',
native: 'App',
},
},
},
},
style: {
color: {
type: {
wechatMp: '#32CD32',
web: '#00FF7F',
wechatPublic: '#90EE90',
native: '#008000',
}
}
}
};

55
test/entities/Area.ts Normal file
View File

@ -0,0 +1,55 @@
import { String, Geo } from 'oak-domain/lib/types/DataType';
import { EntityShape, Configuration } from 'oak-domain/lib/types/Entity';
import { EntityDesc } from 'oak-domain/lib/types/EntityDesc';
export interface Schema extends EntityShape {
name: String<32>;
level: 'province' | 'city' | 'district' | 'street' | 'country';
depth: 0 | 1 | 2 | 3 | 4;
parent?: Schema;
code: String<12>;
center: Geo;
};
const entityDesc: EntityDesc<Schema, '', '', {
level: Schema['level'];
}> = {
locales: {
zh_CN: {
name: '地区',
attr: {
level: '层级',
depth: '深度',
parent: '上级地区',
name: '名称',
code: '地区编码',
center: '中心坐标',
},
v: {
level: {
country: '国家',
province: '省',
city: '市',
district: '区',
street: '街道',
}
}
},
},
configuration: {
actionType: 'readOnly',
static: true,
},
style: {
color: {
level: {
province: '#00FF7F',
city: '#1E90FF',
district: '#4682B4',
street: '#808080',
country: '#2F4F4F',
}
}
}
};

View File

@ -1,27 +1,27 @@
import { String, Int, Datetime, Image, Boolean, Text, Float } from 'oak-domain/lib/types/DataType';
import { Schema as Area } from 'oak-general-business/lib/entities/Area';
import { Schema as User } from 'oak-general-business/lib/entities/User';
import { Schema as ExtraFile } from 'oak-general-business/lib/entities/ExtraFile';
import { EntityShape } from 'oak-domain/lib/types';
import { LocaleDef } from 'oak-domain/lib/types/Locale';
import { String, Int, Datetime, Image, Boolean, Text, Float } from 'oak-domain/lib/types/DataType';
import { Schema as User } from './User';
import { Schema as Area } from './Area';
import { EntityDesc, EntityShape } from 'oak-domain/lib/types';
export interface Schema extends EntityShape {
district: String<16>;
area: Area;
owner: User;
dd: Array<ExtraFile>;
size: Float<4, 2>;
size: Float<8, 4>;
data?: Object;
};
const locale: LocaleDef<Schema, '', '', {}> = {
zh_CN: {
name: '房屋',
attr: {
district: '街区',
area: '地区',
owner: '房主',
dd: '文件',
size: '面积',
const entityDesc: EntityDesc<Schema> = {
locales: {
zh_CN: {
name: '房屋',
attr: {
district: '街区',
area: '地区',
owner: '房主',
size: '面积',
data: '数据',
},
},
},
}
};

24
test/entities/Platform.ts Normal file
View File

@ -0,0 +1,24 @@
import { String, Int, Datetime, Image, Boolean, Text } from 'oak-domain/lib/types/DataType';
import { EntityShape } from 'oak-domain/lib/types/Entity';
import { EntityDesc } from 'oak-domain/lib/types/EntityDesc';
export interface Schema extends EntityShape {
name: String<32>;
description?: Text;
entity?: String<32>; // System是抽象对象应用上级与之一对一的对象可以使用双向指针以方便编程
entityId?: String<64>;
};
export const entityDesc: EntityDesc<Schema> = {
locales: {
zh_CN: {
name: '平台',
attr: {
name: '名称',
description: '描述',
entity: '关联对象',
entityId: '关联对象id',
},
},
}
};

31
test/entities/System.ts Normal file
View File

@ -0,0 +1,31 @@
import { String, Boolean, Text } from 'oak-domain/lib/types/DataType';
import { EntityShape } from 'oak-domain/lib/types/Entity';
import { EntityDesc } from 'oak-domain/lib/types/EntityDesc';
import { Schema as Platform } from './Platform';
export interface Schema extends EntityShape {
name: String<32>;
description?: Text;
folder?: String<16>;
platform?: Platform;
super?: Boolean; // super表示是这个platform本身的系统可以操作application/system这些数据也可以访问超出本system的其它数据。
entity?: String<32>; // System是抽象对象应用上级与之一对一的对象可以使用双向指针以方便编程
entityId?: String<64>;
};
export const entityDesc: EntityDesc<Schema> = {
locales: {
zh_CN: {
name: '系统',
attr: {
name: '名称',
description: '描述',
super: '超级系统',
folder: '代码目录名',
entity: '关联对象',
entityId: '关联对象id',
platform: '平台',
},
},
}
};

91
test/entities/Token.ts Normal file
View File

@ -0,0 +1,91 @@
import { String, Int, Datetime, Image, Boolean } from 'oak-domain/lib/types/DataType';
import { Schema as User } from 'oak-domain/lib/entities/User';
import { Schema as Application } from './Application';
import { AbleAction, AbleState, makeAbleActionDef } from 'oak-domain/lib/actions/action';
import { EntityShape } from 'oak-domain/lib/types/Entity';
import { ActionDef } from 'oak-domain/lib/types/Action';
import { EntityDesc } from 'oak-domain/lib/types/EntityDesc';
import { Environment } from 'oak-domain/lib/types/Environment';
export interface Schema extends EntityShape {
application?: Application;
entity?: String<32>;
entityId?: String<64>;
user?: User;
player?: User;
disablesAt?: Datetime;
env: Environment;
refreshedAt: Datetime;
value: String<64>;
oldValue?: String<64>;
};
export type Action = AbleAction;
export const AbleActionDef: ActionDef<AbleAction, AbleState> = makeAbleActionDef('enabled');
export const entityDesc: EntityDesc<
Schema,
Action,
'',
{
ableState: AbleState;
}
> = {
locales: {
zh_CN: {
name: '令牌',
attr: {
application: '应用',
entity: '关联对象',
entityId: '关联对象id',
user: '用户',
player: '扮演者',
env: '环境',
ableState: '状态',
disablesAt: '禁用时间',
refreshedAt: '刷新时间',
value: '令牌值',
oldValue: "老令牌",
},
action: {
enable: '激活',
disable: '禁用',
},
v: {
ableState: {
enabled: '使用中',
disabled: '已禁用',
},
},
},
},
indexes: [
{
name: 'index_value',
attributes: [
{
name: 'value',
},
{
name: '$$deleteAt$$',
},
],
config: {
unique: true,
},
},
],
style: {
icon: {
enable: '',
disable: '',
},
color: {
ableState: {
enabled: '#008000',
disabled: '#A9A9A9',
},
},
},
};

176
test/entities/User.ts Normal file
View File

@ -0,0 +1,176 @@
import { String, Int, Text, Boolean, Datetime } from 'oak-domain/lib/types/DataType';
import { ActionDef } from 'oak-domain/lib/types/Action';
import { EntityShape } from 'oak-domain/lib/types/Entity';
import { EntityDesc } from 'oak-domain/lib/types/EntityDesc';
import { Schema as User } from 'oak-domain/lib/entities/User';
export interface Schema extends User {
passwordSha1?: Text;
birth?: Datetime;
gender?: 'male' | 'female';
idCardType?: 'ID-Card' | 'passport' | 'Mainland-passport';
idNumber?: String<32>;
isRoot?: Boolean;
hasPassword?: Boolean;
};
export type IdAction = 'verify' | 'accept' | 'reject';
export type IdState = 'unverified' | 'verified' | 'verifying';
export const IdActionDef: ActionDef<IdAction, IdState> = {
stm: {
verify: ['unverified', 'verifying'],
accept: [['unverified', 'verifying'], 'verified'],
reject: [['verifying', 'verified'], 'unverified'],
},
is: 'unverified',
};
export type UserAction = 'activate' | 'disable' | 'enable' | 'mergeTo' | 'mergeFrom';
export type UserState = 'shadow' | 'normal' | 'disabled' | 'merged';
export const UserActionDef: ActionDef<UserAction, UserState> = {
stm: {
activate: ['shadow', 'normal'],
disable: [['normal', 'shadow'], 'disabled'],
enable: ['disabled', 'normal'],
mergeTo: [['normal', 'shadow'], 'merged'],
mergeFrom: ['normal', 'normal'],
},
};
export type Action = UserAction | IdAction;
export const entityDesc: EntityDesc<
Schema,
Action,
'',
{
userState: UserState;
idState: IdState;
gender: Required<Schema>['gender'];
idCardType: Required<Schema>['idCardType'];
}
> = {
locales: {
zh_CN: {
name: '用户',
attr: {
name: '姓名',
nickname: '昵称',
birth: '生日',
password: '密码',
passwordSha1: 'sha1加密密码',
gender: '性别',
idCardType: '证件类型',
idNumber: '证件号码',
ref: '指向用户',
userState: '用户状态',
idState: '认证状态',
isRoot: '是否超级用户',
hasPassword: '用户是否存在密码'
},
action: {
activate: '激活',
accept: '同意',
verify: '认证',
reject: '拒绝',
enable: '启用',
disable: '禁用',
mergeTo: '合并',
mergeFrom: '使合并',
},
v: {
userState: {
shadow: '未激活',
normal: '正常',
disabled: '禁用',
merged: '已被合并',
},
idState: {
unverified: '未认证',
verifying: '认证中',
verified: '已认证',
},
gender: {
male: '男',
female: '女',
},
idCardType: {
'ID-Card': '身份证',
passport: '护照',
'Mainland-passport': '港澳台通行证',
},
},
},
},
indexes: [
{
name: 'index_birth',
attributes: [
{
name: 'birth',
direction: 'ASC',
},
],
},
{
name: 'index_fulltext',
attributes: [
{
name: 'name',
},
{
name: 'nickname',
},
],
config: {
type: 'fulltext',
parser: 'ngram',
},
},
{
name: 'index_userState_refId',
attributes: [
{
name: 'userState',
},
{
name: 'ref',
},
],
},
],
style: {
icon: {
verify: '',
accept: '',
reject: '',
activate: '',
enable: '',
disable: '',
mergeTo: '',
mergeFrom: '',
},
color: {
userState: {
normal: '#0000FF',
disabled: '#FF0000',
merged: '#9A9A9A',
shadow: '#D3D3D3',
},
idState: {
unverified: '#FF0000',
verified: '#0000FF',
verifying: '#EEE8AA',
},
gender: {
male: '#0000FF',
female: '#EE82EE',
},
idCardType: {
'ID-Card': '#E0FFFF',
'Mainland-passport': '#2E8B57',
passport: '#2F4F4F',
},
},
},
};

File diff suppressed because it is too large Load Diff

34
test/testPostgresStore.ts Normal file
View File

@ -0,0 +1,34 @@
import { PostgreSQLStore } from '../src/PostgreSQL/store';
import assert from 'assert';
import { TestContext } from './Context';
import { v4 } from 'uuid';
import { EntityDict, storageSchema } from './test-app-domain';
import { generateNewIdAsync } from 'oak-domain/lib/utils/uuid';
import { describe, it, before, after } from 'mocha';
import { tests } from './testcase';
describe('test postgresstore', async function () {
this.timeout(60000);
let store: PostgreSQLStore<EntityDict, TestContext>;
before(async () => {
store = new PostgreSQLStore(storageSchema, {
host: 'localhost',
database: 'oakdb',
user: 'postgres',
password: 'postgres',
max: 20,
port: 5432,
});
store.connect();
await store.initialize({
ifExists: 'drop',
});
});
tests(() => store!)
after(() => {
store.disconnect();
});
});

View File

@ -1,5 +1,5 @@
import { describe, it } from 'mocha';
import { MySqlTranslator } from '../src/MySQL/translator';
import { MySqlTranslator } from '../lib/MySQL/translator';
import { EntityDict, storageSchema } from './test-app-domain';
describe('test MysqlTranslator', function () {
@ -33,10 +33,6 @@ describe('test MysqlTranslator', function () {
id: 1,
$$createAt$$: 1,
userId: 1,
mobile: {
id: 1,
mobile: 1,
},
},
});
// console.log(sql);
@ -47,10 +43,6 @@ describe('test MysqlTranslator', function () {
id: 1,
$$createAt$$: 1,
userId: 1,
mobile: {
id: 1,
mobile: 1,
},
},
distinct: true,
});
@ -110,10 +102,9 @@ describe('test MysqlTranslator', function () {
},
},
'#aggr': {
email: {
email: 1,
$$createAt$$: 1,
},
env: {
type: 1,
}
},
},
filter: {
@ -158,24 +149,10 @@ describe('test MysqlTranslator', function () {
id: 1,
$$createAt$$: 1,
userId: 1,
mobile: {
id: 1,
mobile: 1,
},
},
filter: {
id: 'xc',
$$createAt$$: 1,
mobile: {
$or: [
{
id: 'mob',
},
{
mobile: '135',
}
]
},
},
});
console.log(sql);
@ -190,14 +167,15 @@ describe('test MysqlTranslator', function () {
filter: {
id: 'xc',
$$createAt$$: 1,
mobile$user: {
id: '123',
},
$and: [
{
mobile$user: {
"#sqp": 'all',
mobile: '456',
token$user: {
entity: 'email',
}
},
{
token$user: {
entity: 'mobile',
}
}
]

323
test/testcase/aggr.ts Normal file
View File

@ -0,0 +1,323 @@
import { TestContext } from "../Context";
import { EntityDict } from "../test-app-domain";
import { v4 } from 'uuid';
import assert from 'assert';
import { generateNewIdAsync } from 'oak-domain/lib/utils/uuid';
import { describe, it, before, after } from 'mocha';
import { DbStore } from "../../lib/types/dbStore";
export default (storeGetter: () => DbStore<EntityDict, TestContext>) => {
// ==================== 聚合函数测试 ====================
it('[7.1]aggregation $$count', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const systemId = v4();
await store.operate('system', {
id: v4(),
action: 'create',
data: {
id: systemId,
name: 'test-count',
description: 'test',
folder: '/test',
application$system: [
{ id: v4(), action: 'create', data: { id: v4(), name: 'app1', description: 't1', type: 'web' } },
{ id: v4(), action: 'create', data: { id: v4(), name: 'app2', description: 't2', type: 'web' } },
{ id: v4(), action: 'create', data: { id: v4(), name: 'app3', description: 't3', type: 'wechatMp' } },
]
}
} as EntityDict['system']['CreateSingle'], context, {});
await context.commit();
const result = await store.aggregate('application', {
data: {
'#aggr': { systemId: 1 },
'#count-1': { id: 1 }
},
filter: { systemId }
}, context, {});
assert(result.length === 1, `Expected 1 group`);
assert(result[0]['#count-1'] === 3, `Expected count 3, got ${result[0]['#count-1']}`);
});
it('[1.17]aggregation functions $$sum, $$max, $$min, $$avg', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const areaId = v4();
const ownerId = v4();
// 创建多个房屋记录用于测试聚合函数
await store.operate('house', {
id: v4(),
action: 'create',
data: [
{
id: v4(),
areaId,
ownerId,
district: '杭州',
size: 80.0,
},
{
id: v4(),
areaId,
ownerId,
district: '杭州',
size: 100.0,
},
{
id: v4(),
areaId,
ownerId,
district: '杭州',
size: 120.0,
},
{
id: v4(),
areaId,
ownerId,
district: '上海',
size: 150.0,
},
]
}, context, {});
await context.commit();
// 测试 $$count
const countResult = await store.aggregate('house', {
data: {
'#aggr': {
district: 1,
},
'#count-1': {
id: 1,
}
},
filter: {
areaId,
},
}, context, {});
assert(countResult.length === 2, `Expected 2 groups, got ${countResult.length}`);
const hangzhouCount = countResult.find(r => r['#data']?.district === '杭州');
const shanghaiCount = countResult.find(r => r['#data']?.district === '上海');
assert(hangzhouCount && hangzhouCount['#count-1'] === 3, `Expected 3 houses in 杭州, got ${hangzhouCount?.['#count-1']}`);
assert(shanghaiCount && shanghaiCount['#count-1'] === 1, `Expected 1 house in 上海, got ${shanghaiCount?.['#count-1']}`);
// TODO: 下面的测试用例暂不支持,先注释掉
// 测试 $$sum
// const sumResult = await store.aggregate('house', {
// data: {
// '#aggr': {
// district: 1,
// },
// '#sum-1': {
// $$sum: {
// '#attr': 'size',
// },
// }
// },
// filter: {
// areaId,
// },
// }, context, {});
// const hangzhouSum = sumResult.find(r => r['#data']?.district === '杭州');
// assert(hangzhouSum && hangzhouSum['#sum-1'] === 300, `Expected sum 300 for 杭州, got ${hangzhouSum?.['#sum-1']}`);
// 测试 $$max
// const maxResult = await store.aggregate('house', {
// data: {
// '#aggr': {
// district: 1,
// },
// '#max-1': {
// $$max: {
// '#attr': 'size',
// },
// }
// },
// filter: {
// areaId,
// },
// }, context, {});
// const hangzhouMax = maxResult.find(r => r['#data']?.district === '杭州');
// assert(hangzhouMax && hangzhouMax['#max-1'] === 120, `Expected max 120 for 杭州, got ${hangzhouMax?.['#max-1']}`);
// 测试 $$min
// const minResult = await store.aggregate('house', {
// data: {
// '#aggr': {
// district: 1,
// },
// '#min-1': {
// $$min: {
// '#attr': 'size',
// },
// }
// },
// filter: {
// areaId,
// },
// }, context, {});
// const hangzhouMin = minResult.find(r => r['#data']?.district === '杭州');
// assert(hangzhouMin && hangzhouMin['#min-1'] === 80, `Expected min 80 for 杭州, got ${hangzhouMin?.['#min-1']}`);
// 测试 $$avg
// const avgResult = await store.aggregate('house', {
// data: {
// '#aggr': {
// district: 1,
// },
// '#avg-1': {
// $$avg: {
// '#attr': 'size',
// },
// }
// },
// filter: {
// areaId,
// },
// }, context, {});
// const hangzhouAvg = avgResult.find(r => r['#data']?.district === '杭州');
// assert(hangzhouAvg && hangzhouAvg['#avg-1'] === 100, `Expected avg 100 for 杭州, got ${hangzhouAvg?.['#avg-1']}`);
});
// // TODO: 下面聚合暂不支持
// it('[7.2]aggregation $$sum', async () => {
// const context = new TestContext(store);
// await context.begin();
// const areaId = v4();
// await store.operate('house', {
// id: v4(),
// action: 'create',
// data: [
// { id: v4(), areaId, ownerId: v4(), district: '杭州', size: 100.0 },
// { id: v4(), areaId, ownerId: v4(), district: '杭州', size: 150.0 },
// { id: v4(), areaId, ownerId: v4(), district: '杭州', size: 200.0 },
// ]
// }, context, {});
// await context.commit();
// const result = await store.aggregate('house', {
// data: {
// '#aggr': { areaId: 1 },
// '#sum-1': { $$sum: { '#attr': 'size' } }
// },
// filter: { areaId }
// }, context, {});
// assert(result.length === 1, `Expected 1 group`);
// assert(result[0]['#sum-1'] === 450, `Expected sum 450, got ${result[0]['#sum-1']}`);
// });
// it('[7.3]aggregation $$max $$min', async () => {
// const context = new TestContext(store);
// await context.begin();
// const areaId = v4();
// await store.operate('house', {
// id: v4(),
// action: 'create',
// data: [
// { id: v4(), areaId, ownerId: v4(), district: '杭州', size: 80.0 },
// { id: v4(), areaId, ownerId: v4(), district: '杭州', size: 120.0 },
// { id: v4(), areaId, ownerId: v4(), district: '杭州', size: 200.0 },
// ]
// }, context, {});
// await context.commit();
// const result = await store.aggregate('house', {
// data: {
// '#aggr': { areaId: 1 },
// '#max-1': { $$max: { '#attr': 'size' } },
// '#min-1': { $$min: { '#attr': 'size' } }
// },
// filter: { areaId }
// }, context, {});
// assert(result.length === 1, `Expected 1 group`);
// assert(result[0]['#max-1'] === 200, `Expected max 200, got ${result[0]['#max-1']}`);
// assert(result[0]['#min-1'] === 80, `Expected min 80, got ${result[0]['#min-1']}`);
// });
// it('[7.4]aggregation $$avg', async () => {
// const context = new TestContext(store);
// await context.begin();
// const areaId = v4();
// await store.operate('house', {
// id: v4(),
// action: 'create',
// data: [
// { id: v4(), areaId, ownerId: v4(), district: '杭州', size: 100.0 },
// { id: v4(), areaId, ownerId: v4(), district: '杭州', size: 200.0 },
// { id: v4(), areaId, ownerId: v4(), district: '杭州', size: 300.0 },
// ]
// }, context, {});
// await context.commit();
// const result = await store.aggregate('house', {
// data: {
// '#aggr': { areaId: 1 },
// '#avg-1': { $$avg: { '#attr': 'size' } }
// },
// filter: { areaId }
// }, context, {});
// assert(result.length === 1, `Expected 1 group`);
// assert(result[0]['#avg-1'] === 200, `Expected avg 200, got ${result[0]['#avg-1']}`);
// });
// it('[7.5]aggregation with multiple groups', async () => {
// const context = new TestContext(store);
// await context.begin();
// const areaId = v4();
// await store.operate('house', {
// id: v4(),
// action: 'create',
// data: [
// { id: v4(), areaId, ownerId: v4(), district: '杭州', size: 100.0 },
// { id: v4(), areaId, ownerId: v4(), district: '杭州', size: 150.0 },
// { id: v4(), areaId, ownerId: v4(), district: '上海', size: 200.0 },
// { id: v4(), areaId, ownerId: v4(), district: '上海', size: 300.0 },
// { id: v4(), areaId, ownerId: v4(), district: '北京', size: 250.0 },
// ]
// }, context, {});
// await context.commit();
// const result = await store.aggregate('house', {
// data: {
// '#aggr': { district: 1 },
// '#count-1': { id: 1 },
// '#sum-1': { $$sum: { '#attr': 'size' } },
// '#avg-1': { $$avg: { '#attr': 'size' } }
// },
// filter: { areaId }
// }, context, {});
// assert(result.length === 3, `Expected 3 groups, got ${result.length}`);
// const hz = result.find(r => r['#data']?.district === '杭州');
// const sh = result.find(r => r['#data']?.district === '上海');
// const bj = result.find(r => r['#data']?.district === '北京');
// assert(hz && hz['#count-1'] === 2 && hz['#sum-1'] === 250, `杭州 aggregation failed`);
// assert(sh && sh['#count-1'] === 2 && sh['#sum-1'] === 500, `上海 aggregation failed`);
// assert(bj && bj['#count-1'] === 1 && bj['#sum-1'] === 250, `北京 aggregation failed`);
// });
}

1924
test/testcase/base.ts Normal file

File diff suppressed because it is too large Load Diff

119
test/testcase/bool.ts Normal file
View File

@ -0,0 +1,119 @@
import { TestContext } from "../Context";
import { EntityDict } from "../test-app-domain";
import { v4 } from 'uuid';
import assert from 'assert';
import { generateNewIdAsync } from 'oak-domain/lib/utils/uuid';
import { describe, it, before, after } from 'mocha';
import { DbStore } from "../../lib/types/dbStore";
export default (storeGetter: () => DbStore<EntityDict, TestContext>) => {
// ==================== 布尔运算符测试 ====================
it('[5.1]boolean expression $true $false', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
const id2 = v4();
await store.operate('house', {
id: v4(),
action: 'create',
data: [
{ id: id1, areaId: v4(), ownerId: v4(), district: '杭州', size: 100.0 },
{ id: id2, areaId: v4(), ownerId: v4(), district: '上海', size: 50.0 },
]
}, context, {});
await context.commit();
// $true: 检查表达式为真
const r1 = await context.select('house', {
data: { id: 1 },
filter: {
id: { $in: [id1, id2] },
$expr: {
$true: { $gt: [{ '#attr': 'size' }, 80] }
}
}
}, {});
assert(r1.length === 1 && r1[0].id === id1, `$true failed`);
// $false: 检查表达式为假
const r2 = await context.select('house', {
data: { id: 1 },
filter: {
id: { $in: [id1, id2] },
$expr: {
$false: { $gt: [{ '#attr': 'size' }, 80] }
}
}
}, {});
assert(r2.length === 1 && r2[0].id === id2, `$false failed`);
});
it('[5.2]logic expression $and $or $not', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
const id2 = v4();
const id3 = v4();
await store.operate('house', {
id: v4(),
action: 'create',
data: [
{ id: id1, areaId: v4(), ownerId: v4(), district: '杭州', size: 80.0 },
{ id: id2, areaId: v4(), ownerId: v4(), district: '上海', size: 120.0 },
{ id: id3, areaId: v4(), ownerId: v4(), district: '北京', size: 100.0 },
]
}, context, {});
await context.commit();
const ids = [id1, id2, id3];
// $and: size > 70 AND size < 110
const r1 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: { $in: ids },
$expr: {
$and: [
{ $gt: [{ '#attr': 'size' }, 70] },
{ $lt: [{ '#attr': 'size' }, 110] }
]
}
}
}, {});
assert(r1.length === 2, `$and failed, expected 2, got ${r1.length}`);
// $or: size < 90 OR size > 110
const r2 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: { $in: ids },
$expr: {
$or: [
{ $lt: [{ '#attr': 'size' }, 90] },
{ $gt: [{ '#attr': 'size' }, 110] }
]
}
}
}, {});
assert(r2.length === 2, `$or failed, expected 2, got ${r2.length}`);
// $not: NOT (size = 100)
const r3 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: { $in: ids },
$expr: {
$not: { $eq: [{ '#attr': 'size' }, 100] }
}
}
}, {});
assert(r3.length === 2, `$not failed, expected 2, got ${r3.length}`);
});
}

85
test/testcase/compare.ts Normal file
View File

@ -0,0 +1,85 @@
import { TestContext } from "../Context";
import { EntityDict } from "../test-app-domain";
import { v4 } from 'uuid';
import assert from 'assert';
import { generateNewIdAsync } from 'oak-domain/lib/utils/uuid';
import { describe, it, before, after } from 'mocha';
import { DbStore } from "../../lib/types/dbStore";
export default (storeGetter: () => DbStore<EntityDict, TestContext>) => {
// ==================== 比较运算符测试 ====================
it('[4.1]comparison expressions $gt $gte $lt $lte $ne', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
const id2 = v4();
const id3 = v4();
await store.operate('house', {
id: v4(),
action: 'create',
data: [
{ id: id1, areaId: v4(), ownerId: v4(), district: '杭州', size: 50.0 },
{ id: id2, areaId: v4(), ownerId: v4(), district: '上海', size: 100.0 },
{ id: id3, areaId: v4(), ownerId: v4(), district: '北京', size: 150.0 },
]
}, context, {});
await context.commit();
const ids = [id1, id2, id3];
// $gt: size > 100
const r1 = await context.select('house', {
data: { id: 1 },
filter: {
id: { $in: ids },
$expr: { $gt: [{ '#attr': 'size' }, 100] }
}
}, {});
assert(r1.length === 1 && r1[0].id === id3, `$gt failed`);
// $gte: size >= 100
const r2 = await context.select('house', {
data: { id: 1 },
filter: {
id: { $in: ids },
$expr: { $gte: [{ '#attr': 'size' }, 100] }
}
}, {});
assert(r2.length === 2, `$gte failed, expected 2, got ${r2.length}`);
// $lt: size < 100
const r3 = await context.select('house', {
data: { id: 1 },
filter: {
id: { $in: ids },
$expr: { $lt: [{ '#attr': 'size' }, 100] }
}
}, {});
assert(r3.length === 1 && r3[0].id === id1, `$lt failed`);
// $lte: size <= 100
const r4 = await context.select('house', {
data: { id: 1 },
filter: {
id: { $in: ids },
$expr: { $lte: [{ '#attr': 'size' }, 100] }
}
}, {});
assert(r4.length === 2, `$lte failed, expected 2, got ${r4.length}`);
// $ne: size != 100
const r5 = await context.select('house', {
data: { id: 1 },
filter: {
id: { $in: ids },
$expr: { $ne: [{ '#attr': 'size' }, 100] }
}
}, {});
assert(r5.length === 2, `$ne failed, expected 2, got ${r5.length}`);
});
}

340
test/testcase/complax.ts Normal file
View File

@ -0,0 +1,340 @@
import { TestContext } from "../Context";
import { EntityDict } from "../test-app-domain";
import { v4 } from 'uuid';
import assert from 'assert';
import { generateNewIdAsync } from 'oak-domain/lib/utils/uuid';
import { describe, it, before, after } from 'mocha';
import { DbStore } from "../../lib/types/dbStore";
export default (storeGetter: () => DbStore<EntityDict, TestContext>) => {
// ==================== 复合表达式测试 ====================
it('[8.1]nested math expressions', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
await store.operate('house', {
id: v4(),
action: 'create',
data: {
id: id1,
areaId: v4(),
ownerId: v4(),
district: '杭州',
size: 100.0,
}
}, context, {});
await context.commit();
// 测试嵌套表达式: size * 2 + 50 / 5 = 210
const r1 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: id1,
$expr: {
$eq: [
{
$divide: [
{
$add: [
{ $multiply: [{ '#attr': 'size' }, 2] },
50
]
},
5
]
},
210
]
}
}
}, {});
assert(r1.length === 1, `Nested math expression failed`);
});
it('[8.2]complex logic with date and math', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const now = Date.now();
const twoDaysAgo = now - 2 * 24 * 3600 * 1000;
const fiveDaysAgo = now - 5 * 24 * 3600 * 1000;
const id1 = v4();
const id2 = v4();
await store.operate('token', {
id: v4(),
action: 'create',
data: [
{
id: id1,
env: { type: 'web' } as any,
applicationId: v4(),
userId: v4(),
playerId: v4(),
refreshedAt: twoDaysAgo,
value: v4(),
},
{
id: id2,
env: { type: 'web' } as any,
applicationId: v4(),
userId: v4(),
playerId: v4(),
refreshedAt: fiveDaysAgo,
value: v4(),
}
]
}, context, {});
await context.commit();
// 查询: 刷新时间在3天内 OR 刷新时间超过4天
const r1 = await context.select('token', {
data: { id: 1 },
filter: {
id: { $in: [id1, id2] },
$expr: {
$or: [
{
$lt: [
{ $dateDiff: [now, { '#attr': 'refreshedAt' }, 'd'] },
3
]
},
{
$gt: [
{ $dateDiff: [now, { '#attr': 'refreshedAt' }, 'd'] },
4
]
}
]
}
}
}, {});
assert(r1.length === 2, `Complex logic failed, expected 2, got ${r1.length}`);
// 查询: 刷新时间在3天到4天之间
const r2 = await context.select('token', {
data: { id: 1 },
filter: {
id: { $in: [id1, id2] },
$expr: {
$and: [
{
$gte: [
{ $dateDiff: [now, { '#attr': 'refreshedAt' }, 'd'] },
3
]
},
{
$lte: [
{ $dateDiff: [now, { '#attr': 'refreshedAt' }, 'd'] },
4
]
}
]
}
}
}, {});
assert(r2.length === 0, `Complex logic failed, expected 0, got ${r2.length}`);
});
it('[8.3]expression with cross-entity reference', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const systemId = v4();
const appId1 = v4();
const appId2 = v4();
await store.operate('system', {
id: v4(),
action: 'create',
data: {
id: systemId,
name: 'parent_system',
description: 'test',
folder: '/test',
application$system: [
{
id: v4(),
action: 'create',
data: {
id: appId1,
name: 'parent_system_app1',
description: 't1',
type: 'web',
}
},
{
id: v4(),
action: 'create',
data: {
id: appId2,
name: 'child_app',
description: 't2',
type: 'web',
}
}
]
}
} as EntityDict['system']['CreateSingle'], context, {});
await context.commit();
// // 查询: application.name 以 system.name 的前缀开头
const r1 = await context.select('application', {
data: {
id: 1,
name: 1,
system: { id: 1, name: 1 }
},
filter: {
systemId,
$expr: {
$startsWith: [
{ '#attr': 'name' },
{
'#refAttr': 'name',
'#refId': 'node-1'
}
]
},
system: {
'#id': 'node-1'
}
}
}, {});
assert(r1.length === 1, `Cross-entity expression failed, expected 1, got ${r1.length}`);
assert(r1[0].id === appId1, `Expected appId1, got ${r1[0].id}`);
});
// ==================== $mod 运算符测试 ====================
it('[10.1]math expression $mod', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
const id2 = v4();
const id3 = v4();
await store.operate('user', {
id: v4(),
action: 'create',
data: [
{ id: id1, name: 'user1', nickname: 'n1', idState: 'unverified', userState: 'normal' },
{ id: id2, name: 'user2', nickname: 'n2', idState: 'unverified', userState: 'normal' },
{ id: id3, name: 'user3', nickname: 'n3', idState: 'unverified', userState: 'normal' },
]
}, context, {});
await context.commit();
// 测试 $mod: $$seq$$ % 2 = 0 (偶数序列号)
const r1 = await context.select('user', {
data: { id: 1, $$seq$$: 1 },
filter: {
id: { $in: [id1, id2, id3] },
$expr: {
$eq: [
{ $mod: [{ '#attr': '$$seq$$' }, 2] },
0
]
}
}
}, {});
// 偶数序列号的数量取决于插入顺序,这里只检查查询能正常执行
assert(r1.length >= 0, `$mod expression failed`);
// 测试 $mod: $$seq$$ % 3 = 1
const r2 = await context.select('user', {
data: { id: 1, $$seq$$: 1 },
filter: {
id: { $in: [id1, id2, id3] },
$expr: {
$eq: [
{ $mod: [{ '#attr': '$$seq$$' }, 3] },
1
]
}
}
}, {});
assert(r2.length >= 0, `$mod with 3 failed`);
});
// ==================== 聚合与普通查询组合测试 ====================
it('[20.2]select with nested aggregation', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const systemId1 = v4();
const systemId2 = v4();
await store.operate('system', {
id: v4(),
action: 'create',
data: [
{
id: systemId1,
name: 'agg_system1',
description: 'test',
folder: '/test1',
platformId: 'p1',
application$system: [
{ id: v4(), action: 'create', data: { id: v4(), name: 'app1_1', description: 't', type: 'web' } },
{ id: v4(), action: 'create', data: { id: v4(), name: 'app1_2', description: 't', type: 'web' } },
{ id: v4(), action: 'create', data: { id: v4(), name: 'app1_3', description: 't', type: 'wechatMp' } },
]
},
{
id: systemId2,
name: 'agg_system2',
description: 'test',
folder: '/test2',
platformId: 'p1',
application$system: [
{ id: v4(), action: 'create', data: { id: v4(), name: 'app2_1', description: 't', type: 'web' } },
]
}
]
} as EntityDict['system']['CreateMulti'], context, {});
await context.commit();
// 查询 system 并附带 application 聚合统计
const r1 = await context.select('system', {
data: {
id: 1,
name: 1,
application$system$$aggr: {
$entity: 'application',
data: {
'#aggr': { type: 1 },
'#count-1': { id: 1 }
}
}
},
filter: {
id: { $in: [systemId1, systemId2] }
}
}, {});
assert(r1.length === 2, `Nested aggregation query failed`);
const sys1 = r1.find(s => s.id === systemId1);
assert(sys1, `System1 not found`);
assert(sys1.application$system$$aggr && sys1.application$system$$aggr.length === 2, `System1 should have 2 type groups`);
const webCount = sys1.application$system$$aggr.find((a: any) => a['#data']?.type === 'web');
assert(webCount && webCount['#count-1'] === 2, `System1 web count should be 2`);
});
}

811
test/testcase/date.ts Normal file
View File

@ -0,0 +1,811 @@
import { TestContext } from "../Context";
import { EntityDict } from "../test-app-domain";
import { v4 } from 'uuid';
import assert from 'assert';
import { generateNewIdAsync } from 'oak-domain/lib/utils/uuid';
import { describe, it, before, after } from 'mocha';
import { DbStore } from "../../lib/types/dbStore";
export default (storeGetter: () => DbStore<EntityDict, TestContext>) => {
// ==================== 日期函数测试 ====================
it('[6.1]date expression $year $month $day', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
// 2024-06-15 14:30:45
const specificDate = new Date('2024-06-15T14:30:45.000Z').valueOf();
const id1 = v4();
await store.operate('token', {
id: v4(),
action: 'create',
data: {
id: id1,
env: { type: 'web' } as any,
applicationId: v4(),
userId: v4(),
playerId: v4(),
refreshedAt: specificDate,
value: v4(),
}
}, context, {});
await context.commit();
// 测试 $year
const r1 = await context.select('token', {
data: { id: 1 },
filter: {
id: id1,
$expr: { $eq: [{ $year: { '#attr': 'refreshedAt' } }, 2024] }
}
}, {});
assert(r1.length === 1, `$year failed`);
// 测试 $month
const r2 = await context.select('token', {
data: { id: 1 },
filter: {
id: id1,
$expr: { $eq: [{ $month: { '#attr': 'refreshedAt' } }, 6] }
}
}, {});
assert(r2.length === 1, `$month failed`);
// 测试 $day
const r3 = await context.select('token', {
data: { id: 1 },
filter: {
id: id1,
$expr: { $eq: [{ $day: { '#attr': 'refreshedAt' } }, 15] }
}
}, {});
assert(r3.length === 1, `$day failed`);
});
it('[6.2]date expression $dayOfMonth $dayOfWeek $dayOfYear', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
// 2024-06-15 是星期六是一年中的第167天
const specificDate = new Date('2024-06-15T14:30:45.000Z').valueOf();
const id1 = v4();
await store.operate('token', {
id: v4(),
action: 'create',
data: {
id: id1,
env: { type: 'web' } as any,
applicationId: v4(),
userId: v4(),
playerId: v4(),
refreshedAt: specificDate,
value: v4(),
}
}, context, {});
await context.commit();
// 测试 $dayOfMonth
const r1 = await context.select('token', {
data: { id: 1 },
filter: {
id: id1,
$expr: { $eq: [{ $dayOfMonth: { '#attr': 'refreshedAt' } }, 15] }
}
}, {});
assert(r1.length === 1, `$dayOfMonth failed`);
// 测试 $dayOfWeek (MySQL: 1=周日, 7=周六; 2024-06-15是周六)
const r2 = await context.select('token', {
data: { id: 1 },
filter: {
id: id1,
$expr: { $eq: [{ $dayOfWeek: { '#attr': 'refreshedAt' } }, 7] }
}
}, {});
assert(r2.length === 1, `$dayOfWeek failed`);
// 测试 $dayOfYear (2024-06-15 是第167天)
const r3 = await context.select('token', {
data: { id: 1 },
filter: {
id: id1,
$expr: { $eq: [{ $dayOfYear: { '#attr': 'refreshedAt' } }, 167] }
}
}, {});
assert(r3.length === 1, `$dayOfYear failed`);
});
it('[6.3]date expression $weekday $weekOfYear', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
// 2024-06-15 是第24周
const specificDate = new Date('2024-06-15T14:30:45.000Z').valueOf();
const id1 = v4();
await store.operate('token', {
id: v4(),
action: 'create',
data: {
id: id1,
env: { type: 'web' } as any,
applicationId: v4(),
userId: v4(),
playerId: v4(),
refreshedAt: specificDate,
value: v4(),
}
}, context, {});
await context.commit();
// 测试 $weekday (MySQL WEEKDAY: 0=周一, 6=周日; 2024-06-15是周六=5)
const r1 = await context.select('token', {
data: { id: 1 },
filter: {
id: id1,
$expr: { $eq: [{ $weekday: { '#attr': 'refreshedAt' } }, 5] }
}
}, {});
assert(r1.length === 1, `$weekday failed`);
// 测试 $weekOfYear
const r2 = await context.select('token', {
data: { id: 1 },
filter: {
id: id1,
$expr: { $eq: [{ $weekOfYear: { '#attr': 'refreshedAt' } }, 24] }
}
}, {});
assert(r2.length === 1, `$weekOfYear failed`);
});
it('[6.4]$dateDiff with different units', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const now = Date.now();
const oneYearAgo = now - 365 * 24 * 3600 * 1000;
const id1 = v4();
await store.operate('token', {
id: v4(),
action: 'create',
data: {
id: id1,
env: { type: 'web' } as any,
applicationId: v4(),
userId: v4(),
playerId: v4(),
refreshedAt: oneYearAgo,
value: v4(),
}
}, context, {});
await context.commit();
// 测试年份差异
const r1 = await context.select('token', {
data: { id: 1 },
filter: {
id: id1,
$expr: {
$gte: [
{ $dateDiff: [now, { '#attr': 'refreshedAt' }, 'y'] },
1
]
}
}
}, {});
assert(r1.length === 1, `$dateDiff year failed`);
// 测试月份差异
const r2 = await context.select('token', {
data: { id: 1 },
filter: {
id: id1,
$expr: {
$gte: [
{ $dateDiff: [now, { '#attr': 'refreshedAt' }, 'M'] },
11
]
}
}
}, {});
assert(r2.length === 1, `$dateDiff month failed`);
});
it('[6.5]$dateFloor with different units', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
// 2024-06-15 14:30:45.123
const specificDate = new Date('2024-06-15T14:30:45.123Z').valueOf();
const id1 = v4();
await store.operate('token', {
id: v4(),
action: 'create',
data: {
id: id1,
env: { type: 'web' } as any,
applicationId: v4(),
userId: v4(),
playerId: v4(),
refreshedAt: specificDate,
value: v4(),
}
}, context, {});
await context.commit();
//TODO: 暂不支持
// // 测试向下取整到月 - 应该是 2024-06-01
const startOfMonth = new Date('2024-06-01T00:00:00.000Z').valueOf();
const r1 = await context.select('token', {
data: { id: 1 },
filter: {
id: id1,
$expr: {
$gte: [
{ $dateFloor: [{ '#attr': 'refreshedAt' }, 'M'] },
startOfMonth
]
}
}
}, {});
assert(r1.length === 1, `$dateFloor month failed: Expected 1, got ${r1.length}`);
// 测试向下取整到年 - 应该是 2024-01-01
const startOfYear = new Date('2024-01-01T00:00:00.000Z').valueOf();
const r2 = await context.select('token', {
data: { id: 1 },
filter: {
id: id1,
$expr: {
$gte: [
{ $dateFloor: [{ '#attr': 'refreshedAt' }, 'y'] },
startOfYear
]
}
}
}, {});
assert(r2.length === 1, `$dateFloor year failed`);
});
// TODO: 暂不支持
it('[6.6]$dateCeil with different units', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
// 2024-06-15 14:30:45.123
const specificDate = new Date('2024-06-15T14:30:45.123Z').valueOf();
const id1 = v4();
await store.operate('token', {
id: v4(),
action: 'create',
data: {
id: id1,
env: { type: 'web' } as any,
applicationId: v4(),
userId: v4(),
playerId: v4(),
refreshedAt: specificDate,
value: v4(),
}
}, context, {});
await context.commit();
// 测试向上取整到月 - 应该是 2024-07-01
const startOfNextMonth = new Date('2024-07-01T00:00:00.000Z').valueOf();
const r1 = await context.select('token', {
data: { id: 1 },
filter: {
id: id1,
$expr: {
$lte: [
{ $dateCeil: [{ '#attr': 'refreshedAt' }, 'M'] },
startOfNextMonth
]
}
}
}, {});
assert(r1.length === 1, `$dateCeil month failed`);
// 测试向上取整到年 - 应该是 2025-01-01
const startOfNextYear = new Date('2025-01-01T00:00:00.000Z').valueOf();
const r2 = await context.select('token', {
data: { id: 1 },
filter: {
id: id1,
$expr: {
$lte: [
{ $dateCeil: [{ '#attr': 'refreshedAt' }, 'y'] },
startOfNextYear
]
}
}
}, {});
assert(r2.length === 1, `$dateCeil year failed`);
});
it('[1.14]$dateDiff expression', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const now = Date.now();
const oneHourAgo = now - 3600 * 1000; // 1小时前
const oneDayAgo = now - 24 * 3600 * 1000; // 1天前
const oneMonthAgo = now - 30 * 24 * 3600 * 1000; // 约30天前
const id1 = v4();
const id2 = v4();
const id3 = v4();
await store.operate('token', {
id: v4(),
action: 'create',
data: [
{
id: id1,
env: { type: 'web' } as any,
applicationId: v4(),
userId: v4(),
playerId: v4(),
refreshedAt: oneHourAgo,
value: v4(),
},
{
id: id2,
env: { type: 'web' } as any,
applicationId: v4(),
userId: v4(),
playerId: v4(),
refreshedAt: oneDayAgo,
value: v4(),
},
{
id: id3,
env: { type: 'web' } as any,
applicationId: v4(),
userId: v4(),
playerId: v4(),
refreshedAt: oneMonthAgo,
value: v4(),
}
]
}, context, {});
await context.commit();
// 测试秒级差异
const r1 = await context.select('token', {
data: {
id: 1,
refreshedAt: 1,
},
filter: {
id: id1,
$expr: {
$gte: [
{
$dateDiff: [
now,
{ '#attr': 'refreshedAt' },
's'
]
},
3500 // 约1小时 = 3600秒给一些误差
]
}
}
}, {});
assert(r1.length === 1, `Expected 1 row for seconds diff, got ${r1.length}`);
// 测试分钟级差异
const r2 = await context.select('token', {
data: {
id: 1,
refreshedAt: 1,
},
filter: {
id: id1,
$expr: {
$gte: [
{
$dateDiff: [
now,
{ '#attr': 'refreshedAt' },
'm'
]
},
55 // 约1小时 = 60分钟
]
}
}
}, {});
assert(r2.length === 1, `Expected 1 row for minutes diff, got ${r2.length}`);
// 测试小时级差异
const r3 = await context.select('token', {
data: {
id: 1,
refreshedAt: 1,
},
filter: {
id: id2,
$expr: {
$gte: [
{
$dateDiff: [
now,
{ '#attr': 'refreshedAt' },
'h'
]
},
23 // 约1天 = 24小时
]
}
}
}, {});
assert(r3.length === 1, `Expected 1 row for hours diff, got ${r3.length}`);
// 测试天级差异
const r4 = await context.select('token', {
data: {
id: 1,
refreshedAt: 1,
},
filter: {
id: id3,
$expr: {
$gte: [
{
$dateDiff: [
now,
{ '#attr': 'refreshedAt' },
'd'
]
},
25 // 约30天
]
}
}
}, {});
assert(r4.length === 1, `Expected 1 row for days diff, got ${r4.length}`);
});
it('[1.15]$dateFloor expression', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
// 创建一个特定时间的记录: 2024-06-15 14:30:45
const specificTime = new Date('2024-06-15T14:30:45.123Z').valueOf();
const id1 = v4();
await store.operate('token', {
id: v4(),
action: 'create',
data: {
id: id1,
env: { type: 'web' } as any,
applicationId: v4(),
userId: v4(),
playerId: v4(),
refreshedAt: specificTime,
value: v4(),
}
}, context, {});
await context.commit();
// 测试向下取整到分钟 - 应该得到 14:30:00
const startOfMinute = new Date('2024-06-15T14:30:00.000Z').valueOf();
const endOfMinute = new Date('2024-06-15T14:31:00.000Z').valueOf();
const r1 = await context.select('token', {
data: {
id: 1,
refreshedAt: 1,
},
filter: {
id: id1,
$expr: {
$and: [
{
$gte: [
{
$dateFloor: [
{ '#attr': 'refreshedAt' },
'm'
]
},
startOfMinute
]
},
{
$lt: [
{
$dateFloor: [
{ '#attr': 'refreshedAt' },
'm'
]
},
endOfMinute
]
}
]
}
}
}, {});
assert(r1.length === 1, `Expected 1 row for minute floor, got ${r1.length}`);
// 测试向下取整到小时 - 应该得到 14:00:00
const startOfHour = new Date('2024-06-15T14:00:00.000Z').valueOf();
const endOfHour = new Date('2024-06-15T15:00:00.000Z').valueOf();
const r2 = await context.select('token', {
data: {
id: 1,
refreshedAt: 1,
},
filter: {
id: id1,
$expr: {
$and: [
{
$gte: [
{
$dateFloor: [
{ '#attr': 'refreshedAt' },
'h'
]
},
startOfHour
]
},
{
$lt: [
{
$dateFloor: [
{ '#attr': 'refreshedAt' },
'h'
]
},
endOfHour
]
}
]
}
}
}, {});
assert(r2.length === 1, `Expected 1 row for hour floor, got ${r2.length}`);
// 测试向下取整到天 - 应该得到 2024-06-15 00:00:00
const startOfDay = new Date('2024-06-15T00:00:00.000Z').valueOf();
const endOfDay = new Date('2024-06-16T00:00:00.000Z').valueOf();
const r3 = await context.select('token', {
data: {
id: 1,
refreshedAt: 1,
},
filter: {
id: id1,
$expr: {
$and: [
{
$gte: [
{
$dateFloor: [
{ '#attr': 'refreshedAt' },
'd'
]
},
startOfDay
]
},
{
$lt: [
{
$dateFloor: [
{ '#attr': 'refreshedAt' },
'd'
]
},
endOfDay
]
}
]
}
}
}, {});
assert(r3.length === 1, `Expected 1 row for day floor, got ${r3.length}`);
});
it('[1.16]$dateCeil expression', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
// 创建一个特定时间的记录: 2024-06-15 14:30:45
const specificTime = new Date('2024-06-15T14:30:45.123Z').valueOf();
const id1 = v4();
await store.operate('token', {
id: v4(),
action: 'create',
data: {
id: id1,
env: { type: 'web' } as any,
applicationId: v4(),
userId: v4(),
playerId: v4(),
refreshedAt: specificTime,
value: v4(),
}
}, context, {});
await context.commit();
// 测试向上取整到分钟 - 应该得到 14:31:00
const ceilMinute = new Date('2024-06-15T14:31:00.000Z').valueOf();
const r1 = await context.select('token', {
data: {
id: 1,
refreshedAt: 1,
},
filter: {
id: id1,
$expr: {
$gte: [
{
$dateCeil: [
{ '#attr': 'refreshedAt' },
'm'
]
},
ceilMinute
]
}
}
}, {});
assert(r1.length === 1, `Expected 1 row for minute ceil, got ${r1.length}`);
// 测试向上取整到小时 - 应该得到 15:00:00
const ceilHour = new Date('2024-06-15T15:00:00.000Z').valueOf();
const r2 = await context.select('token', {
data: {
id: 1,
refreshedAt: 1,
},
filter: {
id: id1,
$expr: {
$gte: [
{
$dateCeil: [
{ '#attr': 'refreshedAt' },
'h'
]
},
ceilHour
]
}
}
}, {});
assert(r2.length === 1, `Expected 1 row for hour ceil, got ${r2.length}`);
// 测试向上取整到天 - 应该得到 2024-06-16 00:00:00
const ceilDay = new Date('2024-06-16T00:00:00.000Z').valueOf();
const r3 = await context.select('token', {
data: {
id: 1,
refreshedAt: 1,
},
filter: {
id: id1,
$expr: {
$gte: [
{
$dateCeil: [
{ '#attr': 'refreshedAt' },
'd'
]
},
ceilDay
]
}
}
}, {});
assert(r3.length === 1, `Expected 1 row for day ceil, got ${r3.length}`);
});
it('[1.18]date expression with constants', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const now = Date.now();
const id1 = v4();
await store.operate('token', {
id: v4(),
action: 'create',
data: {
id: id1,
env: { type: 'web' } as any,
applicationId: v4(),
userId: v4(),
playerId: v4(),
refreshedAt: now,
value: v4(),
}
}, context, {});
await context.commit();
// 测试 $dateDiff 使用常量日期
const yesterday = now - 24 * 3600 * 1000;
const r1 = await context.select('token', {
data: {
id: 1,
},
filter: {
id: id1,
$expr: {
$eq: [
{
$dateDiff: [
now,
yesterday,
'd'
]
},
1
]
}
}
}, {});
assert(r1.length === 1, `Expected 1 row for constant date diff, got ${r1.length}`);
// 测试 $dateDiff 混合使用属性和常量
const r2 = await context.select('token', {
data: {
id: 1,
},
filter: {
id: id1,
$expr: {
$lt: [
{
$dateDiff: [
{ '#attr': 'refreshedAt' },
yesterday,
'h'
]
},
48 // 应该在48小时以内
]
}
}
}, {});
assert(r2.length === 1, `Expected 1 row for mixed date diff, got ${r2.length}`);
});
}

33
test/testcase/index.ts Normal file
View File

@ -0,0 +1,33 @@
import { TestContext } from "../Context";
import { EntityDict } from "../test-app-domain";
import { v4 } from 'uuid';
import assert from 'assert';
import { generateNewIdAsync } from 'oak-domain/lib/utils/uuid';
import { describe, it, before, after } from 'mocha';
import { DbStore } from "../../lib/types/dbStore";
import aggr from "./aggr";
import base from "./base";
import bool from "./bool";
import compare from "./compare";
import complax from "./complax";
import date from "./date";
import json from "./json";
import math from "./math";
import projection from "./projection";
import string from "./string";
import subquery from "./subquery";
export const tests = (storeGetter: () => DbStore<EntityDict, TestContext>) => {
base(storeGetter);
subquery(storeGetter);
projection(storeGetter);
string(storeGetter);
math(storeGetter);
date(storeGetter);
bool(storeGetter);
compare(storeGetter);
aggr(storeGetter);
json(storeGetter);
complax(storeGetter);
}

698
test/testcase/json.ts Normal file
View File

@ -0,0 +1,698 @@
import { TestContext } from "../Context";
import { EntityDict } from "../test-app-domain";
import { v4 } from 'uuid';
import assert from 'assert';
import { generateNewIdAsync } from 'oak-domain/lib/utils/uuid';
import { describe, it, before, after } from 'mocha';
import { DbStore } from "../../lib/types/dbStore";
export default (storeGetter: () => DbStore<EntityDict, TestContext>) => {
it('[1.10]json insert/select', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id = await generateNewIdAsync();
await context.operate('house', {
id: await generateNewIdAsync(),
action: 'create',
data: {
id,
areaId: 'area1',
district: 'district1',
ownerId: 'owner1',
size: 123.4567,
data: {
rooms: 3,
features: ['garden', 'garage'],
address: {
street: '123 Main St',
city: 'Metropolis',
zip: '12345'
}
}
}
}, {});
const result = await context.select('house', {
data: {
id: 1,
// TODO: 在projection中支持json字段的部分展开但是展开后返回值都为字符串
data: 1,
},
filter: {
id,
},
}, {});
assert(result.length === 1, `Expected 1 result, got ${result.length}`);
assert((result[0] as any).data.rooms === 3, `Expected rooms to be 3, got ${(result[0] as any).data.rooms}`);
assert.deepStrictEqual((result[0] as any).data.features, ['garden', 'garage'], `Expected features to match`);
assert.deepStrictEqual((result[0] as any).data.address, {
street: '123 Main St',
city: 'Metropolis',
zip: '12345',
}, `Expected address to match`);
});
it('[1.11]json as filter', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id = await generateNewIdAsync();
await store.operate('oper', {
id: await generateNewIdAsync(),
action: 'create',
data: {
id,
action: 'test',
data: {
name: 'xc',
books: [{
title: 'mathmatics',
price: 1,
}, {
title: 'english',
price: 2,
}],
},
targetEntity: 'bbb',
bornAt: 111,
iState: 'normal',
}
}, context, {});
const row = await store.select('oper', {
data: {
id: 1,
data: {
name: 1,
books: [undefined, {
title: 1,
price: 1,
}],
},
},
filter: {
id,
data: {
name: 'xc',
}
}
}, context, {});
const row2 = await store.select('oper', {
data: {
id: 1,
data: {
name: 1,
books: [undefined, {
title: 1,
price: 1,
}],
},
},
filter: {
id,
data: {
name: 'xc2',
}
}
}, context, {});
await context.commit();
// console.log(JSON.stringify(row));
assert(row.length === 1, JSON.stringify(row));
assert(row2.length === 0, JSON.stringify(row2));
});
it('[1.11.2]json filter on top level', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id = await generateNewIdAsync();
await store.operate('actionAuth', {
id: await generateNewIdAsync(),
action: 'create',
data: {
id,
pathId: await generateNewIdAsync(),
deActions: ['1.12'],
}
}, context, {});
await context.commit();
const row = await store.select('actionAuth', {
data: {
id: 1,
deActions: 1,
},
filter: {
id,
deActions: {
$overlaps: '1.12',
}
}
}, context, {});
const row2 = await store.select('actionAuth', {
data: {
id: 1,
deActions: 1,
},
filter: {
id,
deActions: {
$contains: ['1.13333'],
}
}
}, context, {});
// console.log(JSON.stringify(row));
assert(row.length === 1, JSON.stringify(row));
console.log(JSON.stringify(row));
assert(row2.length === 0, JSON.stringify(row2));
const row3 = await store.select('actionAuth', {
data: {
id: 1,
deActions: 1,
},
filter: {
id,
deActions: {
$exists: true,
}
}
}, context, {});
assert(row3.length === 1);
const row4 = await store.select('actionAuth', {
data: {
id: 1,
deActions: 1,
},
filter: {
id,
deActions: {
$exists: false,
}
}
}, context, {});
assert(row4.length === 0);
});
it('[1.11.3]json escape', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id = await generateNewIdAsync();
await store.operate('oper', {
id: await generateNewIdAsync(),
action: 'create',
data: {
id,
action: 'test',
data: {
$or: [{
name: 'xc',
}, {
name: {
$includes: 'xc',
}
}],
},
targetEntity: 'bbb',
bornAt: 123,
iState: 'normal',
}
}, context, {});
const row = await store.select('oper', {
data: {
id: 1,
data: {
name: 1,
price: 1,
},
},
filter: {
id,
data: {
'.$or': {
$contains: {
name: 'xc',
},
},
},
}
}, context, {});
process.env.NODE_ENV = 'development';
const row2 = await store.select('oper', {
data: {
id: 1,
data: {
name: 1,
price: 1,
},
},
filter: {
id,
data: {
'.$or': [
{
name: 'xc',
},
{
name: {
'.$includes': 'xc',
}
}
],
},
}
}, context, {});
await context.commit();
assert(row.length === 1);
assert(row2.length === 1);
});
it('[1.12]complicated json filter', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id = await generateNewIdAsync();
await store.operate('oper', {
id: await generateNewIdAsync(),
action: 'create',
data: {
id,
action: 'test',
data: {
name: 'xcc',
price: [100, 400, 1000],
},
targetEntity: 'bbb',
bornAt: 123,
iState: 'normal',
}
}, context, {});
const row = await store.select('oper', {
data: {
id: 1,
data: {
name: 1,
price: 1,
},
},
filter: {
data: {
price: [undefined, 400],
}
}
}, context, {});
const row2 = await store.select('oper', {
data: {
id: 1,
data: {
name: 1,
price: 1,
},
},
filter: {
data: {
price: [undefined, 200],
}
}
}, context, {});
const row3 = await store.select('oper', {
data: {
id: 1,
data: {
name: 1,
price: 1,
},
},
filter: {
data: {
price: [undefined, {
$gt: 300,
}],
}
}
}, context, {});
const row4 = await store.select('oper', {
data: {
id: 1,
data: {
name: 1,
price: 1,
},
},
filter: {
data: {
price: {
$contains: [200, 500],
},
}
}
}, context, {});
const row5 = await store.select('oper', {
data: {
id: 1,
data: {
name: 1,
price: 1,
},
},
filter: {
data: {
price: {
$contains: [100, 400],
},
}
}
}, context, {});
const row6 = await store.select('oper', {
data: {
id: 1,
data: {
name: 1,
price: 1,
},
},
filter: {
data: {
price: {
$contains: ['xc'],
},
}
}
}, context, {});
const row7 = await store.select('oper', {
data: {
id: 1,
data: {
name: 1,
price: 1,
},
},
filter: {
data: {
name: {
$includes: 'xc',
},
price: {
$overlaps: [200, 400, 800],
},
}
}
}, context, {});
/**
* $or的查询
*/
process.env.NODE_ENV = 'development';
const row8 = await store.select('oper', {
data: {
id: 1,
data: {
name: 1,
price: 1,
},
},
filter: {
data: {
$or: [
{
name: {
$includes: 'xc',
},
},
{
name: {
$includes: 'xzw',
}
}
],
price: {
$overlaps: [200, 400, 800],
},
}
}
}, context, {});
const row9 = await store.select('oper', {
data: {
id: 1,
data: {
name: 1,
price: 1,
},
},
filter: {
data: {
price: {
$length: 3,
},
}
}
}, context, {});
const row10 = await store.select('oper', {
data: {
id: 1,
data: {
name: 1,
price: 1,
},
},
filter: {
data: {
price: {
$length: {
$gt: 3,
},
},
}
}
}, context, {});
await context.commit();
assert(row.length === 1);
assert(row2.length === 0);
assert(row3.length === 1);
assert(row4.length === 0);
assert(row5.length === 1);
assert(row6.length === 0);
assert(row7.length === 1);
assert(row8.length === 1);
assert(row9.length === 1);
assert(row10.length === 0);
// console.log(JSON.stringify(row7));
});
// ==================== JSON 操作符补充测试 ====================
it('[9.1]json $exists operator', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
const id2 = v4();
await store.operate('oper', {
id: v4(),
action: 'create',
data: [
{
id: id1,
action: 'test1',
data: { name: 'xc', age: 25 },
targetEntity: 'test',
bornAt: Date.now(),
iState: 'normal',
},
{
id: id2,
action: 'test2',
data: { name: 'zz' }, // 没有 age 字段
targetEntity: 'test',
bornAt: Date.now(),
iState: 'normal',
}
]
}, context, {});
await context.commit();
// 测试 $exists: true - 检查 age 字段存在
const r1 = await context.select('oper', {
data: { id: 1 },
filter: {
id: { $in: [id1, id2] },
data: { age: { $exists: true } }
}
}, {});
assert(r1.length === 1 && r1[0].id === id1, `$exists true failed`);
// 测试 $exists: false - 检查 age 字段不存在
const r2 = await context.select('oper', {
data: { id: 1 },
filter: {
id: { $in: [id1, id2] },
data: { age: { $exists: false } }
}
}, {});
assert(r2.length === 1 && r2[0].id === id2, `$exists false failed`);
// 暂不支持这种写法
// 测试 $exists: 'keyName' - 检查 JSON 对象是否包含指定键
// const r3 = await context.select('oper', {
// data: { id: 1 },
// filter: {
// id: { $in: [id1, id2] },
// data: { $exists: 'age' }
// }
// }, {});
// assert(r3.length === 1 && r3[0].id === id1, `$exists keyName failed`);
});
it('[9.2]json nested $and $or in filter', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
const id2 = v4();
const id3 = v4();
await store.operate('oper', {
id: v4(),
action: 'create',
data: [
{
id: id1,
action: 'test1',
data: { name: 'alice', role: 'admin', level: 10 },
targetEntity: 'test',
bornAt: Date.now(),
iState: 'normal',
},
{
id: id2,
action: 'test2',
data: { name: 'bob', role: 'user', level: 5 },
targetEntity: 'test',
bornAt: Date.now(),
iState: 'normal',
},
{
id: id3,
action: 'test3',
data: { name: 'charlie', role: 'admin', level: 3 },
targetEntity: 'test',
bornAt: Date.now(),
iState: 'normal',
}
]
}, context, {});
await context.commit();
// 测试: (role = 'admin' AND level > 5) OR (role = 'user')
const r1 = await context.select('oper', {
data: { id: 1 },
filter: {
id: { $in: [id1, id2, id3] },
data: {
$or: [
{
$and: [
{ role: 'admin' },
{ level: { $gt: 5 } }
]
},
{ role: 'user' }
]
}
}
}, {});
assert(r1.length === 2, `Nested $and $or failed, expected 2, got ${r1.length}`);
const ids = r1.map(r => r.id);
assert(ids.includes(id1) && ids.includes(id2), `Expected id1 and id2`);
});
it('[9.3]json array index access in filter', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
const id2 = v4();
await store.operate('oper', {
id: v4(),
action: 'create',
data: [
{
id: id1,
action: 'test1',
data: { scores: [90, 85, 95] },
targetEntity: 'test',
bornAt: Date.now(),
iState: 'normal',
},
{
id: id2,
action: 'test2',
data: { scores: [70, 75, 80] },
targetEntity: 'test',
bornAt: Date.now(),
iState: 'normal',
}
]
}, context, {});
await context.commit();
// 测试数组第一个元素 > 80
const r1 = await context.select('oper', {
data: { id: 1 },
filter: {
id: { $in: [id1, id2] },
data: {
scores: [{ $gt: 80 }]
}
}
}, {});
assert(r1.length === 1 && r1[0].id === id1, `Array index filter failed`);
// 测试数组第三个元素 = 95
const r2 = await context.select('oper', {
data: { id: 1 },
filter: {
id: { $in: [id1, id2] },
data: {
scores: [undefined, undefined, 95]
}
}
}, {});
assert(r2.length === 1 && r2[0].id === id1, `Array specific index filter failed`);
});
}

423
test/testcase/math.ts Normal file
View File

@ -0,0 +1,423 @@
import { TestContext } from "../Context";
import { EntityDict } from "../test-app-domain";
import { v4 } from 'uuid';
import assert from 'assert';
import { generateNewIdAsync } from 'oak-domain/lib/utils/uuid';
import { describe, it, before, after } from 'mocha';
import { DbStore } from "../../lib/types/dbStore";
export default (storeGetter: () => DbStore<EntityDict, TestContext>) => {
// ==================== 数学运算符测试 ====================
it('[2.1]math expression $add', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
const id2 = v4();
await store.operate('house', {
id: v4(),
action: 'create',
data: [
{
id: id1,
areaId: v4(),
ownerId: v4(),
district: '杭州',
size: 80.5,
},
{
id: id2,
areaId: v4(),
ownerId: v4(),
district: '上海',
size: 100.0,
},
]
}, context, {});
await context.commit();
// 测试 $add: size + 20 > 100
const r1 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: { $in: [id1, id2] },
$expr: {
$gt: [
{
$add: [{ '#attr': 'size' }, 20]
},
100
]
}
}
}, {});
assert(r1.length === 2, `Expected 2 rows, got ${r1.length}`);
// 测试多参数 $add: size + 10 + 20 > 120
const r2 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: { $in: [id1, id2] },
$expr: {
$gt: [
{
$add: [{ '#attr': 'size' }, 10, 20]
},
120
]
}
}
}, {});
assert(r2.length === 1 && r2[0].id === id2, `Expected id2, got ${r2.map(r => r.id)}`);
});
it('[2.2]math expression $subtract', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
const id2 = v4();
await store.operate('house', {
id: v4(),
action: 'create',
data: [
{
id: id1,
areaId: v4(),
ownerId: v4(),
district: '杭州',
size: 80.0,
},
{
id: id2,
areaId: v4(),
ownerId: v4(),
district: '上海',
size: 120.0,
},
]
}, context, {});
await context.commit();
// 测试 $subtract: size - 50 > 50
const r1 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: { $in: [id1, id2] },
$expr: {
$gt: [
{
$subtract: [{ '#attr': 'size' }, 50]
},
50
]
}
}
}, {});
assert(r1.length === 1 && r1[0].id === id2, `Expected id2, got ${r1.map(r => r.id)}`);
});
it('[2.3]math expression $multiply', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
const id2 = v4();
await store.operate('house', {
id: v4(),
action: 'create',
data: [
{
id: id1,
areaId: v4(),
ownerId: v4(),
district: '杭州',
size: 50.0,
},
{
id: id2,
areaId: v4(),
ownerId: v4(),
district: '上海',
size: 100.0,
},
]
}, context, {});
await context.commit();
// 测试 $multiply: size * 2 >= 200
const r1 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: { $in: [id1, id2] },
$expr: {
$gte: [
{
$multiply: [{ '#attr': 'size' }, 2]
},
200
]
}
}
}, {});
assert(r1.length === 1 && r1[0].id === id2, `Expected id2, got ${r1.map(r => r.id)}`);
// 测试多参数 $multiply: size * 2 * 3 > 500
const r2 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: { $in: [id1, id2] },
$expr: {
$gt: [
{
$multiply: [{ '#attr': 'size' }, 2, 3]
},
500
]
}
}
}, {});
assert(r2.length === 1 && r2[0].id === id2, `Expected id2, got ${r2.map(r => r.id)}`);
});
it('[2.4]math expression $divide', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
const id2 = v4();
await store.operate('house', {
id: v4(),
action: 'create',
data: [
{
id: id1,
areaId: v4(),
ownerId: v4(),
district: '杭州',
size: 80.0,
},
{
id: id2,
areaId: v4(),
ownerId: v4(),
district: '上海',
size: 200.0,
},
]
}, context, {});
await context.commit();
// 测试 $divide: size / 2 > 50
const r1 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: { $in: [id1, id2] },
$expr: {
$gt: [
{
$divide: [{ '#attr': 'size' }, 2]
},
50
]
}
}
}, {});
assert(r1.length === 1 && r1[0].id === id2, `Expected id2, got ${r1.map(r => r.id)}`);
});
it('[2.5]math expression $abs', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
const id2 = v4();
await store.operate('house', {
id: v4(),
action: 'create',
data: [
{
id: id1,
areaId: v4(),
ownerId: v4(),
district: '杭州',
size: 80.0,
},
{
id: id2,
areaId: v4(),
ownerId: v4(),
district: '上海',
size: 120.0,
},
]
}, context, {});
await context.commit();
// 测试 $abs: abs(size - 100) <= 20
const r1 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: { $in: [id1, id2] },
$expr: {
$lte: [
{
$abs: {
$subtract: [{ '#attr': 'size' }, 100]
}
},
20
]
}
}
}, {});
assert(r1.length === 2, `Expected 2 rows, got ${r1.length}`);
});
it('[2.6]math expression $round', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
await store.operate('house', {
id: v4(),
action: 'create',
data: {
id: id1,
areaId: v4(),
ownerId: v4(),
district: '杭州',
size: 80.567,
}
}, context, {});
await context.commit();
// 测试 $round: round(size, 1) = 80.6
const r1 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: id1,
$expr: {
$eq: [
{
$round: [{ '#attr': 'size' }, 1]
},
80.6
]
}
}
}, {});
assert(r1.length === 1, `Expected 1 row, got ${r1.length}`);
// 测试 $round: round(size, 0) = 81
const r2 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: id1,
$expr: {
$eq: [
{
$round: [{ '#attr': 'size' }, 0]
},
81
]
}
}
}, {});
assert(r2.length === 1, `Expected 1 row, got ${r2.length}`);
});
it('[2.7]math expression $ceil and $floor', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
await store.operate('house', {
id: v4(),
action: 'create',
data: {
id: id1,
areaId: v4(),
ownerId: v4(),
district: '杭州',
size: 80.3,
}
}, context, {});
await context.commit();
// 测试 $ceil: ceil(size) = 81
const r1 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: id1,
$expr: {
$eq: [
{ $ceil: { '#attr': 'size' } },
81
]
}
}
}, {});
assert(r1.length === 1, `Expected 1 row for ceil, got ${r1.length}`);
// 测试 $floor: floor(size) = 80
const r2 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: id1,
$expr: {
$eq: [
{ $floor: { '#attr': 'size' } },
80
]
}
}
}, {});
assert(r2.length === 1, `Expected 1 row for floor, got ${r2.length}`);
});
it('[2.8]math expression $pow', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
await store.operate('house', {
id: v4(),
action: 'create',
data: {
id: id1,
areaId: v4(),
ownerId: v4(),
district: '杭州',
size: 10.0,
}
}, context, {});
await context.commit();
// 测试 $pow: pow(size, 2) = 100
const r1 = await context.select('house', {
data: { id: 1, size: 1 },
filter: {
id: id1,
$expr: {
$eq: [
{ $pow: [{ '#attr': 'size' }, 2] },
100
]
}
}
}, {});
assert(r1.length === 1, `Expected 1 row, got ${r1.length}`);
});
}

View File

@ -0,0 +1,91 @@
import { TestContext } from "../Context";
import { EntityDict } from "../test-app-domain";
import { v4 } from 'uuid';
import assert from 'assert';
import { generateNewIdAsync } from 'oak-domain/lib/utils/uuid';
import { describe, it, before, after } from 'mocha';
import { DbStore } from "../../lib/types/dbStore";
export default (storeGetter: () => DbStore<EntityDict, TestContext>) => {
// ==================== 投影中的表达式测试 ====================
it('[11.1]expression in projection', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
await store.operate('house', {
id: v4(),
action: 'create',
data: {
id: id1,
areaId: v4(),
ownerId: v4(),
district: '杭州',
size: 100.0,
}
}, context, {});
await context.commit();
// 在投影中使用表达式计算 size * 2
const r1 = await context.select('house', {
data: {
id: 1,
size: 1,
$expr: {
$multiply: [{ '#attr': 'size' }, 2]
}
},
filter: { id: id1 }
}, {});
assert(r1.length === 1, `Expression in projection failed`);
assert(r1[0].$expr == 200, `Expected $expr = 200, got ${r1[0].$expr}`);
});
it('[11.2]multiple expressions in projection', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
await store.operate('house', {
id: v4(),
action: 'create',
data: {
id: id1,
areaId: v4(),
ownerId: v4(),
district: '杭州',
size: 100.0,
}
}, context, {});
await context.commit();
// 多个表达式在投影中
const r1 = await context.select('house', {
data: {
id: 1,
size: 1,
$expr1: {
$multiply: [{ '#attr': 'size' }, 2]
},
$expr2: {
$add: [{ '#attr': 'size' }, 50]
},
$expr3: {
$gt: [{ '#attr': 'size' }, 80]
}
},
filter: { id: id1 }
}, {});
assert(r1.length === 1, `Multiple expressions failed`);
assert(r1[0].$expr1 == 200, `Expected $expr1 = 200`);
assert(r1[0].$expr2 == 150, `Expected $expr2 = 150`);
assert(r1[0].$expr3 == true || r1[0].$expr3 == 1, `Expected $expr3 = true`);
});
}

163
test/testcase/string.ts Normal file
View File

@ -0,0 +1,163 @@
import { TestContext } from "../Context";
import { EntityDict } from "../test-app-domain";
import { v4 } from 'uuid';
import assert from 'assert';
import { generateNewIdAsync } from 'oak-domain/lib/utils/uuid';
import { describe, it, before, after } from 'mocha';
import { DbStore } from "../../lib/types/dbStore";
export default (storeGetter: () => DbStore<EntityDict, TestContext>) => {
// ==================== 字符串操作测试 ====================
it('[3.1]string expression $endsWith', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
const id2 = v4();
await store.operate('user', {
id: v4(),
action: 'create',
data: [
{
id: id1,
name: 'test_user',
nickname: 'test', idState: 'unverified', userState: 'normal'
},
{
id: id2,
name: 'admin_role',
nickname: 'admin', idState: 'unverified', userState: 'normal'
},
]
}, context, {});
await context.commit();
// 测试 $endsWith: name ends with '_user'
const r1 = await context.select('user', {
data: { id: 1, name: 1 },
filter: {
id: { $in: [id1, id2] },
$expr: {
$endsWith: [{ '#attr': 'name' }, '_user']
}
}
}, {});
assert(r1.length === 1 && r1[0].id === id1, `Expected id1, got ${r1.map(r => r.id)}`);
});
it('[3.2]string expression $includes', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
const id2 = v4();
await store.operate('user', {
id: v4(),
action: 'create',
data: [
{
id: id1,
name: 'test_admin_user',
nickname: 'test', idState: 'unverified', userState: 'normal'
},
{
id: id2,
name: 'guest_role',
nickname: 'guest', idState: 'unverified', userState: 'normal'
},
]
}, context, {});
await context.commit();
// 测试 $includes: name includes 'admin'
const r1 = await context.select('user', {
data: { id: 1, name: 1 },
filter: {
id: { $in: [id1, id2] },
$expr: {
$includes: [{ '#attr': 'name' }, 'admin']
}
}
}, {});
assert(r1.length === 1 && r1[0].id === id1, `Expected id1, got ${r1.map(r => r.id)}`);
});
it('[3.3]string expression $concat', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
await store.operate('user', {
id: v4(),
action: 'create',
data: {
id: id1,
name: 'hello',
nickname: 'world', idState: 'unverified', userState: 'normal'
}
}, context, {});
await context.commit();
// 测试 $concat: concat(name, '_', nickname) = 'hello_world'
const r1 = await context.select('user', {
data: { id: 1, name: 1, nickname: 1 },
filter: {
id: id1,
$expr: {
$eq: [
{ $concat: [{ '#attr': 'name' }, '_', { '#attr': 'nickname' }] },
'hello_world'
]
}
}
}, {});
assert(r1.length === 1, `Expected 1 row, got ${r1.length}`);
});
// ==================== 字符串 $startsWith 在表达式中测试 ====================
it('[10.2]string $startsWith in expression', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const id1 = v4();
const id2 = v4();
const id3 = v4();
await store.operate('user', {
id: v4(),
action: 'create',
data: [
{ id: id1, name: 'test_admin', nickname: 'test', idState: 'unverified', userState: 'normal' },
{ id: id2, name: 'test_user', nickname: 'test', idState: 'unverified', userState: 'normal' },
{ id: id3, name: 'admin_role', nickname: 'admin1', idState: 'unverified', userState: 'normal' },
]
}, context, {});
await context.commit();
// 测试 name 以 nickname 开头
const r1 = await context.select('user', {
data: { id: 1, name: 1, nickname: 1 },
filter: {
id: { $in: [id1, id2, id3] },
$expr: {
$startsWith: [
{ '#attr': 'name' },
{ '#attr': 'nickname' }
]
}
}
}, {});
assert(r1.length === 2, `$startsWith with two attrs failed, expected 2, got ${r1.length}`);
const ids = r1.map(r => r.id);
assert(ids.includes(id1) && ids.includes(id2), `Expected id1 and id2`);
});
}

62
test/testcase/subquery.ts Normal file
View File

@ -0,0 +1,62 @@
import { TestContext } from "../Context";
import { EntityDict } from "../test-app-domain";
import { v4 } from 'uuid';
import assert from 'assert';
import { generateNewIdAsync } from 'oak-domain/lib/utils/uuid';
import { describe, it, before, after } from 'mocha';
import { DbStore } from "../../lib/types/dbStore";
export default (storeGetter: () => DbStore<EntityDict, TestContext>) => {
// ==================== 子查询中的表达式测试 ====================
it('[12.1]expression in subquery filter', async () => {
const store = storeGetter();
const context = new TestContext(store);
await context.begin();
const systemId1 = v4();
const systemId2 = v4();
await store.operate('system', {
id: v4(),
action: 'create',
data: [
{
id: systemId1,
name: 'system_a',
description: 'test',
folder: '/a',
application$system: [
{ id: v4(), action: 'create', data: { id: v4(), name: 'app_a1', description: 't', type: 'web' } },
{ id: v4(), action: 'create', data: { id: v4(), name: 'app_a2', description: 't', type: 'web' } },
]
},
{
id: systemId2,
name: 'system_b',
description: 'test',
folder: '/b',
application$system: [
{ id: v4(), action: 'create', data: { id: v4(), name: 'other_b1', description: 't', type: 'web' } },
]
}
]
} as EntityDict['system']['CreateMulti'], context, {});
await context.commit();
// 查询: 存在以 'app_' 开头的 application 的 system
const r1 = await context.select('system', {
data: { id: 1, name: 1 },
filter: {
id: { $in: [systemId1, systemId2] },
application$system: {
name: { $startsWith: 'app_' }
}
}
}, {});
assert(r1.length === 1 && r1[0].id === systemId1, `Subquery with expression failed`);
});
}

108
test/utils/test.ts Normal file
View File

@ -0,0 +1,108 @@
type AsyncFunc = () => Promise<void>;
type SyncFunc = () => void;
const beforeFuncs: AsyncFunc[] = [];
const itFuncs: { desc: string; fn: AsyncFunc }[] = [];
const afterFuncs: SyncFunc[] = [];
const stats = {
total: 0,
passed: 0,
failed: 0,
errors: [] as {
type: 'before' | 'it' | 'after';
desc?: string;
error: unknown;
}[],
};
export const before = (func: AsyncFunc) => {
beforeFuncs.push(func);
return func;
};
export const it = (desc: string, func: AsyncFunc) => {
itFuncs.push({ desc, fn: func });
};
export const after = (func: SyncFunc) => {
afterFuncs.push(func);
return func;
};
export const describe = async (desc: string, func: () => Promise<void> | void) => {
console.log(desc);
// 注册阶段
await func();
// before
for (const f of beforeFuncs) {
try {
await f();
} catch (err) {
stats.failed++;
stats.errors.push({
type: 'before',
error: err,
});
console.error(' ✗ before failed:', err);
}
}
// it
for (const { desc, fn } of itFuncs) {
stats.total++;
try {
console.log(' ' + desc);
await fn();
stats.passed++;
console.log(' ✓ passed');
} catch (err) {
stats.failed++;
stats.errors.push({
type: 'it',
desc,
error: err,
});
console.error(' ✗ failed:', err);
}
}
// after无论如何都执行
for (const f of afterFuncs) {
try {
f();
} catch (err) {
stats.failed++;
stats.errors.push({
type: 'after',
error: err,
});
console.error(' ✗ after failed:', err);
}
}
// 统计输出
printSummary();
};
const printSummary = () => {
console.log('\n====== Test Summary ======');
console.log(`Total: ${stats.total}`);
console.log(`Passed: ${stats.passed}`);
console.log(`Failed: ${stats.failed}`);
if (stats.errors.length) {
console.log('\nErrors:');
for (const e of stats.errors) {
console.log(
`- [${e.type}]${e.desc ? ' ' + e.desc : ''}`,
'\n ',
e.error
);
}
}
console.log('==========================');
};

32
tsconfig.test.json Normal file
View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"module": "CommonJS",
"target": "ESNext",
"declaration": true,
"allowJs": false,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"strict": true,
"skipLibCheck": true,
"importHelpers": true,
"lib": [
"ESNext",
"DOM"
],
"outDir": "test-dist", /* Redirect output structure to the directory. */
"rootDir": "test", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
"types": [
"node",
"mocha"
],
"forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
"resolveJsonModule": true
},
"include": [
"test/**/*.ts"
],
"exclude": [
"node_modules"
]
}