src/model/ModelDefinition.js
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