/* eslint-disable @typescript-eslint/restrict-template-expressions */ /* eslint-disable @typescript-eslint/no-unsafe-return */ import { plainToClass } from "class-transformer"; import { TransactionScope } from "./Model.connection"; import { EntityColumnOptions, EntityColumnType } from "./Model.entity"; import { EntityRelationColumnOptions, EntityRelationType } from "./Model.entity.relation"; import { CountOptions, DeleteOptions, FindChainingConditions, FindConditions, FindOneOptions, FindOptions, InsertId, OrderCondition, Paginatable, PaginationOptions, UpdateOptions, } from "./Model.query"; import { Metadata } from "../core/Decorator"; import { TraceableError } from "../core/Error"; import { ClassType } from "../types"; import { hasOwnProperty } from "../util/builtin"; const joinSeparator = "_"; interface RelationalRepositoryInfo<T = any> { options: EntityRelationColumnOptions; repository: RepositoryInfo<T>; } export interface RepositoryInfo<T = any> { target: ClassType<T>; tableName: string; columns: Array<string>; fields: { [key: string]: EntityColumnOptions }; primaryColumns: Array<string>; criteriaColumns: Array<string>; oneToOneRelationColumns: Array<string>; oneToOneRelations: { [key: string]: RelationalRepositoryInfo }; } export const symRepositoryInfo = Symbol(); export class Repository<T> { private readonly repositoryInfo: RepositoryInfo<T>; constructor(private readonly scope: TransactionScope, entity: ClassType<T>) { this.repositoryInfo = Repository.getRepositoryInfo(entity); } static getRepositoryInfo<T>(entity: ClassType<T>): RepositoryInfo<T> { if (entity[symRepositoryInfo]) return entity[symRepositoryInfo]; const info: RepositoryInfo<T> = {} as any; // To preventing infinite recursive initialization. entity[symRepositoryInfo] = info; const metadata = Metadata.getStorage().entities.find(e => e.target === entity); const columns = Metadata.getStorage().entityColumns.filter(e => e.target === entity); const relations = Metadata.getStorage().entityRelations.filter(e => e.target === entity); if (!metadata) { throw new Error(`Invalid Repository: No Decorated Entity (${entity.name})`); } else if (!columns.length) { throw new Error(`Invalid Repository: No Decorated Columns (${entity.name})`); } (info.target = metadata.target), (info.tableName = metadata.options.name), (info.columns = columns.map(e => e.propertyKey)), (info.fields = columns.reduce((p, e) => { p[e.propertyKey] = e.options; return p; }, {})), (info.primaryColumns = columns.filter(e => e.type === EntityColumnType.Primary).map(e => e.propertyKey)), (info.criteriaColumns = columns.filter(e => e.type === EntityColumnType.Primary).map(e => e.propertyKey)), (info.oneToOneRelationColumns = relations.filter(e => e.type === EntityRelationType.OneToOne).map(e => e.propertyKey)), (info.oneToOneRelations = relations .filter(e => e.type === EntityRelationType.OneToOne) .reduce((p, e) => { p[e.propertyKey] = { options: { ...e.options, name: Array.isArray(e.options.name) ? e.options.name : [e.options.name], }, repository: Repository.getRepositoryInfo(e.options.target), }; return p; }, {})); return info; } async save(entity: T): Promise<InsertId> { try { const [res] = await this.scope.kx .insert( this.repositoryInfo.columns.reduce((p, e) => { const [key, val] = (() => { const column = this.repositoryInfo.fields[e]; if (hasOwnProperty(column, "name")) { const key = column.name; const val = ((v): any => { if (typeof v === "function") return this.scope.kx.raw(v(key)); else if (v === undefined && column.default) { return this.scope.kx.raw(column.default(key)); } else return v; })(entity[e]); return [key, val]; } else { if (hasOwnProperty(entity, e)) { throw new Error(`Invalid Usage: Save with raw column not allowed. (${column.raw(this.repositoryInfo.tableName)})`); } else { return [null, null]; } } })(); if (key === null) return p; p[key] = val; return p; }, {}) ) .into(this.repositoryInfo.tableName); if (this.repositoryInfo.primaryColumns.length === 1) { const id = entity[this.repositoryInfo.primaryColumns[0]]; if (id && typeof id !== "function") { return entity[this.repositoryInfo.primaryColumns[0]]; } } const [[lid]] = await this.scope.kx.raw("SELECT LAST_INSERT_ID() AS seq"); return res || lid.seq; } catch (err) { throw TraceableError(err); } } async update(entity: T, options?: UpdateOptions<T>): Promise<number> { try { let kx = this.scope.kx.from(this.repositoryInfo.tableName); const conditions: FindConditions<T> = Object.assign({}, options?.where || {}); this.repositoryInfo.primaryColumns.forEach(e => { conditions[e] = entity[e]; }); kx = this.where(kx, conditions); kx = kx.update( ((options?.update || this.repositoryInfo.columns) as any).reduce((p, e) => { const column = this.repositoryInfo.fields[e]; if (hasOwnProperty(column, "name")) { p[column.name] = entity[e]; } else { throw new Error(`Invalid Usage: Update with raw column not allowed. (${column.raw(this.repositoryInfo.tableName)})`); } return p; }, {}) ); if (options?.debug) { // eslint-disable-next-line no-console console.log(">", kx.toSQL()); } const affected = await kx; return Number(affected); } catch (err) { throw TraceableError(err); } } async delete(entity: T, options?: DeleteOptions<T>): Promise<number> { try { let kx = this.scope.kx.from(this.repositoryInfo.tableName); const conditions: FindConditions<T> = Object.assign({}, options?.where || {}); this.repositoryInfo.primaryColumns.forEach(e => { conditions[e] = entity[e]; }); kx = this.where(kx, conditions); kx = kx.del(); if (options?.debug) { // eslint-disable-next-line no-console console.log(">", kx.toSQL()); } const affected = await kx; return Number(affected); } catch (err) { throw TraceableError(err); } } async findOne(options?: FindOneOptions<T>): Promise<T> { try { const [res] = await this.select({ ...options, limit: 1 }); return res || null; } catch (err) { throw TraceableError(err); } } async find(options?: FindOptions<T>): Promise<T[]> { try { const res = await this.select(options); return res; } catch (err) { throw TraceableError(err); } } async pagination(options?: PaginationOptions<T>): Promise<Paginatable<T>> { try { const page = Math.max(1, options && options.page ? options.page : 1); const rpp = Math.max(1, options && options.rpp ? options.rpp : 30); const limit = BigInt(rpp); const offset: bigint = (BigInt(page) - BigInt(1)) * limit; const count = (await this.where(this.scope.kx.from(this.repositoryInfo.tableName), options.where || {}).count("* as cnt"))[0].cnt; const items = await this.find({ ...options, limit, offset }); return { page, rpp, count, items, }; } catch (err) { throw TraceableError(err); } } private async select(options: FindOptions<T>): Promise<T[]> { try { const joinAliases: { [key: string]: string } = {}; let kx = this.scope.kx.from(this.repositoryInfo.tableName); const selectColumns: any[] = options.select || this.repositoryInfo.columns; const select = selectColumns .filter(x => this.repositoryInfo.columns.indexOf(x) !== -1) .map(alias => { const column = this.repositoryInfo.fields[alias]; if (hasOwnProperty(column, "name")) { return `${this.repositoryInfo.tableName}.${column.name} as ${alias}`; } else { kx.select(this.scope.kx.raw(`${column.raw(this.repositoryInfo.tableName)} as ${alias}`)); } }) .filter(x => x); kx = kx.select(select); if (options.forUpdate) { kx = kx.forUpdate(); } // Join kx = this.join(kx, options.select, joinAliases); // Query if (options.where) { kx = this.where(kx, options.where); } const joinAliasesProp = Object.keys(joinAliases).reduce((p, e) => { p[`.${e.split(joinSeparator).join(".")}`] = joinAliases[e]; return p; }, {}); // Sort if (options.order) { const orderBy = Array.isArray(options.order) ? options.order : [options.order]; for (let i = 0; i < orderBy.length; i++) { kx = this.order(kx, orderBy[i], joinAliasesProp); } } // Pagination if (options.offset) kx = kx.offset(String(options.offset) as any); if (options.limit) kx = kx.limit(String(options.limit) as any); if (options.debug) { // eslint-disable-next-line no-console console.log(">", kx.toSQL()); } const rows = await kx; if (!rows || !rows.length) return []; return rows.map((row: any) => this.mapping(row)); } catch (err) { throw TraceableError(err); } } async count(options: CountOptions<T>): Promise<bigint> { try { let kx = this.scope.kx.count("* AS cnt").from(this.repositoryInfo.tableName); kx = this.join(kx, [], {}); // Query if (options.where) { kx = this.where(kx, options.where); } if (options.debug) { // eslint-disable-next-line no-console console.log(">", kx.toSQL()); } const res = await kx; if (!res || !res.length) return BigInt(0); return BigInt(res[0].cnt || 0); } catch (err) { throw TraceableError(err); } } private joinWith( kx: any, rec: number, fromTable: string, propertyKey: string, to: RelationalRepositoryInfo, aliases: { [key: string]: string } ) { const kxx = kx; const fromColumns = to.options.name; const toFields = to.repository.fields; const toColumns = to.repository.primaryColumns; const toTableNameAlias = `${to.repository.tableName}_${rec}`; const toTable = `${to.repository.tableName} AS ${toTableNameAlias}`; const joinTableColumns = to.repository.columns.map(col => { const column = to.repository.fields[col]; if (hasOwnProperty(column, "name")) { return `${toTableNameAlias}.${column.name} as ${propertyKey}${joinSeparator}${col}`; } else { throw new Error(`Invalid Usage: Join with raw column not allowed. (${column.raw(toTableNameAlias)})`); } }); // Assign alias aliases[propertyKey] = toTableNameAlias; if (fromColumns.length !== toColumns.length) { throw new Error( `Invalid Relation: Joining columns are not matched (${(fromColumns as string[]).join(",")} -> ${toColumns.join(",")})` ); } kxx.leftOuterJoin(toTable, function () { for (let i = 0; i < fromColumns.length; i++) { const column = toFields[toColumns[i]]; if (hasOwnProperty(column, "name")) { this.on(`${fromTable}.${fromColumns[i]}`, `${toTableNameAlias}.${column.name}`); } else { throw new Error(`Invalid Usage: Join with raw column not allowed. (${column.raw(fromTable)})`); } } }); kxx.select(joinTableColumns); let idx = 0; to.repository.oneToOneRelationColumns.forEach(relationColumn => { this.joinWith( kxx, rec * 10 + idx++, toTableNameAlias, `${propertyKey}${joinSeparator}${relationColumn}`, to.repository.oneToOneRelations[relationColumn], aliases ); }); return kxx; } private join(kx: any, selectColumns: Array<keyof T>, aliases: { [key: string]: string }): any { const kxx = kx; let idx = 0; this.repositoryInfo.oneToOneRelationColumns.forEach(relationColumn => { if (!selectColumns || selectColumns.indexOf(relationColumn as keyof T) !== -1) { this.joinWith( kxx, ++idx, this.repositoryInfo.tableName, relationColumn, this.repositoryInfo.oneToOneRelations[relationColumn], aliases ); } }); return kxx; } private where(kx: any, where?: FindConditions<T> | FindChainingConditions<T>, orWhere?: boolean): any { /* eslint-disable no-prototype-builtins, @typescript-eslint/no-this-alias */ let kxx = kx; Object.keys(where).forEach(ke => { if (ke === "$AND" || ke === "$OR") { const that = this; if (Array.isArray(where[ke])) { if (ke === "$AND") { where[ke].forEach(chWhere => { kxx = kx.andWhere(function () { that.where(this, chWhere, false); }); }); } else if (ke === "$OR") { where[ke].forEach(chWhere => { kxx = kx.orWhere(function () { that.where(this, chWhere, true); }); }); } } else { if (ke === "$AND") { kx.andWhere(function () { that.where(this, where[ke], false); }); } else if (ke === "$OR") { kx.orWhere(function () { that.where(this, where[ke], true); }); } } } else { const column = this.repositoryInfo.fields[ke]; const isConditional = (v: any) => typeof v === "object" && (v["$AND"] || v["$OR"]); const isManipulatable = (v: any) => typeof v === "object" && (v.hasOwnProperty(">=") || v.hasOwnProperty(">") || v.hasOwnProperty("<=") || v.hasOwnProperty("<") || v.hasOwnProperty("!=") || v.hasOwnProperty("LIKE") || v.hasOwnProperty("LIKE%") || v.hasOwnProperty("%LIKE") || v.hasOwnProperty("%LIKE%") || typeof v["IS_NULL"] === "boolean" || typeof v["IS_NOT_NULL"] === "boolean"); const raw = !!hasOwnProperty(column, "raw"); const k = hasOwnProperty(column, "name") ? `${this.repositoryInfo.tableName}.${column.name}` : column.raw(this.repositoryInfo.tableName); const v = where[ke]; if (Array.isArray(v)) { if (!raw) { kxx.whereIn(k, v); } else { if (v.length > 0) kxx.whereRaw(`${k} IN (?)`, [v]); else kxx.whereRaw("false"); } } else if (isManipulatable(v) || isConditional(v)) { const that = this; kxx[orWhere ? "orWhere" : "andWhere"](function () { Object.keys(v).forEach(cond => { if (cond === "$AND" || cond === "$OR") { this[cond === "$OR" ? "orWhere" : "andWhere"](function () { v[cond].forEach((vv: any) => { that.where(this, { [ke]: vv }, cond === "$OR"); }); }); } else if (cond === "IS_NULL" || cond === "IS_NOT_NULL") { if (v["IS_NULL"] === true || v["IS_NOT_NULL"] === false) { this[orWhere ? "orWhereNull" : "whereNull"](k); } else if (v["IS_NULL"] === false || v["IS_NOT_NULL"] === true) { this[orWhere ? "orWhereNotNull" : "whereNotNull"](k); } } else if (cond === "LIKE" || cond === "%LIKE" || cond === "LIKE%" || cond === "%LIKE%") { if (cond === "LIKE") this[orWhere ? "orWhere" : "where"](k, "LIKE", v[cond]); else if (cond === "%LIKE") this[orWhere ? "orWhere" : "where"](k, "LIKE", `%${v[cond]}`); else if (cond === "LIKE%") this[orWhere ? "orWhere" : "where"](k, "LIKE", `${v[cond]}%`); else if (cond === "%LIKE%") this[orWhere ? "orWhere" : "where"](k, "LIKE", `%${v[cond]}%`); } else if (typeof v[cond] === "function") { this.whereRaw(`${k} ${cond} ?`, [v[cond](k)]); } else { this[orWhere ? "orWhereRaw" : "whereRaw"](`${k} ${cond} ?`, [v[cond]]); } }); }); } else if (typeof v === "function") { kxx[orWhere ? "orWhere" : "where"](this.scope.kx.raw(v(k))); } else { if (!raw) { kxx[orWhere ? "orWhere" : "where"](k, v); } else { kxx[orWhere ? "orWhere" : "andWhere"](function () { this.whereRaw(`${k} = ?`, [v]); }); } } /* else { const k = column.raw(this.repositoryInfo.tableName); const v = where[ke]; if (Array.isArray(v)) { kxx[orWhere ? "orWhere" : "andWhere"](function () { if (v.length > 0) this.whereRaw(`${k} IN (?)`, [v]); else this.whereRaw("false"); }); } else if (isConditional(v)) { throw new Error(`Invalid Usage: Query with raw column not supported. (${column.raw(this.repositoryInfo.tableName)})`); } else { // const that = this; kxx[orWhere ? "orWhere" : "andWhere"](function () { this.whereRaw(`${k} = ?`, [v]); }); } } */ } }); return kxx; } private order(kx: any, orderCond: OrderCondition<T>, joinAliases: { [key: string]: string }): any { const order = typeof orderCond === "function" ? [orderCond] : orderCond; let kxx = kx; Object.keys(order).forEach(ke => { const column = this.repositoryInfo.fields[ke]; const oto = !column && this.repositoryInfo.oneToOneRelations[ke]; const v = order[ke]; if (oto) { throw new Error("Invalid Usage: Sorting by joining column must be used delegated function.)"); } else if (!column && typeof v === "function") { kxx = kx.orderByRaw(v(joinAliases)); } else if (hasOwnProperty(column, "name")) { const k = `${this.repositoryInfo.tableName}.${column.name}`; if (typeof v === "function") { kxx = kx.orderByRaw(v(k)); } else { kxx = kx.orderBy(k, v); } } else { const k = column.raw(this.repositoryInfo.tableName); kxx = kx.orderByRaw(`${k} ${v}`); } }); return kxx; } private mapping(row: any, repositoryInfo?: RepositoryInfo, prefix?: string): T { const x = plainToClass( (repositoryInfo || this.repositoryInfo).target, Object.keys(row) .filter(e => !prefix || e.startsWith(`${prefix}${joinSeparator}`)) .reduce((p, c) => { const col = !prefix ? c : c.substring(prefix.length + 1); if (!col.includes(joinSeparator)) { p[col] = row[c]; } else { const [join] = col.split(joinSeparator); if (!p[join]) { p[join] = this.mapping( row, (repositoryInfo || this.repositoryInfo).oneToOneRelations[join].repository, !prefix ? join : `${prefix}_${join}` ); } } return p; }, {}) ); return x; } }