From 8f0319c648dff2a996e802db8dca6250936a67ab Mon Sep 17 00:00:00 2001 From: qcqcqc <1220204124@zust.edu.cn> Date: Tue, 13 Jan 2026 15:02:37 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=B8=80=E5=A4=84?= =?UTF-8?q?mysql=E7=9A=84filter=E6=B7=B1=E5=B1=82=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E8=A1=A8=E8=BE=BE=E5=BC=8F=E6=97=B6=E4=BC=9A?= =?UTF-8?q?=E5=87=BA=E7=8E=B0=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/MySQL/translator.js | 6 ++++-- src/MySQL/translator.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/MySQL/translator.js b/lib/MySQL/translator.js index 1c8beef..4e25c5e 100644 --- a/lib/MySQL/translator.js +++ b/lib/MySQL/translator.js @@ -872,8 +872,10 @@ 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); diff --git a/src/MySQL/translator.ts b/src/MySQL/translator.ts index 67675be..2d8ebc1 100644 --- a/src/MySQL/translator.ts +++ b/src/MySQL/translator.ts @@ -971,8 +971,10 @@ export class MySqlTranslator 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); From c5456b3fcb580cb8c2ceca3568b7d29f184b0e62 Mon Sep 17 00:00:00 2001 From: qcqcqc <1220204124@zust.edu.cn> Date: Tue, 13 Jan 2026 15:04:08 +0800 Subject: [PATCH 2/6] =?UTF-8?q?test:=20=E6=96=B0=E5=A2=9E=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B=EF=BC=9A=E5=B5=8C=E5=A5=97=E8=B7=A8?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E8=A1=A8=E8=BE=BE=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/testcase/base.ts | 86 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/test/testcase/base.ts b/test/testcase/base.ts index 098f282..7195021 100644 --- a/test/testcase/base.ts +++ b/test/testcase/base.ts @@ -1835,4 +1835,90 @@ export default (storeGetter: () => DbStore) => { assert(r1.length === 1, `Deeply nested query failed`); assert(r1[0].system?.platform?.name === 'test_platform', `Nested projection failed`); }); + + it('[1.4.1]嵌套跨节点表达式', async () => { + const store = storeGetter(); + const context = new TestContext(store); + await context.begin(); + + const platformId = v4(); + await store.operate('platform', { + id: v4(), + action: 'create', + data: { + id: platformId, + name: 'test2' + } + }, context, {}) + await store.operate('application', { + id: v4(), + action: 'create', + data: [{ + id: v4(), + name: 'test', + description: 'ttttt', + type: 'web', + system: { + id: v4(), + action: 'create', + data: { + id: v4(), + name: 'systest', + folder: 'systest', + platformId, + } + }, + }, { + id: v4(), + name: 'test222', + description: 'ttttt2', + type: 'web', + system: { + id: v4(), + action: 'create', + data: { + id: v4(), + name: 'test2222', + folder: 'test2', + platformId, + } + }, + }] + }, context, {}); + + process.env.NODE_ENV = 'development'; + + // 查询所有的application,过滤条件是application->system.name = application->system->platform.name + const apps = await store.select('application', { + data: { + id: 1, + name: 1, + system: { + id: 1, + name: 1, + platform: { + id: 1, + name: 1, + } + } + }, + filter: { + system: { + "#id": 'node-1', + platform: { + $expr: { + $eq: [ + { "#attr": 'name' }, + { "#refId": 'node-1', "#refAttr": 'folder' } + ] + } + } + } + } + }, context, {}); + + process.env.NODE_ENV = undefined; + assert(apps.length === 1 && apps[0].name === 'test222'); + await context.commit(); + }); } \ No newline at end of file From 195e97b3d977697771af49b20bd893324fe9e26e Mon Sep 17 00:00:00 2001 From: qcqcqc <1220204124@zust.edu.cn> Date: Tue, 20 Jan 2026 10:52:53 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BA=86=E9=83=A8?= =?UTF-8?q?=E5=88=86=E5=86=85=E7=BD=AE=E5=AD=97=E6=AE=B5=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E4=B8=BAstring=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E4=B8=8A=E5=B1=82=E8=BD=AC=E6=8D=A2=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/PostgreSQL/store.js | 14 ++++ src/PostgreSQL/store.ts | 177 ++++++++++++++++++++++------------------ 2 files changed, 110 insertions(+), 81 deletions(-) diff --git a/lib/PostgreSQL/store.js b/lib/PostgreSQL/store.js index bf51e90..b2c565e 100644 --- a/lib/PostgreSQL/store.js +++ b/lib/PostgreSQL/store.js @@ -8,6 +8,12 @@ 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); @@ -203,6 +209,14 @@ class PostgreSQLStore extends CascadeStore_1.CascadeStore { // 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; } diff --git a/src/PostgreSQL/store.ts b/src/PostgreSQL/store.ts index 6da632d..cf7f3ef 100644 --- a/src/PostgreSQL/store.ts +++ b/src/PostgreSQL/store.ts @@ -1,12 +1,12 @@ -import { - EntityDict, - OperateOption, - OperationResult, - TxnOption, - StorageSchema, - SelectOption, - AggregationResult, - Geo +import { + EntityDict, + OperateOption, + OperationResult, + TxnOption, + StorageSchema, + SelectOption, + AggregationResult, + Geo } from 'oak-domain/lib/types'; import { EntityDict as BaseEntityDict } from 'oak-domain/lib/base-app-domain'; import { CascadeStore } from 'oak-domain/lib/store/CascadeStore'; @@ -22,6 +22,13 @@ import { CreateEntityOption } from '../types/Translator'; import { QueryResult } from 'pg'; import { DbStore } 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[]; @@ -59,119 +66,119 @@ function convertGeoTextToObject(geoText: string): Geo { }; } } - + throw new Error(`Unsupported geometry type: ${geoText.slice(0, 50)}`); } export class PostgreSQLStore< - ED extends EntityDict & BaseEntityDict, + ED extends EntityDict & BaseEntityDict, Cxt extends AsyncContext > extends CascadeStore implements DbStore { - + protected countAbjointRow>( - entity: T, - selection: Pick, - context: Cxt, + entity: T, + selection: Pick, + context: Cxt, option: OP ): number { throw new Error('PostgreSQL store 不支持同步取数据'); } - + protected aggregateAbjointRowSync>( - entity: T, - aggregation: ED[T]['Aggregation'], - context: Cxt, + entity: T, + aggregation: ED[T]['Aggregation'], + context: Cxt, option: OP ): AggregationResult { throw new Error('PostgreSQL store 不支持同步取数据'); } - + protected selectAbjointRow( - entity: T, - selection: ED[T]['Selection'], - context: SyncContext, + entity: T, + selection: ED[T]['Selection'], + context: SyncContext, option: OP ): Partial[] { throw new Error('PostgreSQL store 不支持同步取数据'); } - + protected updateAbjointRow( - entity: T, - operation: ED[T]['Operation'], - context: SyncContext, + entity: T, + operation: ED[T]['Operation'], + context: SyncContext, option: OP ): number { throw new Error('PostgreSQL store 不支持同步更新数据'); } - + async exec(script: string, txnId?: string) { await this.connector.exec(script, txnId); } - + connector: PostgreSQLConnector; translator: PostgreSQLTranslator; - + constructor(storageSchema: StorageSchema, configuration: PostgreSQLConfiguration) { super(storageSchema); this.connector = new PostgreSQLConnector(configuration); this.translator = new PostgreSQLTranslator(storageSchema); } - + checkRelationAsync>( - entity: T, - operation: Omit, + entity: T, + operation: Omit, context: Cxt ): Promise { throw new Error('Method not implemented.'); } - + protected async aggregateAbjointRowAsync>( - entity: T, - aggregation: ED[T]['Aggregation'], - context: Cxt, + entity: T, + aggregation: ED[T]['Aggregation'], + context: Cxt, option: OP ): Promise> { 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: T, - aggregation: ED[T]['Aggregation'], - context: Cxt, + entity: T, + aggregation: ED[T]['Aggregation'], + context: Cxt, option: OP ): Promise> { return this.aggregateAsync(entity, aggregation, context, option); } - + protected supportManyToOneJoin(): boolean { return true; } - + protected supportMultipleCreate(): boolean { return true; } - + private formResult(entity: T, result: any): any { const schema = this.getSchema(); - + function resolveAttribute( - entity2: E, - r: Record, - attr: string, + entity2: E, + r: Record, + 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 { @@ -190,7 +197,7 @@ export class PostgreSQLStore< } } else if (attributes[attr]) { const { type } = attributes[attr]; - + switch (type) { case 'date': case 'time': { @@ -266,6 +273,14 @@ export class PostgreSQLStore< } 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; } @@ -281,7 +296,7 @@ export class PostgreSQLStore< function removeNullObjects(r: Record, 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); @@ -305,7 +320,7 @@ export class PostgreSQLStore< function formSingleRow(r: any): any { let result2: Record = {}; - + for (let attr in r) { const value = r[attr]; resolveAttribute(entity, result2, attr, value); @@ -320,7 +335,7 @@ export class PostgreSQLStore< } return formSingleRow(result); } - + protected async selectAbjointRowAsync( entity: T, selection: ED[T]['Selection'], @@ -331,7 +346,7 @@ export class PostgreSQLStore< const result = await this.connector.exec(sql, context.getCurrentTxnId()); return this.formResult(entity, result[0]); } - + protected async updateAbjointRowAsync( entity: T, operation: ED[T]['Operation'], @@ -363,76 +378,76 @@ export class PostgreSQLStore< } } } - + async operate( - entity: T, - operation: ED[T]['Operation'], - context: Cxt, + entity: T, + operation: ED[T]['Operation'], + context: Cxt, option: OperateOption ): Promise> { const { action } = operation; assert(!['select', 'download', 'stat'].includes(action), '不支持使用 select operation'); return await super.operateAsync(entity, operation as any, context, option); } - + async select( - entity: T, - selection: ED[T]['Selection'], - context: Cxt, + entity: T, + selection: ED[T]['Selection'], + context: Cxt, option: SelectOption ): Promise[]> { return await super.selectAsync(entity, selection, context, option); } - + protected async countAbjointRowAsync( - entity: T, - selection: Pick, - context: AsyncContext, + entity: T, + selection: Pick, + context: AsyncContext, option: SelectOption ): Promise { 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: T, - selection: Pick, - context: Cxt, + entity: T, + selection: Pick, + context: Cxt, option: SelectOption ) { return this.countAsync(entity, selection, context, option); } - + async begin(option?: TxnOption): Promise { return await this.connector.startTransaction(option); } - + async commit(txnId: string): Promise { await this.connector.commitTransaction(txnId); } - + async rollback(txnId: string): Promise { 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(); - + // 可选:先创建 PostGIS 扩展 // await this.connector.exec('CREATE EXTENSION IF NOT EXISTS postgis;'); - + for (const entity in schema) { const sqls = this.translator.translateCreateEntity(entity, option); for (const sql of sqls) { From b4e0b08ba7ea15fcf18f3f591c54b2fcf3e115ef Mon Sep 17 00:00:00 2001 From: qcqcqc <1220204124@zust.edu.cn> Date: Tue, 20 Jan 2026 11:00:51 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80datatime=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E7=9A=84=E5=A4=84=E7=90=86=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/PostgreSQL/store.js | 11 +++++++++-- src/PostgreSQL/store.ts | 10 ++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/PostgreSQL/store.js b/lib/PostgreSQL/store.js index b2c565e..42bef83 100644 --- a/lib/PostgreSQL/store.js +++ b/lib/PostgreSQL/store.js @@ -127,12 +127,19 @@ class PostgreSQLStore extends CascadeStore_1.CascadeStore { const { type } = attributes[attr]; switch (type) { case 'date': - case 'time': { + case 'time': + case 'datetime': { if (value instanceof Date) { r[attr] = value.valueOf(); } else { - r[attr] = value; + if (typeof value === 'string') { + r[attr] = parseInt(value, 10); + } + else { + (0, assert_1.default)(typeof value === 'number' || value === null); + r[attr] = value; + } } break; } diff --git a/src/PostgreSQL/store.ts b/src/PostgreSQL/store.ts index cf7f3ef..b14580d 100644 --- a/src/PostgreSQL/store.ts +++ b/src/PostgreSQL/store.ts @@ -200,11 +200,17 @@ export class PostgreSQLStore< switch (type) { case 'date': - case 'time': { + case 'time': + case 'datetime': { if (value instanceof Date) { r[attr] = value.valueOf(); } else { - r[attr] = value; + if (typeof value === 'string') { + r[attr] = parseInt(value, 10); + } else { + assert(typeof value === 'number' || value === null); + r[attr] = value; + } } break; } From 5a5ac5c1947767bfa398f3536e7e98e10c5690e3 Mon Sep 17 00:00:00 2001 From: qcqcqc <1220204124@zust.edu.cn> Date: Tue, 20 Jan 2026 12:04:57 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=9C=A8init?= =?UTF-8?q?=E6=97=B6=E8=87=AA=E5=8A=A8=E5=88=9B=E5=BB=BA=E5=9C=B0=E7=90=86?= =?UTF-8?q?=E5=92=8Cpaser=E6=8F=92=E4=BB=B6=EF=BC=8C=E4=BB=A5=E5=8F=8A?= =?UTF-8?q?=E5=B0=86=E5=88=9D=E5=A7=8B=E5=8C=96=E5=8C=85=E8=A3=B9=E5=9C=A8?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/PostgreSQL/store.js | 61 ++++++++++++++++++++++++++++++++++----- src/PostgreSQL/store.ts | 64 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 111 insertions(+), 14 deletions(-) diff --git a/lib/PostgreSQL/store.js b/lib/PostgreSQL/store.js index 42bef83..6e2ef7f 100644 --- a/lib/PostgreSQL/store.js +++ b/lib/PostgreSQL/store.js @@ -336,14 +336,61 @@ class PostgreSQLStore extends CascadeStore_1.CascadeStore { await this.connector.disconnect(); } async initialize(option) { - const schema = this.getSchema(); - // 可选:先创建 PostGIS 扩展 - // await this.connector.exec('CREATE EXTENSION IF NOT EXISTS postgis;'); - for (const entity in schema) { - const sqls = this.translator.translateCreateEntity(entity, option); - for (const sql of sqls) { - await this.connector.exec(sql); + // PG的DDL支持事务,所以这里直接用一个事务包裹所有的初始化操作 + const txn = await this.connector.startTransaction({ + isolationLevel: 'serializable', + }); + try { + const schema = this.getSchema(); + let hasGeoType = false; + let hasChineseTsConfig = false; + for (const entity in schema) { + const { attributes, indexes } = schema[entity]; + for (const attr in attributes) { + const { type } = attributes[attr]; + if (type === 'geometry') { + hasGeoType = true; + } + } + for (const index of indexes || []) { + if (index.config?.tsConfig === 'chinese' || index.config?.tsConfig?.includes('chinese')) { + hasChineseTsConfig = true; + } + } } + 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 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); + const count = parseInt(result[0][0]?.cnt || '0', 10); + if (count === 0) { + const createChineseConfigSql = ` + CREATE EXTENSION IF NOT EXISTS zhparser; + CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser); + ALTER TEXT SEARCH CONFIGURATION chinese ADD MAPPING FOR n,v,a,i,e,l WITH simple; + `; + await this.connector.exec(createChineseConfigSql); + } + } + 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; } } } diff --git a/src/PostgreSQL/store.ts b/src/PostgreSQL/store.ts index b14580d..17ce927 100644 --- a/src/PostgreSQL/store.ts +++ b/src/PostgreSQL/store.ts @@ -449,16 +449,66 @@ export class PostgreSQLStore< } async initialize(option: CreateEntityOption) { - const schema = this.getSchema(); + // PG的DDL支持事务,所以这里直接用一个事务包裹所有的初始化操作 + const txn = await this.connector.startTransaction({ + isolationLevel: 'serializable', + }); + try { - // 可选:先创建 PostGIS 扩展 - // await this.connector.exec('CREATE EXTENSION IF NOT EXISTS postgis;'); + const schema = this.getSchema(); - for (const entity in schema) { - const sqls = this.translator.translateCreateEntity(entity, option); - for (const sql of sqls) { - await this.connector.exec(sql); + let hasGeoType = false; + let hasChineseTsConfig = false; + + for (const entity in schema) { + const { attributes, indexes } = schema[entity]; + for (const attr in attributes) { + const { type } = attributes[attr]; + if (type === 'geometry') { + hasGeoType = true; + } + } + for (const index of indexes || []) { + if (index.config?.tsConfig === 'chinese' || index.config?.tsConfig?.includes('chinese')) { + hasChineseTsConfig = true; + } + } } + + 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 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); + const count = parseInt(result[0][0]?.cnt || '0', 10); + if (count === 0) { + const createChineseConfigSql = ` + CREATE EXTENSION IF NOT EXISTS zhparser; + CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser); + ALTER TEXT SEARCH CONFIGURATION chinese ADD MAPPING FOR n,v,a,i,e,l WITH simple; + `; + await this.connector.exec(createChineseConfigSql); + } + } + + 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; } } } From 49dc9141de182f685e02ab978c5b5a53cf530648 Mon Sep 17 00:00:00 2001 From: qcqcqc <1220204124@zust.edu.cn> Date: Tue, 20 Jan 2026 18:03:33 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=94=AF=E6=8C=81=EF=BC=8C=E5=9C=A8pg=E4=B8=AD?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E9=83=A8=E5=88=86ddl=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/MySQL/store.d.ts | 17 +- lib/MySQL/translator.js | 10 + lib/PostgreSQL/connector.d.ts | 4 - lib/PostgreSQL/connector.js | 10 - lib/PostgreSQL/store.d.ts | 14 +- lib/PostgreSQL/store.js | 130 ++++++++++ lib/PostgreSQL/translator.d.ts | 22 +- lib/PostgreSQL/translator.js | 385 +++++++++++++++++++++++++++- lib/types/dbStore.d.ts | 28 +- src/MySQL/store.ts | 16 +- src/MySQL/translator.ts | 10 + src/PostgreSQL/connector.ts | 11 - src/PostgreSQL/store.ts | 141 ++++++++++- src/PostgreSQL/translator.ts | 450 ++++++++++++++++++++++++++++++++- src/types/dbStore.ts | 55 +++- 15 files changed, 1222 insertions(+), 81 deletions(-) diff --git a/lib/MySQL/store.d.ts b/lib/MySQL/store.d.ts index 7728690..4c01ac2 100644 --- a/lib/MySQL/store.d.ts +++ b/lib/MySQL/store.d.ts @@ -1,4 +1,4 @@ -import { EntityDict, OperateOption, OperationResult, TxnOption, StorageSchema, SelectOption, AggregationResult, Attribute, Index } from 'oak-domain/lib/types'; +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 { MySQLConfiguration } from './types/Configuration'; @@ -7,7 +7,7 @@ import { MySqlTranslator, MySqlSelectOption, MysqlOperateOption } from './transl import { AsyncContext } from 'oak-domain/lib/store/AsyncRowStore'; import { SyncContext } from 'oak-domain/lib/store/SyncRowStore'; import { CreateEntityOption } from '../types/Translator'; -import { DbStore } from '../types/dbStore'; +import { DbStore, Plan } from '../types/dbStore'; export declare class MysqlStore> extends CascadeStore implements DbStore { protected countAbjointRow>(entity: T, selection: Pick, context: Cxt, option: OP): number; protected aggregateAbjointRowSync>(entity: T, aggregation: ED[T]['Aggregation'], context: Cxt, option: OP): AggregationResult; @@ -48,16 +48,3 @@ export declare class MysqlStore, schemaNew: StorageSchema): Plan; } -type Plan = { - newTables: Record; - }>; - newIndexes: Record[]>; - updatedTables: Record; - }>; - updatedIndexes: Record[]>; -}; -export {}; diff --git a/lib/MySQL/translator.js b/lib/MySQL/translator.js index 4e25c5e..20dcad3 100644 --- a/lib/MySQL/translator.js +++ b/lib/MySQL/translator.js @@ -81,6 +81,16 @@ class MySqlTranslator extends sqlTranslator_1.SqlTranslator { makeUpSchema() { for (const entity in this.schema) { const { attributes, indexes } = this.schema[entity]; + // 非特殊索引自动添加 $$deleteAt$$ (by qcqcqc) + for (const index of indexes || []) { + if (index.config?.type) { + continue; + } + const indexAttrNames = index.attributes.map(attr => attr.name); + if (!indexAttrNames.includes('$$deleteAt$$')) { + index.attributes.push({ name: '$$deleteAt$$' }); + } + } const geoIndexes = []; for (const attr in attributes) { if (attributes[attr].type === 'geometry') { diff --git a/lib/PostgreSQL/connector.d.ts b/lib/PostgreSQL/connector.d.ts index 34b8bf6..b3fccad 100644 --- a/lib/PostgreSQL/connector.d.ts +++ b/lib/PostgreSQL/connector.d.ts @@ -16,10 +16,6 @@ export declare class PostgreSQLConnector { exec(sql: string, txn?: string): Promise<[QueryResultRow[], QueryResult]>; commitTransaction(txn: string): Promise; rollbackTransaction(txn: string): Promise; - /** - * 执行多条 SQL 语句(用于初始化等场景) - */ - execBatch(sqls: string[], txn?: string): Promise; /** * 获取连接池状态 */ diff --git a/lib/PostgreSQL/connector.js b/lib/PostgreSQL/connector.js index 4a2992e..8b31e75 100644 --- a/lib/PostgreSQL/connector.js +++ b/lib/PostgreSQL/connector.js @@ -133,16 +133,6 @@ class PostgreSQLConnector { connection.release(); } } - /** - * 执行多条 SQL 语句(用于初始化等场景) - */ - async execBatch(sqls, txn) { - for (const sql of sqls) { - if (sql.trim()) { - await this.exec(sql, txn); - } - } - } /** * 获取连接池状态 */ diff --git a/lib/PostgreSQL/store.d.ts b/lib/PostgreSQL/store.d.ts index 64c32d8..d81fffe 100644 --- a/lib/PostgreSQL/store.d.ts +++ b/lib/PostgreSQL/store.d.ts @@ -7,7 +7,7 @@ import { PostgreSQLTranslator, PostgreSQLSelectOption, PostgreSQLOperateOption } import { AsyncContext } from 'oak-domain/lib/store/AsyncRowStore'; import { SyncContext } from 'oak-domain/lib/store/SyncRowStore'; import { CreateEntityOption } from '../types/Translator'; -import { DbStore } from '../types/dbStore'; +import { DbStore, Plan } from '../types/dbStore'; export declare class PostgreSQLStore> extends CascadeStore implements DbStore { protected countAbjointRow>(entity: T, selection: Pick, context: Cxt, option: OP): number; protected aggregateAbjointRowSync>(entity: T, aggregation: ED[T]['Aggregation'], context: Cxt, option: OP): AggregationResult; @@ -35,4 +35,16 @@ export declare class PostgreSQLStore; disconnect(): Promise; initialize(option: CreateEntityOption): Promise; + readSchema(): Promise>; + /** + * 根据载入的dataSchema,和数据库中原来的schema,决定如何来upgrade + * 制订出来的plan分为两阶段:增加阶段和削减阶段,在两个阶段之间,由用户来修正数据 + */ + makeUpgradePlan(): Promise; + /** + * 比较两个schema的不同,这里计算的是new对old的增量 + * @param schemaOld + * @param schemaNew + */ + diffSchema(schemaOld: StorageSchema, schemaNew: StorageSchema): Plan; } diff --git a/lib/PostgreSQL/store.js b/lib/PostgreSQL/store.js index 6e2ef7f..12a2a54 100644 --- a/lib/PostgreSQL/store.js +++ b/lib/PostgreSQL/store.js @@ -393,5 +393,135 @@ class PostgreSQLStore extends CascadeStore_1.CascadeStore { 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; diff --git a/lib/PostgreSQL/translator.d.ts b/lib/PostgreSQL/translator.d.ts index af07cc2..7fea5fd 100644 --- a/lib/PostgreSQL/translator.d.ts +++ b/lib/PostgreSQL/translator.d.ts @@ -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"; @@ -80,4 +80,24 @@ export declare class PostgreSQLTranslator(entity: T, data: ED[T]['CreateMulti']['data'], conflictKeys: string[], updateAttrs?: string[]): string; protected populateUpdateStmt(updateText: string, fromText: string, aliasDict: Record, filterText: string, sorterText?: string, indexFrom?: number, count?: number, option?: PostgreSQLOperateOption): string; protected populateRemoveStmt(updateText: string, fromText: string, aliasDict: Record, 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): Promise>; + /** + * 将属性定义转换为 PostgreSQL DDL 语句 + * @param attr 属性名 + * @param attrDef 属性定义 + */ + translateAttributeDef(attr: string, attrDef: Attribute): string; + /** + * 比较两个 SQL 语句是否等价(用于 schema diff) + * 忽略空格、大小写等格式差异 + */ + compareSql(sql1: string, sql2: string): boolean; } diff --git a/lib/PostgreSQL/translator.js b/lib/PostgreSQL/translator.js index 61ab1c7..ff74050 100644 --- a/lib/PostgreSQL/translator.js +++ b/lib/PostgreSQL/translator.js @@ -171,6 +171,16 @@ class PostgreSQLTranslator extends sqlTranslator_1.SqlTranslator { makeUpSchema() { for (const entity in this.schema) { const { attributes, indexes } = this.schema[entity]; + // 非特殊索引自动添加 $$deleteAt$$ + for (const index of indexes || []) { + if (index.config?.type) { + continue; + } + const indexAttrNames = index.attributes.map(attr => attr.name); + if (!indexAttrNames.includes('$$deleteAt$$')) { + index.attributes.push({ name: '$$deleteAt$$' }); + } + } const geoIndexes = []; for (const attr in attributes) { if (attributes[attr].type === 'geometry') { @@ -741,6 +751,7 @@ class PostgreSQLTranslator extends sqlTranslator_1.SqlTranslator { else if (typeof value === 'number') { return `${value}`; } + (0, assert_1.default)(typeof value === 'string', 'Invalid date/time value'); return `'${(new Date(value)).valueOf()}'`; } case 'object': @@ -967,12 +978,8 @@ class PostgreSQLTranslator extends sqlTranslator_1.SqlTranslator { } indexSql += '('; const indexColumns = []; - let includeDeleteAt = false; for (const indexAttr of indexAttrs) { const { name: attrName, direction } = indexAttr; - if (attrName === '$$deleteAt$$') { - includeDeleteAt = true; - } if (indexType === 'fulltext') { // 全文索引:使用 to_tsvector indexColumns.push(`to_tsvector('${tsLang}', COALESCE("${attrName}", ''))`); @@ -986,10 +993,6 @@ class PostgreSQLTranslator extends sqlTranslator_1.SqlTranslator { indexColumns.push(col); } } - // 非特殊索引自动包含 deleteAt - if (!includeDeleteAt && !indexType) { - indexColumns.push('"$$deleteAt$$"'); - } indexSql += indexColumns.join(', '); indexSql += ');'; sqls.push(indexSql); @@ -1766,5 +1769,371 @@ class PostgreSQLTranslator extends sqlTranslator_1.SqlTranslator { // 这个方法不应该被直接调用了,因为translateRemove已经重写 throw new Error('populateRemoveStmt should not be called directly in PostgreSQL. Use translateRemove instead.'); } + /** + * 将 PostgreSQL 返回的 Type 回译成 oak 的类型,是 populateDataTypeDef 的反函数 + * @param type PostgreSQL 类型字符串 + */ + reTranslateToAttribute(type) { + // 处理带长度的类型:character varying(255), character(10) + const varcharMatch = /^character varying\((\d+)\)$/.exec(type); + if (varcharMatch) { + return { + type: 'varchar', + params: { + length: parseInt(varcharMatch[1], 10), + } + }; + } + const charMatch = /^character\((\d+)\)$/.exec(type); + if (charMatch) { + return { + type: 'char', + params: { + length: parseInt(charMatch[1], 10), + } + }; + } + // 处理带精度和小数位的类型:numeric(10,2) + const numericWithScaleMatch = /^numeric\((\d+),(\d+)\)$/.exec(type); + if (numericWithScaleMatch) { + return { + type: 'decimal', + params: { + precision: parseInt(numericWithScaleMatch[1], 10), + scale: parseInt(numericWithScaleMatch[2], 10), + }, + }; + } + // 处理只带精度的类型:numeric(10), timestamp(6) + const numericMatch = /^numeric\((\d+)\)$/.exec(type); + if (numericMatch) { + return { + type: 'decimal', + params: { + precision: parseInt(numericMatch[1], 10), + scale: 0, + }, + }; + } + const timestampMatch = /^timestamp\((\d+)\) without time zone$/.exec(type); + if (timestampMatch) { + return { + type: 'timestamp', + params: { + precision: parseInt(timestampMatch[1], 10), + }, + }; + } + const timeMatch = /^time\((\d+)\) without time zone$/.exec(type); + if (timeMatch) { + return { + type: 'time', + params: { + precision: parseInt(timeMatch[1], 10), + }, + }; + } + // PostgreSQL 类型映射到 oak 类型 + const typeMap = { + 'bigint': 'bigint', + 'integer': 'integer', + 'smallint': 'smallint', + 'real': 'real', + 'double precision': 'double precision', + 'boolean': 'boolean', + 'text': 'text', + 'jsonb': 'object', // 框架使用 jsonb 存储 object/array + 'json': 'object', + 'bytea': 'bytea', + 'character varying': 'varchar', + 'character': 'char', + 'timestamp without time zone': 'timestamp', + 'time without time zone': 'time', + 'date': 'date', + 'uuid': 'uuid', + 'geometry': 'geometry', + }; + const mappedType = typeMap[type]; + if (mappedType) { + return { type: mappedType }; + } + // 如果是用户定义的枚举类型,返回 enum(具体值需要额外查询) + // 这里先返回基础类型,枚举值在 readSchema 中单独处理 + return { type: type }; + } + /** + * 从 PostgreSQL 数据库读取当前的 schema 结构 + */ + async readSchema(execFn) { + const result = {}; + // 1. 获取所有表 + const tablesSql = ` + SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + ORDER BY tablename; + `; + const [tablesResult] = await execFn(tablesSql); + for (const tableRow of tablesResult) { + const tableName = tableRow.tablename; + // 2. 获取表的列信息 + const columnsSql = ` + SELECT + column_name, + data_type, + character_maximum_length, + numeric_precision, + numeric_scale, + is_nullable, + column_default, + udt_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = '${tableName}' + ORDER BY ordinal_position; + `; + const [columnsResult] = await execFn(columnsSql); + const attributes = {}; + for (const col of columnsResult) { + const { column_name: colName, data_type: dataType, character_maximum_length: maxLength, numeric_precision: precision, numeric_scale: scale, is_nullable: isNullable, column_default: defaultValue, udt_name: udtName, } = col; + let attr; + // 处理用户定义类型(枚举) + if (dataType === 'USER-DEFINED') { + // 查询枚举值 + const enumSql = ` + SELECT e.enumlabel + FROM pg_type t + JOIN pg_enum e ON t.oid = e.enumtypid + WHERE t.typname = '${udtName}' + ORDER BY e.enumsortorder; + `; + const [enumResult] = await execFn(enumSql); + const enumeration = enumResult.map((r) => r.enumlabel); + attr = { + type: 'enum', + enumeration, + }; + } + else { + // 构建完整的类型字符串 + let fullType = dataType; + if (maxLength) { + fullType = `${dataType}(${maxLength})`; + } + else if (precision !== null && scale !== null) { + fullType = `${dataType}(${precision},${scale})`; + } + else if (precision !== null) { + fullType = `${dataType}(${precision})`; + } + attr = this.reTranslateToAttribute(fullType); + } + // 处理约束 + attr.notNull = isNullable === 'NO'; + // 处理默认值(简化处理,复杂的默认值可能需要更精细的解析) + if (defaultValue && !defaultValue.includes('nextval')) { + // 跳过序列默认值 + // 简单处理:移除类型转换 + let cleanDefault = defaultValue.replace(/::[a-z]+/gi, '').replace(/'/g, ''); + if (cleanDefault === 'true') { + attr.default = true; + } + else if (cleanDefault === 'false') { + attr.default = false; + } + else if (!isNaN(Number(cleanDefault))) { + attr.default = Number(cleanDefault); + } + else { + attr.default = cleanDefault; + } + } + // 检查是否是序列列(IDENTITY) + if (colName === '$$seq$$' || (defaultValue && defaultValue.includes('nextval'))) { + attr.sequenceStart = 10000; // 默认起始值 + } + attributes[colName] = attr; + } + // 3. 获取索引信息 + const indexesSql = ` + SELECT + i.relname as index_name, + ix.indisunique as is_unique, + am.amname as index_type, + pg_get_indexdef(ix.indexrelid) as index_def + FROM pg_class t + JOIN pg_index ix ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_am am ON i.relam = am.oid + WHERE t.relname = '${tableName}' + AND t.relkind = 'r' + AND i.relname NOT LIKE '%_pkey' + AND NOT ix.indisprimary + ORDER BY i.relname; +`; + const [indexesResult] = await execFn(indexesSql); + if (indexesResult.length > 0) { + const indexes = []; + for (const row of indexesResult) { + const { index_name: indexName, is_unique: isUnique, index_type: indexType, index_def: indexDef } = row; + // 解析索引定义以获取列名和配置 + const index = { + name: indexName, + attributes: [], + }; + // 解析索引定义字符串 + // 示例: CREATE INDEX "user_index_fulltext_chinese" ON public."user" USING gin (to_tsvector('chinese'::regconfig, (COALESCE(name, ''::text) || ' '::text) || COALESCE(nickname, ''::text))) + if (indexType === 'gin' && indexDef.includes('to_tsvector')) { + // 全文索引 + index.config = { type: 'fulltext' }; + // 提取 tsConfig + const tsConfigMatch = indexDef.match(/to_tsvector\('([^']+)'/); + if (tsConfigMatch) { + const tsConfig = tsConfigMatch[1]; + index.config.tsConfig = tsConfig; + } + // 提取列名(从 COALESCE 中) + const columnMatches = indexDef.matchAll(/COALESCE\("?([^",\s]+)"?/g); + const columns = Array.from(columnMatches, m => m[1]); + index.attributes = columns.map(col => ({ name: col })); + // 处理多语言索引的情况:移除语言后缀 + // 例如: user_index_fulltext_chinese -> index_fulltext + const nameParts = indexName.split('_'); + if (nameParts.length > 2) { + const possibleLang = nameParts[nameParts.length - 1]; + // 如果最后一部分是语言代码,移除它 + if (['chinese', 'english', 'simple', 'german', 'french', 'spanish', 'russian', 'japanese'].includes(possibleLang)) { + index.name = nameParts.slice(0, -1).join('_'); + } + } + } + else if (indexType === 'gist') { + // 空间索引 + index.config = { type: 'spatial' }; + // 提取列名 + const columnMatch = indexDef.match(/\(([^)]+)\)/); + if (columnMatch) { + const columns = columnMatch[1].split(',').map(c => c.trim().replace(/"/g, '')); + index.attributes = columns.map(col => ({ name: col })); + } + } + else if (indexType === 'hash') { + // 哈希索引 + index.config = { type: 'hash' }; + // 提取列名 + const columnMatch = indexDef.match(/\(([^)]+)\)/); + if (columnMatch) { + const columns = columnMatch[1].split(',').map(c => c.trim().replace(/"/g, '')); + index.attributes = columns.map(col => ({ name: col })); + } + } + else { + // B-tree 索引(默认) + // 提取列名和排序方向 + const columnMatch = indexDef.match(/\(([^)]+)\)/); + if (columnMatch) { + const columnDefs = columnMatch[1].split(','); + index.attributes = columnDefs.map(colDef => { + const trimmed = colDef.trim().replace(/"/g, ''); + const parts = trimmed.split(/\s+/); + const attr = { name: parts[0] }; + // 检查排序方向 + if (parts.includes('DESC')) { + attr.direction = 'DESC'; + } + else if (parts.includes('ASC')) { + attr.direction = 'ASC'; + } + return attr; + }); + } + // 如果是唯一索引 + if (isUnique) { + index.config = { unique: true }; + } + } + // 移除表名前缀(如果存在) + // 例如: user_index_fulltext -> index_fulltext + if (index.name.startsWith(`${tableName}_`)) { + index.name = index.name.substring(tableName.length + 1); + } + indexes.push(index); + } + Object.assign(result, { + [tableName]: { + attributes, + indexes, + } + }); + } + else { + Object.assign(result, { + [tableName]: { + attributes, + } + }); + } + } + return result; + } + /** + * 将属性定义转换为 PostgreSQL DDL 语句 + * @param attr 属性名 + * @param attrDef 属性定义 + */ + translateAttributeDef(attr, attrDef) { + let sql = `"${attr}" `; + const { type, params, default: defaultValue, unique, notNull, sequenceStart, enumeration, } = attrDef; + // 处理序列类型(IDENTITY) + if (typeof sequenceStart === 'number') { + sql += `bigint GENERATED BY DEFAULT AS IDENTITY (START WITH ${sequenceStart}) UNIQUE`; + return sql; + } + // 处理枚举类型 + if (type === 'enum') { + (0, assert_1.default)(enumeration, 'Enum type requires enumeration values'); + // 注意:这里返回的是占位符,实际的枚举类型名在 translateCreateEntity 中确定 + // 为了比较一致性,我们使用枚举值的字符串表示 + sql += `enum(${enumeration.map(v => `'${v}'`).join(',')})`; + } + else { + sql += this.populateDataTypeDef(type, params, enumeration); + } + // NOT NULL 约束 + if (notNull || type === 'geometry') { + sql += ' NOT NULL'; + } + // UNIQUE 约束 + if (unique) { + sql += ' UNIQUE'; + } + // 默认值 + if (defaultValue !== undefined && !sequenceStart) { + (0, assert_1.default)(type !== 'ref', 'ref type should not have default value'); + sql += ` DEFAULT ${this.translateAttrValue(type, defaultValue)}`; + } + // 主键 + if (attr === 'id') { + sql += ' PRIMARY KEY'; + } + return sql; + } + /** + * 比较两个 SQL 语句是否等价(用于 schema diff) + * 忽略空格、大小写等格式差异 + */ + compareSql(sql1, sql2) { + // 标准化 SQL:移除多余空格,统一大小写 + const normalize = (sql) => { + return sql + .replace(/\s+/g, ' ') // 多个空格合并为一个 + .replace(/\(\s+/g, '(') // 移除括号后的空格 + .replace(/\s+\)/g, ')') // 移除括号前的空格 + .replace(/,\s+/g, ',') // 移除逗号后的空格 + .trim() + .toLowerCase(); + }; + return normalize(sql1) === normalize(sql2); + } } exports.PostgreSQLTranslator = PostgreSQLTranslator; diff --git a/lib/types/dbStore.d.ts b/lib/types/dbStore.d.ts index ca1d747..22ccc34 100644 --- a/lib/types/dbStore.d.ts +++ b/lib/types/dbStore.d.ts @@ -1,8 +1,32 @@ -import { EntityDict } from "oak-domain/lib/base-app-domain"; +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"; -export interface DbStore> extends AsyncRowStore { +import { AggregationResult, SelectOption } from "oak-domain/lib/types"; +export type Plan = { + newTables: Record; + }>; + newIndexes: Record[]>; + updatedTables: Record; + }>; + updatedIndexes: Record[]>; +}; +export interface DbStore> extends AsyncRowStore { connect: () => Promise; disconnect: () => Promise; initialize(options: CreateEntityOption): Promise; + aggregate(entity: T, aggregation: ED[T]['Aggregation'], context: Cxt, option: OP): Promise>; + operate(entity: T, operation: ED[T]['Operation'], context: Cxt, option: OperateOption): Promise>; + select(entity: T, selection: ED[T]['Selection'], context: Cxt, option: SelectOption): Promise[]>; + count(entity: T, selection: Pick, context: Cxt, option: SelectOption): Promise; + begin(option?: TxnOption): Promise; + commit(txnId: string): Promise; + rollback(txnId: string): Promise; + readSchema(): Promise>; + makeUpgradePlan(): Promise; + diffSchema(schemaOld: StorageSchema, schemaNew: StorageSchema): Plan; } diff --git a/src/MySQL/store.ts b/src/MySQL/store.ts index efe2e3a..497efd9 100644 --- a/src/MySQL/store.ts +++ b/src/MySQL/store.ts @@ -11,7 +11,7 @@ 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 } from '../types/dbStore'; +import { DbStore, Plan } from '../types/dbStore'; function convertGeoTextToObject(geoText: string): Geo { @@ -466,16 +466,4 @@ export class MysqlStore; - }>; - newIndexes: Record[]>; - updatedTables: Record; - }>; - updatedIndexes: Record[]>; -}; +} \ No newline at end of file diff --git a/src/MySQL/translator.ts b/src/MySQL/translator.ts index 2d8ebc1..ddfdee0 100644 --- a/src/MySQL/translator.ts +++ b/src/MySQL/translator.ts @@ -109,6 +109,16 @@ export class MySqlTranslator extends Sql private makeUpSchema() { for (const entity in this.schema) { const { attributes, indexes } = this.schema[entity]; + // 非特殊索引自动添加 $$deleteAt$$ (by qcqcqc) + for (const index of indexes || []) { + if (index.config?.type) { + continue; + } + const indexAttrNames = index.attributes.map(attr => attr.name); + if (!indexAttrNames.includes('$$deleteAt$$')) { + index.attributes.push({ name: '$$deleteAt$$' }); + } + } const geoIndexes: Index[] = []; for (const attr in attributes) { if (attributes[attr].type === 'geometry') { diff --git a/src/PostgreSQL/connector.ts b/src/PostgreSQL/connector.ts index 039e3e5..9b8b2f3 100644 --- a/src/PostgreSQL/connector.ts +++ b/src/PostgreSQL/connector.ts @@ -155,17 +155,6 @@ export class PostgreSQLConnector { } } - /** - * 执行多条 SQL 语句(用于初始化等场景) - */ - async execBatch(sqls: string[], txn?: string): Promise { - for (const sql of sqls) { - if (sql.trim()) { - await this.exec(sql, txn); - } - } - } - /** * 获取连接池状态 */ diff --git a/src/PostgreSQL/store.ts b/src/PostgreSQL/store.ts index 17ce927..c66e797 100644 --- a/src/PostgreSQL/store.ts +++ b/src/PostgreSQL/store.ts @@ -6,7 +6,10 @@ import { StorageSchema, SelectOption, AggregationResult, - Geo + 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'; @@ -20,7 +23,7 @@ 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 } from '../types/dbStore'; +import { DbStore, Plan } from '../types/dbStore'; const ToNumberAttrs = new Set([ '$$seq$$', @@ -511,4 +514,138 @@ export class PostgreSQLStore< 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, schemaNew: StorageSchema) { + 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, 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; + } } + diff --git a/src/PostgreSQL/translator.ts b/src/PostgreSQL/translator.ts index 6a23d75..91770ba 100644 --- a/src/PostgreSQL/translator.ts +++ b/src/PostgreSQL/translator.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import { format } from 'util'; import { assign, difference } from 'lodash'; -import { EntityDict, Geo, Q_FullTextValue, RefOrExpression, Ref, StorageSchema, Index, RefAttr, DeleteAtAttribute, TriggerDataAttribute, TriggerUuidAttribute, UpdateAtAttribute } from "oak-domain/lib/types"; +import { EntityDict, Geo, Q_FullTextValue, RefOrExpression, Ref, StorageSchema, Index, RefAttr, DeleteAtAttribute, TriggerDataAttribute, TriggerUuidAttribute, UpdateAtAttribute, Attribute, Attributes } 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"; @@ -210,6 +210,16 @@ export class PostgreSQLTranslator extend private makeUpSchema() { for (const entity in this.schema) { const { attributes, indexes } = this.schema[entity]; + // 非特殊索引自动添加 $$deleteAt$$ + for (const index of indexes || []) { + if (index.config?.type) { + continue; + } + const indexAttrNames = index.attributes.map(attr => attr.name); + if (!indexAttrNames.includes('$$deleteAt$$')) { + index.attributes.push({ name: '$$deleteAt$$' }); + } + } const geoIndexes: Index[] = []; for (const attr in attributes) { if (attributes[attr].type === 'geometry') { @@ -796,6 +806,7 @@ export class PostgreSQLTranslator extend else if (typeof value === 'number') { return `${value}`; } + assert(typeof value === 'string', 'Invalid date/time value'); return `'${(new Date(value)).valueOf()}'`; } case 'object': @@ -1066,15 +1077,10 @@ export class PostgreSQLTranslator extend indexSql += '('; const indexColumns: string[] = []; - let includeDeleteAt = false; for (const indexAttr of indexAttrs) { const { name: attrName, direction } = indexAttr; - if (attrName === '$$deleteAt$$') { - includeDeleteAt = true; - } - if (indexType === 'fulltext') { // 全文索引:使用 to_tsvector indexColumns.push(`to_tsvector('${tsLang}', COALESCE("${attrName as string}", ''))`); @@ -1088,11 +1094,6 @@ export class PostgreSQLTranslator extend } } - // 非特殊索引自动包含 deleteAt - if (!includeDeleteAt && !indexType) { - indexColumns.push('"$$deleteAt$$"'); - } - indexSql += indexColumns.join(', '); indexSql += ');'; @@ -2038,4 +2039,431 @@ export class PostgreSQLTranslator extend // 这个方法不应该被直接调用了,因为translateRemove已经重写 throw new Error('populateRemoveStmt should not be called directly in PostgreSQL. Use translateRemove instead.'); } + + /** + * 将 PostgreSQL 返回的 Type 回译成 oak 的类型,是 populateDataTypeDef 的反函数 + * @param type PostgreSQL 类型字符串 + */ + private reTranslateToAttribute(type: string): Attribute { + // 处理带长度的类型:character varying(255), character(10) + const varcharMatch = /^character varying\((\d+)\)$/.exec(type); + if (varcharMatch) { + return { + type: 'varchar', + params: { + length: parseInt(varcharMatch[1], 10), + } + }; + } + + const charMatch = /^character\((\d+)\)$/.exec(type); + if (charMatch) { + return { + type: 'char', + params: { + length: parseInt(charMatch[1], 10), + } + }; + } + + // 处理带精度和小数位的类型:numeric(10,2) + const numericWithScaleMatch = /^numeric\((\d+),(\d+)\)$/.exec(type); + if (numericWithScaleMatch) { + return { + type: 'decimal', + params: { + precision: parseInt(numericWithScaleMatch[1], 10), + scale: parseInt(numericWithScaleMatch[2], 10), + }, + }; + } + + // 处理只带精度的类型:numeric(10), timestamp(6) + const numericMatch = /^numeric\((\d+)\)$/.exec(type); + if (numericMatch) { + return { + type: 'decimal', + params: { + precision: parseInt(numericMatch[1], 10), + scale: 0, + }, + }; + } + + const timestampMatch = /^timestamp\((\d+)\) without time zone$/.exec(type); + if (timestampMatch) { + return { + type: 'timestamp', + params: { + precision: parseInt(timestampMatch[1], 10), + }, + }; + } + + const timeMatch = /^time\((\d+)\) without time zone$/.exec(type); + if (timeMatch) { + return { + type: 'time', + params: { + precision: parseInt(timeMatch[1], 10), + }, + }; + } + + // PostgreSQL 类型映射到 oak 类型 + const typeMap: Record = { + 'bigint': 'bigint', + 'integer': 'integer', + 'smallint': 'smallint', + 'real': 'real', + 'double precision': 'double precision', + 'boolean': 'boolean', + 'text': 'text', + 'jsonb': 'object', // 框架使用 jsonb 存储 object/array + 'json': 'object', + 'bytea': 'bytea', + 'character varying': 'varchar', + 'character': 'char', + 'timestamp without time zone': 'timestamp', + 'time without time zone': 'time', + 'date': 'date', + 'uuid': 'uuid', + 'geometry': 'geometry', + }; + + const mappedType = typeMap[type]; + if (mappedType) { + return { type: mappedType }; + } + + // 如果是用户定义的枚举类型,返回 enum(具体值需要额外查询) + // 这里先返回基础类型,枚举值在 readSchema 中单独处理 + return { type: type as DataType }; + } + + /** + * 从 PostgreSQL 数据库读取当前的 schema 结构 + */ + async readSchema(execFn: (sql: string) => Promise): Promise> { + const result: StorageSchema = {} as StorageSchema; + + // 1. 获取所有表 + const tablesSql = ` + SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + ORDER BY tablename; + `; + const [tablesResult] = await execFn(tablesSql); + + for (const tableRow of tablesResult) { + const tableName = tableRow.tablename; + + // 2. 获取表的列信息 + const columnsSql = ` + SELECT + column_name, + data_type, + character_maximum_length, + numeric_precision, + numeric_scale, + is_nullable, + column_default, + udt_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = '${tableName}' + ORDER BY ordinal_position; + `; + const [columnsResult] = await execFn(columnsSql); + + const attributes: Attributes = {}; + + for (const col of columnsResult) { + const { + column_name: colName, + data_type: dataType, + character_maximum_length: maxLength, + numeric_precision: precision, + numeric_scale: scale, + is_nullable: isNullable, + column_default: defaultValue, + udt_name: udtName, + } = col; + + let attr: Attribute; + + // 处理用户定义类型(枚举) + if (dataType === 'USER-DEFINED') { + // 查询枚举值 + const enumSql = ` + SELECT e.enumlabel + FROM pg_type t + JOIN pg_enum e ON t.oid = e.enumtypid + WHERE t.typname = '${udtName}' + ORDER BY e.enumsortorder; + `; + const [enumResult] = await execFn(enumSql); + const enumeration = enumResult.map((r: any) => r.enumlabel); + + attr = { + type: 'enum', + enumeration, + }; + } else { + // 构建完整的类型字符串 + let fullType = dataType; + if (maxLength) { + fullType = `${dataType}(${maxLength})`; + } else if (precision !== null && scale !== null) { + fullType = `${dataType}(${precision},${scale})`; + } else if (precision !== null) { + fullType = `${dataType}(${precision})`; + } + + attr = this.reTranslateToAttribute(fullType); + } + + // 处理约束 + attr.notNull = isNullable === 'NO'; + + // 处理默认值(简化处理,复杂的默认值可能需要更精细的解析) + if (defaultValue && !defaultValue.includes('nextval')) { + // 跳过序列默认值 + // 简单处理:移除类型转换 + let cleanDefault = defaultValue.replace(/::[a-z]+/gi, '').replace(/'/g, ''); + if (cleanDefault === 'true') { + attr.default = true; + } else if (cleanDefault === 'false') { + attr.default = false; + } else if (!isNaN(Number(cleanDefault))) { + attr.default = Number(cleanDefault); + } else { + attr.default = cleanDefault; + } + } + + // 检查是否是序列列(IDENTITY) + if (colName === '$$seq$$' || (defaultValue && defaultValue.includes('nextval'))) { + attr.sequenceStart = 10000; // 默认起始值 + } + + attributes[colName] = attr; + } + + // 3. 获取索引信息 + const indexesSql = ` + SELECT + i.relname as index_name, + ix.indisunique as is_unique, + am.amname as index_type, + pg_get_indexdef(ix.indexrelid) as index_def + FROM pg_class t + JOIN pg_index ix ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_am am ON i.relam = am.oid + WHERE t.relname = '${tableName}' + AND t.relkind = 'r' + AND i.relname NOT LIKE '%_pkey' + AND NOT ix.indisprimary + ORDER BY i.relname; +`; + const [indexesResult] = await execFn(indexesSql) as [{ + index_name: string; + is_unique: boolean; + index_type: string; + index_def: string; + }[]] + + if (indexesResult.length > 0) { + const indexes: Index[] = []; + + for (const row of indexesResult) { + const { index_name: indexName, is_unique: isUnique, index_type: indexType, index_def: indexDef } = row; + + // 解析索引定义以获取列名和配置 + const index: Index = { + name: indexName, + attributes: [], + }; + + // 解析索引定义字符串 + // 示例: CREATE INDEX "user_index_fulltext_chinese" ON public."user" USING gin (to_tsvector('chinese'::regconfig, (COALESCE(name, ''::text) || ' '::text) || COALESCE(nickname, ''::text))) + + if (indexType === 'gin' && indexDef.includes('to_tsvector')) { + // 全文索引 + index.config = { type: 'fulltext' }; + + // 提取 tsConfig + const tsConfigMatch = indexDef.match(/to_tsvector\('([^']+)'/); + if (tsConfigMatch) { + const tsConfig = tsConfigMatch[1]; + index.config.tsConfig = tsConfig; + } + + // 提取列名(从 COALESCE 中) + const columnMatches = indexDef.matchAll(/COALESCE\("?([^",\s]+)"?/g); + const columns = Array.from(columnMatches, m => m[1]); + index.attributes = columns.map(col => ({ name: col })); + + // 处理多语言索引的情况:移除语言后缀 + // 例如: user_index_fulltext_chinese -> index_fulltext + const nameParts = indexName.split('_'); + if (nameParts.length > 2) { + const possibleLang = nameParts[nameParts.length - 1]; + // 如果最后一部分是语言代码,移除它 + if (['chinese', 'english', 'simple', 'german', 'french', 'spanish', 'russian', 'japanese'].includes(possibleLang)) { + index.name = nameParts.slice(0, -1).join('_'); + } + } + } else if (indexType === 'gist') { + // 空间索引 + index.config = { type: 'spatial' }; + + // 提取列名 + const columnMatch = indexDef.match(/\(([^)]+)\)/); + if (columnMatch) { + const columns = columnMatch[1].split(',').map(c => c.trim().replace(/"/g, '')); + index.attributes = columns.map(col => ({ name: col })); + } + } else if (indexType === 'hash') { + // 哈希索引 + index.config = { type: 'hash' }; + + // 提取列名 + const columnMatch = indexDef.match(/\(([^)]+)\)/); + if (columnMatch) { + const columns = columnMatch[1].split(',').map(c => c.trim().replace(/"/g, '')); + index.attributes = columns.map(col => ({ name: col })); + } + } else { + // B-tree 索引(默认) + // 提取列名和排序方向 + const columnMatch = indexDef.match(/\(([^)]+)\)/); + if (columnMatch) { + const columnDefs = columnMatch[1].split(','); + index.attributes = columnDefs.map(colDef => { + const trimmed = colDef.trim().replace(/"/g, ''); + const parts = trimmed.split(/\s+/); + const attr: any = { name: parts[0] }; + + // 检查排序方向 + if (parts.includes('DESC')) { + attr.direction = 'DESC'; + } else if (parts.includes('ASC')) { + attr.direction = 'ASC'; + } + + return attr; + }); + } + + // 如果是唯一索引 + if (isUnique) { + index.config = { unique: true }; + } + } + + // 移除表名前缀(如果存在) + // 例如: user_index_fulltext -> index_fulltext + if (index.name.startsWith(`${tableName}_`)) { + index.name = index.name.substring(tableName.length + 1); + } + + indexes.push(index); + } + + Object.assign(result, { + [tableName]: { + attributes, + indexes, + } + }); + } else { + Object.assign(result, { + [tableName]: { + attributes, + } + }); + } + } + + return result; + } + + /** + * 将属性定义转换为 PostgreSQL DDL 语句 + * @param attr 属性名 + * @param attrDef 属性定义 + */ + translateAttributeDef(attr: string, attrDef: Attribute): string { + let sql = `"${attr}" `; + const { + type, + params, + default: defaultValue, + unique, + notNull, + sequenceStart, + enumeration, + } = attrDef; + + // 处理序列类型(IDENTITY) + if (typeof sequenceStart === 'number') { + sql += `bigint GENERATED BY DEFAULT AS IDENTITY (START WITH ${sequenceStart}) UNIQUE`; + return sql; + } + + // 处理枚举类型 + if (type === 'enum') { + assert(enumeration, 'Enum type requires enumeration values'); + // 注意:这里返回的是占位符,实际的枚举类型名在 translateCreateEntity 中确定 + // 为了比较一致性,我们使用枚举值的字符串表示 + sql += `enum(${enumeration.map(v => `'${v}'`).join(',')})`; + } else { + sql += this.populateDataTypeDef(type, params, enumeration); + } + + // NOT NULL 约束 + if (notNull || type === 'geometry') { + sql += ' NOT NULL'; + } + + // UNIQUE 约束 + if (unique) { + sql += ' UNIQUE'; + } + + // 默认值 + if (defaultValue !== undefined && !sequenceStart) { + assert(type !== 'ref', 'ref type should not have default value'); + sql += ` DEFAULT ${this.translateAttrValue(type, defaultValue)}`; + } + + // 主键 + if (attr === 'id') { + sql += ' PRIMARY KEY'; + } + + return sql; + } + + /** + * 比较两个 SQL 语句是否等价(用于 schema diff) + * 忽略空格、大小写等格式差异 + */ + compareSql(sql1: string, sql2: string): boolean { + // 标准化 SQL:移除多余空格,统一大小写 + const normalize = (sql: string): string => { + return sql + .replace(/\s+/g, ' ') // 多个空格合并为一个 + .replace(/\(\s+/g, '(') // 移除括号后的空格 + .replace(/\s+\)/g, ')') // 移除括号前的空格 + .replace(/,\s+/g, ',') // 移除逗号后的空格 + .trim() + .toLowerCase(); + }; + + return normalize(sql1) === normalize(sql2); + } } diff --git a/src/types/dbStore.ts b/src/types/dbStore.ts index e29c95a..6264256 100644 --- a/src/types/dbStore.ts +++ b/src/types/dbStore.ts @@ -1,9 +1,60 @@ -import { EntityDict } from "oak-domain/lib/base-app-domain"; +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 interface DbStore> extends AsyncRowStore { +export type Plan = { + newTables: Record; + }>; + newIndexes: Record[]>; + updatedTables: Record; + }>; + updatedIndexes: Record[]>; +}; + +export interface DbStore> extends AsyncRowStore { connect: () => Promise; disconnect: () => Promise; initialize(options: CreateEntityOption): Promise; + aggregate( + entity: T, + aggregation: ED[T]['Aggregation'], + context: Cxt, + option: OP + ): Promise>; + operate( + entity: T, + operation: ED[T]['Operation'], + context: Cxt, + option: OperateOption + ): Promise>; + select( + entity: T, + selection: ED[T]['Selection'], + context: Cxt, + option: SelectOption + ): Promise[]>; + count( + entity: T, + selection: Pick, + context: Cxt, + option: SelectOption + ): Promise; + begin(option?: TxnOption): Promise; + commit(txnId: string): Promise; + rollback(txnId: string): Promise; + readSchema(): Promise>; + makeUpgradePlan(): Promise; + diffSchema(schemaOld: StorageSchema, schemaNew: StorageSchema): Plan; }; \ No newline at end of file