"use strict"; const StringUtils = require("./StringUtils"); const { required } = require("./Annotations"); /** * Describes a DB Mapping * * @property {string} tableName - Fully qualified name of DB Table * @property {string} [identifiedBy = "id"] - Primary key column * @property {Array<String | ColumnDescriptor>} [columns] - columns to fetch. 'underscore_space' will be converted to 'lowerCamelCase' in Entity * @property {Object | Function} [discriminator] - Fetch only Entities which match a given query, Knex where condition * @property {Object} [onDelete] - Execute instead of regular delete statement, Knex update statement * * @property {Boolean} [keepHistory = false] - Keep an History History in this table. New states are appended instead of updated. * Columns 'revision_id' and 'parent_id' will be added to mapping, thus requires these columns in DB. * 'revision_id' must have a unique default value, is the Primary Key at best. * 'identifiedBy' must not be the Primary Key, since many revisions with the same ID can exist. * * @property {Boolean} [historyColumns = { revisionId: "revision_id", parentId: "parent_id" }] - Configure alias for history columns * * @property {Array<RelationDescriptor>} [relations] - Managed relations of this Entity. * There will be a getter and setter for n:1 relations * There will be a getter and modifiers ("add"/"remove" + relation.name) for m:n relations */ class BookshelfMapping { constructor(dbContext, config) { this.dbContext = dbContext; this.tableName = config.tableName; this.identifiedBy = BookshelfMapping.getOptionOrDefault(config.identifiedBy, "id"); this.relations = BookshelfMapping.getOptionOrDefault(config.relations, []); this.relationNames = BookshelfMapping.getOptionOrDefault(this.relations, []).map((r) => r.name); this.columns = BookshelfMapping.getOptionOrDefault(config.columns, []); this.discriminator = config.discriminator; this.onDelete = config.onDelete; this.keepHistory = BookshelfMapping.getOptionOrDefault(config.keepHistory, false); this.historyColumns = BookshelfMapping.getOptionOrDefault(config.historyColumns, { revisionId: "revision_id", parentId: "parent_id" }); this.configureHistory(); this.Model = this.createModel(); this.Collection = this.createCollection(); this.startTransaction = dbContext.transaction.bind(dbContext); this.deriveColumnAccessors(); this.provideForeignKeyColumnsToRelatedMappings(this.relations); } static getOptionOrDefault(configProperty, defaultValue) { return configProperty || defaultValue; } configureHistory() { if (this.keepHistory) { this.discriminator = this.addHistoryDiscriminator(); const columns = new Set(this.columns).add(this.historyColumns.revisionId).add(this.historyColumns.parentId); this.columns = [...columns]; } } addHistoryDiscriminator() { const discriminator = this.discriminator; const { revisionId, parentId } = this.historyColumns; return (q) => { q.whereNotIn(revisionId, (q) => q.from(this.tableName).whereNotNull(parentId).select(parentId)); q.andWhere(discriminator); }; } deriveColumnAccessors() { this.columnMappings = this.columns.map((column) => typeof column === "string" ? { name: column } : column); this.columnNames = this.columnMappings.map((column) => column.name); this.regularColumns = this.columnMappings.filter((c) => c.type !== "sql"); this.regularColumnNames = this.regularColumns.map((column) => column.name); this.sqlColumns = this.columnMappings.filter((c) => c.type === "sql"); this.writeableSqlColumns = this.sqlColumns.filter((c) => c.set); this.readableSqlColumns = this.sqlColumns.filter((c) => c.get); this.qualifiedRegularColumnNames = this.relations .filter((r) => r.type === "belongsTo") .map((r) => r.references.mappedBy) .concat(this.regularColumnNames) .map((name) => `${this.tableName}.${name}`); } provideForeignKeyColumnsToRelatedMappings() { this.relations.filter((r) => r.type === "hasMany" || r.type === "hasOne").forEach((r) => { r.references.mapping.qualifiedRegularColumnNames.push(r.references.mappedBy); }); } createModel() { const prototype = { tableName: this.tableName, idAttribute: this.identifiedBy }; this.relations.forEach(this.addRelation.bind(this, prototype)); return this.dbContext.Model.extend(prototype); } createCollection() { return this.dbContext.Collection.extend({ model: this.Model }); } addRelation(prototype, relation) { const relationName = StringUtils.camelToSnakeCase(relation.name); const fkName = relation.references.mappedBy = relation.references.mappedBy || relationName + "_id"; prototype["relation_" + relation.name] = function () { if (!(relation.type in this)) { throw new Error("Relation of type '" + relation.type + "' doesn't exist"); } const referencedColumnName = relation.references.identifies || relation.references.mapping.Model.identifiedBy; return this[relation.type](relation.references.mapping.Model, fkName, referencedColumnName); }; } createQuery(item, options = required("options")) { /* eslint complexity: 0 */ const query = this.dbContext.knex(this.tableName); if (item) { query.where(this.identifiedBy, item.get(this.identifiedBy)); } if (this.discriminator) { query.andWhere(this.discriminator); } if (options && options.transacting) { query.transacting(options.transacting); } return query; } raw(...args) { return this.dbContext.knex.raw(...args); } } module.exports = BookshelfMapping;