diff --git a/lib/annotations/ForeignKey.ts b/lib/annotations/ForeignKey.ts index 6825d434..44f2878b 100644 --- a/lib/annotations/ForeignKey.ts +++ b/lib/annotations/ForeignKey.ts @@ -1,10 +1,17 @@ +import {AssociationForeignKeyOptions} from 'sequelize'; import {Model} from "../models/Model"; import {addForeignKey} from "../services/association"; -export function ForeignKey(relatedClassGetter: () => typeof Model): Function { +export function ForeignKey(relatedClassGetter: () => typeof Model, options?: AssociationForeignKeyOptions): Function { return (target: any, propertyName: string) => { - addForeignKey(target, relatedClassGetter, propertyName); + if (!options) { + options = {name: propertyName}; + } else if (!options.name) { + options.name = propertyName; + } + + addForeignKey(target, relatedClassGetter, options); }; } diff --git a/lib/annotations/association/BelongsTo.ts b/lib/annotations/association/BelongsTo.ts index 8f83bdce..3bf795a4 100644 --- a/lib/annotations/association/BelongsTo.ts +++ b/lib/annotations/association/BelongsTo.ts @@ -1,17 +1,23 @@ +import {AssociationOptionsBelongsTo} from 'sequelize'; + import {Model} from "../../models/Model"; import {BELONGS_TO, addAssociation} from "../../services/association"; export function BelongsTo(relatedClassGetter: () => typeof Model, - foreignKey?: string): Function { + options?: string | AssociationOptionsBelongsTo): Function { return (target: any, propertyName: string) => { + if (typeof options === 'string') { + options = {foreignKey: {name: options}}; + } + addAssociation( target, BELONGS_TO, relatedClassGetter, propertyName, - foreignKey + options ); }; } diff --git a/lib/annotations/association/BelongsToMany.ts b/lib/annotations/association/BelongsToMany.ts index bcac716f..798bd195 100644 --- a/lib/annotations/association/BelongsToMany.ts +++ b/lib/annotations/association/BelongsToMany.ts @@ -1,19 +1,24 @@ +import {AssociationOptionsBelongsToMany} from 'sequelize'; + import {Model} from "../../models/Model"; import {BELONGS_TO_MANY, addAssociation} from "../../services/association"; export function BelongsToMany(relatedClassGetter: () => typeof Model, through: (() => typeof Model)|string, - foreignKey?: string, + options?: string | AssociationOptionsBelongsToMany, otherKey?: string): Function { return (target: any, propertyName: string) => { - + if (typeof options === 'string') { + // don't worry, through is mainly here to avoid TS error; actual through value is resolved later + options = {through: '', foreignKey: {name: options}}; + } addAssociation( target, BELONGS_TO_MANY, relatedClassGetter, propertyName, - foreignKey, + options, otherKey, through ); diff --git a/lib/annotations/association/HasMany.ts b/lib/annotations/association/HasMany.ts index cef52b07..1bb8d429 100644 --- a/lib/annotations/association/HasMany.ts +++ b/lib/annotations/association/HasMany.ts @@ -1,17 +1,21 @@ +import {AssociationOptionsHasMany} from 'sequelize'; + import {Model} from "../../models/Model"; import {HAS_MANY, addAssociation} from "../../services/association"; export function HasMany(relatedClassGetter: () => typeof Model, - foreignKey?: string): Function { + options?: string | AssociationOptionsHasMany): Function { return (target: any, propertyName: string) => { - + if (typeof options === 'string') { + options = {foreignKey: {name: options}}; + } addAssociation( target, HAS_MANY, relatedClassGetter, propertyName, - foreignKey + options ); }; } diff --git a/lib/annotations/association/HasOne.ts b/lib/annotations/association/HasOne.ts index 61f711f9..31d30425 100644 --- a/lib/annotations/association/HasOne.ts +++ b/lib/annotations/association/HasOne.ts @@ -1,17 +1,21 @@ +import {AssociationOptionsHasOne} from 'sequelize'; + import {Model} from "../../models/Model"; import {addAssociation, HAS_ONE} from "../../services/association"; export function HasOne(relatedClassGetter: () => typeof Model, - foreignKey?: string): Function { + options?: string | AssociationOptionsHasOne): Function { return (target: any, propertyName: string) => { - + if (typeof options === 'string') { + options = {foreignKey: {name: options}}; + } addAssociation( target, HAS_ONE, relatedClassGetter, propertyName, - foreignKey + options ); }; } diff --git a/lib/interfaces/ISequelizeAssociation.ts b/lib/interfaces/ISequelizeAssociation.ts index 4db187af..85245a27 100644 --- a/lib/interfaces/ISequelizeAssociation.ts +++ b/lib/interfaces/ISequelizeAssociation.ts @@ -1,3 +1,5 @@ +import {AssociationOptionsBelongsTo, AssociationOptionsBelongsToMany, AssociationOptionsHasMany, + AssociationOptionsHasOne, AssociationOptionsManyToMany} from 'sequelize'; import {Model} from "../models/Model"; export interface ISequelizeAssociation { @@ -6,7 +8,8 @@ export interface ISequelizeAssociation { relatedClassGetter: () => typeof Model; through?: string; throughClassGetter?: () => typeof Model; - foreignKey?: string; + options?: AssociationOptionsBelongsTo | AssociationOptionsBelongsToMany | AssociationOptionsHasMany | + AssociationOptionsHasOne | AssociationOptionsManyToMany; otherKey?: string; as: string; } diff --git a/lib/interfaces/ISequelizeForeignKeyConfig.ts b/lib/interfaces/ISequelizeForeignKeyConfig.ts index 9afdeb50..7766c14d 100644 --- a/lib/interfaces/ISequelizeForeignKeyConfig.ts +++ b/lib/interfaces/ISequelizeForeignKeyConfig.ts @@ -1,7 +1,8 @@ +import {AssociationForeignKeyOptions} from 'sequelize'; import {Model} from "../models/Model"; export interface ISequelizeForeignKeyConfig { relatedClassGetter: () => typeof Model; - foreignKey: string; + options: string | AssociationForeignKeyOptions; } diff --git a/lib/models/BaseSequelize.ts b/lib/models/BaseSequelize.ts index 74de145b..e903bb5a 100644 --- a/lib/models/BaseSequelize.ts +++ b/lib/models/BaseSequelize.ts @@ -1,3 +1,4 @@ +import {merge} from 'lodash'; import {Model} from "./Model"; import {getModels} from "../services/models"; import {getAssociations, BELONGS_TO_MANY} from "../services/association"; @@ -89,7 +90,6 @@ export abstract class BaseSequelize { associations.forEach(association => { - const foreignKey = association.foreignKey || getForeignKey(model, association); const relatedClass = association.relatedClassGetter(); let through; let otherKey; @@ -126,12 +126,17 @@ export abstract class BaseSequelize { } } - model[association.relation](relatedClass, { + // ensure association options by default have most explicit foreignKey options + // so it merges properly with different foreignKey option permutations, or is overrwritten + // completely by options.foreignKey + const foreignKey = getForeignKey(model, association); + const options = merge({foreignKey: {name: foreignKey}}, association.options, + { as: association.as, through, - foreignKey, otherKey }); + model[association.relation](relatedClass, options); // The associations has to be adjusted const _association = model['associations'][association.as]; diff --git a/lib/services/association.ts b/lib/services/association.ts index d89225d1..00b9db77 100644 --- a/lib/services/association.ts +++ b/lib/services/association.ts @@ -1,4 +1,6 @@ import 'reflect-metadata'; +import {AssociationOptions, AssociationForeignKeyOptions, AssociationOptionsBelongsTo, AssociationOptionsBelongsToMany, + AssociationOptionsHasMany, AssociationOptionsHasOne, AssociationOptionsManyToMany} from 'sequelize'; import {Model} from "../models/Model"; import {ISequelizeForeignKeyConfig} from "../interfaces/ISequelizeForeignKeyConfig"; import {ISequelizeAssociation} from "../interfaces/ISequelizeAssociation"; @@ -18,7 +20,9 @@ export function addAssociation(target: any, relation: string, relatedClassGetter: () => typeof Model, as: string, - foreignKey?: string, + options?: AssociationOptionsBelongsTo | + AssociationOptionsBelongsToMany | AssociationOptionsHasMany | + AssociationOptionsHasOne | AssociationOptionsManyToMany, otherKey?: string, through?: (() => typeof Model)|string): void { @@ -42,7 +46,7 @@ export function addAssociation(target: any, throughClassGetter, through: through as string, as, - foreignKey, + options, otherKey }); } @@ -52,11 +56,19 @@ export function addAssociation(target: any, */ export function getForeignKey(_class: typeof Model, association: ISequelizeAssociation): string { + const options = association.options as AssociationOptions; - // if foreign key is defined return this one - if (association.foreignKey) { + if (options && options.foreignKey) { + const foreignKey = options.foreignKey; + // if options is an object and has a string foreignKey property, use that as the name + if (typeof foreignKey === 'string') { + return foreignKey; + } - return association.foreignKey; + // if options is an object with foreignKey.name, use that as the name + if (foreignKey.name) { + return foreignKey.name; + } } // otherwise calculate the foreign key by related or through class @@ -90,8 +102,10 @@ export function getForeignKey(_class: typeof Model, for (const foreignKey of foreignKeys) { if (foreignKey.relatedClassGetter() === relatedClass) { - - return foreignKey.foreignKey; + if (typeof foreignKey.options === 'string') { + return foreignKey.options; + } + return (foreignKey.options as any).name; } } @@ -123,7 +137,7 @@ export function getAssociationsByRelation(target: any, relatedClass: any): ISequ */ export function addForeignKey(target: any, relatedClassGetter: () => typeof Model, - attrName: string): void { + options: string | AssociationForeignKeyOptions): void { let foreignKeys = getForeignKeys(target); @@ -134,7 +148,7 @@ export function addForeignKey(target: any, foreignKeys.push({ relatedClassGetter, - foreignKey: attrName + options }); } diff --git a/test/specs/association.spec.ts b/test/specs/association.spec.ts index 025d7a46..bcabbb77 100644 --- a/test/specs/association.spec.ts +++ b/test/specs/association.spec.ts @@ -594,6 +594,166 @@ describe('association', () => { oneToManyTestSuites(Book2, Page2); }); + + function oneToManyWithOptionsTestSuites(Book: typeof Model, Page: typeof Model, alternateName: boolean = false): void { + const foreignKey = alternateName ? 'book_id' : 'bookId'; + + beforeEach(() => { + sequelize.addModels([Page, Book]); + + return sequelize.sync({force: true}); + }); + + it('should create models with specified relations', () => { + expect(Book) + .to.have.property('associations') + .that.has.property('pages') + .that.is.an.instanceOf(Association['HasMany']) + .which.includes({foreignKey}) + .and.has.property('foreignKeyAttribute') + .which.includes({allowNull: false, name: foreignKey}) + ; + + expect(Book) + .to.have.property('associations') + .that.has.property('pages') + .that.has.property('options') + .with.property('onDelete', 'CASCADE') + ; + + expect(Page) + .to.have.property('associations') + .that.has.property('book') + .that.is.an.instanceOf(Association['BelongsTo']) + .which.includes({foreignKey}) + .and.has.property('foreignKeyAttribute') + .which.includes({allowNull: false, name: foreignKey}) + ; + + expect(Page) + .to.have.property('associations') + .that.has.property('book') + .that.has.property('options') + .with.property('onDelete', 'CASCADE') + ; + }); + + describe('create()', () => { + it('should fail creating instances that require a primary key', () => { + const page = { + content: 'written by Oscar Wilde', + book: { + title: 'The Picture of Dorian Gray' + } + }; + + return Page.create(page, {include: [Book]}) + .catch(err => expect(err.message).to.eq(`notNull Violation: ${foreignKey} cannot be null`)); + }); + + it('should create instances that require a parent primary key', () => { + const book = { + title: 'Sherlock Holmes', + pages: [ + {content: 'Watson'}, + {content: 'Moriaty'}, + ] + }; + + return Book.create(book, {include: [Page]}) + .then((actual: any) => { + expect(actual.id).to.be.gt(0); + expect(actual.title).to.eq(book.title); + expect(actual.pages).to.have.lengthOf(2); + expect(actual.pages[0].id).to.be.gt(0); + expect(actual.pages[0].content).to.eq(book.pages[0].content); + expect(actual.pages[1].id).to.be.gt(0); + expect(actual.pages[1].content).to.eq(book.pages[1].content); + }); + }); + }); + } + + describe('resolve foreign keys automatically with association options', () => { + + @Table + class Book3 extends Model { + + @Column + title: string; + + @HasMany(() => Page3, { foreignKey: {allowNull: false}, onDelete: 'CASCADE'}) + pages: Page3[]; + } + + @Table + class Page3 extends Model { + + @Column(DataType.TEXT) + content: string; + + @ForeignKey(() => Book3) + bookId: number; + + @BelongsTo(() => Book3, { foreignKey: {allowNull: false}, onDelete: 'CASCADE'}) + book: Book3; + } + + oneToManyWithOptionsTestSuites(Book3, Page3); + }); + + describe('set foreign keys explicitly with association options', () => { + + @Table + class Book4 extends Model { + + @Column + title: string; + + @HasMany(() => Page4, { foreignKey: {allowNull: false, name: 'book_id'}, onDelete: 'CASCADE'}) + pages: Page4[]; + } + + @Table + class Page4 extends Model { + + @Column(DataType.TEXT) + content: string; + + @ForeignKey(() => Book4) + bookId: number; + + @BelongsTo(() => Book4, { foreignKey: {allowNull: false, name: 'book_id'}, onDelete: 'CASCADE'}) + book: Book4; + } + + oneToManyWithOptionsTestSuites(Book4, Page4, true); + }); + + describe('set foreign keys explicitly via options', () => { + + @Table + class Book5 extends Model { + + @Column + title: string; + + @HasMany(() => Page5, {foreignKey: 'bookId'}) + pages: Page5[]; + } + + @Table + class Page5 extends Model { + + @Column(DataType.TEXT) + content: string; + + @BelongsTo(() => Book5, {foreignKey: 'bookId'}) + book: Book5; + } + + oneToManyTestSuites(Book5, Page5); + }); }); describe('Many-to-many', () => { @@ -1456,6 +1616,172 @@ describe('association', () => { oneToOneTestSuites(User2, Address2); }); + + function oneToOneWithOptionsTestSuites(User: typeof Model, Address: typeof Model, alternateName: boolean = false): void { + const foreignKey = alternateName ? 'user_id' : 'userId'; + beforeEach(() => { + sequelize.addModels([User, Address]); + + return sequelize.sync({force: true}); + }); + + it('should create models with specified relations', () => { + expect(User) + .to.have.property('associations') + .that.has.property('address') + .that.is.an.instanceOf(Association['HasOne']) + .which.includes({foreignKey}) + .and.has.property('foreignKeyAttribute') + .which.includes({allowNull: false, name: foreignKey}) + ; + + expect(User) + .to.have.property('associations') + .that.has.property('address') + .that.has.property('options') + .with.property('onDelete', 'CASCADE') + ; + + expect(Address) + .to.have.property('associations') + .that.has.property('user') + .that.is.an.instanceOf(Association['BelongsTo']) + .which.includes({foreignKey}) + .and.has.property('foreignKeyAttribute') + .which.includes({allowNull: false, name: foreignKey}) + ; + + expect(Address) + .to.have.property('associations') + .that.has.property('user') + .that.has.property('options') + .with.property('onDelete', 'CASCADE') + ; + }); + + describe('create()', () => { + it('should fail creating instances that require a primary key', () => { + + return Address.create(petersAddress, {include: [User]}) + .catch(err => expect(err.message).to.eq(`notNull Violation: ${foreignKey} cannot be null`)); + }); + + it('should create instances that require a parent primary key', () => { + return User.create(userWithAddress, {include: [Address]}) + .then((actual: any) => { + assertInstance(actual, userWithAddress); + }); + }); + }); + } + + describe('resolve foreign keys automatically with association options', () => { + + @Table + class User3 extends Model { + + @Column + name: string; + + @HasOne(() => Address3, { foreignKey: {allowNull: false}, onDelete: 'CASCADE'}) + address: any; + } + + @Table + class Address3 extends Model { + + @Column + street: string; + + @Column + zipCode: string; + + @Column + city: string; + + @Column + country: string; + + @ForeignKey(() => User3, {allowNull: false}) + userId: number; + + @BelongsTo(() => User3, { foreignKey: {allowNull: false}, onDelete: 'CASCADE'}) + user: User3; + } + + oneToOneWithOptionsTestSuites(User3, Address3); + }); + + describe('set foreign keys explicitly with association options', () => { + + @Table + class User4 extends Model { + + @Column + name: string; + + @HasOne(() => Address4, { foreignKey: {allowNull: false, name: 'user_id'}, onDelete: 'CASCADE'}) + address: any; + } + + @Table + class Address4 extends Model { + + @Column + street: string; + + @Column + zipCode: string; + + @Column + city: string; + + @Column + country: string; + + @ForeignKey(() => User4, {allowNull: false, name: 'user_id'}) + userId: number; + + @BelongsTo(() => User4, { foreignKey: {allowNull: false, name: 'user_id'}, onDelete: 'CASCADE'}) + user: User4; + } + + oneToOneWithOptionsTestSuites(User4, Address4, true); + }); + + describe('set foreign keys explicitly via options', () => { + + @Table + class User5 extends Model { + + @Column + name: string; + + @HasOne(() => Address5, {foreignKey: 'userId'}) + address: any; + } + + @Table + class Address5 extends Model { + + @Column + street: string; + + @Column + zipCode: string; + + @Column + city: string; + + @Column + country: string; + + @BelongsTo(() => User5, {foreignKey: 'userId'}) + user: User5; + } + + oneToOneTestSuites(User5, Address5); + }); }); });