/** * This file is part of the NocoBase (R) project. * Copyright (c) 2020-2024 NocoBase Co., Ltd. * Authors: NocoBase Team. * * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. * For more information, please refer to: https://www.nocobase.com/agreement. */ import lodash from 'lodash'; import { FindAttributeOptions, ModelStatic, Op, Sequelize } from 'sequelize'; import { Collection } from './collection'; import { Database } from './database'; import FilterParser from './filter-parser'; import { Appends, Except, FindOptions } from './repository'; import qs from 'qs'; const debug = require('debug')('noco-database'); interface OptionsParserContext { collection: Collection; targetKey?: string; } export class OptionsParser { options: FindOptions; database: Database; collection: Collection; model: ModelStatic; filterParser: FilterParser; context: OptionsParserContext; constructor(options: FindOptions, context: OptionsParserContext) { const { collection } = context; this.collection = collection; this.model = collection.model; this.options = options; this.database = collection.context.database; this.filterParser = new FilterParser(options?.filter, { collection, app: { ctx: options?.context, }, }); this.context = context; } static appendInheritInspectAttribute(include, collection): any { // if include already has __tableName, __schemaName, skip if (include.find((item) => item?.[1] === '__tableName')) { return; } include.push([ Sequelize.literal(`(select relname from pg_class where pg_class.oid = "${collection.name}".tableoid)`), '__tableName', ]); include.push([ Sequelize.literal(` (SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = "${collection.name}".tableoid) `), '__schemaName', ]); } isAssociation(key: string) { return this.model.associations[key] !== undefined; } isAssociationPath(path: string) { return this.isAssociation(path.split('.')[0]); } toSequelizeParams() { const queryParams = this.filterParser.toSequelizeParams(); if (this.options?.filterByTk) { queryParams.where = { [Op.and]: [ queryParams.where, { [this.context.targetKey || this.collection.filterTargetKey]: this.options.filterByTk, }, ], }; } if (this.options?.include) { if (!queryParams.include) { queryParams.include = []; } queryParams.include.push(...lodash.castArray(this.options.include)); } return this.parseSort(this.parseFields(queryParams)); } /** * parser sort options * @param filterParams * @protected */ protected parseSort(filterParams) { let sort = this.options?.sort || []; if (typeof sort === 'string') { sort = sort.split(','); } const primaryKeyField = this.model.primaryKeyAttribute; if (primaryKeyField && !this.options?.group) { if (!sort.includes(primaryKeyField)) { sort.push(primaryKeyField); } } const orderParams = []; for (const sortKey of sort) { let direction = sortKey.startsWith('-') ? 'DESC' : 'ASC'; const sortField: Array = sortKey.startsWith('-') ? sortKey.replace('-', '').split('.') : sortKey.split('.'); if (this.database.inDialect('postgres', 'sqlite')) { direction = `${direction} NULLS LAST`; } // handle sort by association if (sortField.length > 1) { let associationModel = this.model; for (let i = 0; i < sortField.length - 1; i++) { const associationKey = sortField[i]; sortField[i] = associationModel.associations[associationKey].target; associationModel = sortField[i]; } } else { const rawField = this.model.rawAttributes[sortField[0]]; sortField[0] = rawField?.field || sortField[0]; } sortField.push(direction); if (this.database.isMySQLCompatibleDialect()) { orderParams.push([Sequelize.fn('ISNULL', Sequelize.col(`${this.model.name}.${sortField[0]}`))]); } orderParams.push(sortField); } if (orderParams.length > 0) { return { order: orderParams, ...filterParams, }; } return filterParams; } protected parseFields(filterParams: any) { const appends = this.options?.appends || []; const except = []; if (this.options?.attributes) { return { attributes: this.options.attributes, }; } let attributes: FindAttributeOptions = { include: [], exclude: [], }; // out put all fields by default if (this.collection.isParent()) { OptionsParser.appendInheritInspectAttribute(attributes.include, this.collection); } if (this.options?.fields) { attributes = []; if (this.collection.isParent()) { OptionsParser.appendInheritInspectAttribute(attributes, this.collection); } // 将fields拆分为 attributes 和 appends for (const field of this.options.fields) { if (this.isAssociationPath(field)) { // field is association field appends.push(field); } else { // field is model attribute, change attributes to array type attributes.push(field); } } } if (this.options?.except) { for (const exceptKey of this.options.except) { if (this.isAssociationPath(exceptKey)) { // except association field except.push(exceptKey); } else { // if attributes is array form, ignore except if (Array.isArray(attributes)) continue; attributes.exclude.push(exceptKey); } } } return { attributes, ...this.parseExcept(except, this.parseAppends(appends, filterParams)), }; } protected parseExcept(except: Except, filterParams: any) { if (!except) return filterParams; const setExcept = (queryParams: any, except: string) => { // split exceptKey to path form // posts.comments.content => ['posts', 'comments', 'content'] // then set except on include attributes const exceptPath = except.split('.'); const association = exceptPath[0]; const lastLevel = exceptPath.length <= 2; const existIncludeIndex = queryParams['include'].findIndex((include) => include['association'] == association); if (existIncludeIndex == -1) { // if include not exists, ignore this except return; } if (lastLevel) { // if it not have exclude form if (Array.isArray(queryParams['include'][existIncludeIndex]['attributes'])) { return; } else { if (!queryParams['include'][existIncludeIndex]['attributes']['exclude']) { queryParams['include'][existIncludeIndex]['attributes']['exclude'] = []; } queryParams['include'][existIncludeIndex]['attributes']['exclude'].push(exceptPath[1]); } } else { setExcept(queryParams['include'][existIncludeIndex], exceptPath.filter((_, index) => index !== 0).join('.')); } }; for (const exceptKey of except) { setExcept(filterParams, exceptKey); } return filterParams; } protected parseAppendWithOptions(append: string) { const parts = append.split('('); const obj: { name: string; options?: object; raw?: string } = { name: parts[0], }; if (parts.length > 1) { const optionsStr = parts[1].replace(')', ''); obj.options = qs.parse(optionsStr); obj.raw = `(${optionsStr})`; } return obj; } protected parseAppends(appends: Appends, filterParams: any) { if (!appends) return filterParams; // sort appends by path length appends = lodash.sortBy(appends, (append) => append.split('.').length); /** * set include params * @param model * @param queryParams * @param append */ const setInclude = (model: ModelStatic, queryParams: any, append: string) => { const appendWithOptions = this.parseAppendWithOptions(append); append = appendWithOptions.name; const appendFields = append.split('.'); const appendAssociation = appendFields[0]; const associations = model.associations; // if append length less or equal 2 // example: // appends: ['posts'] // appends: ['posts.title'] // All of these can be seen as last level let lastLevel = false; if (appendFields.length == 1) { lastLevel = true; } if (appendFields.length == 2) { const association = associations[appendFields[0]]; if (!association) { throw new Error(`association ${appendFields[0]} in ${model.name} not found`); } const associationModel = associations[appendFields[0]].target; if (associationModel.rawAttributes[appendFields[1]]) { lastLevel = true; } } // find association index if (queryParams['include'] == undefined) { queryParams['include'] = []; } let existIncludeIndex = queryParams['include'].findIndex( (include) => include['association'] == appendAssociation, ); // if include from filter, remove fromFilter attribute if (existIncludeIndex != -1) { delete queryParams['include'][existIncludeIndex]['fromFilter']; // set include attributes to all attributes if ( Array.isArray(queryParams['include'][existIncludeIndex]['attributes']) && queryParams['include'][existIncludeIndex]['attributes'].length == 0 ) { queryParams['include'][existIncludeIndex]['attributes'] = { include: [], }; } } if ( lastLevel && existIncludeIndex != -1 && lodash.get(queryParams, ['include', existIncludeIndex, 'attributes', 'include'])?.length == 0 ) { // if append is last level and association exists, ignore it return; } // if association not exist, create it if (existIncludeIndex == -1) { // association not exists queryParams['include'].push({ association: appendAssociation, options: appendWithOptions.options || {}, }); existIncludeIndex = queryParams['include'].length - 1; } // end appends // without nests association if (lastLevel) { // get exist association attributes let attributes = queryParams['include'][existIncludeIndex]['attributes'] || { include: [], // all fields are output by default }; // if need set attribute if (appendFields.length == 2) { if (!Array.isArray(attributes)) { attributes = []; } const attributeName = appendFields[1]; // push field to it attributes.push(attributeName); } else { // if attributes is empty array, change it to object if (Array.isArray(attributes) && attributes.length == 0) { attributes = { include: [], }; } } // set new attributes queryParams['include'][existIncludeIndex] = { ...queryParams['include'][existIncludeIndex], attributes, }; } else { const existInclude = queryParams['include'][existIncludeIndex]; if (existInclude.attributes && Array.isArray(existInclude.attributes) && existInclude.attributes.length == 0) { existInclude.attributes = { include: [], }; } let nextAppend = appendFields.filter((_, index) => index !== 0).join('.'); if (appendWithOptions.raw) { nextAppend += appendWithOptions.raw; } setInclude( model.associations[queryParams['include'][existIncludeIndex].association].target, queryParams['include'][existIncludeIndex], nextAppend, ); } }; // handle every appends for (const append of appends) { setInclude(this.model, filterParams, append); } debug('filter params: %o', filterParams); return filterParams; } }