UNPKG

@goatlab/fluent

Version:

Readable query Interface & API generator for TS and Node

832 lines 33.6 kB
"use strict"; /** * Inspiration: https://github.com/laravel/framework/blob/9.x/src/Illuminate/Database/Eloquent/Model.php */ Object.defineProperty(exports, "__esModule", { value: true }); exports.TypeOrmConnector = void 0; const tslib_1 = require("tslib"); const js_utils_1 = require("@goatlab/js-utils"); const bson_1 = require("bson"); const BaseConnector_1 = require("../BaseConnector"); const generatorDatasource_1 = require("../generatorDatasource"); const outputKeys_1 = require("../outputKeys"); const getMongoFindAggregatedQuery_1 = require("./queryBuilder/mongodb/getMongoFindAggregatedQuery"); const getMongoWhere_1 = require("./queryBuilder/mongodb/getMongoWhere"); const getQueryBuilderWhere_1 = require("./queryBuilder/sql/getQueryBuilderWhere"); const getTypeOrmWhere_1 = require("./queryBuilder/sql/getTypeOrmWhere"); const clearEmpties_1 = require("./util/clearEmpties"); const extractInclude_1 = require("./util/extractInclude"); const extractOrderBy_1 = require("./util/extractOrderBy"); const getRelationsFromModelGenerator_1 = require("./util/getRelationsFromModelGenerator"); class TypeOrmConnector extends BaseConnector_1.BaseConnector { repository; dataSourceOrGetter; cachedDataSource = null; get dataSource() { if (!this.cachedDataSource) { if (typeof this.dataSourceOrGetter === 'function') { this.cachedDataSource = this.dataSourceOrGetter(); } else { this.cachedDataSource = this.dataSourceOrGetter; } } return this.cachedDataSource; } inputSchema; outputSchema; entity; constructor({ entity, dataSource, inputSchema, outputSchema, }) { super(); this.dataSourceOrGetter = dataSource; this.inputSchema = inputSchema; this.outputSchema = outputSchema || inputSchema; this.entity = entity; } initDB() { this.repository = this.dataSource.getRepository(this.entity); this.isMongoDB = this.repository.metadata.connection.driver.options.type === 'mongodb'; if (this.isMongoDB) { this.repository = this.dataSource.getMongoRepository(this.entity); } const relationShipBuilder = generatorDatasource_1.modelGeneratorDataSource.getRepository(this.entity); const { relations } = (0, getRelationsFromModelGenerator_1.getRelationsFromModelGenerator)(relationShipBuilder); this.modelRelations = relations; this.outputKeys = (0, outputKeys_1.getOutputKeys)(relationShipBuilder) || []; return 1; } // CREATE /** * Insert the data object into the database. * @param data */ async insert(data) { this.initDB(); // Validate Input const validatedData = this.inputSchema.parse(data); if (this.isMongoDB && validatedData.id) { validatedData._id = new bson_1.ObjectId(validatedData.id); validatedData.id = undefined; } // Only Way to Skip the DeepPartial requirement from TypeORm const datum = await this.repository.save(validatedData); if (this.isMongoDB) { datum.id = datum.id.toString(); } // Validate Output return this.outputSchema.parse((0, clearEmpties_1.clearEmpties)(js_utils_1.Objects.deleteNulls(datum))); } async insertMany(data) { this.initDB(); const validatedData = this.inputSchema.array().parse(data); // const inserted = await this.repository.save(validatedData, { chunk: data.length / 300, }); const processedData = new Array(inserted.length); for (let i = 0; i < inserted.length; i++) { const d = inserted[i]; if (this.isMongoDB) { // Handle both _id and id cases if (d._id) { d.id = d._id.toString(); delete d._id; } else if (d.id && typeof d.id !== 'string') { d.id = d.id.toString(); } } processedData[i] = (0, clearEmpties_1.clearEmpties)(js_utils_1.Objects.deleteNulls(d)); } return this.outputSchema.array().parse(processedData); } // READ async findMany(query) { this.initDB(); const requiresCustomQuery = query?.include && Object.keys(query.include).length; if (this.isMongoDB && requiresCustomQuery) { const results = await this.customMongoRelatedFind(query); return results; } if (requiresCustomQuery) { const { queryBuilder: customQuery, selectedKeys } = this.customTypeOrmRelatedFind({ fluentQuery: query, }); customQuery.select(selectedKeys); // Get the count for pagination // TODO: do the pagination const [result, _count] = await customQuery.getManyAndCount(); // Process MongoDB IDs if needed for (let i = 0; i < result.length; i++) { const d = result[i]; if (this.isMongoDB) { // Handle both _id and id cases if (d._id) { d.id = d._id.toString(); delete d._id; } else if (d.id && typeof d.id !== 'string') { d.id = d.id.toString(); } } (0, clearEmpties_1.clearEmpties)(js_utils_1.Objects.deleteNulls(d)); } // Apply select filtering if needed // Validate the results const validatedResult = this.outputSchema ?.array() .parse(result); return validatedResult; } // Generate normal TypeORM Query const generatedQuery = this.generateTypeOrmQuery(query); const [found, count] = await this.repository.findAndCount(generatedQuery); for (let i = 0; i < found.length; i++) { const d = found[i]; if (this.isMongoDB) { // Handle both _id and id cases if (d._id) { d.id = d._id.toString(); delete d._id; } else if (d.id && typeof d.id !== 'string') { d.id = d.id.toString(); } } (0, clearEmpties_1.clearEmpties)(js_utils_1.Objects.deleteNulls(d)); } // Apply select filter if needed let processedResults = found; if (query?.select) { // Filter out fields that are explicitly set to false processedResults = this.applySelectFilter(found, query.select); } // Validate the results - skip validation if select is used (partial objects) const validatedFound = query?.select ? processedResults : this.outputSchema ?.array() .parse(processedResults); if (query?.paginated) { const paginationInfo = { total: count, perPage: query.paginated.perPage, currentPage: query.paginated.page, nextPage: query.paginated.page + 1, firstPage: 1, lastPage: Math.ceil(count / query.paginated.perPage), prevPage: query.paginated.page === 1 ? null : query.paginated.page - 1, from: (query.paginated.page - 1) * query.paginated.perPage + 1, to: query.paginated.perPage * query.paginated.page, data: validatedFound, }; return paginationInfo; } // Return the already validated results return validatedFound; } // UPDATE /** * PATCH operation * @param data */ async updateById(id, data) { this.initDB(); const dataToInsert = this.outputKeys.includes('updated') ? { ...data, ...{ updated: new Date() }, } : data; const validatedData = this.inputSchema.parse(dataToInsert); await this.repository.update(id, validatedData); // Validate Output return (await this.requireById(id)); } /** * * PUT operation. All fields not included in the data * param will be set to null * * @param id * @param data */ async replaceById(id, data) { this.initDB(); const _idFieldName = this.isMongoDB ? '_id' : 'id'; const value = this.requireById(id); const flatValue = js_utils_1.Objects.flatten(JSON.parse(JSON.stringify(value))); Object.keys(flatValue).forEach(key => { ; flatValue[key] = null; }); const nullObject = js_utils_1.Objects.nest(flatValue); const newValue = { ...nullObject, ...data }; newValue._id = undefined; newValue.id = undefined; newValue.created = undefined; newValue.updated = undefined; const dataToInsert = this.outputKeys.includes('updated') ? { ...data, ...{ updated: new Date() }, } : data; const validatedData = this.inputSchema.parse(dataToInsert); await this.repository.update(id, validatedData); return (await this.requireById(id)); } // DELETE /** * * @param id * @returns */ async deleteById(id) { this.initDB(); const parsedId = this.isMongoDB ? new bson_1.ObjectId(id) : id; await this.repository.delete(parsedId); return id; } /** * * @returns */ async clear() { this.initDB(); await this.repository.clear(); return true; } // RELATIONS /** * * @param query * @returns */ loadFirst(query) { this.initDB(); // Create a clone of the original class // to avoid polluting attributes (relatedQuery) const newInstance = this.clone(); newInstance.setRelatedQuery({ entity: this.entity, repository: this, query: { ...query, limit: 1, }, }); return newInstance; } /** * * @param id * @returns */ loadById(id) { this.initDB(); // Create a new instance to avoid polluting the original one const newInstance = this.clone(); newInstance.setRelatedQuery({ entity: this.entity, repository: this, query: { where: { id, }, }, }); return newInstance; } /** * * Returns the TypeOrm Repository, you can use it * form more complex queries and to get * the TypeOrm query builder * * @param query */ raw() { this.initDB(); return this.repository; } /** * * Returns the TypeOrm Repository, you can use it * form more complex queries and to get * the TypeOrm query builder * * @param query */ mongoRaw() { this.initDB(); return this.repository; } /** * Creates a Clone of the current instance of the class * @returns */ clone() { this.initDB(); return new this.constructor(); } /** * Apply select filter to remove fields explicitly set to false * @param results The query results * @param select The select configuration * @returns Filtered results */ applySelectFilter(results, select) { if (!select) { return results; } const flatSelect = js_utils_1.Objects.flatten(select); const fieldsToInclude = new Set(); const fieldsToExclude = new Set(); let hasIncludes = false; // Separate fields to include and exclude for (const [key, value] of Object.entries(flatSelect)) { // Convert to string for consistent comparison const strValue = String(value); if (strValue === 'true' || strValue === '1') { fieldsToInclude.add(key); hasIncludes = true; } else if (strValue === 'false' || strValue === '0') { fieldsToExclude.add(key); } } return results.map(result => { if (hasIncludes) { return this.applyInclusiveSelection(result, fieldsToInclude); } return this.applyExclusiveSelection(result, fieldsToExclude); }); } applyInclusiveSelection(result, fieldsToInclude) { const filtered = {}; for (const field of fieldsToInclude) { if (field.includes('.')) { this.copyNestedField(result, filtered, field); } else if (field in result) { filtered[field] = result[field]; } } return filtered; } applyExclusiveSelection(result, fieldsToExclude) { const filtered = { ...result }; for (const field of fieldsToExclude) { if (field.includes('.')) { this.deleteNestedField(filtered, field); } else { delete filtered[field]; } } return filtered; } copyNestedField(source, target, fieldPath) { const parts = fieldPath.split('.'); let currentSource = source; let currentTarget = target; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; if (!part || !currentSource[part]) { return; } currentSource = currentSource[part]; if (!currentTarget[part]) { currentTarget[part] = {}; } currentTarget = currentTarget[part]; } if (currentSource && parts.length > 0) { const lastPart = parts[parts.length - 1]; if (lastPart) { currentTarget[lastPart] = currentSource[lastPart]; } } } deleteNestedField(target, fieldPath) { const parts = fieldPath.split('.'); let current = target; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; if (!part || !current[part]) { return; } current = current[part]; } if (current && parts.length > 0) { const lastPart = parts[parts.length - 1]; if (lastPart) { delete current[lastPart]; } } } /** * * @param query * @returns */ generateTypeOrmQuery(query) { const filter = {}; filter.where = this.isMongoDB ? (0, getMongoWhere_1.getMongoWhere)({ where: query?.where, }) : (0, getTypeOrmWhere_1.getTypeOrmWhere)({ where: query?.where, }); filter.take = query?.limit; filter.skip = query?.offset; // Pagination if (query?.paginated) { filter.take = query.paginated.perPage; filter.skip = (query.paginated?.page - 1) * query?.paginated.perPage; } if (query?.select) { const selectQuery = js_utils_1.Objects.flatten(query?.select || {}); // Filter out fields with false values - TypeORM only accepts fields to include const fieldsToSelect = Object.keys(selectQuery).filter(key => { const val = selectQuery[key]; return (val !== undefined && (String(val) === 'true' || String(val) === '1')); }); if (fieldsToSelect.length > 0) { // TypeORM expects select to be an object with field names as keys const selectObject = fieldsToSelect.reduce((acc, field) => { acc[field] = true; return acc; }, {}); filter.select = selectObject; } } if (query?.orderBy) { filter.order = (0, extractOrderBy_1.extractOrderBy)(query.orderBy); } if (query?.include) { filter.relations = (0, extractInclude_1.extractInclude)(query.include); } return filter; } /** * * @param query * @returns */ customTypeOrmRelatedFind({ fluentQuery: query, queryBuilder, targetFluentRepository, alias, isLeftJoin, }) { const queryAlias = alias || queryBuilder?.alias || `${this.repository.metadata.tableName}`; let customQuery = queryBuilder || this.raw().createQueryBuilder(queryAlias); const self = targetFluentRepository || this; if (!isLeftJoin) { customQuery = (0, getQueryBuilderWhere_1.getQueryBuilderWhere)({ queryBuilder: customQuery, queryAlias, where: query?.where, }); } const { queryBuilder: qb, selectedKeys } = this.getTypeOrmQueryBuilderSubqueries({ queryBuilder: customQuery, selfReference: targetFluentRepository, include: query?.include, leftTableAlias: alias, }); customQuery = qb; const extraKeys = this.getTypeOrmQueryBuilderSelect(queryAlias, self, query?.select); const keySet = new Set([...selectedKeys, ...extraKeys]); // if (query?.limit) { // customQuery = customQuery.limit(query?.limit) // } // if (query?.offset) { // customQuery = customQuery.offset(query?.offset) // } // if (query?.take) { // customQuery = customQuery.take(query?.take) // } return { queryBuilder: customQuery, selectedKeys: Array.from(keySet), }; } getTypeOrmQueryBuilderSelect(queryAlias, self, select) { const selected = js_utils_1.Objects.flatten(select || {}); const selectedKeys = []; const iterableKeys = Object.keys(selected).length ? Object.keys(selected) : self.outputKeys || []; const baseNestedKeys = new Set(); for (const key of iterableKeys) { const keyArray = key.split('.'); // There are no nested objects if (keyArray.length <= 1) { continue; } const total = keyArray.length; for (const [index, val] of keyArray.entries()) { // No need to iterate over the last object if (total === index + 1) { continue; } let excludedField = ''; if (excludedField) { excludedField = `${excludedField}.${excludedField}${val}`; } excludedField = `${excludedField}${val}`; baseNestedKeys.add(excludedField); } } for (const k of iterableKeys) { const field = k.includes('.') ? js_utils_1.Strings.camel(`${k}`) : k; const search = `${queryAlias}.${field}`; // isRelatedField: We can tell if the field is a "related model" // checking "this" for the name of the relation let isNestedRelation = false; for (const item of k.split('.')) { if (self[item]) { isNestedRelation = true; break; } } if (!!self[field] || !!self[queryAlias] || isNestedRelation) { continue; } // No need to include base keys if (baseNestedKeys.has(field)) { continue; } selectedKeys.push(search); } return selectedKeys; } getTypeOrmQueryBuilderSubqueries({ queryBuilder, selfReference, include, leftTableAlias, }) { const selectedKeys = []; if (!include) { return { queryBuilder, selectedKeys }; } for (const relation of Object.keys(include)) { // i.e To make this code more understandable // table "users" has many "cars" // For a first level query, represents "users" const self = selfReference || this; // All information about the users[cars] relation const dbRelation = self.modelRelations[relation]; // The "cars" table repository // this will be use for possible recursive queries const newSelf = self[relation](); // Extract new query for this included relationship const fluentRelatedQuery = include[relation] === true ? {} : include[relation]; if (!dbRelation) { throw new Error(`The relation ${relation} is not properly defined. Check your entity and repository`); } // Now we need to decide which properties we want to select from the related model // If the query has some {select: [x]: true} const selectedKeysArray = fluentRelatedQuery?.select ? Object.keys(js_utils_1.Objects.flatten(fluentRelatedQuery.select)) : []; if (dbRelation.isManyToOne) { // We now have the opposite "cars" has one "users" // "cars" // Or users___cars if it comes from a nested relation const leftSideTableName = leftTableAlias || queryBuilder.alias; // "cars.userId" // users___cars.userId (if nested) const leftSideForeignKey = `${leftSideTableName}.${dbRelation.joinColumns[0].propertyPath}`; // Right side considering nested relations // users___cars___cars___user const rightSideTableName = `${leftSideTableName}_${relation}`; const rightSidePrimaryKey = `${rightSideTableName}.id`; const keys = new Set(selectedKeysArray.map(k => `${rightSideTableName}.${k}`)); selectedKeys.push(...Array.from(keys)); const shallowQuery = { ...fluentRelatedQuery }; shallowQuery.include = undefined; const { queryBuilder: leftJoinBuilder, selectedKeys: deepkeys } = this.customTypeOrmRelatedFind({ queryBuilder: this.raw().createQueryBuilder(rightSideTableName), fluentQuery: shallowQuery, targetFluentRepository: newSelf, alias: rightSideTableName, }); selectedKeys.push(...deepkeys); const joinQuery = leftJoinBuilder.getQuery().split('WHERE'); const customLeftJoin = joinQuery?.[1] ? joinQuery[1].trim() : '1=1'; const leftJoinParams = leftJoinBuilder.getParameters(); // Finally we get to do the LEFT JOIN queryBuilder.leftJoinAndMapOne(`${leftSideTableName}.${relation}`, dbRelation.targetClass, // Right side of the JOIN table name // The name of the table that comes from the query above! rightSideTableName, // Keys to JOIN ON // This must account for all aliases used above `(${leftSideForeignKey} = ${rightSidePrimaryKey} AND ${customLeftJoin} )`, leftJoinParams); const { queryBuilder: qb, selectedKeys: k } = this.customTypeOrmRelatedFind({ queryBuilder, fluentQuery: fluentRelatedQuery, targetFluentRepository: newSelf, alias: rightSideTableName, isLeftJoin: true, }); selectedKeys.push(...k); queryBuilder = qb; } if (dbRelation.isOneToMany) { // "users" const leftSideTableName = leftTableAlias || queryBuilder.alias; // As it is one to many, primary key will always be "id" // users.id const leftSidePrimaryKey = `${leftSideTableName}.id`; // "cars" const rightSideTableName = `${leftSideTableName}_${relation}`; // "cars".userId const rightSideForeignKey = `${rightSideTableName}.${dbRelation.inverseSidePropertyPath}`; const keys = new Set(selectedKeysArray.map(k => `${rightSideTableName}.${k}`)); selectedKeys.push(...Array.from(keys)); // Left join query, without including any nested tables const shallowQuery = { ...fluentRelatedQuery }; shallowQuery.include = undefined; const { queryBuilder: leftJoinBuilder, selectedKeys: deepKeys } = this.customTypeOrmRelatedFind({ queryBuilder: this.raw().createQueryBuilder(rightSideTableName), fluentQuery: shallowQuery, targetFluentRepository: newSelf, alias: rightSideTableName, }); selectedKeys.push(...deepKeys); const joinQuery = leftJoinBuilder.getQuery().split('WHERE'); const customLeftJoin = joinQuery?.[1] ? joinQuery[1].trim() : '1=1'; const leftJoinParams = leftJoinBuilder.getParameters(); // Finally we get to do the LEFT JOIN queryBuilder.leftJoinAndMapMany(`${leftSideTableName}.${relation}`, dbRelation.targetClass, // Right side of the JOIN table name rightSideTableName, // Keys to JOIN ON `(${leftSidePrimaryKey} = ${rightSideForeignKey} AND ${customLeftJoin} )`, leftJoinParams); const { queryBuilder: q, selectedKeys: k } = this.customTypeOrmRelatedFind({ queryBuilder, fluentQuery: fluentRelatedQuery, targetFluentRepository: newSelf, alias: rightSideTableName, isLeftJoin: true, }); selectedKeys.push(...k); queryBuilder = q; } // if (dbRelation.isManyToMany) { // const relatedTableName = dbRelation.tableName // const pivotTableName = // dbRelation.joinColumns[0].relationMetadata.joinTableName // const pivotForeignField = dbRelation.joinColumns[0].propertyPath // const inverseForeignField = // dbRelation.inverseJoinColumns[0].propertyPath // if ( // !relatedTableName || // !pivotTableName || // !pivotForeignField || // !inverseForeignField // ) { // throw new Error( // `Your many to many relation is not properly set up. Please check both your models and schema for relation: ${relation}` // ) // } // // "users" // const leftSideTableName = leftTableAlias || queryBuilder.alias // // As it is one to many, primary key will always be "id" // // users.id // const leftSidePrimaryKey = `${leftSideTableName}.id` // // "roles_users" // const rightSideTableName = `${relatedTableName}` // // "roles_users".userId // const rightSideForeignKey = `${rightSideTableName}.${pivotForeignField}` // const keys = new Set( // selectedKeysArray.map(k => `${rightSideTableName}.${k}`) // ) // selectedKeys.push(...Array.from(keys)) // // Left join query, without including any nested tables // const shallowQuery = { ...fluentRelatedQuery } // delete shallowQuery['include'] // const { queryBuilder: leftJoinBuilder, selectedKeys: deepKeys } = // this.customTypeOrmRelatedFind({ // queryBuilder: this.raw().createQueryBuilder(rightSideTableName), // fluentQuery: shallowQuery, // targetFluentRepository: newSelf, // alias: rightSideTableName // }) // selectedKeys.push(...deepKeys) // const joinQuery = leftJoinBuilder.getQuery().split('WHERE') // const customLeftJoin = // joinQuery && joinQuery[1] ? joinQuery[1].trim() : '1=1' // const leftJoinParams = leftJoinBuilder.getParameters() // // Finally we get to do the LEFT JOIN // queryBuilder.leftJoinAndMapMany( // `${leftSideTableName}.${relation}`, // dbRelation.targetClass, // // Right side of the JOIN table name // rightSideTableName, // // Keys to JOIN ON // `(${leftSidePrimaryKey} = ${rightSideForeignKey} AND ${customLeftJoin} )`, // leftJoinParams // ) // const { queryBuilder: q, selectedKeys: k } = // this.customTypeOrmRelatedFind({ // queryBuilder, // fluentQuery: fluentRelatedQuery, // targetFluentRepository: newSelf, // alias: rightSideTableName, // isLeftJoin: true // }) // selectedKeys.push(...k) // queryBuilder = q // console.log( // relatedTableName, // pivotTableName, // pivotForeignField, // inverseForeignField // ) // continue // // lookUps.push({ $addFields: { id: { $toString: '$_id' } } }) // // lookUps.push({ // // $addFields: { parentStringId: { $toString: '$_id' } } // // }) // // lookUps.push({ // // $lookup: { // // from: pivotTableName, // // localField: 'parentStringId', // // foreignField: pivotForeignField, // // as: dbRelation.propertyName, // // pipeline: [ // // // This is the pivot table // // { $addFields: { id: { $toString: '$_id' } } }, // // { // // $addFields: { // // [`${inverseForeignField}_object`]: { // // $toObjectId: `$${inverseForeignField}` // // } // // } // // }, // // // The other side of the relationShip // // { // // $lookup: { // // from: relatedTableName, // // localField: `${inverseForeignField}_object`, // // foreignField: '_id', // // pipeline: [ // // { $addFields: { id: { $toString: '$_id' } } } // // // Here we could add more filters like // // //{ $limit: 2 } // // ], // // as: dbRelation.propertyName // // } // // }, // // { $unwind: `$${dbRelation.propertyName}` }, // // // Select (ish) // // { // // $project: { // // [dbRelation.propertyName]: `$${dbRelation.propertyName}`, // // pivot: '$$ROOT' // // } // // }, // // { // // $replaceRoot: { // // newRoot: { // // $mergeObjects: ['$$ROOT', `$${dbRelation.propertyName}`] // // } // // } // // }, // // { $project: { [dbRelation.propertyName]: 0 } } // // // Here we could add more filters like // // //{ $limit: 2 } // // ] // // } // // }) // } } return { queryBuilder, selectedKeys }; } /** * * @param query * @returns */ async customMongoRelatedFind(query) { const aggregate = (0, getMongoFindAggregatedQuery_1.getMongoFindAggregatedQuery)({ query, self: this, }); const raw = await this.mongoRaw().aggregate(aggregate).toArray(); if (query?.select) { // Apply select filtering for MongoDB results const filtered = this.applySelectFilter(raw, query.select); return filtered; } return this.outputSchema?.array().parse(raw); } } exports.TypeOrmConnector = TypeOrmConnector; tslib_1.__decorate([ js_utils_1.Memo.syncMethod(), tslib_1.__metadata("design:type", Function), tslib_1.__metadata("design:paramtypes", []), tslib_1.__metadata("design:returntype", void 0) ], TypeOrmConnector.prototype, "initDB", null); //# sourceMappingURL=TypeOrmConnector.js.map