Skip to content

Commit

Permalink
Create many-to-many association utility (#7561)
Browse files Browse the repository at this point in the history
  • Loading branch information
Rotorsoft authored Apr 25, 2024
1 parent 53dff01 commit 3c7c356
Show file tree
Hide file tree
Showing 11 changed files with 107 additions and 53 deletions.
30 changes: 9 additions & 21 deletions libs/model/src/models/associations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,25 +24,13 @@ export const buildAssociations = (db: DB) => {
'stake_id',
);

// TODO: build many-to-many utility
db.Address.hasMany(db.Collaboration, {
foreignKey: { name: 'address_id', allowNull: false },
});
db.Thread.hasMany(db.Collaboration, {
foreignKey: 'thread_id',
});
db.Collaboration.belongsTo(db.Address, {
foreignKey: { name: 'thread_id' },
});
db.Collaboration.belongsTo(db.Thread);
db.Address.belongsToMany(db.Thread, {
through: db.Collaboration,
as: 'collaboration',
foreignKey: { name: 'address_id', allowNull: false },
});
db.Thread.belongsToMany(db.Address, {
through: db.Collaboration,
as: 'collaborators',
foreignKey: { name: 'thread_id', allowNull: false },
});
// Many-to-many associations (cross-references)
db.Collaboration.withManyToMany(
[db.Address, 'address_id', 'collaborators'],
[db.Thread, 'thread_id', 'collaborations'],
);
db.CommunityContract.withManyToMany(
[db.Community, 'community_id', 'communities'],
[db.Contract, 'contract_id', 'contracts'],
);
};
2 changes: 1 addition & 1 deletion libs/model/src/models/collaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export default (
},
);

// sequelize requires a PK on "id" column when defnining a model
// sequelize requires a PK on "id" column when defining a model
Collaboration.removeAttribute('id');

return Collaboration;
Expand Down
4 changes: 0 additions & 4 deletions libs/model/src/models/community.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,6 @@ export default (
models.Community.hasMany(models.StarredCommunity, {
foreignKey: 'community_id',
});
models.Community.belongsToMany(models.Contract, {
through: models.CommunityContract,
foreignKey: 'community_id',
});
models.Community.hasMany(models.Group, {
as: 'groups',
foreignKey: 'community_id',
Expand Down
21 changes: 4 additions & 17 deletions libs/model/src/models/community_contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export type CommunityContractAttributes = {
id?: number;
community_id: string;
contract_id: number;
created_at: Date;
updated_at: Date;

// Associations
Contract?: ContractAttributes;
Expand All @@ -26,8 +28,8 @@ export type CommunityContractModelStatic =
export default (
sequelize: Sequelize.Sequelize,
dataTypes: typeof DataTypes,
): CommunityContractModelStatic => {
const CommunityContract = <CommunityContractModelStatic>sequelize.define(
): CommunityContractModelStatic =>
<CommunityContractModelStatic>sequelize.define<CommunityContractInstance>(
'CommunityContract',
{
id: { type: dataTypes.INTEGER, primaryKey: true, autoIncrement: true },
Expand All @@ -42,20 +44,5 @@ export default (
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [{ fields: ['community_id'], unique: true }],
},
);

CommunityContract.associate = (models) => {
models.CommunityContract.belongsTo(models.Contract, {
foreignKey: 'contract_id',
targetKey: 'id',
});
models.CommunityContract.belongsTo(models.Community, {
foreignKey: 'community_id',
targetKey: 'id',
});
};

return CommunityContract;
};
4 changes: 0 additions & 4 deletions libs/model/src/models/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,6 @@ export default (
);

Contract.associate = (models) => {
models.Contract.belongsToMany(models.Community, {
through: models.CommunityContract,
foreignKey: 'community_id',
});
models.Contract.belongsTo(models.ChainNode, {
foreignKey: 'chain_node_id',
targetKey: 'id',
Expand Down
3 changes: 2 additions & 1 deletion libs/model/src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { DataTypes, Sequelize } from 'sequelize';
import { buildAssociations } from './associations';
import { Factories } from './factories';
import type { Models } from './types';
import { createFk, dropFk, mapFk, oneToMany } from './utils';
import { createFk, dropFk, manyToMany, mapFk, oneToMany } from './utils';

export type DB = Models<typeof Factories> & {
sequelize: Sequelize;
Expand Down Expand Up @@ -43,6 +43,7 @@ export const buildDb = (sequelize: Sequelize): DB => {
Object.entries(Factories).map(([key, factory]) => {
const model = factory(sequelize, DataTypes);
model.withMany = oneToMany as any; // TODO: can we make this work without any?
model.withManyToMany = manyToMany as any; // TODO: can we make this work without any?
return [key, model];
}),
) as Models<typeof Factories>;
Expand Down
20 changes: 19 additions & 1 deletion libs/model/src/models/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { DataTypes, Model, Sequelize, type BuildOptions } from 'sequelize';
import {
DataTypes,
Model,
Sequelize,
type Attributes,
type BuildOptions,
} from 'sequelize';

type ModelFactory<T> = (sequelize: Sequelize, dataTypes: typeof DataTypes) => T;
type ModelFactories = Record<string, ModelFactory<unknown>>;
Expand All @@ -16,6 +22,18 @@ export type ModelStatic<ParentModel extends Model> =
foreignKey: keyof Child & string,
options?: { as?: string; optional?: boolean },
) => ModelStatic<ParentModel>;
withManyToMany: <A extends State, B extends State>(
a: [
ModelStatic<Model<A>>,
keyof Attributes<ParentModel> & string,
string,
],
b: [
ModelStatic<Model<B>>,
keyof Attributes<ParentModel> & string,
string,
],
) => ModelStatic<ParentModel>;
} & {
new (values?: State, options?: BuildOptions): ParentModel;
};
Expand Down
31 changes: 28 additions & 3 deletions libs/model/src/models/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import type { CompositeKey, State } from './types';
* Builds on-to-many association between parent/child models
* @param this parent model with PK
* @param child child model with FK
* @param foreignKey the foreign key field in the child model - sequelize defaults the PK
* @param as association alias, defaults to model name
* @param optional true to allow children without parents (null FKs), defaults to false
* @param foreignKey foreign key field in the child model - sequelize defaults the PK
* @param options -
* - `as`: association alias - defaults to model name
* - `optional`: true to allow children without parents (null FKs) - defaults to false
*/
export function oneToMany<Parent extends State, Child extends State>(
this: ModelStatic<Model<Parent>>,
Expand All @@ -27,6 +28,30 @@ export function oneToMany<Parent extends State, Child extends State>(
return this;
}

/**
* Builds many-to-many association between three models (A->X<-B)
* @param this cross-reference model with FKs to A and B
* @param a left model with PK
* @param b right model with PK
* @param foreignKey foreign key fields in X to [A,B]
* @param as association aliases [a,b]
*/
export function manyToMany<X extends State, A extends State, B extends State>(
this: ModelStatic<Model<X>>,
a: [ModelStatic<Model<A>>, keyof X & string, string],
b: [ModelStatic<Model<B>>, keyof X & string, string],
) {
this.belongsTo(a[0], { foreignKey: { name: a[1], allowNull: false } });
this.belongsTo(b[0], { foreignKey: { name: b[1], allowNull: false } });
a[0].hasMany(this, { foreignKey: { name: a[1], allowNull: false } });
b[0].hasMany(this, { foreignKey: { name: b[1], allowNull: false } });
a[0].belongsToMany(b[0], { through: this, foreignKey: a[1], as: b[2] });
b[0].belongsToMany(a[0], { through: this, foreignKey: b[1], as: a[2] });

// don't forget to return this (fluent)
return this;
}

/**
* Maps composite FK constraints with type safety
*/
Expand Down
6 changes: 6 additions & 0 deletions libs/model/src/tester/seedDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,14 +362,20 @@ export const seedDb = async () => {
{
community_id: alex.id!,
contract_id: alexContract.id!,
created_at: new Date(),
updated_at: new Date(),
},
{
community_id: yearn.id!,
contract_id: yearnContract.id!,
created_at: new Date(),
updated_at: new Date(),
},
{
community_id: sushi.id!,
contract_id: sushiContract.id!,
created_at: new Date(),
updated_at: new Date(),
},
]);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use strict';

module.exports = {
up: async (queryInterface) => {
await queryInterface.sequelize.transaction(async (t) => {
await queryInterface.sequelize.query(
`
ALTER TABLE ONLY public."CommunityContracts"
ADD CONSTRAINT "CommunityContracts_community_id_contract_id_key" UNIQUE(community_id, contract_id);
ALTER TABLE ONLY public."CommunityContracts"
DROP CONSTRAINT IF EXISTS "community_contracts_community_id";
`,
{
transaction: t,
},
);
});
},

down: async (queryInterface) => {
await queryInterface.sequelize.transaction(async (t) => {
await queryInterface.sequelize.query(
`
ALTER TABLE ONLY public."CommunityContracts"
DROP CONSTRAINT IF EXISTS "CommunityContracts_community_id_contract_id_key";
ALTER TABLE ONLY public."CommunityContracts"
ADD CONSTRAINT "community_contracts_community_id" UNIQUE(community_id);
`,
{
transaction: t,
},
);
});
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('Thread queries', () => {
await dispose()();
});

it('query_thread_through_collabo', async () => {
it('query_thread_through_collaborations', async () => {
const chain = await models.Community.findOne();
expect(chain.id).to.not.be.null;
const address = (
Expand Down

0 comments on commit 3c7c356

Please sign in to comment.