import { action, transaction } from 'mobx'
import Model from './Model'
/**
* Gets only the relationships from one direction, ie 'toOne' or 'toMany'
*
* @param {object} model the model with the relationship
* @param {string} direction the direction of the relationship
*/
export const definitionsByDirection = action((model, direction) => {
const { relationshipDefinitions = {} } = model
const definitionValues = Object.entries(relationshipDefinitions)
return definitionValues.filter((definition) => definition[1].direction === direction)
})
/**
* Takes the `toOne` definitions from a document type and creates getters and setters.
* A getter finds a record from the store. The setter calls `setRelatedRecord`, which will
* return an instance of a model and add it to the inverse relationship if necessary.
* A definition will look something like this:
*
* todo: {
* direction: 'toOne',
* inverse: {
* name: 'notes',
* direction: 'toMany'
* }
* }
*
* @param {object} record the record that will have the relationship
* @param {object} store the data store
* @param {object} toOneDefinitions an object with formatted definitions
* @returns {object} an object with getters and setters based on the defintions
*/
export const defineToOneRelationships = action((record, store, toOneDefinitions) => {
return toOneDefinitions.reduce((object, [relationshipName, definition]) => {
const { inverse } = definition
Object.defineProperty(object, relationshipName, {
get () {
const reference = record.relationships[relationshipName]?.data
if (reference) {
return coerceDataToExistingRecord(store, reference)
}
},
set (relatedReference) {
return setRelatedRecord(relationshipName, record, relatedReference, store, inverse)
}
})
return object
}, {})
})
/**
* Takes the `toMany` definitions from a document type and creates getters and setters.
* A getter finds records from the store, falling back to a lookup of the inverse records if
* none are defined in the `relationships` hash.
*
* The setter will unset the previous inverse and set the current inverse.
* Both return a `RelatedRecordsArray`, which is an array with added methods `add`, `remove`, and `replace`
*
* A definition will look like this:
*
* categories: {
* direction: 'toMany',
* inverse: {
* name: 'organization',
* direction: 'toOne'
* }
* }
*
* @param {object} record the record that will have the relationship
* @param {object} store the data store
* @param {object} toManyDefinitions an object with formatted definitions
* @returns {object} an object with getters and setters based on the defintions
*/
export const defineToManyRelationships = action((record, store, toManyDefinitions) => {
return toManyDefinitions.reduce((object, [relationshipName, definition]) => {
const { inverse, types: relationshipTypes } = definition
Object.defineProperty(object, relationshipName, {
get () {
const references = record.relationships[relationshipName]?.data
let relatedRecords
if (references) {
relatedRecords = references.filter((reference) => store.getKlass(reference.type)).map((reference) => coerceDataToExistingRecord(store, reference))
} else if (inverse) {
const types = relationshipTypes || [relationshipName]
relatedRecords = types.map((type) => record.store.getAll(type)).flat().filter((potentialRecord) => {
const reference = potentialRecord.relationships[inverse.name]?.data
return reference && (reference.type === record.type) && (String(reference.id) === record.id)
})
}
return new RelatedRecordsArray(record, relationshipName, relatedRecords)
},
set (relatedRecords) {
const previousReferences = this.relationships[relationshipName]
if (previousReferences?.data?.length === 0 && relatedRecords.length === 0) { return this[relationshipName] }
this.relationships[relationshipName] = { data: relatedRecords.map(({ id, type }) => ({ id, type })) }
relatedRecords = relatedRecords.map((reference) => coerceDataToExistingRecord(store, reference))
if (inverse?.direction === 'toOne') {
const { name: inverseName } = inverse
const inferredType = relatedRecords[0]?.type || previousReferences?.data[0]?.type
const types = inverse.types || [inferredType]
const oldRelatedRecords = types.map((type) => record.store.getAll(type)).flat().filter((potentialRecord) => {
const reference = potentialRecord.relationships[inverseName]?.data
return reference && (reference.type === record.type) && (reference.id === record.id)
})
oldRelatedRecords.forEach((oldRelatedRecord) => {
oldRelatedRecord.relationships[inverseName] = null
})
relatedRecords.forEach((relatedRecord) => {
relatedRecord.relationships[inverseName] = { data: { id: record.id, type: record.type } }
})
}
record.takeSnapshot()
return new RelatedRecordsArray(record, relationshipName, relatedRecords)
}
})
return object
}, {})
})
/**
* Sets a related record, as well as the inverse. Can also remove the record from a relationship.
*
* @param {string} relationshipName the name of the relationship
* @param {object} record the object being set with a related record
* @param {object} relatedRecord the related record
* @param {object} store the store
* @param {object} inverse the inverse object information
* @returns {object} the related record
*/
export const setRelatedRecord = action((relationshipName, record, relatedRecord, store, inverse) => {
if (record == null) { return null }
if (relatedRecord != null) {
relatedRecord = coerceDataToExistingRecord(store, relatedRecord)
if (inverse?.direction === 'toOne') {
setRelatedRecord(inverse.name, relatedRecord, record, store)
} else if (inverse?.direction === 'toMany') {
const previousRelatedRecord = record[relationshipName]
removeRelatedRecord(inverse.name, previousRelatedRecord, record)
addRelatedRecord(inverse.name, relatedRecord, record)
}
record.relationships[relationshipName] = { data: { id: relatedRecord.id, type: relatedRecord.type } }
} else {
if (inverse?.direction === 'toOne') {
const previousRelatedRecord = record[relationshipName]
setRelatedRecord(inverse.name, previousRelatedRecord, null, store)
} else if (inverse?.direction === 'toMany') {
const previousRelatedRecord = record[relationshipName]
removeRelatedRecord(inverse.name, previousRelatedRecord, record)
}
record.relationships[relationshipName] = null
}
record.takeSnapshot()
return relatedRecord
})
/**
* Removes a record from an array of related records, removing both the object and the reference.
*
* @param {string} relationshipName the name of the relationship
* @param {object} record the record with the relationship
* @param {object} relatedRecord the related record being removed from the relationship
* @param {object} inverse the definition of the inverse relationship
* @returns {object} the removed record
*/
export const removeRelatedRecord = action((relationshipName, record, relatedRecord, inverse) => {
if (relatedRecord == null || record == null) { return relatedRecord }
const existingData = (record.relationships[relationshipName]?.data || [])
const recordIndexToRemove = existingData.findIndex(({ id: comparedId, type: comparedType }) => {
return comparedId === relatedRecord.id && comparedType === relatedRecord.type
})
if (recordIndexToRemove > -1) {
if (inverse?.direction === 'toOne') {
setRelatedRecord(inverse.name, relatedRecord, null, record.store)
} else if (inverse?.direction === 'toMany') {
removeRelatedRecord(inverse.name, relatedRecord, record)
}
existingData.splice(recordIndexToRemove, 1)
}
record.takeSnapshot()
return relatedRecord
})
/**
* Adds a record to a related array and updates the jsonapi reference in the relationships
*
* @param {string} relationshipName the name of the relationship
* @param {object} record the record with the relationship
* @param {object} relatedRecord the related record being added to the relationship
* @param {object} inverse the definition of the inverse relationship
* @returns {object} the added record
*/
export const addRelatedRecord = action((relationshipName, record, relatedRecord, inverse) => {
if (Array.isArray(relatedRecord)) {
return relatedRecord.map(singleRecord => addRelatedRecord(relationshipName, record, singleRecord, inverse))
}
if (relatedRecord == null || record == null || !record.store?.getKlass(record.type)) { return relatedRecord }
const relatedRecordFromStore = coerceDataToExistingRecord(record.store, relatedRecord)
if (inverse?.direction === 'toOne') {
const previousRelatedRecord = relatedRecordFromStore[inverse.name]
removeRelatedRecord(relationshipName, previousRelatedRecord, relatedRecordFromStore)
setRelatedRecord(inverse.name, relatedRecordFromStore, record, record.store)
} else if (inverse?.direction === 'toMany') {
addRelatedRecord(inverse.name, relatedRecord, record)
}
if (!record.relationships[relationshipName]?.data) {
record.relationships[relationshipName] = { data: [] }
}
const alreadyThere = record.relationships[relationshipName].data.some(({ id, type }) => id === relatedRecord.id && type === relatedRecord.type)
if (!alreadyThere) {
record.relationships[relationshipName].data.push({ id: relatedRecord.id, type: relatedRecord.type })
}
record.takeSnapshot()
return relatedRecordFromStore
})
/**
* Takes any object with { id, type } properties and gets an object from the store with that structure.
* Useful for allowing objects to be serialized in real time, saving overhead, while at the same time
* always returning an object of the same type.
*
* @param {object} store the store with the reference
* @param {object} record the potential record
* @returns {object} the store object
*/
export const coerceDataToExistingRecord = action((store, record) => {
if (record == null || !store?.data?.[record.type]) { return null }
if (record && !(record instanceof Model)) {
const { id, type } = record
record = store.getOne(type, id) || store.add(type, { id }, { skipInitialization: true })
}
return record
})
/**
* An array that allows for updating store references and relationships
*/
export class RelatedRecordsArray extends Array {
/**
* Extends an array to create an enhanced array.
*
* @param {object} record the record with the referenced array
* @param {string} property the property on the record that references the array
* @param {Array} array the array to extend
*/
constructor (record, property, array = []) {
super(...array)
this.property = property
this.record = record
this.store = record.store
this.inverse = record.relationshipDefinitions[this.property].inverse
}
/**
* Adds a record to the array, and updates references in the store, as well as inverse references
*
* @param {object} relatedRecord the record to add to the array
* @returns {object} a model record reflecting the original relatedRecord
*/
add = (relatedRecord) => {
const { inverse, record, property } = this
return addRelatedRecord(property, record, relatedRecord, inverse)
}
/**
* Removes a record from the array, and updates references in the store, as well as inverse references
*
* @param {object} relatedRecord the record to remove from the array
* @returns {object} a model record reflecting the original relatedRecord
*/
remove = (relatedRecord) => {
const { inverse, record, property } = this
return removeRelatedRecord(property, record, relatedRecord, inverse)
}
/**
* Replaces the internal array of objects with a new one, including inverse relationships
*
* @param {Array} array the array of objects that will replace the existing one
* @returns {Array} this internal array
*/
replace = (array = []) => {
const { inverse, record, property, store } = this
let newRecords
transaction(() => {
if (inverse?.direction === 'toOne') {
this.forEach((relatedRecord) => {
setRelatedRecord(inverse.name, relatedRecord, null, store)
})
} else if (inverse?.direction === 'toMany') {
this.forEach((relatedRecord) => {
removeRelatedRecord(inverse.name, relatedRecord, record)
})
}
record.relationships[property] = { data: [] }
newRecords = array.map((relatedRecord) => addRelatedRecord(property, record, relatedRecord, inverse))
})
return newRecords
}
/* eslint-disable */
/*
* This method is used by Array internals to decide
* which class to use for resulting derived objects from array manipulation methods
* such as `map` or `filter`
*
* Without this, `RelatedRecordsArray.map` would return a `RelatedRecordsArray` instance
* but such derived arrays should not maintain the behavior of the source `RelatedRecordsArray`
*
* For more details, see:
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/species
*/
static get [Symbol.species] () {
return Array
}
/* eslint-enable */
}