mirror of
https://github.com/nocobase/nocobase
synced 2024-11-16 09:45:18 +00:00
05f815655f
* test: add belongsTo case * fix: change update strategy from add to set
497 lines
13 KiB
TypeScript
497 lines
13 KiB
TypeScript
import {
|
||
Model as SequelizeModel, Op, Sequelize, ProjectionAlias, Utils, SaveOptions,
|
||
} from 'sequelize';
|
||
import Database from './database';
|
||
import { HasOne, HasMany, BelongsTo, BelongsToMany, getDataTypeKey } from './fields';
|
||
import { toInclude } from './utils';
|
||
|
||
export interface ApiJsonOptions {
|
||
|
||
/**
|
||
* 字段
|
||
*
|
||
* 数组式:
|
||
* ['col', 'association.col1', 'association_count'],
|
||
*
|
||
* 白名单:
|
||
* {
|
||
* only: ['col1'],
|
||
* appends: ['association_count'],
|
||
* }
|
||
*
|
||
* 黑名单:
|
||
* {
|
||
* except: ['col1'],
|
||
* appends: ['association_count'],
|
||
* }
|
||
*/
|
||
fields?: string[] | {
|
||
only?: string[];
|
||
appends?: string[];
|
||
} | {
|
||
except?: string[];
|
||
appends?: string[];
|
||
};
|
||
|
||
/**
|
||
* 过滤
|
||
*
|
||
* 常规用法:
|
||
* {
|
||
* col1: {
|
||
* $eq: 'val1'
|
||
* },
|
||
* }
|
||
*
|
||
* scope 的用法(如果 scope 与 col 同名,只会执行 scope):
|
||
* {
|
||
* scope1: value
|
||
* }
|
||
*
|
||
* json 数据 & 关系数据,可以用点号:
|
||
* {
|
||
* 'association.col1': {
|
||
* $eq: 'val1'
|
||
* },
|
||
* }
|
||
*
|
||
* meta 为 json 字段时
|
||
* {
|
||
* 'meta.key': {
|
||
* $eq: 'val1'
|
||
* },
|
||
* }
|
||
*
|
||
* json 数据 & 关系数据的查询也可以不用点号:
|
||
* {
|
||
* association: {
|
||
* col1: {
|
||
* $eq: 'val1'
|
||
* },
|
||
* },
|
||
* }
|
||
*/
|
||
filter?: any;
|
||
|
||
/**
|
||
* 排序
|
||
*
|
||
* TODO
|
||
*
|
||
* ['col1', '-col2', 'association.col1', '-association.col2']
|
||
*/
|
||
sort?: any;
|
||
|
||
/**
|
||
* 页码
|
||
*/
|
||
page?: number;
|
||
perPage?: number;
|
||
|
||
context?: any;
|
||
|
||
[key: string]: any;
|
||
}
|
||
|
||
export interface WithCountAttributeOptions {
|
||
|
||
/**
|
||
* 关系名
|
||
*/
|
||
association: string;
|
||
|
||
/**
|
||
* SourceModel 别名
|
||
*
|
||
* 在 include 里使用时,需要指定,一般与 include 的 association 同名
|
||
*
|
||
* include: {
|
||
* association: 'user', // Post.belongsTo(User)
|
||
* attributes: [
|
||
* User.withCountAttribute({
|
||
* association: 'posts',
|
||
* sourceAlias: 'user', // 内嵌时,需要指定 source 别名
|
||
* })
|
||
* ]
|
||
* }
|
||
*/
|
||
sourceAlias?: string;
|
||
|
||
where?: any;
|
||
|
||
/**
|
||
* 别名,默认为 association_count
|
||
*/
|
||
alias?: string;
|
||
|
||
[key: string]: any;
|
||
}
|
||
|
||
export const DEFAULT_OFFSET = 0;
|
||
export const DEFAULT_LIMIT = 100;
|
||
export const MAX_LIMIT = 500;
|
||
|
||
/**
|
||
* Model 相关
|
||
*
|
||
* TODO: 自定义 model 时的提示问题
|
||
*/
|
||
// @ts-ignore
|
||
export abstract class Model extends SequelizeModel {
|
||
|
||
/**
|
||
* 防止 ts 报错提示
|
||
*/
|
||
[key: string]: any;
|
||
|
||
/**
|
||
* 当前 Model 的 database
|
||
*
|
||
* 与 Model.sequelize 对应,database 也用了 public static readonly
|
||
*/
|
||
public static database: Database;
|
||
|
||
/**
|
||
* 供 model 实例访问的 database
|
||
*/
|
||
get database(): Database {
|
||
// @ts-ignore
|
||
return this.constructor.database;
|
||
}
|
||
|
||
/**
|
||
* sub query 关联数据的数量
|
||
*
|
||
* TODO: 关联字段暂不支持主键以外的字段
|
||
*
|
||
* @param options
|
||
*/
|
||
static withCountAttribute(options?: string | WithCountAttributeOptions): (string | ProjectionAlias) {
|
||
if (typeof options === 'string') {
|
||
options = { association: options };
|
||
}
|
||
|
||
const { sourceAlias, association, where = {}, alias, ...restOptions } = options;
|
||
const associator = this.associations[association];
|
||
const table = this.database.getTable(this.name);
|
||
const field = table.getField(association);
|
||
const { targetKey, otherKey, foreignKey, sourceKey } = field.options as any;
|
||
|
||
if (associator.associationType === 'HasMany') {
|
||
where[foreignKey as string] = {
|
||
[Op.eq]: Sequelize.col(`${sourceAlias||this.name}.${sourceKey}`),
|
||
};
|
||
} else if (associator.associationType === 'BelongsToMany') {
|
||
where[targetKey] = {
|
||
// @ts-ignore
|
||
[Op.in]: Sequelize.literal(`(${associator.through.model.selectQuery({
|
||
attributes: [otherKey],
|
||
where: {
|
||
[foreignKey]: {
|
||
[Op.eq]: Sequelize.col(`${sourceAlias||this.name}.${sourceKey}`),
|
||
},
|
||
// @ts-ignore
|
||
...(associator.through.scope||{}),
|
||
},
|
||
})})`),
|
||
};
|
||
}
|
||
|
||
let countLiteral = 'count(*)';
|
||
|
||
if (this.database.sequelize.getDialect() === 'postgres') {
|
||
countLiteral = 'cast(count(*) as integer)';
|
||
}
|
||
|
||
const attribute = [
|
||
Sequelize.literal(
|
||
// @ts-ignore
|
||
`(${associator.target.selectQuery({
|
||
...restOptions,
|
||
attributes: [[Sequelize.literal(countLiteral), 'count']],
|
||
where: {
|
||
// @ts-ignore
|
||
...where, ...(associator.scope||{}),
|
||
},
|
||
})})`
|
||
),
|
||
alias || Utils.underscoredIf(`${association}Count`, this.options.underscored),
|
||
].filter(Boolean);
|
||
|
||
return attribute as ProjectionAlias;
|
||
}
|
||
|
||
/**
|
||
* 当前 Model 的 SQL
|
||
*
|
||
* @param options
|
||
*/
|
||
static selectQuery(options = {}): string {
|
||
// @ts-ignore
|
||
return this.queryGenerator.selectQuery(
|
||
this.getTableName(),
|
||
options,
|
||
this,
|
||
).replace(/;$/, '');
|
||
}
|
||
|
||
static parseApiJson(options: ApiJsonOptions) {
|
||
const { fields, filter, sort, context, page, perPage } = options;
|
||
const data = toInclude({fields, filter, sort}, {
|
||
model: this,
|
||
associations: this.associations,
|
||
dialect: this.sequelize.getDialect(),
|
||
ctx: context,
|
||
});
|
||
if (page || perPage) {
|
||
data.limit = perPage === -1 ? MAX_LIMIT : Math.min(perPage || DEFAULT_LIMIT, MAX_LIMIT);
|
||
data.offset = data.limit * (page > 0 ? page - 1 : DEFAULT_OFFSET);
|
||
}
|
||
if (data.attributes && data.attributes.length === 0) {
|
||
delete data.attributes;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
async updateSingleAssociation(key: string, data: any, options: SaveOptions<any> & { context?: any; } = {}) {
|
||
const {
|
||
fields,
|
||
transaction = await this.sequelize.transaction(),
|
||
...opts
|
||
} = options;
|
||
Object.assign(opts, { transaction });
|
||
|
||
const table = this.database.getTable(this.constructor.name);
|
||
const association = table.getAssociations().get(key);
|
||
const accessors = association.getAccessors();
|
||
|
||
if (typeof data === 'number' || typeof data === 'string' || data instanceof SequelizeModel) {
|
||
await this[accessors.set](data, opts);
|
||
} else if (typeof data === 'object') {
|
||
const Target = association.getTargetModel();
|
||
const targetAttribute = association instanceof BelongsTo
|
||
? association.options.targetKey
|
||
: association.options.sourceKey;
|
||
if (data[targetAttribute]) {
|
||
await this[accessors.set](data[targetAttribute], opts);
|
||
if (Object.keys(data).length > 1) {
|
||
const target = await Target.findOne({
|
||
where: {
|
||
[targetAttribute]: data[targetAttribute],
|
||
},
|
||
transaction
|
||
});
|
||
await target.update(data, opts);
|
||
// @ts-ignore
|
||
await target.updateAssociations(data, opts);
|
||
}
|
||
} else {
|
||
const t = await this[accessors.create](data, opts);
|
||
await t.updateAssociations(data, opts);
|
||
}
|
||
}
|
||
if (!options.transaction) {
|
||
await transaction.commit();
|
||
}
|
||
}
|
||
|
||
async updateMultipleAssociation(associationName: string, data: any, options: SaveOptions<any> & { context?: any; } = {}) {
|
||
const items = Array.isArray(data) ? data : [data];
|
||
if (!items.length) {
|
||
return;
|
||
}
|
||
|
||
const {
|
||
fields,
|
||
transaction = await this.sequelize.transaction(),
|
||
...opts
|
||
} = options;
|
||
Object.assign(opts, { transaction });
|
||
|
||
const table = this.database.getTable(this.constructor.name);
|
||
const association = table.getAssociations().get(associationName);
|
||
const accessors = association.getAccessors();
|
||
const Target = association.getTargetModel();
|
||
// 当前表关联 target 表的外键(大部分情况与 target 表主键相同,但可以设置为不同的,要考虑)
|
||
const { targetKey = Target.primaryKeyAttribute } = association.options;
|
||
// target 表的主键
|
||
const targetPk = Target.primaryKeyAttribute;
|
||
const targetKeyIsPk = targetKey === targetPk;
|
||
// 准备设置的关联主键
|
||
const toSetPks = new Set();
|
||
const toSetUks = new Set();
|
||
// 筛选后准备设置的关联主键
|
||
const toSetItems = new Set();
|
||
// 准备添加的关联对象
|
||
const toUpsertObjects = [];
|
||
|
||
// 遍历所有值成员准备数据
|
||
items.forEach(item => {
|
||
if (item instanceof SequelizeModel) {
|
||
if (targetKeyIsPk) {
|
||
toSetPks.add(item.getDataValue(targetPk));
|
||
} else {
|
||
toSetUks.add(item.getDataValue(targetKey));
|
||
}
|
||
return;
|
||
}
|
||
if (typeof item === 'number' || typeof item === 'string') {
|
||
let targetKeyType = getDataTypeKey(Target.rawAttributes[targetKey].type).toLocaleLowerCase();
|
||
if (targetKeyType === 'integer') {
|
||
targetKeyType = 'number';
|
||
}
|
||
// 如果传值类型与之前在 Model 上定义的 targetKey 不同,则报错。
|
||
// 不应兼容定义的 targetKey 不是 primaryKey 却传了 primaryKey 的值的情况。
|
||
if (typeof item !== targetKeyType) {
|
||
throw new Error(`target key type [${typeof item}] does not match to [${targetKeyType}]`);
|
||
}
|
||
if (targetKeyIsPk) {
|
||
toSetPks.add(item);
|
||
} else {
|
||
toSetUks.add(item);
|
||
}
|
||
return;
|
||
}
|
||
if (typeof item === 'object') {
|
||
toUpsertObjects.push(item);
|
||
}
|
||
});
|
||
|
||
/* 仅传关联键处理开始 */
|
||
// 查找已存在的数据
|
||
const byPkExistItems = toSetPks.size ? await Target.findAll({
|
||
...opts,
|
||
// @ts-ignore
|
||
where: {
|
||
[targetPk]: {
|
||
[Op.in]: Array.from(toSetPks)
|
||
}
|
||
},
|
||
attributes: [targetPk]
|
||
}) : [];
|
||
byPkExistItems.forEach(item => {
|
||
toSetItems.add(item);
|
||
});
|
||
|
||
const byUkExistItems = toSetUks.size ? await Target.findAll({
|
||
...opts,
|
||
// @ts-ignore
|
||
where: {
|
||
[targetKey]: {
|
||
[Op.in]: Array.from(toSetUks)
|
||
}
|
||
},
|
||
attributes: [targetPk, targetKey]
|
||
}) : [];
|
||
byUkExistItems.forEach(item => {
|
||
toSetItems.add(item);
|
||
});
|
||
/* 仅传关联键处理结束 */
|
||
|
||
const belongsToManyList = [];
|
||
/* 值为对象处理开始 */
|
||
for (const item of toUpsertObjects) {
|
||
let target;
|
||
if (typeof item[targetKey] === 'undefined') {
|
||
// TODO(optimize): 不确定 bulkCreate 的结果是否能保证顺序,能保证的话这里可以优化为批量处理
|
||
target = await Target.create(item, opts);
|
||
} else {
|
||
let created: boolean;
|
||
[target, created] = await Target.findOrCreate({
|
||
where: { [targetKey]: item[targetKey] },
|
||
defaults: item,
|
||
transaction
|
||
});
|
||
if (!created) {
|
||
await target.update(item, opts);
|
||
}
|
||
}
|
||
|
||
if (association instanceof BelongsToMany) {
|
||
belongsToManyList.push({
|
||
item,
|
||
target
|
||
});
|
||
}
|
||
toSetItems.add(target);
|
||
|
||
await target.updateAssociations(item, opts);
|
||
}
|
||
/* 值为对象处理结束 */
|
||
|
||
// 添加所有计算后的关联
|
||
await this[accessors.set](Array.from(toSetItems), opts);
|
||
|
||
// 后处理 belongsToMany 的更新内容
|
||
if (belongsToManyList.length) {
|
||
const ThroughModel = (association as BelongsToMany).getThroughModel();
|
||
const throughName = (association as BelongsToMany).getThroughName();
|
||
|
||
for (const { item, target } of belongsToManyList) {
|
||
const throughValues = item[throughName];
|
||
if (typeof throughValues === 'object') {
|
||
const { foreignKey, sourceKey, otherKey } = association.options;
|
||
const through = await ThroughModel.findOne({
|
||
where: {
|
||
[foreignKey]: this.get(sourceKey),
|
||
[otherKey]: target.get(targetKey),
|
||
},
|
||
transaction
|
||
});
|
||
await through.update(throughValues, opts);
|
||
await through.updateAssociations(throughValues, opts);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!options.transaction) {
|
||
await transaction.commit();
|
||
}
|
||
}
|
||
|
||
async updateAssociation(key: string, data: any, options: SaveOptions<any> & { context?: any; }) {
|
||
const table = this.database.getTable(this.constructor.name);
|
||
const association = table.getAssociations().get(key);
|
||
switch (true) {
|
||
case association instanceof BelongsTo:
|
||
case association instanceof HasOne:
|
||
return this.updateSingleAssociation(key, data, options);
|
||
case association instanceof HasMany:
|
||
case association instanceof BelongsToMany:
|
||
return this.updateMultipleAssociation(key, data, options);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 关联数据的更新
|
||
*
|
||
* TODO: 暂不支持除主键以外关联字段的更新
|
||
*
|
||
* @param data
|
||
*/
|
||
async updateAssociations(data: any, options: SaveOptions & { context?: any } = {}) {
|
||
const { transaction = await this.sequelize.transaction() } = options;
|
||
const table = this.database.getTable(this.constructor.name);
|
||
for (const key of table.getAssociations().keys()) {
|
||
if (!data[key]) {
|
||
continue;
|
||
}
|
||
await this.updateAssociation(key, data[key], {
|
||
...options,
|
||
transaction
|
||
});
|
||
}
|
||
|
||
if (!options.transaction) {
|
||
await transaction.commit();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ModelCtor 需要为当前 Model 的
|
||
*/
|
||
export type ModelCtor<M extends Model> = typeof Model & { new(): M } & { [key: string]: any };
|
||
|
||
export default Model;
|