Source: BookshelfMapping.js

"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;