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

View File

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

View File

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

View File

@ -21,6 +21,72 @@ describe('update associations', () => {
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 () => {
db.collection({
name: 'a',

View File

@ -126,6 +126,7 @@ export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'>
*/
origin?: string;
asStrategyResource?: boolean;
[key: string]: any;
}
@ -155,6 +156,7 @@ export class Collection<
this.modelInit();
this.db.modelCollection.set(this.model, this);
this.db.modelNameCollectionMap.set(this.model.name, this);
// set tableName to collection map
// the form of key is `${schema}.${tableName}` if schema exists
@ -259,8 +261,58 @@ export class Collection<
M = model;
}
const collection = this;
// @ts-ignore
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.options.modelName = this.options.name;
@ -856,12 +908,15 @@ export class Collection<
protected sequelizeModelOptions() {
const { name } = this.options;
return {
const attr = {
..._.omit(this.options, ['name', 'fields', 'model', 'targetKey']),
modelName: name,
sequelize: this.context.database.sequelize,
tableName: this.tableName(),
};
return attr;
}
protected bindFieldEventListener() {

View File

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

View File

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

View File

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

View File

@ -40,10 +40,13 @@ export function createResourcer(options: ApplicationOptions) {
}
export function registerMiddlewares(app: Application, options: ApplicationOptions) {
app.use(async (ctx, next) => {
app.context.reqId = randomUUID();
await next();
});
app.use(
async function generateReqId(ctx, next) {
app.context.reqId = randomUUID();
await next();
},
{ tag: 'generateReqId' },
);
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();
});
app.use(i18n, { tag: 'i18n', after: 'cors' });
app.use(i18n, { tag: 'i18n', before: 'cors' });
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' });

View File

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

View File

@ -112,13 +112,7 @@ describe('create with exception', () => {
expect(response.statusCode).toEqual(400);
expect(response.body).toEqual({
errors: [
{
message: 'name must be unique',
},
],
});
expect(response.body['errors'][0]['message']).toBe('name must be unique');
});
it('should render error with field title', async () => {
@ -174,7 +168,7 @@ describe('create with exception', () => {
const db: Database = ctx.db;
const sql = `INSERT INTO ${userCollection.model.tableName} (name)
VALUES (:name)`;
VALUES (:name)`;
await db.sequelize.query(sql, {
replacements: { name: ctx.action.params.values.name },

View File

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