chore: set default association reference on delete action to no action (#3722)

* chore: tmp commit

* chore: build association reference

* fix: test
This commit is contained in:
ChengLei Shao 2024-03-24 09:37:52 +08:00 committed by GitHub
parent fbed0201aa
commit 5ee278557d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 149 additions and 31 deletions

View File

@ -0,0 +1,52 @@
import { Database } from '../../database';
import { mockDatabase } from '../index';
describe('association references', () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase();
await db.clean({ drop: true });
});
afterEach(async () => {
await db.close();
});
it('should add reference with default priority', async () => {
const User = db.collection({
name: 'users',
fields: [{ type: 'hasOne', name: 'profile' }],
});
const Profile = db.collection({
name: 'profiles',
fields: [{ type: 'belongsTo', name: 'user' }],
});
await db.sync();
const references = db.referenceMap.getReferences('users');
expect(references[0].priority).toBe('default');
});
it('should add reference with user defined priority', async () => {
const User = db.collection({
name: 'users',
fields: [{ type: 'hasOne', name: 'profile', onDelete: 'CASCADE' }],
});
const Profile = db.collection({
name: 'profiles',
fields: [{ type: 'belongsTo', name: 'user' }],
});
await db.sync();
const references = db.referenceMap.getReferences('users');
expect(references.length).toBe(1);
expect(references[0].priority).toBe('user');
});
});

View File

@ -14,6 +14,36 @@ describe('belongs to field', () => {
await db.close(); await db.close();
}); });
it('should load with no action', async () => {
const User = db.collection({
name: 'users',
fields: [{ type: 'string', name: 'name', unique: true }],
});
const Post = db.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
{ type: 'belongsTo', name: 'user', onDelete: 'NO ACTION' },
],
});
await db.sync();
const u1 = await User.repository.create({ values: { name: 'u1' } });
const p1 = await Post.repository.create({ values: { title: 'p1', user: u1.id } });
// delete u1
await User.repository.destroy({ filterByTk: u1.id });
// list posts with user
const post = await Post.repository.findOne({
appends: ['user'],
});
expect(post.user).toBeNull();
});
it('should throw error when associated with item that null with target key', async () => { it('should throw error when associated with item that null with target key', async () => {
const User = db.collection({ const User = db.collection({
name: 'users', name: 'users',

View File

@ -27,7 +27,7 @@ import { CollectionFactory } from './collection-factory';
import { CollectionGroupManager } from './collection-group-manager'; import { CollectionGroupManager } from './collection-group-manager';
import { ImporterReader, ImportFileExtension } from './collection-importer'; import { ImporterReader, ImportFileExtension } from './collection-importer';
import DatabaseUtils from './database-utils'; import DatabaseUtils from './database-utils';
import ReferencesMap from './features/ReferencesMap'; import ReferencesMap from './features/references-map';
import { referentialIntegrityCheck } from './features/referential-integrity-check'; import { referentialIntegrityCheck } from './features/referential-integrity-check';
import { ArrayFieldRepository } from './field-repository/array-field-repository'; import { ArrayFieldRepository } from './field-repository/array-field-repository';
import * as FieldTypes from './fields'; import * as FieldTypes from './fields';

View File

@ -1,30 +1,57 @@
export type ReferencePriority = 'default' | 'user';
export interface Reference { export interface Reference {
sourceCollectionName: string; sourceCollectionName: string;
sourceField: string; sourceField: string;
targetField: string; targetField: string;
targetCollectionName: string; targetCollectionName: string;
onDelete: string; onDelete: string;
priority: ReferencePriority;
} }
const DEFAULT_ON_DELETE = 'NO ACTION';
export function buildReference(options: Partial<Reference>): Reference {
const { sourceCollectionName, sourceField, targetField, targetCollectionName, onDelete, priority } = options;
return {
sourceCollectionName,
sourceField,
targetField,
targetCollectionName,
onDelete: (onDelete || DEFAULT_ON_DELETE).toUpperCase(),
priority: assignPriority(priority, onDelete),
};
}
function assignPriority(priority: string | undefined, onDelete: string | undefined): ReferencePriority {
if (priority) {
return priority as ReferencePriority;
}
return onDelete ? 'user' : 'default';
}
const PRIORITY_MAP = {
default: 1,
user: 2,
};
class ReferencesMap { class ReferencesMap {
protected map: Map<string, Reference[]> = new Map(); protected map: Map<string, Reference[]> = new Map();
addReference(reference: Reference) { addReference(reference: Reference) {
if (!reference.onDelete) {
reference.onDelete = 'SET NULL';
}
reference.onDelete = reference.onDelete.toUpperCase();
const existReference = this.existReference(reference); const existReference = this.existReference(reference);
if (existReference && existReference.onDelete !== reference.onDelete) { if (existReference && existReference.onDelete !== reference.onDelete) {
if (reference.onDelete === 'SET NULL') { // check two references onDelete priority, using the higher priority, if both are the same, throw error
// using existing reference const existPriority = PRIORITY_MAP[existReference.priority];
return; const newPriority = PRIORITY_MAP[reference.priority];
} else if (existReference.onDelete === 'SET NULL') {
if (newPriority > existPriority) {
existReference.onDelete = reference.onDelete; existReference.onDelete = reference.onDelete;
} else { existReference.priority = reference.priority;
} else if (newPriority === existPriority && newPriority === PRIORITY_MAP['user']) {
throw new Error( throw new Error(
`On Delete Conflict, exist reference ${JSON.stringify(existReference)}, new reference ${JSON.stringify( `On Delete Conflict, exist reference ${JSON.stringify(existReference)}, new reference ${JSON.stringify(
reference, reference,
@ -52,7 +79,7 @@ class ReferencesMap {
return null; return null;
} }
const keys = Object.keys(reference).filter((k) => k !== 'onDelete'); const keys = Object.keys(reference).filter((k) => k !== 'onDelete' && k !== 'priority');
return references.find((ref) => keys.every((key) => ref[key] === reference[key])); return references.find((ref) => keys.every((key) => ref[key] === reference[key]));
} }

View File

@ -21,6 +21,11 @@ export async function referentialIntegrityCheck(options: ReferentialIntegrityChe
for (const reference of references) { for (const reference of references) {
const { sourceCollectionName, sourceField, targetField, onDelete } = reference; const { sourceCollectionName, sourceField, targetField, onDelete } = reference;
if (onDelete === 'NO ACTION') {
continue;
}
const sourceCollection = db.collections.get(sourceCollectionName); const sourceCollection = db.collections.get(sourceCollectionName);
const sourceRepository = sourceCollection.repository; const sourceRepository = sourceCollection.repository;

View File

@ -1,6 +1,6 @@
import lodash, { omit } from 'lodash'; import lodash, { omit } from 'lodash';
import { BelongsToOptions as SequelizeBelongsToOptions, Utils } from 'sequelize'; import { BelongsToOptions as SequelizeBelongsToOptions, Utils } from 'sequelize';
import { Reference } from '../features/ReferencesMap'; import { buildReference, Reference, ReferencePriority } from '../features/references-map';
import { checkIdentifier } from '../utils'; import { checkIdentifier } from '../utils';
import { BaseRelationFieldOptions, RelationField } from './relation-field'; import { BaseRelationFieldOptions, RelationField } from './relation-field';
@ -16,20 +16,26 @@ export class BelongsToField extends RelationField {
return target || Utils.pluralize(name); return target || Utils.pluralize(name);
} }
static toReference(db, association, onDelete) { static toReference(db, association, onDelete, priority: ReferencePriority = 'default'): Reference {
const targetKey = association.targetKey; const targetKey = association.targetKey;
return { return buildReference({
sourceCollectionName: db.modelCollection.get(association.source).name, sourceCollectionName: db.modelCollection.get(association.source).name,
sourceField: association.foreignKey, sourceField: association.foreignKey,
targetField: targetKey, targetField: targetKey,
targetCollectionName: db.modelCollection.get(association.target).name, targetCollectionName: db.modelCollection.get(association.target).name,
onDelete: onDelete, onDelete: onDelete,
}; priority: priority,
});
} }
reference(association): Reference { reference(association): Reference {
return BelongsToField.toReference(this.database, association, this.options.onDelete); return BelongsToField.toReference(
this.database,
association,
this.options.onDelete,
this.options.onDelete ? 'user' : 'default',
);
} }
checkAssociationKeys() { checkAssociationKeys() {

View File

@ -1,7 +1,7 @@
import { omit } from 'lodash'; import { omit } from 'lodash';
import { AssociationScope, BelongsToManyOptions as SequelizeBelongsToManyOptions, Utils } from 'sequelize'; import { AssociationScope, BelongsToManyOptions as SequelizeBelongsToManyOptions, Utils } from 'sequelize';
import { Collection } from '../collection'; import { Collection } from '../collection';
import { Reference } from '../features/ReferencesMap'; import { Reference } from '../features/references-map';
import { checkIdentifier } from '../utils'; import { checkIdentifier } from '../utils';
import { BelongsToField } from './belongs-to-field'; import { BelongsToField } from './belongs-to-field';
import { MultipleRelationFieldOptions, RelationField } from './relation-field'; import { MultipleRelationFieldOptions, RelationField } from './relation-field';
@ -32,6 +32,8 @@ export class BelongsToManyField extends RelationField {
const onDelete = this.options.onDelete || 'CASCADE'; const onDelete = this.options.onDelete || 'CASCADE';
const priority = this.options.onDelete ? 'user' : 'default';
const targetAssociation = association.toTarget; const targetAssociation = association.toTarget;
if (association.targetKey) { if (association.targetKey) {
@ -45,8 +47,8 @@ export class BelongsToManyField extends RelationField {
} }
return [ return [
BelongsToField.toReference(db, targetAssociation, onDelete), BelongsToField.toReference(db, targetAssociation, onDelete, priority),
BelongsToField.toReference(db, sourceAssociation, onDelete), BelongsToField.toReference(db, sourceAssociation, onDelete, priority),
]; ];
} }
@ -149,10 +151,6 @@ export class BelongsToManyField extends RelationField {
Object.defineProperty(Through.model, 'isThrough', { value: true }); Object.defineProperty(Through.model, 'isThrough', { value: true });
} }
if (!this.options.onDelete) {
this.options.onDelete = 'CASCADE';
}
const belongsToManyOptions = { const belongsToManyOptions = {
constraints: false, constraints: false,
...omit(this.options, ['name', 'type', 'target']), ...omit(this.options, ['name', 'type', 'target']),

View File

@ -8,7 +8,7 @@ import {
Utils, Utils,
} from 'sequelize'; } from 'sequelize';
import { Collection } from '../collection'; import { Collection } from '../collection';
import { Reference } from '../features/ReferencesMap'; import { buildReference, Reference } from '../features/references-map';
import { checkIdentifier } from '../utils'; import { checkIdentifier } from '../utils';
import { MultipleRelationFieldOptions, RelationField } from './relation-field'; import { MultipleRelationFieldOptions, RelationField } from './relation-field';
@ -89,13 +89,13 @@ export class HasManyField extends RelationField {
reference(association): Reference { reference(association): Reference {
const sourceKey = association.sourceKey; const sourceKey = association.sourceKey;
return { return buildReference({
sourceCollectionName: this.database.modelCollection.get(association.target).name, sourceCollectionName: this.database.modelCollection.get(association.target).name,
sourceField: association.foreignKey, sourceField: association.foreignKey,
targetField: sourceKey, targetField: sourceKey,
targetCollectionName: this.database.modelCollection.get(association.source).name, targetCollectionName: this.database.modelCollection.get(association.source).name,
onDelete: this.options.onDelete, onDelete: this.options.onDelete,
}; });
} }
checkAssociationKeys() { checkAssociationKeys() {

View File

@ -8,7 +8,7 @@ import {
Utils, Utils,
} from 'sequelize'; } from 'sequelize';
import { Collection } from '../collection'; import { Collection } from '../collection';
import { Reference } from '../features/ReferencesMap'; import { buildReference, Reference } from '../features/references-map';
import { checkIdentifier } from '../utils'; import { checkIdentifier } from '../utils';
import { BaseRelationFieldOptions, RelationField } from './relation-field'; import { BaseRelationFieldOptions, RelationField } from './relation-field';
@ -98,13 +98,13 @@ export class HasOneField extends RelationField {
reference(association): Reference { reference(association): Reference {
const sourceKey = association.sourceKey; const sourceKey = association.sourceKey;
return { return buildReference({
sourceCollectionName: this.database.modelCollection.get(association.target).name, sourceCollectionName: this.database.modelCollection.get(association.target).name,
sourceField: association.foreignKey, sourceField: association.foreignKey,
targetField: sourceKey, targetField: sourceKey,
targetCollectionName: this.database.modelCollection.get(association.source).name, targetCollectionName: this.database.modelCollection.get(association.source).name,
onDelete: this.options.onDelete, onDelete: this.options.onDelete,
}; });
} }
checkAssociationKeys() { checkAssociationKeys() {