Skip to content

Commit

Permalink
feat: support iterables in association methods (#16491)
Browse files Browse the repository at this point in the history
  • Loading branch information
ephys committed Sep 12, 2023
1 parent 4539e08 commit 8acefff
Show file tree
Hide file tree
Showing 11 changed files with 475 additions and 68 deletions.
29 changes: 23 additions & 6 deletions packages/core/src/associations/base.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import isObject from 'lodash/isObject.js';
import type { AttributeNames, AttributeOptions, Hookable, Model, ModelStatic } from '../model';
import { isIterable } from '../utils/check.js';
import { cloneDeep } from '../utils/object.js';
import type { AllowArray, PartialBy } from '../utils/types.js';
import type { AllowIterable, Nullish, PartialBy } from '../utils/types.js';
import { AssociationSecret } from './helpers';
import type { NormalizeBaseAssociationOptions } from './helpers';

Expand Down Expand Up @@ -163,7 +165,9 @@ export abstract class Association<

this.options = cloneDeep(options) ?? {};

source.modelDefinition.hooks.runSync('beforeDefinitionRefresh');
source.associations[this.as] = this;
source.modelDefinition.hooks.runSync('afterDefinitionRefresh');
}

/**
Expand Down Expand Up @@ -201,6 +205,21 @@ export abstract class MultiAssociation<
return true;
}

protected toInstanceOrPkArray(
input: Nullish<AllowIterable<T | Exclude<T[TargetKey], any[]>>>,
): Array<T | Exclude<T[TargetKey], any[]>> {
if (input == null) {
return [];
}

if (!isIterable(input) || !isObject(input)) {
return [input];
}

return [...input];

}

/**
* Normalize input
*
Expand All @@ -209,12 +228,10 @@ export abstract class MultiAssociation<
* @private
* @returns built objects
*/
protected toInstanceArray(input: AllowArray<T | Exclude<T[TargetKey], any[]>>): T[] {
if (!Array.isArray(input)) {
input = [input];
}
protected toInstanceArray(input: AllowIterable<T | Exclude<T[TargetKey], any[]>> | null): T[] {
const normalizedInput = this.toInstanceOrPkArray(input);

return input.map(element => {
return normalizedInput.map(element => {
if (element instanceof this.target) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- needed for TS < 5.0
return element as T;
Expand Down
35 changes: 17 additions & 18 deletions packages/core/src/associations/belongs-to-many.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import type { Sequelize } from '../sequelize';
import { isModelStatic, isSameInitialModel } from '../utils/model-utils.js';
import { removeUndefined } from '../utils/object.js';
import { camelize } from '../utils/string.js';
import type { AllowArray } from '../utils/types.js';
import type { AllowIterable } from '../utils/types.js';
import { MultiAssociation } from './base';
import type {
Association,
Expand Down Expand Up @@ -528,14 +528,12 @@ Add your own primary key to the through model, on different attributes than the
*/
async has(
sourceInstance: SourceModel,
targetInstancesOrPks: AllowArray<TargetModel | Exclude<TargetModel[TargetKey], any[]>>,
targetInstancesOrPks: AllowIterable<TargetModel | Exclude<TargetModel[TargetKey], any[]>>,
options?: BelongsToManyHasAssociationMixinOptions<TargetModel>,
): Promise<boolean> {
if (!Array.isArray(targetInstancesOrPks)) {
targetInstancesOrPks = [targetInstancesOrPks];
}
const targets = this.toInstanceOrPkArray(targetInstancesOrPks);

const targetPrimaryKeys: Array<TargetModel[TargetKey]> = targetInstancesOrPks.map(instance => {
const targetPrimaryKeys: Array<TargetModel[TargetKey]> = targets.map(instance => {
if (instance instanceof this.target) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- needed for TS < 5.0
return (instance as TargetModel).get(this.targetKey);
Expand Down Expand Up @@ -578,15 +576,15 @@ Add your own primary key to the through model, on different attributes than the
*/
async set(
sourceInstance: SourceModel,
newInstancesOrPrimaryKeys: AllowArray<TargetModel | Exclude<TargetModel[TargetKey], any[]>>,
newInstancesOrPrimaryKeys: AllowIterable<TargetModel | Exclude<TargetModel[TargetKey], any[]>>,
options: BelongsToManySetAssociationsMixinOptions<TargetModel> = {},
): Promise<void> {
const sourceKey = this.sourceKey;
const targetKey = this.targetKey;
const foreignKey = this.foreignKey;
const otherKey = this.otherKey;

const newInstances = newInstancesOrPrimaryKeys === null ? [] : this.toInstanceArray(newInstancesOrPrimaryKeys);
const newInstances = this.toInstanceArray(newInstancesOrPrimaryKeys);

const where: WhereOptions = {
[foreignKey]: sourceInstance.get(sourceKey),
Expand Down Expand Up @@ -644,16 +642,14 @@ Add your own primary key to the through model, on different attributes than the
*/
async add(
sourceInstance: SourceModel,
newInstancesOrPrimaryKeys: AllowArray<TargetModel | Exclude<TargetModel[TargetKey], any[]>>,
newInstancesOrPrimaryKeys: AllowIterable<TargetModel | Exclude<TargetModel[TargetKey], any[]>>,
options?: BelongsToManyAddAssociationsMixinOptions<TargetModel>,
): Promise<void> {
// If newInstances is null or undefined, no-op
if (!newInstancesOrPrimaryKeys) {
const newInstances = this.toInstanceArray(newInstancesOrPrimaryKeys);
if (newInstances.length === 0) {
return;
}

const newInstances = this.toInstanceArray(newInstancesOrPrimaryKeys);

const where: WhereOptions = {
[this.foreignKey]: sourceInstance.get(this.sourceKey),
[this.otherKey]: newInstances.map(newInstance => newInstance.get(this.targetKey)),
Expand Down Expand Up @@ -778,10 +774,13 @@ Add your own primary key to the through model, on different attributes than the
*/
async remove(
sourceInstance: SourceModel,
targetInstanceOrPks: AllowArray<TargetModel | Exclude<TargetModel[TargetKey], any[]>>,
targetInstanceOrPks: AllowIterable<TargetModel | Exclude<TargetModel[TargetKey], any[]>>,
options?: BelongsToManyRemoveAssociationMixinOptions,
): Promise<void> {
const targetInstance = this.toInstanceArray(targetInstanceOrPks);
if (targetInstance.length === 0) {
return;
}

const where: WhereOptions = {
[this.foreignKey]: sourceInstance.get(this.sourceKey),
Expand Down Expand Up @@ -1095,7 +1094,7 @@ export interface BelongsToManySetAssociationsMixinOptions<TargetModel extends Mo
* @see Model.belongsToMany
*/
export type BelongsToManySetAssociationsMixin<TModel extends Model, TModelPrimaryKey> = (
newAssociations?: Array<TModel | TModelPrimaryKey>,
newAssociations?: Iterable<TModel | TModelPrimaryKey> | null,
options?: BelongsToManySetAssociationsMixinOptions<TModel>
) => Promise<void>;

Expand Down Expand Up @@ -1127,7 +1126,7 @@ export interface BelongsToManyAddAssociationsMixinOptions<TModel extends Model>
* @see Model.belongsToMany
*/
export type BelongsToManyAddAssociationsMixin<T extends Model, TModelPrimaryKey> = (
newAssociations?: Array<T | TModelPrimaryKey>,
newAssociations?: Iterable<T | TModelPrimaryKey>,
options?: BelongsToManyAddAssociationsMixinOptions<T>
) => Promise<void>;

Expand Down Expand Up @@ -1240,7 +1239,7 @@ export interface BelongsToManyRemoveAssociationsMixinOptions extends InstanceDes
* @see Model.belongsToMany
*/
export type BelongsToManyRemoveAssociationsMixin<TModel, TModelPrimaryKey> = (
associationsToRemove?: Array<TModel | TModelPrimaryKey>,
associationsToRemove?: Iterable<TModel | TModelPrimaryKey>,
options?: BelongsToManyRemoveAssociationsMixinOptions
) => Promise<void>;

Expand Down Expand Up @@ -1294,7 +1293,7 @@ export interface BelongsToManyHasAssociationsMixinOptions<T extends Model>
* @see Model.belongsToMany
*/
export type BelongsToManyHasAssociationsMixin<TModel extends Model, TModelPrimaryKey> = (
targets: Array<TModel | TModelPrimaryKey>,
targets: Iterable<TModel | TModelPrimaryKey>,
options?: BelongsToManyHasAssociationsMixinOptions<TModel>
) => Promise<boolean>;

Expand Down
52 changes: 24 additions & 28 deletions packages/core/src/associations/has-many.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { Op } from '../operators';
import { isPlainObject } from '../utils/check.js';
import { isSameInitialModel } from '../utils/model-utils.js';
import { removeUndefined } from '../utils/object.js';
import type { AllowArray } from '../utils/types.js';
import type { AllowIterable } from '../utils/types.js';
import { MultiAssociation } from './base';
import type { Association, AssociationOptions, MultiAssociationAccessors, MultiAssociationOptions } from './base';
import { BelongsTo } from './belongs-to.js';
Expand Down Expand Up @@ -302,27 +302,25 @@ export class HasMany<
* Check if one or more rows are associated with `this`.
*
* @param sourceInstance the source instance
* @param targetInstances Can be an array of instances or their primary keys
* @param targets A list of instances or their primary keys
* @param options Options passed to getAssociations
*/
async has(
sourceInstance: S,
targetInstances: AllowArray<T | Exclude<T[TargetPrimaryKey], any[]>>,
targets: AllowIterable<T | Exclude<T[TargetPrimaryKey], any[]>>,
options?: HasManyHasAssociationsMixinOptions<T>,
): Promise<boolean> {
if (!Array.isArray(targetInstances)) {
targetInstances = [targetInstances];
}
const normalizedTargets = this.toInstanceOrPkArray(targets);

const where = {
[Op.or]: targetInstances.map(instance => {
[Op.or]: normalizedTargets.map(instance => {
if (instance instanceof this.target) {

// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- needed for TS < 5.0
return (instance as T).where();
}

return {
// TODO: support composite foreign keys
// @ts-expect-error -- TODO: what if the target has no primary key?
[this.target.primaryKeyAttribute]: instance,
};
Expand All @@ -332,6 +330,7 @@ export class HasMany<
const findOptions: HasManyGetAssociationsMixinOptions<T> = {
...options,
scope: false,
// TODO: support composite foreign keys
// @ts-expect-error -- TODO: what if the target has no primary key?
attributes: [this.target.primaryKeyAttribute],
raw: true,
Expand All @@ -346,33 +345,33 @@ export class HasMany<

const associatedObjects = await this.get(sourceInstance, findOptions);

return associatedObjects.length === targetInstances.length;
return associatedObjects.length === normalizedTargets.length;
}

/**
* Set the associated models by passing an array of persisted instances or their primary keys. Everything that is not in the passed array will be un-associated
*
* @param sourceInstance source instance to associate new instances with
* @param rawTargetInstances An array of persisted instances or primary key of instances to associate with this. Pass `null` to remove all associations.
* @param targets An array of persisted instances or primary key of instances to associate with this. Pass `null` to remove all associations.
* @param options Options passed to `target.findAll` and `update`.
*/
async set(
sourceInstance: S,
rawTargetInstances: AllowArray<T | Exclude<T[TargetPrimaryKey], any[]>> | null,
targets: AllowIterable<T | Exclude<T[TargetPrimaryKey], any[]>> | null,
options?: HasManySetAssociationsMixinOptions<T>,
): Promise<void> {
const targetInstances = rawTargetInstances === null ? [] : this.toInstanceArray(rawTargetInstances);
const normalizedTargets = this.toInstanceArray(targets);

const oldAssociations = await this.get(sourceInstance, { ...options, scope: false, raw: true });
const promises: Array<Promise<any>> = [];
const obsoleteAssociations = oldAssociations.filter(old => {
return !targetInstances.some(obj => {
return !normalizedTargets.some(obj => {
// @ts-expect-error -- old is a raw result
return obj.get(this.target.primaryKeyAttribute) === old[this.target.primaryKeyAttribute];
});
});

const unassociatedObjects = targetInstances.filter(obj => {
const unassociatedObjects = normalizedTargets.filter(obj => {
return !oldAssociations.some(old => {
// @ts-expect-error -- old is a raw result
return obj.get(this.target.primaryKeyAttribute) === old[this.target.primaryKeyAttribute];
Expand Down Expand Up @@ -422,7 +421,7 @@ export class HasMany<
*/
async add(
sourceInstance: S,
rawTargetInstances: AllowArray<T | Exclude<T[TargetPrimaryKey], any[]>>,
rawTargetInstances: AllowIterable<T | Exclude<T[TargetPrimaryKey], any[]>>,
options: HasManyAddAssociationsMixinOptions<T> = {},
): Promise<void> {
const targetInstances = this.toInstanceArray(rawTargetInstances);
Expand Down Expand Up @@ -451,30 +450,27 @@ export class HasMany<
* Un-associate one or several target rows.
*
* @param sourceInstance instance to un associate instances with
* @param targetInstances Can be an Instance or its primary key, or a mixed array of instances and primary keys
* @param targets Can be an Instance or its primary key, or a mixed array of instances and primary keys
* @param options Options passed to `target.update`
*/
async remove(
sourceInstance: S,
targetInstances: AllowArray<T | Exclude<T[TargetPrimaryKey], any[]>>,
targets: AllowIterable<T | Exclude<T[TargetPrimaryKey], any[]>>,
options: HasManyRemoveAssociationsMixinOptions<T> = {},
): Promise<void> {
if (targetInstances == null) {
if (targets == null) {
return;
}

if (!Array.isArray(targetInstances)) {
targetInstances = [targetInstances];
}

if (targetInstances.length === 0) {
const normalizedTargets = this.toInstanceOrPkArray(targets);
if (normalizedTargets.length === 0) {
return;
}

const where: WhereOptions = {
[this.foreignKey]: sourceInstance.get(this.sourceKey),
// @ts-expect-error -- TODO: what if the target has no primary key?
[this.target.primaryKeyAttribute]: targetInstances.map(targetInstance => {
[this.target.primaryKeyAttribute]: normalizedTargets.map(targetInstance => {
if (targetInstance instanceof this.target) {
// @ts-expect-error -- TODO: what if the target has no primary key?
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- needed for TS < 5.0
Expand Down Expand Up @@ -648,7 +644,7 @@ export interface HasManySetAssociationsMixinOptions<T extends Model>
* @see Model.hasMany
*/
export type HasManySetAssociationsMixin<T extends Model, TModelPrimaryKey> = (
newAssociations?: Array<T | TModelPrimaryKey> | null,
newAssociations?: Iterable<T | TModelPrimaryKey> | null,
options?: HasManySetAssociationsMixinOptions<T>,
) => Promise<void>;

Expand All @@ -675,7 +671,7 @@ export interface HasManyAddAssociationsMixinOptions<T extends Model>
* @see Model.hasMany
*/
export type HasManyAddAssociationsMixin<T extends Model, TModelPrimaryKey> = (
newAssociations?: Array<T | TModelPrimaryKey>,
newAssociations?: Iterable<T | TModelPrimaryKey>,
options?: HasManyAddAssociationsMixinOptions<T>
) => Promise<void>;

Expand Down Expand Up @@ -795,7 +791,7 @@ export interface HasManyRemoveAssociationsMixinOptions<T extends Model>
* @see Model.hasMany
*/
export type HasManyRemoveAssociationsMixin<T extends Model, TModelPrimaryKey> = (
oldAssociateds?: Array<T | TModelPrimaryKey>,
oldAssociateds?: Iterable<T | TModelPrimaryKey>,
options?: HasManyRemoveAssociationsMixinOptions<T>
) => Promise<void>;

Expand Down Expand Up @@ -852,7 +848,7 @@ export interface HasManyHasAssociationsMixinOptions<T extends Model>
// we should also add a "HasManyHasAnyAssociationsMixin"
// and "HasManyHasAssociationsMixin" should instead return a Map of id -> boolean or WeakMap of instance -> boolean
export type HasManyHasAssociationsMixin<TModel extends Model, TModelPrimaryKey> = (
targets: Array<TModel | TModelPrimaryKey>,
targets: Iterable<TModel | TModelPrimaryKey>,
options?: HasManyHasAssociationsMixinOptions<TModel>
) => Promise<boolean>;

Expand Down
20 changes: 10 additions & 10 deletions packages/core/src/dialects/abstract/query-generator-typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,18 +188,18 @@ export class AbstractQueryGeneratorTypeScript {
throw new Error(`listTablesQuery has not been implemented in ${this.dialect.name}.`);
}

removeColumnQuery(tableName: TableNameOrModel, attributeName: string, options?: RemoveColumnQueryOptions): string {
const REMOVE_COLUMN_QUERY_SUPPORTED_OPTIONS = new Set<keyof RemoveColumnQueryOptions>();
removeColumnQuery(tableName: TableNameOrModel, columnName: string, options?: RemoveColumnQueryOptions): string {
if (options) {
const REMOVE_COLUMN_QUERY_SUPPORTED_OPTIONS = new Set<keyof RemoveColumnQueryOptions>();

if (this.dialect.supports.removeColumn.cascade) {
REMOVE_COLUMN_QUERY_SUPPORTED_OPTIONS.add('cascade');
}
if (this.dialect.supports.removeColumn.cascade) {
REMOVE_COLUMN_QUERY_SUPPORTED_OPTIONS.add('cascade');
}

if (this.dialect.supports.removeColumn.ifExists) {
REMOVE_COLUMN_QUERY_SUPPORTED_OPTIONS.add('ifExists');
}
if (this.dialect.supports.removeColumn.ifExists) {
REMOVE_COLUMN_QUERY_SUPPORTED_OPTIONS.add('ifExists');
}

if (options) {
rejectInvalidOptions(
'removeColumnQuery',
this.dialect.name,
Expand All @@ -214,7 +214,7 @@ export class AbstractQueryGeneratorTypeScript {
this.quoteTable(tableName),
'DROP COLUMN',
options?.ifExists ? 'IF EXISTS' : '',
this.quoteIdentifier(attributeName),
this.quoteIdentifier(columnName),
options?.cascade ? 'CASCADE' : '',
]);
}
Expand Down

0 comments on commit 8acefff

Please sign in to comment.