@onehat/data
Version:
JS data modeling package with adapters for many storage mediums.
1,881 lines (1,667 loc) • 60.6 kB
JavaScript
/** @module Repository */
import EventEmitter from '@onehat/events';
import Entity from '../Entity/Entity.js'
import PropertyTypes from '../Property/index.js';
import {
v4 as uuid,
} from 'uuid';
import moment from 'moment';
import { waitUntil } from 'async-wait-until';
import hash from 'object-hash';
import _ from 'lodash';
/**
* Class represents a unique collection of data, with a Schema, and a storage medium.
* The Repository holds a current page of Entities (this.entities), which may or may not be the
* complete set of all Entities in the storage medium. The storage medium is defined by the
* subclasses of Repository (e.g. MemoryRepository, AjaxRepository, etc).
* @extends EventEmitter
*/
export default class Repository extends EventEmitter {
/**
* @constructor
* @param {object} config - Config object
* - id {string} - Optional. If supplied, must be unique
* - name {string} - Optional. Defaults to schema.name
* - schema - Schema object
*/
constructor(config = {}, oneHatData = null) {
super(...arguments);
const { schema } = config;
if (!schema || !schema.model) {
throw Error('Schema cannot be empty'); // don't use throwError() because Repository is not yet successfully constructed
}
const defaults = {
/**
* @member {string} id - Must be unique, if supplied. Defaults to UUID
*/
id: uuid(),
/**
* @member {string} name - Name of this repository. Defaults to Schema.name
*/
name: schema.name,
/**
* @member {boolean} isUnique - Whether this repository is classified as 'unique'
*/
isUnique: false,
/**
* @member {boolean} isAutoLoad - Whether to immediately load this repository's data on instantiation
*/
isAutoLoad: false,
/**
* @member {boolean} isAutoSave - Whether to automatically save entity changes to permanent storage
*/
isAutoSave: false,
/**
* @member {boolean} isAutoSort - Whether to automatically sort entities in permanent storage
*/
isAutoSort: true,
/**
* @member {boolean} isLocal - Whether this Repository saves its data to local permanent storage
* ("permanent" being a relative term)
* @readonly
*/
isLocal: false,
/**
* @member {boolean} isRemote - Whether this Repository saves its data to remote permanent storage
* @readonly
*/
isRemote: false,
/**
* @member {boolean} isRemoteFilter - Whether this Repository filters data remotely
* @readonly
*/
isRemoteFilter: false,
/**
* @member {boolean} isRemoteSort - Whether this Repository sorts data remotely
* @readonly
*/
isRemoteSort: false,
/**
* @member {boolean} isRemotePhantomMode - Whether this Repository uses the "alternate" CRUD mode.
* In this CRUD mode, records are *immediately* saved to server when added to Repository,
* but still marked as "phantom" until the their first "edit" operation takes place.
*
* This mode overrides repository.isAutoSave, entity.isPersisted, && entity.isDelayedSave.
*
* @readonly
*/
isRemotePhantomMode: false,
/**
* @member {boolean} isPaginated - Whether this Repository is paginated
*/
isPaginated: false,
/**
* @member {bool} isShowingMore - Whether this repository is in "show more" mode
*/
isShowingMore: false,
/**
* @member {string} hash - A hash of this.entities, so we can detect changes
*/
hash: null,
/**
* @member {number} pageSize - Max number of entities per page
* Example: For "Showing 21-30 of 45" This would be 10
*/
pageSize: 10,
sorters: schema.model.sorters || [],
/**
* @member {string} batchOrder - Comma-separated ordering of add, edit, and delete batch operations
*/
batchOrder: 'add,edit,delete',
/**
* @member {boolean} batchAsSynchronous - Whether batch operations should be conducted synchronously (waiting for each operation to complete before beginning the next)
*/
batchAsSynchronous: false,
/**
* @member {boolean} combineBatch - Whether this Repository should combine batch operations
*/
combineBatch: false,
/**
* @member {boolean} canAdd - Whether this Repository allows adding entities
*/
canAdd: true,
/**
* @member {boolean} canEdit - Whether this Repository allows editing entities
*/
canEdit: true,
/**
* @member {boolean} canDelete - Whether this Repository allows deleting entities
*/
canDelete: true,
/**
* @member {boolean} debugMode - Whether this Repository should output debug messages
*/
debugMode: false,
};
_.merge(this, defaults, config);
this.originalConfig = config;
// _____ __ __
// / ___// /_____ _/ /____
// \__ \/ __/ __ `/ __/ _ \
// ___/ / /_/ /_/ / /_/ __/
// /____/\__/\__,_/\__/\___/
/**
* This is where the current page of entities are stored.
* All add/edit/delete operations are performed on this array of items.
* Calling save() persists the changes to the storage medium.
* @member {array} entities - Entities on current page
* @private
*/
this.entities = [];
/**
* @member {array} filters - Array of filters
*/
this.filters = [];
/**
* @member {number} page - State: Current page number in pagination
*/
this.page = 1;
/**
* Getter for
* @member {number} pageTotal - State: Total number of entities on the current page
* Example: "Showing 21-25 of 25" This would be 5
*/
this.pageTotal = 0;
/**
* @member {number} pageStart - Index (based on total) of the first item on the current page.
* Example: "Showing 21-30 of 45" This would be 21
*/
this.pageStart = 0;
/**
* @member {number} pageEnd - Index (based on total) of the last item on the current page
* Example: "Showing 21-30 of 45" This would be 30
*/
this.pageEnd = 0;
/**
* @member {number} totalPages - Total number of pages based on this.total and this.pageSize
*/
this.totalPages = 1;
/**
* @member {number} total - Total number of entities in remote storage that pass filters
*/
this.total = 0;
/**
* @member {boolean} isFiltered - State: whether or not any filters are currently applied to entities
*/
this.isFiltered = false;
/**
* @member {boolean} isInitialized - State: whether or not this repository has been completely initialized
*/
this.isInitialized = false;
/**
* @member {boolean} isTree - Whether this Repository contains TreeNodes
* @readonly
*/
this.isTree = schema.model.isTree || false;
/**
* @member {boolean} moveSubtreeUp - Whether to move the subtree up on the next delete operation (trees only)
*/
this.moveSubtreeUp = false;
/**
* @member {boolean} isLoaded - State: whether or not entities have been loaded at least once
*/
this.isLoaded = false;
/**
* @member {boolean} isLoading - State: whether or not entities are currently being loaded
*/
this.isLoading = false;
/**
* @member {string} lastLoaded - Last time this repository was loaded
*/
this.lastLoaded = null;
/**
* @member {boolean} areRootNodesLoaded - State: whether or not root nodes have been loaded at least once
*/
this.areRootNodesLoaded = false;
/**
* @member {boolean} isSaving - State: whether or not entities are currently being saved
*/
this.isSaving = false;
/**
* @member {boolean} isSorted - State: whether or not any sorting is currently applied to entities
*/
this.isSorted = false;
/**
* @member {boolean} allowsMultiSort - Whether to allow >1 sorter
*/
this.allowsMultiSort = true;
/**
* @member {boolean} isDestroyed - Whether this object has been destroyed
*/
this.isDestroyed = false;
/**
* @member {boolean} oneHatData - The global @onehat/data object
*/
this.oneHatData = oneHatData;
this.registerEvents([
'add',
'beforeSave',
'beforeLoad',
'changeData',
'changeFilters',
'changePage',
'changePageSize',
'changeSorters',
'delete',
'destroy',
'error',
'initialize',
'load',
'reloadEntity',
'save',
]);
// create error listener to block Node from throwing the Error. https://nodejs.org/dist/v11.13.0/docs/api/events.html#events_emitter_emit_eventname_args:~:text=Error%20events-,%23,-When%20an%20error
this.on('error', () => {});
}
/**
* Decorator for parent emit() method
* so we can rehash on changeData events
*/
emit(name) { // NOTE: Purposefully do not use an arrow-function, so we have access to arguments
if (name === 'changeData') {
this.rehash();
}
return super.emit(...arguments);
}
/**
* Initializes the Repository.
* - Applies default sorters
* - Autoloads data, if needed
* This is async because we may need to wait for loading and sorting.
*/
async initialize() {
// Create default sorters if none supplied
if (this.isAutoSort && !this.sorters.length) {
this.sorters = this.getDefaultSorters();
}
// Assign event handlers
this.on('entity_change', async (entity) => { // Entity changed its value
if (this.isAutoSave && !this.isRemotePhantomMode) {
return await this.save(entity);
}
});
// Auto load & sort
if (this.isAutoLoad && !this.isTree) {
await this.load();
}
if (!this.isSorted && this.isAutoSort && !this.isRemoteSort && !this.isTree) { // load may have sorted, in which case this will be skipped.
await this.sort();
}
this._createMethods();
this._createStatics();
this._createListeners();
const init = this.schema.repository.init || this.originalConfig.init; // The latter is mainly for lfr repositories
if (init) {
await init.call(this);
}
this.rehash();
this.isInitialized = true;
this.emit('initialize');
}
/**
* Creates the methods for this Repository, based on Schema.
* @private
*/
_createMethods() {
if (this.isDestroyed) {
this.throwError('this._createMethods is no longer valid. Repository has been destroyed.');
return;
}
const methodDefinitions = this.schema.repository.methods || this.originalConfig.methods; // The latter is mainly for lfr repositories
if (!_.isEmpty(methodDefinitions)) {
const oThis = this;
_.each(methodDefinitions, (method, name) => {
oThis[name] = method; // NOTE: Methods must be defined in schema as "function() {}", not as "() => {}" so scope of "this" will be correct
});
}
}
/**
* Creates the static properties for this Repository, based on Schema.
* @private
*/
_createStatics() {
if (this.isDestroyed) {
this.throwError('this._createStatics is no longer valid. Repository has been destroyed.');
return;
}
const staticsDefinitions = this.schema.repository.statics || this.originalConfig.statics; // The latter is mainly for lfr repositories
if (!_.isEmpty(staticsDefinitions)) {
const oThis = this;
_.each(staticsDefinitions, (value, key) => {
oThis[key] = value;
});
}
}
/**
* Creates the initial listeners for this Repository, based on originalConfig.
* @private
*/
_createListeners() {
if (this.isDestroyed) {
this.throwError('this._createListeners is no longer valid. Repository has been destroyed.');
return;
}
const listeners = this.originalConfig.listeners;
if (!_.isEmpty(listeners)) {
const oThis = this;
_.each(listeners, ({ event, handler }) => {
if (!event || !handler) {
oThis.throwError('Invalid listener definition. Must have event and handler.');
return;
}
oThis.on(event, handler);
});
}
}
getModel() {
if (this.model) {
return this.model;
}
if (!this.isUnique) {
return this.name;
}
return this.name.match(/^([^-]*)-(.*)/)[1]; // converts 'ModelName-22f9915c-79f5-4e86-a25b-9446c7b85b63' to 'ModelName'
}
// __ __
// / / ____ ____ _____/ /
// / / / __ \/ __ `/ __ /
// / /___/ /_/ / /_/ / /_/ /
// /_____/\____/\__,_/\__,_/
/**
* Tells storage medium to load data
* @abstract
*/
async load() {
this.throwError('load must be implemented by Repository subclass');
return;
}
/**
* Marks this repository as loading
*/
markLoading(bool = true) {
this.isLoading = bool;
}
/**
* Async function that resolves when !isLoading
*/
async waitUntilDoneLoading(timeout = 10000) {
if (this.isDestroyed) {
this.throwError('this.waitUntilDoneLoading is no longer valid. Repository has been destroyed.');
return;
}
await waitUntil(() => !this.isLoading, { timeout });
}
/**
* Marks this repository as unloaded
*/
markUnloaded() {
this.markLoading(false);
this.isLoaded = false;
this.lastLoaded = null;
}
/**
* Marks this repository as loaded
*/
markLoaded() {
this.markLoading(false);
this.isLoaded = true;
this.lastLoaded = moment(new Date()).format('YYYY-MM-DD HH:mm:ss.SSSS');
}
/**
* Reload data from storage medium, using previous settings.
* Subclasses may override this to provide additional
* or differing functionality.
*/
reload() {
return this.load();
}
/**
* Tells storage medium to reload just this one entity
* @abstract
*/
async reloadEntity(entity) {
this.throwError('reloadEntity must be implemented by Repository subclass');
return;
}
/**
* Sets the isAutoSave setting of this Repository
* @param {boolean} isAutoSave
*/
setAutoSave(isAutoSave) {
if (this.isDestroyed) {
this.throwError('this.setAutoSave is no longer valid. Repository has been destroyed.');
return;
}
this.isAutoSave = isAutoSave
}
/**
* Sets the isAutoLoad setting of this Repository
* @param {boolean} isAutoLoad
*/
setAutoLoad(isAutoLoad) {
if (this.isDestroyed) {
this.throwError('this.setAutoLoad is no longer valid. Repository has been destroyed.');
return;
}
this.isAutoLoad = isAutoLoad
}
// _____ __
// / ___/____ _____/ /_
// \__ \/ __ \/ ___/ __/
// ___/ / /_/ / / / /_
// /____/\____/_/ \__/
/**
* @member {boolean} hasSorters - Whether or not any sorters are applied
*/
get hasSorters() {
if (this.isDestroyed) {
this.throwError('this.hasSorters is no longer valid. Repository has been destroyed.');
return;
}
return this.sorters.length > 0;
}
/**
* Clear all sorting from this Repository.
*/
clearSort() {
if (this.isDestroyed) {
this.throwError('this.clearSort is no longer valid. Repository has been destroyed.');
return;
}
this.setSorters([])
}
/**
* Sets the sorting applied to entities.
* Chainable function.
*
* Usage:
* - repository.sort(); // Reverts back to default sort. To actually *clear* all sorters, use this.clearSort()
* - repository.sort('last_name'); // sort by one property, ASC
* - repository.sort('last_name', 'ASC'); // sort by one property
* - repository.sort('last_name', 'ASC', 'natsort'); // sort by one property with specific function
* - repository.sort('last_name', 'ASC', (a, b) => { ... })); // sort by one property with custom function
* - repository.sort((a, b) => { ... }); // sort by custom function
* - repository.sort({ // sort by one property, object notation
* name: 'last_name',
* direction: 'ASC',
* });
* - repository.sort([ // sort by multiple properties
* {
* name: 'last_name',
* direction: 'ASC',
* fn: 'natsort',
* },
* {
* name: 'first_name',
* direction: 'ASC',
* },
* ]);
* - sort().filter() // combine with filter
*
* @return this
*/
sort(arg1 = null, arg2 = 'ASC', arg3 = null) {
if (this.isDestroyed) {
this.throwError('this.sort is no longer valid. Repository has been destroyed.');
return;
}
// Assemble sorting definition objects
let sorters = [];
if (_.isNil(arg1)) {
sorters = this.getDefaultSorters();
} else if (_.isString(arg1)) {
sorters = [{
name: arg1,
direction: arg2,
fn: arg3,
}];
} else if (_.isPlainObject(arg1)) {
sorters = [arg1];
} else if (_.isArray(arg1)) {
sorters = arg1;
} else if (_.isFunction(arg1)) {
sorters = [arg1];
}
this.setSorters(sorters);
return this;
}
/**
* Gets default sorters. Either what was specified on schema, or sorty by displayProperty ASC.
* @return {array} sorters
*/
getDefaultSorters() {
if (this.isDestroyed) {
this.throwError('this.getDefaultSorters is no longer valid. Repository has been destroyed.');
return;
}
let sorters = [];
if (_.size(this.schema.model.sorters) > 0) {
sorters = this.schema.model.sorters
} else if (!_.isNil(this.schema.model.sortProperty)) {
sorters = [{
name: this.schema.model.sortProperty,
direction: this.schema.model.sortDir,
fn: 'default',
}];
} else if (!_.isNil(this.schema.model.displayProperty)) {
sorters = [{
name: this.schema.model.displayProperty,
direction: this.schema.model.sortDir,
fn: 'default',
}];
}
return sorters;
}
/**
* Sets the sorters directly
* @fires changeSorters
*/
setSorters(sorters) {
if (this.isDestroyed) {
this.throwError('this.setSorters is no longer valid. Repository has been destroyed.');
return;
}
if (!this.allowsMultiSort && sorters.length > 1) {
this.throwError('Cannot have more than one sorter at a time.');
return;
}
let isChanged = false;
if (!_.isEqual(this.sorters, sorters)) {
isChanged = true;
// Check to make sure specified properties are sortable
const oThis = this;
_.each(sorters, (sorter) => {
if (_.isFunction(sorter)) {
return; // skip
}
const propertyDefinition = _.find(oThis.schema.model.properties, (property) => property.name === sorter.name);
if (!propertyDefinition) {
oThis.throwError('Sorting property does not exist.');
return;
}
const propertyType = propertyDefinition.type;
if (propertyType && PropertyTypes[propertyType]) {
const propertyInstance = new PropertyTypes[propertyType]();
if (!propertyInstance.isSortable) {
oThis.throwError('Sorting property type is not sortable.');
return;
}
}
});
this.sorters = sorters;
if (this._onChangeSorters) {
return this._onChangeSorters();
}
this.emit('changeSorters');
}
return isChanged;
}
// _______ ____
// / ____(_) / /____ _____
// / /_ / / / __/ _ \/ ___/
// / __/ / / / /_/ __/ /
// /_/ /_/_/\__/\___/_/
/**
* @member {boolean} hasFilters - Whether or not any filters are applied
*/
get hasFilters() {
return this.filters.length > 0;
}
hasFilter(name) {
if (!this.hasFilters) {
return false;
}
const found = _.find(this.filters, (filter) => filter.name === name);
return !!found;
}
hasFilterValue(name, value) {
if (!this.hasFilters) {
return false;
}
const found = _.find(this.filters, (filter) => {
return filter.name === name;
});
if (!found) {
return false;
}
if (_.isArray(value)) {
// Sort the array elements first, so isEqual doesn't fail simply because of ordering of elements
return _.isEqual(_.sortBy(value), _.sortBy(found.value));
}
return _.isEqual(value, found.value);
}
/**
* Sets one or more filters to the repository.
*
* NOTE: By default, this function REPLACES the existing filters with new ones.
* If you want to ADD to the existing filters, set the third argument to false.
*
* Usage:
* - repository.filter(); // Special case: clear all filtering
* - repository.filter('first_name', 'Scott'); // Set a single filter
* - repository.filter({ // Set a single filter, object notation
* name: 'first_name',
* value: 'Scott',
* });
* - repository.filter([ // Set multiple filters
* {
* name: 'last_name',
* value: 'Spuler',
* },
* {
* name: 'first_name',
* value: 'Scott',
* },
* ]);
*
* @return this
*/
filter(arg1 = null, arg2 = null, clearFirst = true) {
if (this.isDestroyed) {
this.throwError('this.filter is no longer valid. Repository has been destroyed.');
return;
}
if (_.isNil(arg1)) {
return this.clearFilters();
}
// Assemble filtering definition objects
let newFilters = [];
if (_.isString(arg1)) {
newFilters = [{
name: arg1,
value: arg2,
}];
} else if (_.isArray(arg1)) {
newFilters = arg1;
} else if (_.isPlainObject(arg1)) {
if (arg1.name) {
// like { name: 'first_name', value: 'Steve' }
newFilters = [arg1];
} else {
// like { first_name: 'Steve' }
const name = Object.keys(arg1)[0];
newFilters = [{
name,
value: arg1[name],
}];
}
} else if (_.isFunction(arg1)) {
newFilters = [arg1];
}
// Set up new filters
let filters = clearFirst ?
[] : // Clear existing filters
_.clone(this.filters); // so we can detect changes in _setFilters
_.each(newFilters, (newFilter) => {
let deleteExisting = false,
addNew = true;
if (!_.isFunction(newFilter) && _.isNil(newFilter?.fn)) {
if (_.isNil(newFilter?.value)) {
deleteExisting = true;
addNew = false;
} else
if (_.find(filters, (filter) => filter?.name === newFilter?.name)) {
// Filter already exists
deleteExisting = true;
}
}
if (deleteExisting) {
filters = _.filter(filters, (filter) => filter?.name !== newFilter?.name)
}
if (addNew) {
filters.push(newFilter);
}
});
return this._setFilters(filters);
}
/**
* Sets one or more filters.
* This is a convenience function; a special case alias of filter().
* Useful for allowing object notation of filters.
*
* Usage:
* - repository.setFilters({
* first_name: 'Scott',
* last_name: 'Spuler',
* });
*
* @return this
*/
setFilters(filters, clearFirst = true) {
const parsed = _.map(filters, (value, name) => {
return {
name,
value,
};
});
return this.filter(parsed, null, clearFirst);
}
/**
* Clears filters.
* @param {array|string} filtersToClear - Optional string or array of filter names to clear. Leave blank to clear ALL filters.
* @fires changeFilters
*
* Usage:
* - repository.clearFilters(); // Clear all filtering
* - repository.clearFilters('first_name'); // Clear a single filter
* - repository.clearFilters(['first_name', 'last_name']); // Clear multiple filters
*/
clearFilters(filtersToClear) {
let filters = [];
if (filtersToClear) {
if (_.isString(filtersToClear)) {
filtersToClear = [filtersToClear];
}
filters = _.filter(this.filters, (filter) => {
return filtersToClear.indexOf(filter.name) === -1;
});
}
return this._setFilters(filters);
}
/**
* Sets the filters directly
* @private
* @fires changeFilters
*/
_setFilters(filters) {
if (this.isDestroyed) {
this.throwError('this._setFilters is no longer valid. Repository has been destroyed.');
return;
}
if (!_.isEqual(this.filters, filters)) {
this.filters = filters;
this.resetPagination();
let ret;
if (this._onChangeFilters) {
ret = this._onChangeFilters();
}
this.emit('changeFilters');
return ret;
}
return false; // no filters changed
}
// ____ _ __
// / __ \____ _____ _(_)___ ____ _/ /____
// / /_/ / __ `/ __ `/ / __ \/ __ `/ __/ _ \
// / ____/ /_/ / /_/ / / / / / /_/ / /_/ __/
// /_/ \__,_/\__, /_/_/ /_/\__,_/\__/\___/
// /____/
/**
* Resets the pagination to page one
* @fires changePageSize
*/
resetPagination() {
if (this.isDestroyed) {
this.throwError('this.resetPagination is no longer valid. Repository has been destroyed.');
return;
}
this.setPage(1);
}
/**
* Sets isPaginated
*/
setIsPaginated(bool) {
if (this.isDestroyed) {
this.throwError('this.setIsPaginated is no longer valid. Repository has been destroyed.');
return;
}
this.isPaginated = bool;
if (this._onChangePagination) {
return this._onChangePagination();
}
return true;
}
/**
* Sets the pageSize
* @fires changePage
* @fires changePageSize
*/
setPageSize(pageSize) {
if (this.isDestroyed) {
this.throwError('this.setPageSize is no longer valid. Repository has been destroyed.');
return;
}
if (!this.isPaginated) {
return false;
}
pageSize = parseInt(pageSize, 10);
if (_.isEqual(this.pageSize, pageSize)) {
return false;
}
// Reset to page 1 (don't use setPage(), so we can skip _onChangePagination, which we'll do later)
this.page = 1;
this.emit('changePage');
this.pageSize = pageSize;
this.emit('changePageSize', pageSize);
if (this._onChangePagination) {
return this._onChangePagination();
}
return true;
}
/**
* Advances to a specific page of entities
* @return {boolean} success
* @fires changePage
*/
setPage(page) {
if (this.isDestroyed) {
this.throwError('this.setPage is no longer valid. Repository has been destroyed.');
return;
}
if (!this.isPaginated) {
return false;
}
if (_.isEqual(this.page, page)) {
return false;
}
if (page < 1) {
return false;
}
if (page > this.totalPages) {
return false;
}
this.page = page;
this.emit('changePage');
if (this._onChangePagination) {
return this._onChangePagination();
}
return true;
}
/**
* Advances to the previous page of entities
* @return {boolean} success
*/
prevPage() {
return this.setPage(this.page -1);
}
/**
* Advances to the next page of entities
* @return {boolean} success
*/
nextPage() {
return this.setPage(this.page +1);
}
/**
* Sets current pagination vars.
* NOTE: this.total, this.page, and this.pageSize must be managed elsewhere.
* This function takes care of calculating and setting the rest.
* @private
*/
_setPaginationVars() {
if (this.isDestroyed) {
this.throwError('this._setPaginationVars is no longer valid. Repository has been destroyed.');
return;
}
if (!this.isPaginated) {
this.totalPages = 1;
this.pageStart = 1;
this.pageEnd = this.total;
this.pageTotal = this.total;
}
const paginationVars = Repository._calculatePaginationVars(this.total, this.page, this.pageSize);
this.totalPages = paginationVars.totalPages;
this.pageStart = paginationVars.pageStart;
this.pageEnd = paginationVars.pageEnd;
this.pageTotal = paginationVars.pageTotal;
}
/**
* Helper for _setPaginationVars.
* Utility function to calculate all pagination variables.
* @param {number} total - Total number of items
* @param {number} page - Current page number
* @param {number} pageSize - Max items per page
* @return {object} pageVars - Object representing all returned page variables
* - page {number} - Current page number
* - pageSize {number} - Max items per page
* - total {number} - Total number of items
* - totalPages {number} - Total number of pages
* - pageStart {number} - Index (within total, and 1-based) of first item on current page
* - pageEnd {number} - Index (within total, and 1-based) of last item on current page
* - pageTotal {number} - Total number of items on current page
* @private
* @static
*/
static _calculatePaginationVars(total, page, pageSize) {
// Special case: empty pages
if (total < 1) {
return {
page,
pageSize,
total,
totalPages: 1,
pageStart: 0,
pageEnd: 0,
pageTotal: 0,
};
}
const totalPages = Math.ceil(total / pageSize),
pageStart = ((page -1) * pageSize) + 1;
let remainder,
pageEnd,
pageTotal;
if (page === 1 && totalPages === 1) {
pageTotal = total;
} else if (page < totalPages) {
pageTotal = pageSize;
} else {
// last page
remainder = total % pageSize;
pageTotal = remainder || pageSize;
}
pageEnd = pageStart + pageTotal -1;
return {
page,
pageSize,
total,
totalPages,
pageStart,
pageEnd,
pageTotal,
};
}
// __________ __ ______
// / ____/ __ \/ / / / __ \
// / / / /_/ / / / / / / /
// / /___/ _, _/ /_/ / /_/ /
// \____/_/ |_|\____/_____/
/**
* Creates a single new Entity in storage medium.
* @param {object} data - Either raw data object or Entity. If raw data, keys are Property names, Values are Property values.
* @param {boolean} isPersisted - Whether the new entity should be marked as already being persisted in storage medium.
* @param {boolean} isDelayedSave - Should the repository skip autosave when immediately adding the record?
* @return {object} entity - new Entity object
* @fires add
*/
async add(data, isPersisted = false, isDelayedSave = false) {
if (this.isDestroyed) {
this.throwError('this.add is no longer valid. Repository has been destroyed.');
return;
}
if (!this.canAdd) {
this.throwError('Adding has been disabled on this repository.');
return;
}
// Does it already exist? If so, edit the existing
const idProperty = this.getSchema().model.idProperty;
if (data.hasOwnProperty(idProperty)) {
if (this.isInRepository(data[idProperty])) {
const existing = this.getById(data[idProperty]);
existing.setRawValues(data);
if (this.isAutoSave && !existing.isPersisted) {
await this.save(existing);
}
return existing;
}
}
let entity = data;
if (!(data instanceof Entity)) {
// Create the new entity
entity = Repository._createEntity(this.schema, data, this, isPersisted, isDelayedSave, this.isRemotePhantomMode);
}
this._relayEntityEvents(entity);
if (this.isTree && data.parentId) {
// Trees need new node to be added as first child of parent
const ix = this.getIxById(data.parentId) +1;
this.entities = [
...this.entities.slice(0, ix),
entity,
...this.entities.slice(ix)
];
this.assembleTreeNodes();
} else {
this.entities.unshift(entity); // Add to *beginning* of entities array, so the phantom record will appear at the beginning of the current page
}
// Create id if needed
if (!this.isRemotePhantomMode && entity.isPhantom) {
entity.createTempId();
}
this.emit('add', entity);
if (this.isRemotePhantomMode || (this.isAutoSave && !entity.isPersisted && !entity.isDelayedSave)) {
await this.save(entity);
}
return entity;
}
/**
* Creates a new static Entity that does NOT persist in storage medium.
* Used when we want to work with an entity, but don't want that entity to appear in a repository.
* @param {object} data - Either raw data object or Entity. If raw data, keys are Property names, Values are Property values.
* @param {boolean} isPersisted - Whether the new entity should be marked as already being persisted in storage medium.
* @param {boolean} isDelayedSave - Should the repository skip autosave when immediately adding the record?
* @return {object} entity - new Entity object
*/
createStandaloneEntity(data, isPersisted = false, isDelayedSave = false) {
if (this.isDestroyed) {
this.throwError('this.createStandaloneEntity is no longer valid. Repository has been destroyed.');
return;
}
const entity = Repository._createEntity(this.schema, data, this, isPersisted, isDelayedSave, this.isRemotePhantomMode);
if (entity.isPhantom && !this.isRemotePhantomMode) {
entity.createTempId();
}
return entity;
}
/**
* Convenience function to create multiple new Entities in storage medium.
* @param {array} data - Array of data objects or Entities.
* @param {boolean} isPersisted - Whether the new entities should be marked as already being persisted in storage medium.
* @return {array} entities - new Entity objects
*/
async addMultiple(allData, isPersisted = false) {
if (!this.canAdd) {
this.throwError('Adding has been disabled on this repository.');
return;
}
let entities = [],
i,
data,
entity;
for (i = 0; i < allData.length; i++) {
data = allData[i];
entity = await this.add(data, isPersisted);
entities.push(entity);
};
return entities;
}
/**
* Helper for add.
* Creates a new Entity and immediately returns it
* @param {object} schema - Schema object
* @param {object} rawData - Raw data object. Keys are Property names, Values are Property values.
* @param {boolean} repository - Optional repository to connect the entity to.
* @param {boolean} isPersisted - Whether the new entity should be marked as already being persisted in storage medium.
* @param {boolean} isDelayedSave - Should the repository skip autosave when immediately adding the record?
* @return {object} entity - new Entity object
* @private
*/
static _createEntity(schema, rawData, repository = null, isPersisted = false, isDelayedSave = false, isRemotePhantomMode = false) {
const entity = new Entity(schema, rawData, repository, isDelayedSave, isRemotePhantomMode);
entity.initialize();
entity.isPersisted = isPersisted;
return entity;
}
/**
* Helper for add.
* Relays events from entity to this Repository
* @param {object} entity - Entity
* @private
*/
_relayEntityEvents(entity) {
if (this.isDestroyed) {
this.throwError('this._relayEntityEvents is no longer valid. Repository has been destroyed.');
return;
}
this.relayEventsFrom(entity, [
'change',
'delete',
'reset',
'save',
], 'entity_');
}
/**
* Destroys the current entities -
* mostly so they can be replaced with other entities.
*/
_destroyEntities() {
_.each(this.entities, (entity) => {
entity.destroy();
});
this.entities = [];
}
/**
* Inserts the newEntity directly before entity on the current page.
*/
_insertBefore(newEntity, entity = null) {
const
currentEntities = this.getEntities(),
foundIx = _.findIndex(currentEntities, ent => ent === entity),
existingEntityIx = foundIx === -1 ? 0 : foundIx;
let firstHalf = [],
secondHalf = [];
if (!currentEntities.length || existingEntityIx === 0) {
firstHalf.push(newEntity);
secondHalf = currentEntities;
} else {
firstHalf = _.slice(currentEntities, 0, existingEntityIx);
firstHalf.push(newEntity);
secondHalf = _.slice(currentEntities, existingEntityIx);
}
this.entities = [
...firstHalf,
...secondHalf,
];
}
/**
* Deletes all locally cached entities in repository,
* usually, the current "page".
* Does not actually affect anything on the server.
*/
async clear() {
this._destroyEntities();
}
// _ __ __
// | | / /___ _/ /_ _____ _____
// | | / / __ `/ / / / / _ \/ ___/
// | |/ / /_/ / / /_/ / __(__ )
// |___/\__,_/_/\__,_/\___/____/
/**
* Gets an array of "submit" values objects for the entities
* @return {array} map -
*/
getSubmitValues() {
return _.map(this.entities, (entity) => {
return entity.getSubmitValues();
});
}
/**
* Gets an array of "display" values objects for the entities
* @return {array} map -
*/
getDisplayValues() {
return _.map(this.entities, (entity) => {
return entity.getDisplayValues();
});
}
/**
* Gets an array of "raw" values objects for the entities
* @return {array} map -
*/
getRawValues() {
return _.map(this.entities, (entity) => {
return entity.getRawValues();
});
}
/**
* Gets an array of "originalData" values objects for the entities
* @return {array} map -
*/
getOriginalData() {
return _.map(this.entities, (entity) => {
return entity.getOriginalData();
});
}
/**
* Gets a single random entity
* @return {array} map -
*/
getRandomEntity() {
const len = this.entities.length;
if (!len) {
return null;
}
const rand = _.random(0, len -1);
return this.entities[rand];
}
/**
* Gets an array of "parsed" values objects for the entities
* @return {array} map -
*/
getParsedValues() {
return _.map(this.entities, (entity) => {
return entity.getParsedValues();
});
}
/**
* Get a single Entity by its index (zero-indexed) on the current page
* @param {integer} ix - Index
* @return {object} entity - Entity
*/
getByIx(ix) {
if (this.isDestroyed) {
this.throwError('this.getByIx is no longer valid. Repository has been destroyed.');
return;
}
return this.entities[ix];
}
/**
* Get multiple Entities by their range of indices
* (zero-indexed) on the current page
* @param {integer} startIx - Index
* @param {integer} endIx - Index (inclusive)
* @return {array} entities - Array of Entities
*/
getByRange(startIx, endIx) {
if (this.isDestroyed) {
this.throwError('this.getByRange is no longer valid. Repository has been destroyed.');
return;
}
return _.slice(this.entities, startIx, endIx+1);
}
/**
* Get a single Entity by its id
* @param {integer} id - id of record to retrieve
* @return {Entity} The Entity with matching id, or undefined
*/
getById(id) {
if (this.isDestroyed) {
this.throwError('this.getById is no longer valid. Repository has been destroyed.');
return;
}
if (_.isNil(id)) {
return null;
}
return this.getFirstBy(entity => entity.id === id);
}
/**
* Get a single Entity's index by its id.
* @param {integer} id - id of record to retrieve
* @return {integer} The numerical index, or undefined
*/
getIxById(id) {
if (this.isDestroyed) {
this.throwError('this.getIxById is no longer valid. Repository has been destroyed.');
return;
}
if (_.isNil(id)) {
return null;
}
const ix = this.entities.findIndex((entity) => entity.id === id);
if (ix >= 0) {
return ix;
}
return null;
}
/**
* Get an array of Entities by supplied filter function
* @param {function} filter - Filter function to apply to all entities
* @return {Entity[]} Entities that passed through filter, or []
*/
getBy(filter) {
if (this.isDestroyed) {
this.throwError('this.getBy is no longer valid. Repository has been destroyed.');
return;
}
return _.filter(this.entities, filter);
}
/**
* Gets the first Entity that passes through supplied filter function.
* Takes current sorting into account.
* Optional second param determines whether to take other currently-applied
* filters into account. Defaults to false.
*
* @param {function} filter - Filter function to search by
* @return {Entity} First Entity found, or undefined
*/
getFirstBy(filter) {
if (this.isDestroyed) {
this.throwError('this.getFirstBy is no longer valid. Repository has been destroyed.');
return;
}
return _.find(this.entities, filter);
}
/**
* Get all phantom (unsaved) Entities
* @param {array} entities - Array of entities to filter. Optional. Defaults to this.entities
* @return {Entity[]} Array of phantom Entities, or []
*/
getPhantom(entities = null) {
if (this.isDestroyed) {
this.throwError('this.getPhantom is no longer valid. Repository has been destroyed.');
return;
}
if (!entities) {
entities = this.entities;
}
return _.filter(this.entities, entity => entity.isPhantom && !entity.isSaving);
}
/**
* Get all Entities not yet persisted to a storage medium
* @param {array} entities - Array of entities to filter. Optional. Defaults to this.entities
* @return {Entity[]} Array of dirty Entities, or []
*/
getNonPersisted(entities = null) {
if (this.isDestroyed) {
this.throwError('this.getNonPersisted is no longer valid. Repository has been destroyed.');
return;
}
if (!entities) {
entities = this.entities;
}
return _.filter(this.entities, entity => !entity.isPersisted && !entity.isSaving);
}
/**
* Get an array of all Entities.
* Can be overridden by subclasses.
* @return {array} Entities that passed through filter
*/
getEntities() {
if (this.isDestroyed) {
this.throwError('this.getEntities is no longer valid. Repository has been destroyed.');
return;
}
return this.entities;
}
/* */
/**
* Get an array of all Entities on current page,
* which for the base Repository, means all entities.
* Subclasses may change this behavior.
* @return {array} Entities
*/
getEntitiesOnPage() {
if (this.isDestroyed) {
this.throwError('this.getPagedEntities is no longer valid. Repository has been destroyed.');
return;
}
const entities = this.getEntities();
if (!this.isPaginated) {
return entities;
}
const
pageIx = this.page -1, // zero-indexed page#
start = pageIx * this.pageSize,
end = start + this.pageSize;
return entities.slice(start, end);
}
/* */
/**
* Determines whether this repository is bound to the schema
* @return {boolean}
*/
getIsBound() {
if (this.isDestroyed) {
this.throwError('this.getIsBound is no longer valid. Repository has been destroyed.');
return;
}
return this.schema.getBoundRepository() === this;
}
/**
* Get all dirty (having unsaved changes) Entities
* @param {array} entities - Array of entities to filter. Optional. Defaults to this.entities
* @return {Entity[]} Array of dirty Entities, or []
*/
getDirty(entities = null) {
if (this.isDestroyed) {
this.throwError('this.getDirty is no longer valid. Repository has been destroyed.');
return;
}
if (!entities) {
entities = this.entities;
}
return _.filter(entities, entity => !entity.isSaving && (entity.isDestroyed || entity.isDirty));
}
/**
* Get all deleted Entities
* @param {array} entities - Array of entities to filter. Optional. Defaults to this.entities
* @return {Entity[]} Array of deleted Entities, or []
*/
getDeleted(entities = null) {
if (this.isDestroyed) {
this.throwError('this.getDeleted is no longer valid. Repository has been destroyed.');
return;
}
if (!entities) {
entities = this.entities;
}
return _.filter(entities, entity => entity.isDestroyed || entity.isDeleted);
}
/**
* Get all staged Entities
* @param {array} entities - Array of entities to filter. Optional. Defaults to this.entities
* @return {Entity[]} Array of deleted Entities, or []
*/
getStaged(entities = null) {
if (this.isDestroyed) {
this.throwError('this.getStaged is no longer valid. Repository has been destroyed.');
return;
}
if (!entities) {
entities = this.entities;
}
return _.filter(entities, entity => entity.isStaged && !entity.isSaving);
}
/**
* Gets the Schema object
* @return {Schema} schema
*/
getSchema() {
if (this.isDestroyed) {
this.throwError('this.getSchema is no longer valid. Repository has been destroyed.');
return;
}
return this.schema;
}
/**
* Gets the sort field, if only one sorter is applied.
* @return {Schema} schema
*/
getSortField() {
if (this.isDestroyed) {
this.throwError('this.getSortField is no longer valid. Repository has been destroyed.');
return;
}
if (!this.allowsMultiSort || this.sorters.length < 1) {
return null;
}
return this.sorters[0].name;
}
/**
* Gets the sort direction, if only one sorter is applied.
* @return {Schema} schema
*/
getSortDirection() {
if (this.isDestroyed) {
this.throwError('this.getSortDirection is no longer valid. Repository has been destroyed.');
return;
}
if (!this.allowsMultiSort || this.sorters.length < 1) {
return null;
}
return this.sorters[0].direction;
}
/**
* Gets the sort function, if only one sorter is applied.
* @return {Schema} schema
*/
getSortFn() {
if (this.isDestroyed) {
this.throwError('this.getSortDirection is no longer valid. Repository has been destroyed.');
return;
}
if (!this.allowsMultiSort || this.sorters.length < 1) {
return null;
}
return this.sorters[0].fn;
}
/**
* Gets the associated Repository
* @param {string} repositoryName - Name of the Repository to retrieve
* @return {boolean} hasProperty
*/
getAssociatedRepository(repositoryName) {
if (this.isDestroyed) {
this.throwError('this.getAssociatedRepository is no longer valid. Repository has been destroyed.');
return;
}
const schema = this.getSchema();
if (!schema.model.associations?.hasOne.includes(repositoryName) &&
!schema.model.associations?.hasMany.includes(repositoryName) &&
!schema.model.associations?.belongsTo.includes(repositoryName) &&
!schema.model.associations?.belongsToMany.includes(repositoryName)
) {
this.throwError(repositoryName + ' is not associated with this schema');
return;
}
const oneHatData = this.oneHatData;
if (!oneHatData) {
this.throwError('No global oneHatData object');
return;
}
const associatedRepository = oneHatData.getRepository(repositoryName);
if (!associatedRepository) {
this.throwError('Repository ' + repositoryName + ' cannot be found');
return;
}
return associatedRepository;
}
/**
* Utility function.
* Detects if entity is in the current page of the storage medium.
* @param {object|string} entity - Either an Entity object, or an id
* @return {boolean} isInRepository - Whether or not the entity exists in this Repository
*/
isInRepository(idOrEntity) {
if (this.isDestroyed) {
this.throwError('this.isInRepository is no longer valid. Repository has been destroyed.');
return;
}
if (idOrEntity instanceof Entity) {
return this.entities.indexOf(idOrEntity) !== -1;
}
return !_.isNil(this.getById(idOrEntity));
}
/**
* Getter of isBound for this Repository.
* Returns true if this repository is bound to the schema
* @return {boolean} isBound
*/
get isBound() {
if (this.isDestroyed) {
this.throwError('this.isBound is no longer valid. Repository has been destroyed.');
return;
}
return this.getIsBound();
}
/**
* Getter of isDirty for this Repository.
* Returns true if any Entities within it are dirty
* @return {boolean} isDirty
*/
get isDirty() {
if (this.isDestroyed) {
this.throwError('this.isDirty is no longer valid. Repository has been destroyed.');
return;
}
return !!this.getDirty().length;
}
/**
* Convenience function
* Alias for isInRepository
* NOTE: It only searches in memory. Doesn't query server
*/
hasId(id) {
return this.isInRepository(id);
}
/**
* Convenience function
*/
saveStaged() {
return this.save(null, true);
}
/**
* Queues up batch operations for saving
* new, edited, and deleted entities to storage medium.
*
* NOTE: Since multiple operations can take place in one go, we don't change
* this.entities until all operations have completed. We leave it to subclasses
* to implement that.
* @param {object} entity - Optional single entity to save (instead of doing a batch operation on everything)
* @param {boolean} useStaged - Save only the staged items, not all
*/
async save(entity = null, useStaged = false) {
if (this.isDestroyed) {
this.throwError('this.save is no longer valid. Repository has been destroyed.');
return;
}
this.emit('beforeSave'); // So subclasses can prep anything needed for saving
this.isSaving = true;
const results = [];
if (entity) {
// Single operation
let result;
if (!entity.isPersisted && entity.isDeleted) {
result = this._doDeleteNonPersisted(entity);
} else if (!entity.isPersisted) {
result = this._doAdd(entity);
} else if (entity.isDirty && !entity.isDeleted) {
result = this._doEdit(entity);
} else if (entity.isDeleted) {
result = this._doDelete(entity);
}
results.push(result);
} else {
// Batch operations
// TODO: Future feature: Take advantage of storage medium's bulk add/edit/delete functionality, if it exists
const batchOrder = this.batchOrder.split(',');
let n,
i,
entity,
entities,
operation,
result;
for (n = 0; n < batchOrder.length; n++) {
operation = batchOrder[n];
switch(operation) {
case 'add':
entities = this.getNonPersisted();
if (useStaged) {
entities = this.getStaged(entities);
}
if (_.size(entities) > 0) {
if (this.combineBatch) {
result = this.batchAsSynchronous ? await this._doBatchAdd(entities) : this._doBatchAdd(entities);
if (result) {
results.push(result);
}
} else {
for (i = 0; i < entities.length; i++) {
entity = entities[i];
if (entity.isDeleted) {
// This entity is new, but it's also marked for deletion
// Skip it. We'll deal with it later, in 'delete'
continue;
}
result = this.batchAsSynchronous ? await this._doAdd(entity) : this._doAdd(entity);
if (result) {
results.push(result);
}
}
}
}
break;
case 'edit':
entities = this.getDirty();
if (_.isEmpty(entities) && this.isRemotePhantomMode) {
// In remote phantom mode, we need to save phantoms even if they're not dirty
entities = this.getPhantom();
}
if (useStaged) {
entities = this.getStaged(entities);
}
if (_.size(entities) > 0) {
if (this.combineBatch) {
result = this.batchAsSynchronous ? await this._doBatchEdit(entities) : this._doBatchEdit(entities);
if (result) {
results.push(result);
}
} else {
for (i = 0; i < entities.length; i++) {
entity = entities[i];
if (entity.isDeleted) {
// This entity is new, but it's also marked for deletion
// Skip it. We'll deal with it later, in 'delete'
continue;
}
result = this.batchAsSynchronous ? await this._doEdit(entity) : this._doEdit(entity);
if (result) {
results.push(result);
}
}
}
}
break;
case 'delete':
entities = this.getDeleted();
if (useStaged) {
entities = this.getStaged(entities);
}
if (_.size(entities) > 0) {
if (this.combineBatch) {
result = this.batchAsSynchronous ? await this._doBatchDelete(entities) : this._doBatchDelete(entities);
if (result) {
results.push(result);
}
} else {
for (i = 0; i < entities.length; i++) {
entity = entities[i];
if (!entity.isPersisted) {
resul