/**
* A reactive (in terms of Vue reactivity) entities data cache.
* To be used for entities with small (< 2000 rows) amount of data to lookup a display value for specified ID.
*
* Module is injected into `Vue.prototype` as `$lookups` and exported as `@unitybase/adminui-vue`.lookups.
*
* The flow:
* - `subscribe` method loads entity data (ID, description attribute and optionally addition attributes specified in attr array)
* to the reactive client-side store, and adds a `UB.connection.on(${entity}:changed` listener what change a local cache data
* when it's edited locally
* - after `subscribe` methods `lookups.get`, `lookups.getDescriptionById`, `lookups.getEnum` can be used to get a value for
* description attribute (ar any other attribute added during `subscribe`) by entity ID value (or by combination of entity attributes values)
* - when data for entity no longer needed `unsubscribe` should be called to free a resources
*
* **NOTE:** `lookups` subscribes to `ubm_enum` on initialization, so `lookups.getEnum` can be used without addition to `subscribe('umb_enum')`
*
* @module lookups
* @memberOf module:@unitybase/adminui-vue
*/
/**
* @typedef {object} LookupSubscription
*
* @property {number} subscribes Subscribe counter
* @property {function} onEntityChanged Client local changes listener
* @property {Set<string>} attrs Lookup attributes
* @property {Set<string>} [partitions] Lookup partitions
* @property {array<object>} data Lookup data
* @property {object} mapById
* @property {string} descriptionAttrName
*/
const { debounce } = require('throttle-debounce')
const Vue = require('vue')
const UB = require('@unitybase/ub-pub')
const ENUM_ENTITY = 'ubm_enum'
let LOOKUP_CACHE_INTERVAL_MS = 0 // never refresh lookups
const instance = new Vue({
data () {
return {
entities: /** @type {object<string, LookupSubscription>} */ {}
}
},
methods: {
async init () {
const lookupCacheIntervalSecFromCfg = UB.connection.appConfig.uiSettings?.adminUI.lookupCacheRefreshIntervalSec
if (lookupCacheIntervalSecFromCfg !== undefined) {
if (typeof lookupCacheIntervalSecFromCfg !== 'number') {
UB.logError('config.uiSettings.adminUI.lookupCacheRefreshIntervalSec should be an Integer. Refresh of lookups is disabled')
} else {
LOOKUP_CACHE_INTERVAL_MS = lookupCacheIntervalSecFromCfg * 1000
}
}
const availableEntities = Object.keys(UB.connection.domain.entities)
for (const entity of availableEntities) {
this.$set(this.entities, entity, {
subscribes: 0,
refreshedAt: undefined,
onEntityChanged: async response => {
if (response === undefined) {
return
}
const { ID, method, resultData } = response
const responseID = resultData ? resultData.ID : ID
if (responseID === undefined) {
console.error('Lookups: server response must contain ID')
return
}
const cachedEntity = this.entities[entity]
if (method === 'delete') {
const lookupItemIndex = cachedEntity.data.findIndex(item => item.ID === ID)
cachedEntity.data.splice(lookupItemIndex, 1)
delete cachedEntity.mapById[ID]
return
}
const attrs = Array.from(cachedEntity.attrs)
const updatedItem = {}
const hasAllDataInResponse = attrs.every(attr => attr in resultData)
if (hasAllDataInResponse) {
for (const attr of attrs) {
updatedItem[attr] = resultData[attr]
}
} else {
Object.assign(
updatedItem,
await UB.Repository(entity)
.attrs(attrs)
.selectById(resultData.ID)
)
}
if (method === 'insert') {
cachedEntity.data.push(updatedItem)
cachedEntity.mapById[updatedItem.ID] = updatedItem
}
if (method === 'update') {
const lookupItem = cachedEntity.mapById[updatedItem.ID]
if (lookupItem) {
Object.assign(lookupItem, updatedItem)
}
}
},
attrs: new Set(['ID']),
partitions: undefined,
pendingAttrs: undefined,
pendingPartitions: undefined,
pendingPromise: null,
data: [],
mapById: {},
descriptionAttrName: ''
})
}
await this.subscribe(ENUM_ENTITY, ['eGroup', 'code', 'name', 'sortOrder'])
},
async subscribe (entity, attrs = [], partitions) {
const entityInfo = UB.connection.domain.get(entity)
if (partitions && !entityInfo.customSettings?.lookupPartitionAttr) {
UB.logError(`Lookups: Entity "${entity}" supposed to be partitioned, but "lookupPartitionAttr" is not defined in entity custom settings`)
partitions = undefined
}
if (entityInfo.customSettings?.lookupPartitionAttr && !partitions) {
UB.logWarn(`Lookups: Entity "${entity}" is partitioned, but partitions not specified`)
}
const subscription = this.entities[entity]
const isFirstSubscription = subscription.subscribes === 0
const hasAdditionalAttrs = !attrs.every(
attr => subscription.attrs.has(attr) ||
subscription.pendingAttrs?.has(attr)
)
const hasAdditionalPartitions = partitions &&
// In first subscription even if partitions passed - they are not "additional"
!isFirstSubscription &&
// And here, at last, check if there are new partitions to subscribe
!partitions.every(
partition => subscription.partitions?.has(partition) ||
subscription.pendingPartitions?.has(partition)
)
if (isFirstSubscription) {
UB.connection.on(`${entity}:changed`, subscription.onEntityChanged)
if (partitions) {
subscription.pendingPartitions = new Set(partitions)
}
subscription.descriptionAttrName = entityInfo.getDescriptionAttribute()
subscription.pendingAttrs = new Set(['ID', subscription.descriptionAttrName])
}
if (hasAdditionalAttrs) {
if (!subscription.pendingAttrs) {
subscription.pendingAttrs = new Set()
}
for (const attr of attrs) {
subscription.pendingAttrs.add(attr)
}
}
if (hasAdditionalPartitions) {
if (!subscription.pendingPartitions) {
subscription.pendingPartitions = new Set()
}
for (const partition of partitions) {
subscription.pendingPartitions.add(partition)
}
}
subscription.subscribes++
if (subscription.pendingPromise) {
// Even, if there are additional attrs/partitions, we don't need to initiate loading,
// because loading is done in a loop, while pendingAttrs/pendingPartitions are not empty
await subscription.pendingPromise
return
}
if (isFirstSubscription || hasAdditionalAttrs || hasAdditionalPartitions) {
if (!subscription.debouncedLoading) {
const loadData = async () => {
while (subscription.pendingAttrs?.size > 0 || subscription.pendingPartitions?.size > 0) {
// Move pendingAttrs/pendingPartitions to attrs/partitions, clear them
if (subscription.pendingAttrs?.size > 0) {
subscription.attrs = subscription.attrs
? new Set([...subscription.attrs, ...subscription.pendingAttrs])
: subscription.pendingAttrs
subscription.pendingAttrs = undefined
}
if (subscription.pendingPartitions?.size > 0) {
subscription.partitions = subscription.partitions
? new Set([...subscription.partitions, ...subscription.pendingPartitions])
: subscription.pendingPartitions
subscription.pendingPartitions = undefined
}
const resultData = await UB.Repository(entity)
.attrs([...subscription.attrs])
.whereIf(
subscription.partitions,
entityInfo.customSettings.lookupPartitionAttr,
'in',
subscription.partitions ? [...subscription.partitions] : undefined
)
.limit(UB.LIMITS.lookupMaxRows)
.select()
if (resultData.length >= UB.LIMITS.lookupMaxRows) {
UB.logError(`Lookups: Entity "${entity}" result truncated to ${UB.LIMITS.lookupMaxRows} records to prevent performance problems. Consider to avoid lookup to a huge entities`)
} else if (resultData.length >= UB.LIMITS.lookupWarningRows) {
UB.logWarn(`Lookups: Too many rows (${resultData.length}) returned for "${entity}" lookup. Consider to avoid lookups for huge entities to prevents performance degradation`)
}
subscription.data.splice(0, subscription.data.length, ...resultData)
// create hash by ID for O(1) lookup
const mapById = {}
resultData.forEach(r => { mapById[r.ID] = r })
this.$set(subscription, 'mapById', mapById)
subscription.refreshedAt = Date.now()
}
}
// Each subscription must have own debouncedLoading instance, so that for different entities
// debounce work independently.
subscription.debouncedLoading = debounce(10, async () => {
// Only now set pendingPromise, so that before real loading started, all subsequent subscribe calls
// will "bounce" the loading (prolong the debounce timer)
subscription.pendingPromise = loadData()
try {
await subscription.pendingPromise
subscription.debouncePromiseResolve()
} catch (e) {
subscription.debouncePromiseReject(e)
} finally {
subscription.pendingPromise = null
subscription.debouncePromise = null
subscription.debouncePromiseResolve = null
subscription.debouncePromiseReject = null
}
})
}
if (!subscription.debouncePromise) {
// Initialize a promise, which allow to await for the moment, when debounce timer elapsed,
// and loading finishes
subscription.debouncePromise = new Promise((resolve, reject) => {
subscription.debouncePromiseResolve = resolve
subscription.debouncePromiseReject = reject
subscription.debouncedLoading()
})
} else {
// Just prolong the debounce timer
subscription.debouncedLoading()
}
await subscription.debouncePromise
}
},
unsubscribe (entity) {
const subscription = this.entities[entity]
subscription.subscribes--
if (subscription.subscribes === 0) {
UB.connection.removeListener(`${entity}:changed`, subscription.onEntityChanged)
subscription.data.splice(0, subscription.data.length)
// remove additional attrs
subscription.attrs = new Set(['ID'])
subscription.partitions = undefined
subscription.pendingAttrs = undefined
subscription.pendingPartitions = undefined
subscription.mapById = {}
subscription.refreshedAt = undefined
}
},
async refresh (entity, attrs = [], partitions) {
if (!LOOKUP_CACHE_INTERVAL_MS) return
const subscription = this.entities[entity]
if (subscription.refreshedAt + LOOKUP_CACHE_INTERVAL_MS <= Date.now()) {
const subscription = this.entities[entity]
subscription.attrs = new Set(['ID'])
await this.subscribe(entity, attrs, partitions)
}
},
getDescriptionById (entity, ID) {
const subscription = this.entities[entity]
// for safe deleted record
if (subscription.mapById[ID] === undefined) {
return '---'
}
return subscription.mapById[ID][subscription.descriptionAttrName]
},
get (entity, predicate, resultIsRecord = false) {
if (predicate === null) {
return resultIsRecord ? {} : null
}
let founded
if (typeof predicate === 'number') {
founded = this.entities[entity].mapById[predicate]
} else if (typeof predicate === 'object') {
const pKeys = Object.keys(predicate)
founded = this.entities[entity].data.find(
r => pKeys.every(k => r[k] === predicate[k])
)
}
if (resultIsRecord) {
return founded || {}
} else {
if (founded) {
return founded[this.entities[entity].descriptionAttrName]
} else {
return null
}
}
},
getMany (entity, predicate) {
if (typeof predicate !== 'object' || predicate === null) {
return []
}
const pKeys = Object.keys(predicate)
return this.entities[entity].data.filter(
r => pKeys.every(k => r[k] === predicate[k])
)
}
}
})
module.exports = {
/**
* Subscribes to the local (in the current browser) entity changes. First call to `subscribe` for entity loads it data into client
* @example
* const App = require('@unitybase/adminui-vue')
* await App.lookups.subscribe('tst_dictionary', ['code', 'userID'])
*
* @param {string} entity Entity name
* @param {array<string>} [attrs] lookup attributes (in addition to ID and description attribute)
* @param {array<number|string>} [partitions] lookup partitions
* @returns {Promise<void>}
*/
subscribe (entity, attrs, partitions) {
return instance.subscribe(entity, attrs, partitions)
},
/**
* Unsubscribe from entity changes. In case this is a last subscriber, data cache for entity is cleaned
*
* @param {string} entity Entity name
*/
unsubscribe (entity) {
instance.unsubscribe(entity)
},
/**
* Refresh lookups associated with specified entity
* @param {string} entity
* @param {array<string>} [attrs] lookup attributes (in addition to ID and description attribute)
* @param {array<number|string>} partitions lookup partitions
* @returns {Promise<void>}
*/
refresh (entity, attrs = [], partitions) {
return instance.refresh(entity, attrs, partitions)
},
/**
* Initialize lookups reactivity by create stubs for all available domain entities.
* Subscribes to enum entity.
* @private
* @returns {Promise<void>}
*/
init: instance.init,
/**
* Search for cached record inside in-memory entity values cache using predicate
* @example
* // get description attribute value for tst_dictionary with ID=123
* // since second argument is number perform O(1) lookup by ID
* const dictD = lookups.get('tst_dictionary', 123)
* // get description attribute value for tst_dictionary with code='code10'
* // if code is not unique - returns FIRST occurrence
* // complexity is O(N) where n is entity row count
* const dictCode10D = lookups.get('tst_dictionary', {code: 'code10'})
* // search predicate can be complex
* lookups.get('ubm_enum', {eGroup: 'AUDIT_ACTION', code: 'INSERT'})
* // if third parameter specified - use it as attribute name for returned value instead of description attribute
* const dict123UserName = lookups.get('tst_dictionary', 123, 'userID.fullName')
* // if third parameter is `true` - return an object with all attributes specified during `subscribe`
* const objWithAllSubscribedAttrs = lookups.get('tst_dictionary', 245671369782, true)
*
* @param {string} entity Entity name
* @param {number|Object|null} predicate
* In case predicate is of type number - search by ID - O(1)
* In case predicate is Object - search for record what match all predicate attributes - O(N)
* @param {boolean} [resultIsRecord=false]
* - if `true` then return record as a result, in other cases - value of entity `displayAttribute`
* @returns {*}
*/
get (entity, predicate, resultIsRecord) {
return instance.get(entity, predicate, resultIsRecord)
},
/**
* Fast O(1) lookup by ID. The same as `lookups.get('entity_code', idAsNumber)`
* but returns '---' in case row with specified ID is not found
*
* @param {string} entity Entity name
* @param {number} ID
* @returns {string} Value if description attribute or '---' in case record not found
*/
getDescriptionById (entity, ID) {
return instance.getDescriptionById(entity, ID)
},
/**
* Get enum description by eGroup and code. Alias for `.get('ubm_enum', { eGroup, code })`
*
* @param {string} eGroup
* @param {string} code
* @returns {string|null}
*/
getEnum (eGroup, code) {
return instance.get(ENUM_ENTITY, { eGroup, code })
},
/**
* Get all enum items enum by eGroup.
*
* @param {string} eGroup
* @returns {array}
*/
getEnumItems (eGroup) {
const items = instance.getMany(ENUM_ENTITY, { eGroup })
return items.sort((a, b) => a.sortOrder - b.sortOrder).map(item => ({ code: item.code, name: item.name }))
}
}
module.exports.install = function (Vue) {
/** @type {module:lookups} */
Vue.prototype.$lookups = module.exports
if (UB.core.UBApp) {
UB.core.UBApp.on('applicationReady', () => {
module.exports.init()
})
}
}