Compare commits
38 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
c080b15078 | |
|
|
ec085ddfd1 | |
|
|
411f4db18f | |
|
|
62bc866606 | |
|
|
eee2f7c874 | |
|
|
cb8b0428b4 | |
|
|
be10547065 | |
|
|
49dc9141de | |
|
|
5a5ac5c194 | |
|
|
b4e0b08ba7 | |
|
|
195e97b3d9 | |
|
|
c5456b3fcb | |
|
|
a5cb652468 | |
|
|
8f0319c648 | |
|
|
5ab7e9e43b | |
|
|
6d7d3dea1f | |
|
|
66215811fc | |
|
|
3b8892d3e1 | |
|
|
5858dd7db0 | |
|
|
e9059b8514 | |
|
|
1a3e3cb005 | |
|
|
9c0437a025 | |
|
|
789580fd82 | |
|
|
5035f0d520 | |
|
|
3a17335937 | |
|
|
f6322dffff | |
|
|
aa49a12cb0 | |
|
|
e0a447adc0 | |
|
|
b5286fea5d | |
|
|
ef44cc4dc4 | |
|
|
846cccb325 | |
|
|
1d464a38a9 | |
|
|
6ec189001f | |
|
|
03391db61a | |
|
|
27f8fb16c5 | |
|
|
05f58945c4 | |
|
|
66cf652646 | |
|
|
aade97762f |
|
|
@ -115,4 +115,7 @@ dist
|
|||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
package-lock.json
|
||||
test/test-app-domain
|
||||
test/test-app-domain
|
||||
|
||||
|
||||
test-dist
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib"
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
/**
|
||||
* 根据载入的dataSchema,和数据库中原来的schema,决定如何来upgrade
|
||||
* 制订出来的plan分为两阶段:增加阶段和削减阶段,在两个阶段之间,由用户来修正数据
|
||||
*/
|
||||
makeUpgradePlan(): Promise<Plan>;
|
||||
/**
|
||||
* 比较两个schema的不同,这里计算的是new对old的增量
|
||||
* @param schemaOld
|
||||
* @param SchemaNew
|
||||
*/
|
||||
diffSchema(schemaOld: StorageSchema<any>, schemaNew: StorageSchema<any>): Plan;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>>;
|
||||
/**
|
||||
* 根据载入的dataSchema,和数据库中原来的schema,决定如何来upgrade
|
||||
* 制订出来的plan分为两阶段:增加阶段和削减阶段,在两个阶段之间,由用户来修正数据
|
||||
*/
|
||||
makeUpgradePlan(): Promise<Plan>;
|
||||
/**
|
||||
* 比较两个schema的不同,这里计算的是new对old的增量
|
||||
* @param schemaOld
|
||||
* @param schemaNew
|
||||
*/
|
||||
diffSchema(schemaOld: StorageSchema<any>, schemaNew: StorageSchema<any>): Plan;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
export * from './MySQL/store';
|
||||
export { MySqlSelectOption, MysqlOperateOption } from './MySQL/translator';
|
||||
export * from './PostgreSQL/store';
|
||||
export { PostgreSQLSelectOption, PostgreSQLOperateOption } from './PostgreSQL/translator';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
};
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
;
|
||||
11
package.json
11
package.json
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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`);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据载入的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: 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据载入的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: 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;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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';
|
||||
|
|
@ -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 默认
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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: '数据',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
@ -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: '平台',
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
// });
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
}
|
||||
|
|
@ -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`);
|
||||
});
|
||||
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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`);
|
||||
});
|
||||
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
}
|
||||
|
|
@ -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`);
|
||||
});
|
||||
|
||||
}
|
||||
|
|
@ -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`);
|
||||
});
|
||||
|
||||
}
|
||||
|
|
@ -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`);
|
||||
});
|
||||
|
||||
}
|
||||
|
|
@ -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('==========================');
|
||||
};
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue