src/model/ModelDefinition.js

Summary

Maintainability
C
1 day
Test Coverage
import { checkType, isObject, checkDefined, isDefined } from '../lib/check'
import {
    addLockedProperty,
    curry,
    copyOwnProperties,
    updateAPIUrlWithBaseUrlVersionNumber,
    pick,
} from '../lib/utils'
import ModelDefinitions from './ModelDefinitions'
import Model from './Model'
import ModelCollection from './ModelCollection'
import ModelCollectionProperty from './ModelCollectionProperty'
import schemaTypes from '../lib/SchemaTypes'
import Filters from './Filters'

import { DIRTY_PROPERTY_LIST } from './ModelBase'
import { getDefaultValuesForModelType } from './config'
import { getOwnedPropertyJSON } from './helpers/json'

function createModelPropertyDescriptor(propertiesObject, schemaProperty) {
    const propertyName = schemaProperty.collection
        ? schemaProperty.collectionName
        : schemaProperty.name
    const propertyDetails = {
        // Actual property descriptor properties
        configurable: false,
        enumerable: true,
        get() {
            return this.dataValues[propertyName]
        },
    }

    // Store available constants for ENUM type properties
    if (schemaProperty.constants) {
        propertyDetails.constants = schemaProperty.constants
    }

    // Only add a setter for writable properties
    if (schemaProperty.writable) {
        propertyDetails.set = function dynamicPropertySetter(value) {
            // TODO: Objects and Arrays are considered unequal when their data is the same and therefore trigger a dirty
            if (
                (!isObject(value) && value !== this.dataValues[propertyName]) ||
                isObject(value)
            ) {
                this.dirty = true
                this[DIRTY_PROPERTY_LIST].add(propertyName)
                this.dataValues[propertyName] = value
            }
        }
    }

    if (propertyName) {
        propertiesObject[propertyName] = propertyDetails
    }
}

function createPropertiesObject(schemaProperties) {
    const propertiesObject = {}
    const createModelPropertyDescriptorOn = curry(
        createModelPropertyDescriptor,
        propertiesObject
    )

    ;(schemaProperties || []).forEach(createModelPropertyDescriptorOn)

    return propertiesObject
}

// eslint-disable-next-line complexity
function createValidationSetting(validationObject, schemaProperty) {
    const propertyName = schemaProperty.collection
        ? schemaProperty.collectionName
        : schemaProperty.name
    const validationDetails = {
        persisted: schemaProperty.persisted,
        type: schemaTypes.typeLookup(schemaProperty.propertyType),
        required: schemaProperty.required,
        min: schemaProperty.min,
        max: schemaProperty.max,
        owner: schemaProperty.owner,
        unique: schemaProperty.unique,
        writable: schemaProperty.writable,
        ordered: Boolean(schemaProperty.ordered),
        embeddedObject: Boolean(schemaProperty.embeddedObject),
    }

    function getReferenceTypeFrom(property) {
        if (property.href) {
            return property.href.split('/').pop()
        }

        return undefined
    }

    // Add a referenceType to be able to get a hold of the reference objects model.
    if (
        validationDetails.type === 'REFERENCE' ||
        (validationDetails.type === 'COLLECTION' &&
            schemaProperty.itemPropertyType === 'REFERENCE')
    ) {
        validationDetails.referenceType = getReferenceTypeFrom(schemaProperty)
    }

    if (propertyName) {
        validationObject[propertyName] = validationDetails
    }
}

function createValidations(schemaProperties) {
    const validationsObject = {}
    const createModelPropertyOn = curry(
        createValidationSetting,
        validationsObject
    )

    ;(schemaProperties || []).forEach(createModelPropertyOn)

    return validationsObject
}

function shouldBeModelCollectionProperty(model, models) {
    // eslint-disable-next-line complexity
    return function shouldBeModelCollectionPropertyIterator(modelProperty) {
        return (
            model &&
            models &&
            model.modelDefinition &&
            model.modelDefinition.modelValidations &&
            model.modelDefinition.modelValidations[modelProperty] &&
            model.modelDefinition.modelValidations[modelProperty].type ===
                'COLLECTION' &&
            models.hasOwnProperty(
                model.modelDefinition.modelValidations[modelProperty]
                    .referenceType
            )
        )
    }
}

function isAnUpdate(modelToCheck) {
    return Boolean(modelToCheck.id)
}

const translatableProperties = new WeakMap()

/**
 * Definition of a Model. Basically this object contains the meta data related to the Model. Like `name`, `apiEndPoint`, `modelValidation`, etc.
 * It also has methods to create and load Models that are based on this definition. The Data element `ModelDefinition` would be used to create Data Element `Model`s
 *
 * Note: ModelDefinition has a property `api` that is used for the communication with the dhis2 api. The value of this
 * property is an instance of `Api`.
 *
 * @memberof module:model
 */
class ModelDefinition {
    // eslint-disable-next-line complexity
    constructor(schema = {}, properties, validations, attributes, authorities) {
        checkType(schema.singular, 'string')
        checkType(schema.plural, 'string', 'Plural')

        addLockedProperty(this, 'name', schema.singular)
        addLockedProperty(this, 'displayName', schema.displayName)
        addLockedProperty(this, 'plural', schema.plural)
        addLockedProperty(this, 'isShareable', schema.shareable || false)
        addLockedProperty(this, 'isMetaData', schema.metadata || false)
        addLockedProperty(this, 'apiEndpoint', schema.apiEndpoint)
        addLockedProperty(this, 'javaClass', schema.klass)
        addLockedProperty(
            this,
            'identifiableObject',
            schema && schema.identifiableObject
        )
        addLockedProperty(this, 'modelProperties', properties)
        addLockedProperty(this, 'modelValidations', validations)
        addLockedProperty(this, 'attributeProperties', attributes)
        addLockedProperty(this, 'authorities', authorities)
        addLockedProperty(this, 'translatable', schema.translatable || false)

        this.filters = Filters.getFilters(this)

        translatableProperties.set(
            this,
            (schema.properties || [])
                .filter((prop) => prop.translationKey)
                .map(({ name, translationKey }) => ({ name, translationKey }))
        )

        // TODO: The function getOwnedPropertyJSON should probably not be exposed, perhaps we could have a getJSONForModel(ownedPropertiesOnly=true) method.
        this.getOwnedPropertyJSON = getOwnedPropertyJSON.bind(this)
    }

    filter() {
        return this.clone().filters
    }

    /**
     * Creates a fresh Model instance based on the `ModelDefinition`. If data is passed into the method that
     * data will be loaded into the matching properties of the model.
     *
     * @param {Object} [data] Data values that should be loaded into the model.
     *
     * @returns {Model} Returns the newly created model instance.
     *
     * @example
     * dataElement.create({name: 'ANC', id: 'd2sf33s3ssf'});
     */
    create(data) {
        const model = Model.create(this)
        const models = ModelDefinitions.getModelDefinitions()
        const dataValues = data
            ? Object.assign({}, data)
            : getDefaultValuesForModelType(model.modelDefinition.name)

        Object.keys(model)
            .filter(shouldBeModelCollectionProperty(model, models))
            .forEach((modelProperty) => {
                const { referenceType } =
                    model.modelDefinition.modelValidations[modelProperty]
                let values = []

                if (Array.isArray(dataValues[modelProperty])) {
                    values = dataValues[modelProperty].map((value) =>
                        models[referenceType].create(value)
                    )
                } else if (
                    dataValues[modelProperty] === true ||
                    dataValues[modelProperty] === undefined
                ) {
                    values = dataValues[modelProperty]
                }

                dataValues[modelProperty] = ModelCollectionProperty.create(
                    model,
                    models[referenceType],
                    modelProperty,
                    values
                )
                model.dataValues[modelProperty] = dataValues[modelProperty]
            })

        Object.keys(model)
            .filter(
                (modelProperty) =>
                    !shouldBeModelCollectionProperty(
                        model,
                        models
                    )(modelProperty)
            )
            .forEach((modelProperty) => {
                model.dataValues[modelProperty] = dataValues[modelProperty]
            })

        return model
    }

    clone() {
        const ModelDefinitionPrototype = Object.getPrototypeOf(this)
        const priorFilters = this.filters.getFilterList()
        const clonedDefinition = copyOwnProperties(
            Object.create(ModelDefinitionPrototype),
            this
        )

        clonedDefinition.filters = Filters.getFilters(
            clonedDefinition,
            priorFilters
        )

        return clonedDefinition
    }

    /**
     * Get a `Model` instance from the api loaded with data that relates to `identifier`.
     * This will do an API call and return a Promise that resolves with a `Model` or rejects with the api error message.
     *
     * @param {String} identifier
     * @param {Object} [queryParams={fields: ':all'}] Query parameters that should be passed to the GET query.
     * @returns {Promise} Resolves with a `Model` instance or an error message.
     *
     * @example
     * //Do a get request for the dataElement with given id (d2sf33s3ssf) and print it's name
     * //when that request is complete and the model is loaded.
     * dataElement.get('d2sf33s3ssf')
     *   .then(model => console.log(model.name));
     */
    get(
        identifier,
        queryParams = {
            fields: ':all,attributeValues[:all,attribute[id,name,displayName]]',
        }
    ) {
        checkDefined(identifier, 'Identifier')

        if (Array.isArray(identifier)) {
            return this.list({ filter: [`id:in:[${identifier.join(',')}]`] })
        }

        // TODO: should throw error if API has not been defined
        return this.api
            .get([this.apiEndpoint, identifier].join('/'), queryParams)
            .then((data) => this.create(data))
            .catch((response) => {
                if (response.message) {
                    return Promise.reject(response.message)
                }

                return Promise.reject(response)
            })
    }

    /**
     * Loads a list of models.
     *
     * @param {Object} [listParams={fields: ':all'}] Query parameters that should be passed to the GET query.
     * @returns {Promise} ModelCollection collection of models objects of the `ModelDefinition` type.
     *
     * @example
     * // Loads a list of models and prints their name.
     * dataElement.list()
     *   .then(modelCollection => {
     *     modelCollection.forEach(model => console.log(model.name));
     *   });
     */
    list(listParams = {}) {
        const { apiEndpoint, ...extraParams } = listParams
        const definedRootJunction = this.filters.rootJunction
            ? { rootJunction: this.filters.rootJunction }
            : {}
        const params = Object.assign(
            { fields: ':all' },
            definedRootJunction,
            extraParams
        )
        const definedFilters = this.filters.getQueryFilterValues()

        if (!isDefined(params.filter)) {
            delete params.filter
            if (definedFilters.length) {
                params.filter = definedFilters
            }
        }

        // If listParams.apiEndpoint exists, send the request there in stead of this.apiEndpoint
        return this.api
            .get(apiEndpoint || this.apiEndpoint, params)
            .then((responseData) =>
                ModelCollection.create(
                    this,
                    responseData[this.plural].map((data) => this.create(data)),
                    Object.assign(responseData.pager || {}, { query: params })
                )
            )
    }

    /**
     * This method is used by the `Model` instances to save the model when calling `model.save()`.
     *
     * @param {Model} model The model that should be saved to the server.
     * @returns {Promise} A promise which resolves when the save was successful
     * or rejects when it failed. The promise will resolve with the data that is
     * returned from the server.
     *
     * @note {warning} This should generally not be called directly.
     */
    // TODO: check the return status of the save to see if it was actually successful and not ignored
    save(model) {
        if (isAnUpdate(model)) {
            const jsonPayload = getOwnedPropertyJSON.bind(this)(model)
            // Fallback to modelDefinition if href is unavailable
            const updateUrl = model.dataValues.href
                ? updateAPIUrlWithBaseUrlVersionNumber(
                      model.dataValues.href,
                      this.api.baseUrl
                  )
                : [model.modelDefinition.apiEndpoint, model.dataValues.id].join(
                      '/'
                  )

            // Save the existing model
            return this.api.update(updateUrl, jsonPayload, true)
        }

        return this.saveNew(model)
    }

    saveNew(model) {
        const jsonPayload = getOwnedPropertyJSON.bind(this)(model)

        // Its a new object
        return this.api.post(this.apiEndpoint, jsonPayload)
    }

    /**
     * This method returns a list of property names that that are defined
     * as "owner" properties on this schema. This means these properties are used
     * when saving the model to the server.
     *
     * @returns {String[]} Returns an array of property names.
     *
     * @example
     * dataElement.getOwnedPropertyNames()
     */
    getOwnedPropertyNames() {
        return Object.keys(this.modelValidations).filter(
            (propertyName) => this.modelValidations[propertyName].owner
        )
    }

    /**
     * This method is used by the `Model` instances to delete the model when calling `model.delete()`.
     *
     * @returns {Promise} Returns a promise to the deletion operation
     *
     * @note {warning} This should generally not be called directly.
     */
    delete(model) {
        if (model.dataValues.href) {
            return this.api.delete(model.dataValues.href)
        }
        return this.api.delete(
            [model.modelDefinition.apiEndpoint, model.dataValues.id].join('/')
        )
    }

    /**
     * Check for if the Model supports translations
     *
     * @returns {Boolean} True when the schema can be translated, false otherwise
     */
    isTranslatable() {
        return this.translatable
    }

    /**
     * These properties can be translated using the DHIS2 _database_ translations.
     *
     * @returns {String[]} Returns a list of property names on the object that are translatable.
     */
    getTranslatableProperties() {
        return translatableProperties.get(this).map(pick('name'))
    }

    /**
     * This method is similar to getTranslatableProperties() but in addition to the property names also returns the
     * `translationKey` that is used to save the translations for the property names.
     *
     * @returns {Object[]} Returns an array with objects that have `name` and `translationKey` properties.
     */
    getTranslatablePropertiesWithKeys() {
        return translatableProperties.get(this)
    }

    /**
     * @static
     *
     * This method creates a new `ModelDefinition` based on a JSON structure called
     * a schema. A schema represents the structure of a domain model as it is
     * required by DHIS. Since these schemas can not be altered on the server from
     * the modelDefinition is frozen to prevent accidental changes to the definition.
     *
     * @param {Object} schema A schema definition received from the web api (/api/schemas)
     * @param {Object[]} attributes A list of attribute objects that describe custom attributes (/api/attributes)
     *
     * @returns {ModelDefinition} Frozen model definition object.
     *
     * @example
     * ModelDefinition.createFromSchema(schemaDefinition, attributes);
     *
     * @note {info} An example of a schema definition can be found on
     * https://apps.dhis2.org/demo/api/schemas/dataElement
     */
    static createFromSchema(schema, attributes = []) {
        let ModelDefinitionClass
        checkType(schema, Object, 'Schema')

        if (
            typeof ModelDefinition.specialClasses[schema.singular] ===
            'function'
        ) {
            ModelDefinitionClass =
                ModelDefinition.specialClasses[schema.singular]
        } else {
            ModelDefinitionClass = ModelDefinition
        }

        return Object.freeze(
            new ModelDefinitionClass(
                schema,
                Object.freeze(createPropertiesObject(schema.properties)),
                Object.freeze(createValidations(schema.properties)),
                attributes.reduce((current, attributeDefinition) => {
                    current[attributeDefinition.name] = attributeDefinition
                    return current
                }, {}),
                schema.authorities
            )
        )
    }
}

class UserModelDefinition extends ModelDefinition {
    // TODO: userCredentials should always be included, no matter what the query params, that is currently not the case
    get(identifier, queryParams = { fields: ':all,userCredentials[:owner]' }) {
        return super.get(identifier, queryParams)
    }
}

class DataSetModelDefinition extends ModelDefinition {
    create(data = {}) {
        const hasData = Boolean(Object.keys(data).length)

        // Filter out the compulsoryDataElementOperands structure from the retrieved data
        // This structure does not follow the convention of a typical reference. We can not create a proper
        // ModelCollection for this collection.
        const dataClone = Object.keys(data)
            .filter((key) => key !== 'compulsoryDataElementOperands')
            .reduce((obj, key) => {
                obj[key] = data[key]
                return obj
            }, {})

        // Create the model using the usual way of creating a model
        // Only pass data when there is data in the object passed to the constructor. This will guarantee
        // that the empty ModelCollections are created properly.
        const model = super.create(hasData ? dataClone : undefined)

        // Set the compulsoryDataElementOperands onto the dataValues so it will be included during the save operations
        model.dataValues.compulsoryDataElementOperands =
            data.compulsoryDataElementOperands

        return model
    }
}

class OrganisationUnitModelDefinition extends ModelDefinition {
    // If a 'root' is specified when listing organisation units the results will be limited to the root and its
    // descendants. This is special behavior for the organisation unit API endpoint, which is documented here:
    // https://dhis2.github.io/dhis2-docs/master/en/developer/html/webapi_organisation_units.html
    list(extraParams = {}) {
        const { root, ...params } = extraParams

        if (extraParams.hasOwnProperty('root') && root) {
            params.apiEndpoint = `${this.apiEndpoint}/${root}`
        }

        return super.list(params)
    }
}

ModelDefinition.specialClasses = {
    user: UserModelDefinition,
    dataSet: DataSetModelDefinition,
    organisationUnit: OrganisationUnitModelDefinition,
}

export default ModelDefinition