fix: update association with a non-primary key table (#5495)

* fix: update association with non primaryKey table

* fix: test

* fix: test

* fix: get primary key attribute with multi filter target keys

* fix: update has one associations

* fix: test

* fix: test

* chore: test

* chore: test

* chore: test

* chore: test

* chore: middleware

* chore: error condition

* chore: test

* fix: test
This commit is contained in:
ChengLei Shao 2024-10-25 07:21:07 +08:00 committed by GitHub
parent 166681dfad
commit a7f964988b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 171 additions and 33 deletions

View File

@ -39,8 +39,8 @@ jobs:
sqlite-test: sqlite-test:
strategy: strategy:
matrix: matrix:
node_version: ['20'] node_version: [ '20' ]
underscored: [true, false] underscored: [ true, false ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: node:${{ matrix.node_version }} container: node:${{ matrix.node_version }}
services: services:
@ -70,10 +70,10 @@ jobs:
postgres-test: postgres-test:
strategy: strategy:
matrix: matrix:
node_version: ['20'] node_version: [ '20' ]
underscored: [true, false] underscored: [ true, false ]
schema: [public, nocobase] schema: [ public, nocobase ]
collection_schema: [public, user_schema] collection_schema: [ public, user_schema ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: node:${{ matrix.node_version }} container: node:${{ matrix.node_version }}
services: services:
@ -129,8 +129,8 @@ jobs:
mysql-test: mysql-test:
strategy: strategy:
matrix: matrix:
node_version: ['20'] node_version: [ '20' ]
underscored: [true, false] underscored: [ true, false ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: node:${{ matrix.node_version }} container: node:${{ matrix.node_version }}
services: services:
@ -175,8 +175,8 @@ jobs:
mariadb-test: mariadb-test:
strategy: strategy:
matrix: matrix:
node_version: ['20'] node_version: [ '20' ]
underscored: [true, false] underscored: [ true, false ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: node:${{ matrix.node_version }} container: node:${{ matrix.node_version }}
services: services:

View File

@ -346,6 +346,7 @@ describe('collection sync', () => {
const model = collection.model; const model = collection.model;
await collection.sync(); await collection.sync();
if (db.options.underscored) { if (db.options.underscored) {
const tableFields = await (<any>model).queryInterface.describeTable(`${db.getTablePrefix()}posts_tags`); const tableFields = await (<any>model).queryInterface.describeTable(`${db.getTablePrefix()}posts_tags`);
expect(tableFields['post_id']).toBeDefined(); expect(tableFields['post_id']).toBeDefined();

View File

@ -75,18 +75,23 @@ describe('targetKey', () => {
], ],
}); });
await db.sync(); await db.sync();
const r1 = db.getRepository('a1'); const r1 = db.getRepository('a1');
const r2 = db.getRepository('b1'); const r2 = db.getRepository('b1');
const b1 = await r2.create({ const b1 = await r2.create({
values: {}, values: {},
}); });
await r1.create({ await r1.create({
values: { values: {
name: 'a1', name: 'a1',
b1: [b1.toJSON()], b1: [b1.toJSON()],
}, },
}); });
const b1r = await b1.reload(); const b1r = await b1.reload();
expect(b1r.a1Id).toBe(b1.id); expect(b1r.a1Id).toBe(b1.id);
}); });

View File

@ -21,6 +21,72 @@ describe('update associations', () => {
await db.close(); await db.close();
}); });
it('should update associations with target key', async () => {
const T1 = db.collection({
name: 'test1',
autoGenId: false,
timestamps: false,
filterTargetKey: 'id_',
fields: [
{
name: 'id_',
type: 'string',
},
{
type: 'hasMany',
name: 't2',
foreignKey: 'nvarchar2',
targetKey: 'varchar_',
sourceKey: 'id_',
target: 'test2',
},
],
});
const T2 = db.collection({
name: 'test2',
autoGenId: false,
timestamps: false,
filterTargetKey: 'varchar_',
fields: [
{
name: 'varchar_',
type: 'string',
unique: true,
},
{
name: 'nvarchar2',
type: 'string',
},
],
});
await db.sync();
const t2 = await T2.repository.create({
values: {
varchar_: '1',
},
});
await T1.repository.create({
values: {
id_: 1,
t2: [
{
varchar_: '1',
},
],
},
});
const t1 = await T1.repository.findOne({
appends: ['t2'],
});
expect(t1['t2'][0]['varchar_']).toBe('1');
});
it('hasOne', async () => { it('hasOne', async () => {
db.collection({ db.collection({
name: 'a', name: 'a',

View File

@ -126,6 +126,7 @@ export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'>
*/ */
origin?: string; origin?: string;
asStrategyResource?: boolean; asStrategyResource?: boolean;
[key: string]: any; [key: string]: any;
} }
@ -155,6 +156,7 @@ export class Collection<
this.modelInit(); this.modelInit();
this.db.modelCollection.set(this.model, this); this.db.modelCollection.set(this.model, this);
this.db.modelNameCollectionMap.set(this.model.name, this);
// set tableName to collection map // set tableName to collection map
// the form of key is `${schema}.${tableName}` if schema exists // the form of key is `${schema}.${tableName}` if schema exists
@ -259,8 +261,58 @@ export class Collection<
M = model; M = model;
} }
const collection = this;
// @ts-ignore // @ts-ignore
this.model = class extends M {}; this.model = class extends M {};
Object.defineProperty(this.model, 'primaryKeyAttribute', {
get: function () {
const singleFilterTargetKey: string = (() => {
if (!collection.options.filterTargetKey) {
return null;
}
if (Array.isArray(collection.options.filterTargetKey) && collection.options.filterTargetKey.length === 1) {
return collection.options.filterTargetKey[0];
}
return collection.options.filterTargetKey as string;
})();
if (!this._primaryKeyAttribute && singleFilterTargetKey && collection.getField(singleFilterTargetKey)) {
return singleFilterTargetKey;
}
return this._primaryKeyAttribute;
}.bind(this.model),
set(value) {
this._primaryKeyAttribute = value;
},
});
Object.defineProperty(this.model, 'primaryKeyAttributes', {
get: function () {
if (Array.isArray(this._primaryKeyAttributes) && this._primaryKeyAttributes.length) {
return this._primaryKeyAttributes;
}
if (collection.options.filterTargetKey) {
const fields = lodash.castArray(collection.options.filterTargetKey);
if (fields.every((field) => collection.getField(field))) {
return fields;
}
}
return this._primaryKeyAttributes;
}.bind(this.model),
set(value) {
this._primaryKeyAttributes = value;
},
});
this.model.init(null, this.sequelizeModelOptions()); this.model.init(null, this.sequelizeModelOptions());
this.model.options.modelName = this.options.name; this.model.options.modelName = this.options.name;
@ -856,12 +908,15 @@ export class Collection<
protected sequelizeModelOptions() { protected sequelizeModelOptions() {
const { name } = this.options; const { name } = this.options;
return {
const attr = {
..._.omit(this.options, ['name', 'fields', 'model', 'targetKey']), ..._.omit(this.options, ['name', 'fields', 'model', 'targetKey']),
modelName: name, modelName: name,
sequelize: this.context.database.sequelize, sequelize: this.context.database.sequelize,
tableName: this.tableName(), tableName: this.tableName(),
}; };
return attr;
} }
protected bindFieldEventListener() { protected bindFieldEventListener() {

View File

@ -144,6 +144,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
collections = new Map<string, Collection>(); collections = new Map<string, Collection>();
pendingFields = new Map<string, RelationField[]>(); pendingFields = new Map<string, RelationField[]>();
modelCollection = new Map<ModelStatic<any>, Collection>(); modelCollection = new Map<ModelStatic<any>, Collection>();
modelNameCollectionMap = new Map<string, Collection>();
tableNameCollectionMap = new Map<string, Collection>(); tableNameCollectionMap = new Map<string, Collection>();
context: any = {}; context: any = {};
queryInterface: QueryInterface; queryInterface: QueryInterface;
@ -566,6 +567,10 @@ export class Database extends EventEmitter implements AsyncEmitter {
return field; return field;
} }
getCollectionByModelName(name: string) {
return this.modelNameCollectionMap.get(name);
}
/** /**
* get exists collection by its name * get exists collection by its name
* @param name * @param name

View File

@ -18,8 +18,7 @@ interface ReferentialIntegrityCheckOptions extends Transactionable {
export async function referentialIntegrityCheck(options: ReferentialIntegrityCheckOptions) { export async function referentialIntegrityCheck(options: ReferentialIntegrityCheckOptions) {
const { referencedInstance, db, transaction } = options; const { referencedInstance, db, transaction } = options;
// @ts-ignore const collection = db.getCollectionByModelName(referencedInstance.constructor.name);
const collection = db.modelCollection.get(referencedInstance.constructor);
const collectionName = collection.name; const collectionName = collection.name;
const references = db.referenceMap.getReferences(collectionName); const references = db.referenceMap.getReferences(collectionName);

View File

@ -18,10 +18,10 @@ import {
ModelStatic, ModelStatic,
Transactionable, Transactionable,
} from 'sequelize'; } from 'sequelize';
import Database from './database';
import { Model } from './model'; import { Model } from './model';
import { UpdateGuard } from './update-guard'; import { UpdateGuard } from './update-guard';
import { TargetKey } from './repository'; import { TargetKey } from './repository';
import Database from './database';
function isUndefinedOrNull(value: any) { function isUndefinedOrNull(value: any) {
return typeof value === 'undefined' || value === null; return typeof value === 'undefined' || value === null;
@ -449,7 +449,8 @@ export async function updateMultipleAssociation(
} else if (item.sequelize) { } else if (item.sequelize) {
setItems.push(item); setItems.push(item);
} else if (typeof item === 'object') { } else if (typeof item === 'object') {
const targetKey = (association as any).targetKey || 'id'; // @ts-ignore
const targetKey = (association as any).targetKey || association.options.targetKey || 'id';
if (item[targetKey]) { if (item[targetKey]) {
const attributes = { const attributes = {
@ -468,16 +469,19 @@ export async function updateMultipleAssociation(
await model[setAccessor](setItems, { transaction, context, individualHooks: true }); await model[setAccessor](setItems, { transaction, context, individualHooks: true });
const newItems = []; const newItems = [];
const pk = association.target.primaryKeyAttribute; const pk = association.target.primaryKeyAttribute;
const tmpKey = association['options']?.['targetKey'];
let targetKey = pk; let targetKey = pk;
const db = model.constructor['database'] as Database; const db = model.constructor['database'] as Database;
const tmpKey = association['options']?.['targetKey'];
if (tmpKey !== pk) { if (tmpKey !== pk) {
const targetKeyFieldOptions = db.getFieldByPath(`${association.target.name}.${tmpKey}`)?.options; const targetKeyFieldOptions = db.getFieldByPath(`${association.target.name}.${tmpKey}`)?.options;
if (targetKeyFieldOptions?.unique) { if (targetKeyFieldOptions?.unique) {
targetKey = tmpKey; targetKey = tmpKey;
} }
} }
for (const item of objectItems) { for (const item of objectItems) {
const through = (<any>association).through ? (<any>association).through.model.name : null; const through = (<any>association).through ? (<any>association).through.model.name : null;
@ -550,7 +554,10 @@ export async function updateMultipleAssociation(
} }
for (const newItem of newItems) { for (const newItem of newItems) {
const existIndexInSetItems = setItems.findIndex((setItem) => setItem[targetKey] === newItem[targetKey]); // @ts-ignore
const findTargetKey = (association as any).targetKey || association.options.targetKey || targetKey;
const existIndexInSetItems = setItems.findIndex((setItem) => setItem[findTargetKey] === newItem[findTargetKey]);
if (existIndexInSetItems !== -1) { if (existIndexInSetItems !== -1) {
setItems[existIndexInSetItems] = newItem; setItems[existIndexInSetItems] = newItem;

View File

@ -40,10 +40,13 @@ export function createResourcer(options: ApplicationOptions) {
} }
export function registerMiddlewares(app: Application, options: ApplicationOptions) { export function registerMiddlewares(app: Application, options: ApplicationOptions) {
app.use(async (ctx, next) => { app.use(
async function generateReqId(ctx, next) {
app.context.reqId = randomUUID(); app.context.reqId = randomUUID();
await next(); await next();
}); },
{ tag: 'generateReqId' },
);
app.use(requestLogger(app.name, app.requestLogger, options.logger?.request), { tag: 'logger' }); app.use(requestLogger(app.name, app.requestLogger, options.logger?.request), { tag: 'logger' });
@ -82,10 +85,10 @@ export function registerMiddlewares(app: Application, options: ApplicationOption
await next(); await next();
}); });
app.use(i18n, { tag: 'i18n', after: 'cors' }); app.use(i18n, { tag: 'i18n', before: 'cors' });
if (options.dataWrapping !== false) { if (options.dataWrapping !== false) {
app.use(dataWrapping(), { tag: 'dataWrapping', after: 'i18n' }); app.use(dataWrapping(), { tag: 'dataWrapping', after: 'cors' });
} }
app.use(app.dataSourceManager.middleware(), { tag: 'dataSource', after: 'dataWrapping' }); app.use(app.dataSourceManager.middleware(), { tag: 'dataSource', after: 'dataWrapping' });

View File

@ -19,14 +19,17 @@ export async function i18n(ctx, next) {
'en-US'; 'en-US';
return lng; return lng;
}; };
const lng = ctx.getCurrentLocale(); const lng = ctx.getCurrentLocale();
const localeManager = ctx.app.localeManager as Locale; const localeManager = ctx.app.localeManager as Locale;
const i18n = await localeManager.getI18nInstance(lng); const i18n = await localeManager.getI18nInstance(lng);
ctx.i18n = i18n; ctx.i18n = i18n;
ctx.t = i18n.t.bind(i18n); ctx.t = i18n.t.bind(i18n);
if (lng !== '*' && lng) { if (lng !== '*' && lng) {
i18n.changeLanguage(lng); await i18n.changeLanguage(lng);
await localeManager.loadResourcesByLang(lng); await localeManager.loadResourcesByLang(lng);
} }
await next(); await next();
} }

View File

@ -112,13 +112,7 @@ describe('create with exception', () => {
expect(response.statusCode).toEqual(400); expect(response.statusCode).toEqual(400);
expect(response.body).toEqual({ expect(response.body['errors'][0]['message']).toBe('name must be unique');
errors: [
{
message: 'name must be unique',
},
],
});
}); });
it('should render error with field title', async () => { it('should render error with field title', async () => {

View File

@ -65,6 +65,6 @@ export class PluginErrorHandlerServer extends Plugin {
} }
async load() { async load() {
this.app.use(this.errorHandler.middleware(), { before: 'cors', tag: 'errorHandler' }); this.app.use(this.errorHandler.middleware(), { after: 'i18n', tag: 'errorHandler', before: 'cors' });
} }
} }