diff --git a/packages/core/database/src/__tests__/associations/references.test.ts b/packages/core/database/src/__tests__/associations/references.test.ts new file mode 100644 index 000000000..7fe4baabc --- /dev/null +++ b/packages/core/database/src/__tests__/associations/references.test.ts @@ -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'); + }); +}); diff --git a/packages/core/database/src/__tests__/fields/belongs-to-field.test.ts b/packages/core/database/src/__tests__/fields/belongs-to-field.test.ts index abb81b887..dedcce013 100644 --- a/packages/core/database/src/__tests__/fields/belongs-to-field.test.ts +++ b/packages/core/database/src/__tests__/fields/belongs-to-field.test.ts @@ -14,6 +14,36 @@ describe('belongs to field', () => { 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 () => { const User = db.collection({ name: 'users', diff --git a/packages/core/database/src/database.ts b/packages/core/database/src/database.ts index e0726dfdd..106bb8828 100644 --- a/packages/core/database/src/database.ts +++ b/packages/core/database/src/database.ts @@ -27,7 +27,7 @@ import { CollectionFactory } from './collection-factory'; import { CollectionGroupManager } from './collection-group-manager'; import { ImporterReader, ImportFileExtension } from './collection-importer'; import DatabaseUtils from './database-utils'; -import ReferencesMap from './features/ReferencesMap'; +import ReferencesMap from './features/references-map'; import { referentialIntegrityCheck } from './features/referential-integrity-check'; import { ArrayFieldRepository } from './field-repository/array-field-repository'; import * as FieldTypes from './fields'; diff --git a/packages/core/database/src/features/ReferencesMap.ts b/packages/core/database/src/features/references-map.ts similarity index 57% rename from packages/core/database/src/features/ReferencesMap.ts rename to packages/core/database/src/features/references-map.ts index ca4ff7364..a78c6b95c 100644 --- a/packages/core/database/src/features/ReferencesMap.ts +++ b/packages/core/database/src/features/references-map.ts @@ -1,30 +1,57 @@ +export type ReferencePriority = 'default' | 'user'; + export interface Reference { sourceCollectionName: string; sourceField: string; targetField: string; targetCollectionName: string; onDelete: string; + priority: ReferencePriority; } +const DEFAULT_ON_DELETE = 'NO ACTION'; + +export function buildReference(options: Partial): 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 { protected map: Map = new Map(); addReference(reference: Reference) { - if (!reference.onDelete) { - reference.onDelete = 'SET NULL'; - } - - reference.onDelete = reference.onDelete.toUpperCase(); - const existReference = this.existReference(reference); if (existReference && existReference.onDelete !== reference.onDelete) { - if (reference.onDelete === 'SET NULL') { - // using existing reference - return; - } else if (existReference.onDelete === 'SET NULL') { + // check two references onDelete priority, using the higher priority, if both are the same, throw error + const existPriority = PRIORITY_MAP[existReference.priority]; + const newPriority = PRIORITY_MAP[reference.priority]; + + if (newPriority > existPriority) { existReference.onDelete = reference.onDelete; - } else { + existReference.priority = reference.priority; + } else if (newPriority === existPriority && newPriority === PRIORITY_MAP['user']) { throw new Error( `On Delete Conflict, exist reference ${JSON.stringify(existReference)}, new reference ${JSON.stringify( reference, @@ -52,7 +79,7 @@ class ReferencesMap { 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])); } diff --git a/packages/core/database/src/features/referential-integrity-check.ts b/packages/core/database/src/features/referential-integrity-check.ts index b1b1b278e..039806276 100644 --- a/packages/core/database/src/features/referential-integrity-check.ts +++ b/packages/core/database/src/features/referential-integrity-check.ts @@ -21,6 +21,11 @@ export async function referentialIntegrityCheck(options: ReferentialIntegrityChe for (const reference of references) { const { sourceCollectionName, sourceField, targetField, onDelete } = reference; + + if (onDelete === 'NO ACTION') { + continue; + } + const sourceCollection = db.collections.get(sourceCollectionName); const sourceRepository = sourceCollection.repository; diff --git a/packages/core/database/src/fields/belongs-to-field.ts b/packages/core/database/src/fields/belongs-to-field.ts index 38eab8ef9..586607340 100644 --- a/packages/core/database/src/fields/belongs-to-field.ts +++ b/packages/core/database/src/fields/belongs-to-field.ts @@ -1,6 +1,6 @@ import lodash, { omit } from 'lodash'; 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 { BaseRelationFieldOptions, RelationField } from './relation-field'; @@ -16,20 +16,26 @@ export class BelongsToField extends RelationField { return target || Utils.pluralize(name); } - static toReference(db, association, onDelete) { + static toReference(db, association, onDelete, priority: ReferencePriority = 'default'): Reference { const targetKey = association.targetKey; - return { + return buildReference({ sourceCollectionName: db.modelCollection.get(association.source).name, sourceField: association.foreignKey, targetField: targetKey, targetCollectionName: db.modelCollection.get(association.target).name, onDelete: onDelete, - }; + priority: priority, + }); } 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() { diff --git a/packages/core/database/src/fields/belongs-to-many-field.ts b/packages/core/database/src/fields/belongs-to-many-field.ts index b69a68e7a..f82d50520 100644 --- a/packages/core/database/src/fields/belongs-to-many-field.ts +++ b/packages/core/database/src/fields/belongs-to-many-field.ts @@ -1,7 +1,7 @@ import { omit } from 'lodash'; import { AssociationScope, BelongsToManyOptions as SequelizeBelongsToManyOptions, Utils } from 'sequelize'; import { Collection } from '../collection'; -import { Reference } from '../features/ReferencesMap'; +import { Reference } from '../features/references-map'; import { checkIdentifier } from '../utils'; import { BelongsToField } from './belongs-to-field'; import { MultipleRelationFieldOptions, RelationField } from './relation-field'; @@ -32,6 +32,8 @@ export class BelongsToManyField extends RelationField { const onDelete = this.options.onDelete || 'CASCADE'; + const priority = this.options.onDelete ? 'user' : 'default'; + const targetAssociation = association.toTarget; if (association.targetKey) { @@ -45,8 +47,8 @@ export class BelongsToManyField extends RelationField { } return [ - BelongsToField.toReference(db, targetAssociation, onDelete), - BelongsToField.toReference(db, sourceAssociation, onDelete), + BelongsToField.toReference(db, targetAssociation, onDelete, priority), + BelongsToField.toReference(db, sourceAssociation, onDelete, priority), ]; } @@ -149,10 +151,6 @@ export class BelongsToManyField extends RelationField { Object.defineProperty(Through.model, 'isThrough', { value: true }); } - if (!this.options.onDelete) { - this.options.onDelete = 'CASCADE'; - } - const belongsToManyOptions = { constraints: false, ...omit(this.options, ['name', 'type', 'target']), diff --git a/packages/core/database/src/fields/has-many-field.ts b/packages/core/database/src/fields/has-many-field.ts index f266af9f6..28a3f038b 100644 --- a/packages/core/database/src/fields/has-many-field.ts +++ b/packages/core/database/src/fields/has-many-field.ts @@ -8,7 +8,7 @@ import { Utils, } from 'sequelize'; import { Collection } from '../collection'; -import { Reference } from '../features/ReferencesMap'; +import { buildReference, Reference } from '../features/references-map'; import { checkIdentifier } from '../utils'; import { MultipleRelationFieldOptions, RelationField } from './relation-field'; @@ -89,13 +89,13 @@ export class HasManyField extends RelationField { reference(association): Reference { const sourceKey = association.sourceKey; - return { + return buildReference({ sourceCollectionName: this.database.modelCollection.get(association.target).name, sourceField: association.foreignKey, targetField: sourceKey, targetCollectionName: this.database.modelCollection.get(association.source).name, onDelete: this.options.onDelete, - }; + }); } checkAssociationKeys() { diff --git a/packages/core/database/src/fields/has-one-field.ts b/packages/core/database/src/fields/has-one-field.ts index b6ef32c1f..f2673daca 100644 --- a/packages/core/database/src/fields/has-one-field.ts +++ b/packages/core/database/src/fields/has-one-field.ts @@ -8,7 +8,7 @@ import { Utils, } from 'sequelize'; import { Collection } from '../collection'; -import { Reference } from '../features/ReferencesMap'; +import { buildReference, Reference } from '../features/references-map'; import { checkIdentifier } from '../utils'; import { BaseRelationFieldOptions, RelationField } from './relation-field'; @@ -98,13 +98,13 @@ export class HasOneField extends RelationField { reference(association): Reference { const sourceKey = association.sourceKey; - return { + return buildReference({ sourceCollectionName: this.database.modelCollection.get(association.target).name, sourceField: association.foreignKey, targetField: sourceKey, targetCollectionName: this.database.modelCollection.get(association.source).name, onDelete: this.options.onDelete, - }; + }); } checkAssociationKeys() {