UNPKG

@knorm/relations

Version:
350 lines (349 loc) 15.7 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); const knorm_1 = require("@knorm/knorm"); const lodash_1 = require("lodash"); const isArray = Array.isArray; const asArray = (value) => (Array.isArray(value) ? value : [value]); const addReference = (references, field, reference) => { const toModel = reference.model; references[toModel.name] = references[toModel.name] || {}; references[toModel.name][field.name] = field; }; class KnormRelations { constructor({ name = 'relations' } = {}) { this.name = name; } updateField(knorm) { const { Field } = knorm; class RelationsField extends Field { constructor(config = {}) { super(config); if (config.references) { this.references = config.references; } } } knorm.updateField(RelationsField); } updateModel(knorm) { const { Model } = knorm; class RelationsModel extends Model { static createConfig() { const config = super.createConfig(); // NOTE: `rawReferences` is added to prevent breaking changes config.rawReferences = {}; config.references = {}; config.referenceFunctions = {}; return config; } static addField(field) { super.addField(field); if (field.references) { const references = field.references; this._config.rawReferences[field.name] = references; if (typeof references === 'function') { this._config.referenceFunctions[field.name] = references; } else { (isArray(references) ? references : [references]).forEach((reference) => { addReference(this._config.references, field, reference); }); } } } static removeField(field) { super.removeField(field); const { name, references } = field; if (references) { delete this._config.rawReferences[name]; if (typeof references === 'function') { delete this._config.referenceFunctions[name]; } else { const model = references.model.name; delete this._config.references[model][name]; if (!Object.keys(this._config.references[model]).length) { delete this._config.references[model]; } } } } // TODO: v3: make async // TODO: v3: unit test Model.getReferences static getReferences(Model) { const allReferences = this._config.rawReferences; const resolvedReferences = []; for (const [key, value] of Object.entries(allReferences)) { const fieldName = key; // TODO: any params to pass to the function? // TODO: v3: allow function to be async const references = typeof value === 'function' ? value() : value; for (const reference of asArray(references)) { // TODO: validate that reference is Field instance? if (reference.model === Model || Model.prototype instanceof reference.model) { resolvedReferences.push({ // TODO: v3: Model.getField() from: this._config._fields[fieldName], to: reference, }); } } } return resolvedReferences; } } knorm.updateModel(RelationsModel); } updateQuery(knorm) { const { Query, Model } = knorm; class RelationsQuery extends Query { constructor(model) { super(model); // TODO: only initialize parsedRows when needed this.parsedRows = new Map(); this.config.references = model.config.references; this.config.referenceFunctions = model.config.referenceFunctions; } addJoin(type, joins, options) { if (!isArray(joins)) { joins = [joins]; } // TODO: use appendOption this.options.joins = this.options.joins || []; joins.forEach((join) => { if (join.prototype instanceof Model) { join = join.query; } join.options.joinType = type; join.setOptions(options); this.options.joins.push(join); }); return this; } leftJoin(queries, options) { return this.addJoin('leftJoin', queries, options); } innerJoin(queries, options) { return this.addJoin('innerJoin', queries, options); } join(queries, options) { return this.addJoin('join', queries, options); } // TODO: require setting `as` when configuring references as(as) { return this.setOption('as', as); } // TODO: support raw sql on(field) { return this.appendOption('on', field); } prepareOn() { const join = this; const parent = this.parent; const joinReferences = join.model.getReferences(parent.model); const parentReferences = parent.model.getReferences(join.model); if (!parentReferences.length && !joinReferences.length) { throw new Query.QueryError(`${parent.model.name}: there are no references to \`${join.model.name}\``); } const isReverseJoin = !!parentReferences.length; let references = joinReferences.concat(parentReferences); if (this.options.on) { const filteredReferences = []; for (let field of this.options.on) { if (typeof field === 'string') { // TODO: v3: join.model.getField(field) const fieldName = field; field = join.model._config._fields[field]; if (!field) { throw new Query.QueryError(`${join.model.name}: unknown field \`${fieldName}\``); } } for (const reference of references) { if (reference.from === field || reference.to === field) { filteredReferences.push(reference); } } } references = filteredReferences; } const columns = {}; for (const { from, to } of references) { let fromColumn; let toColumn; if (isReverseJoin) { fromColumn = parent.formatColumn(from.column); toColumn = join.formatColumn(to.column); } else { fromColumn = join.formatColumn(from.column); toColumn = parent.formatColumn(to.column); } columns[fromColumn] = toColumn; } return columns; } prepareJoin(sql) { return __awaiter(this, void 0, void 0, function* () { const method = this.options.joinType || 'leftJoin'; sql[method](this.getTable(), this.prepareOn()); this.ensureFields(); return this.prepareSql(sql); }); } ensureUniqueField(to) { if (this.options.fields === false) { // if no fields are requested then no need checking for unique fields return; } const aliases = Object.keys(this.options.fields); if (to.options.fields === false) { // if no fields are requested on the joined query use any field as // unique this.options.unique = aliases[0]; return; } const fields = Object.values(this.options.fields); let unique; [this.config.primary].concat(this.config.unique).some((field) => { const index = fields.indexOf(field); if (index > -1) { unique = aliases[index]; return true; } return false; }); if (!unique) { throw new Query.QueryError(`${this.model.name}: cannot join \`${to.model.name}\` with no primary or unique fields selected`); } this.options.unique = unique; } prepareJoins(sql) { return __awaiter(this, void 0, void 0, function* () { return Promise.all(this.options.joins.map((join) => __awaiter(this, void 0, void 0, function* () { this.ensureUniqueField(join); join.parent = this; // depended on by @knorm/postgres join.config.joined = true; join.config.index = ++this.config.index; // TODO: support custom aliases join.config.alias = `${join.config.alias}_${join.config.index}`; // propagate the forFetch option to nested joins join.config.forFetch = true; // TODO: remove if (!join.options.as) { join.options.as = lodash_1.camelCase(join.model.name); } return join.prepareJoin(sql); }))); }); } ensureFields() { super.ensureFields(); } prepareSql(sql) { const _super = Object.create(null, { prepareSql: { get: () => super.prepareSql } }); return __awaiter(this, void 0, void 0, function* () { sql = yield _super.prepareSql.call(this, sql); if (this.options.joins) { yield this.prepareJoins(sql); } return sql; }); } throwFetchRequireError() { super.throwFetchRequireError(); if (this.options.joins) { this.options.joins.forEach((join) => join.throwFetchRequireError()); } } // TODO: strict mode: throw if the value for the unique field is null or undefined getParsedRow(row) { let parsedRow = super.getParsedRow(row); if (!this.options.joins) { return parsedRow; } if (this.options.fields === false) { return parsedRow; } const unique = row[this.formatFieldAlias(this.options.unique, { quote: false })]; if (unique) { const uniqueRow = this.parsedRows.get(unique); if (uniqueRow) { parsedRow = uniqueRow; } else { this.parsedRows.set(unique, parsedRow); } } return parsedRow; } parseRow(row) { const parsedRow = super.parseRow(row); if (this.options.joins) { this.options.joins.forEach((join) => { const as = join.options.as; const first = join.options.first; const data = join.parseRow(row); // the performance of this check could be improved by checking row // values while parsing the row but at a cost to code complexity const isEmpty = Object.values(data).every( // TODO: the array check will also deem empty postgres arrays or // JSON arrays as empty/null values. do we want that? (value) => value === null || (isArray(value) && !value.length)); // TODO: strict mode: warn if joined data replaces already existing // fields on the row if (isEmpty) { parsedRow[as] = first ? null : []; } else if (first) { if (!parsedRow[as] || !parsedRow[as].knorm) { parsedRow[as] = data; } } else { if (!isArray(parsedRow[as])) { parsedRow[as] = []; } parsedRow[as].push(data); } }); } return parsedRow; } parseRows(rows) { const parsedRows = super.parseRows(rows); if (this.options.joins) { return Array.from(this.parsedRows.values()); } return parsedRows; } } knorm.updateQuery(RelationsQuery); } init(knorm) { if (!knorm) { throw new this.constructor.KnormRelationsError('no Knorm instance provided'); } if (!(knorm instanceof knorm_1.Knorm)) { throw new this.constructor.KnormRelationsError('invalid Knorm instance provided'); } this.updateModel(knorm); this.updateField(knorm); this.updateQuery(knorm); } } exports.KnormRelations = KnormRelations; KnormRelations.KnormRelationsError = class KnormRelationsError extends knorm_1.Knorm.KnormError { };