/* global localStorage, navigator, window */
import EventSystem from './EventSystem'
// import Dexie from 'dexie'
import mongoose from 'mongoose'
import { createMethodSignature, toJSON } from './utils'
/**
* @author Eduardo Perotta de Almeida <web2solucoes@gmail.com>
* @Class DataEntity
* @summary Data Entity API
* @description When composing web applications using this library, we strongly believe the data design and plan should be the entry point of your software design.
* <br><br>The Entity Relationship diagram shall to be one of the initial documents you should to design before starting to write your software.
* <br><br>We assume the {@link https://en.wikipedia.org/wiki/Entity%E2%80%93relationship_model|Entity Relationship} and {@link https://en.wikipedia.org/wiki/Entity%E2%80%93relationship_model|Data Entity} models as technique and paradigm to design the application data.
* <br><br>If you have no idea of how agile you could to design your ER diagram, please take a look at some tools like {@Link https://www.datensen.com/data-modeling/moon-modeler-for-databases.html|Moon Modeler}
* <br><br> Every Data Entity in the system has it own encapsulated properties and methods that cares about where entity data is writen to and read from.
* <br><br> The DataEntity relies on the application instance (passed to it constructor) to access the available data transports.
* <br>It means you can not use DataEntity prior starting a data transport layer.
* <br><br> This class is not for direct usage in your project, unless you are a core developer or want to understand what happens behind the scenes, you should consider to take a look at the {@link Foundation} class.
* @extends EventSystem
* @param {object} DataEntityConfig - Data Entity configuration
* @param {string} DataEntityConfig.foundation - Provide Accesss to Foundation scope
* @param {string} DataEntityConfig.entity - Data entity name which this dataEntity instance is handling
* @param {boolean} DataEntityConfig.strategy - Data transport strategy
* @param {boolean} DataEntityConfig.schema - Data schema for this Data Entity abstraction. <br> Do not declare the params __id and _id inside your schemas.
* @example
import { Schema } from '../foundation/Foundation'
const schema = new Schema({
// do not declare __id
// do not declare _id
name: {
type: String,
required: true,
index: true
},
address: {
type: String,
required: true,
index: true
},
email: {
type: String,
required: true,
index: true
},
cards: {
type: [],
required: true
}
})
const Customer = new DataEntity({
foundation, // Foundation instance, object
entity: 'Customer', // entity name, string
strategy: 'offline', // data strategy, string
schema // data schema, a mongoose like schema
})
// listen to add Customer Data event on Data API
const onAddDocEventListener = Customer.on(
'add',
function(eventObj){
const { error, document, foundation, data } = eventObj
// do something like to update the component View state Based On Information from Incoming Event
component.setState({ propertyName: newValue }) // React:
component.$set(component.data, 'propertyName', newValue) // Vue:
}
)
// listen to edit Customer Data event on Data API
const onEditDocEventListener = Customer.on(
'edit',
function (eventObj) {
const { error, document, foundation, data } = eventObj
// do something like to update the component View state Based On Information from Incoming Event
component.setState({ propertyName: newValue }) // React:
component.$set(component.data, 'propertyName', newValue) // Vue:
}
)
// listen to delete Customer Data event on Data API
const onDeleteDocEventListener = Customer.on(
'delete',
function (eventObj) {
const { error, document, foundation, data } = eventObj
// do something like to update the component View state Based On Information from Incoming Event
component.setState({ propertyName: newValue }) // React:
component.$set(component.data, 'propertyName', newValue) // Vue:
}
)
// Stop to listen to events to avoid memory leak or others kind of problems
// like to change the state of an unmounted component.
// Do something like this -> before component unmount OR before window unload
Customer.stopListenTo(onAddDocEventListener)
Customer.stopListenTo(onEditDocEventListener)
Customer.stopListenTo(onDeleteDocEventListener)
*/
export default class DataEntity extends EventSystem {
#_foundation
#_entity
#_strategy
#_schema
#_pagination
#_stateChangeStorageName
constructor({
foundation,
entity,
strategy = 'offline',
schema
}) {
super()
/* if (Object.keys(foundation).length === 0) {
throw new Error('foundation is invalid')
}
if (Object.keys(schema).length === 0) {
throw new Error('schema is invalid')
}
if (entity === null) {
throw new Error('entity is invalid')
} */
this.#_entity = entity
/**
* @memberof DataEntity
* @member {property} DataEntity.#_strategy
* @summary PRIVATE - Holds the data transport strategy for this Data Entity.
* @description
* Default strategy is <b>offline</b>. <br><br>
* Possible values are: <br>
* - offlineFirst<br>
* Data will be saved on local database first.<br>
* - onlineFirst<br>
* Data will be saved on remote database first.<br>
* - offline<br>
* Data will be saved on local database only.<br>
* - online<br>
* Data will be saved on remote database only.<br>
*/
this.#_strategy = strategy // offlineFirst, onlineFirst, offline, online
/**
* @memberof DataEntity
* @member {property} DataEntity.#_schema
* @summary PRIVATE - Holds the data schema for this Data Entity
* @description Data schema is a mongoose.Schema implementation
*/
this.#_schema = schema
this.#_foundation = foundation
/**
* @memberof DataEntity
* @member {property} DataEntity.#_pagination
* @summary PRIVATE - default internal paging configuration
* @description The default paging configuration is: offset: 0, limit 30. It means it will returns 30 documents starting on index 0.
*/
this.#_pagination = {
offset: 0,
limit: 30
}
this.#_stateChangeStorageName = `__$tabEntityStateChange_${this.#_entity}`
this.#_foundation.localDatabaseTransport.addSchema(this.#_entity, this.#_schema)
this.#_listenToAllOtherSessionsStateChanges()
}
/**
* @memberof DataEntity
* @member {getter} DataEntity.entity
* @example // console.log(DataEntity.entity)
* @description Gets the entity name which which DataEntity instance is handling out
* @return {object} this.#_entity
*/
get entity () {
return this.#_entity
}
/**
* @memberof DataEntity
* @member {getter} DataEntity.schema
* @example // console.log(DataEntity.schema)
* @description Gets the data schema related to this Entity Data API
* @return {object} this.#_schema
*/
get schema () {
return this.#_schema
}
/**
* @memberof DataEntity
* @member {getter} DataEntity.strategy
* @example // console.log(DataEntity.strategy)
* @description Gets the data strategy currently being used
* @return {string} this.#_strategy
*/
get strategy () {
return this.#_strategy
}
/**
* @Method DataEntity.Model
* @description create a Data Model based on given document
* @param {object} doc - A valid document validated against mongoose schema
* @param {object} schema - Mongoose based schema
* @return {object} model - Mongoose document
*/
Model(doc, schema) {
const modelSystem = mongoose.Document
modelSystem.prototype.isNotValid = modelSystem.prototype.validateSync
return modelSystem(doc, schema)
}
/**
* @async
* @Method DataEntity.add
* @description add a new document to the storage
* @param {object} doc - A valid document validated against mongoose schema
* @return {object} signature - Default methods signature format { error, data }
* @return {string|object} signature.error - Execution error
* @return {object} signature.data - Created document
* @example
const doc = {
name: 'Eduardo Almeida',
address: 'Av. Beira Mar. Praia do Morro, Guarapari - ES. Brazil.',
email: 'web2solucoes@gmail.com',
cards: []
}
const { data, error } = await Customer.add(doc)
*/
async add(doc = {}) {
// if (!(doc instanceof Document)) {
// return createMethodSignature('You must pass a valid JSON document as parameter to DataEntity.add() method', null)
// }
if (Object.keys(doc).length < 1) {
return createMethodSignature('You must pass a valid JSON document as parameter to DataEntity.add() method', null)
}
let data = null
let error = null
let rawObj = {}
// console.log('doc', doc)
delete doc.__id
delete doc._id
const model = new this.Model(doc, this.#_schema)
// console.log('model', model)
const invalid = model.validateSync()
if (invalid) {
return createMethodSignature(invalid, data)
}
rawObj = toJSON(model)
// console.log('add', rawObj)
// bug
if (typeof rawObj._id === 'undefined' && typeof rawObj.id !== 'undefined') {
rawObj._id = rawObj.id
}
try {
const __id = await this.#_foundation.localDatabaseTransport
.table(this.#_entity)
.add({ ...rawObj })
data = { __id, ...rawObj }
// console.log('data', data)
// console.log('__id', __id)
} catch (e) {
// console.log('error error ', e)
error = e
}
// console.log({ data, error, rawObj })
this.#_triggerAddEvents({ data, error, primaryKey: (typeof data.__id === 'undefined' ? 0 : data.__id), rawObj })
return createMethodSignature(error, data)
}
/**
* @Method DataEntity.#_triggerAddEvents
* @description PRIVATE - Triggers all events related to 'add document' event
* @param {object} eventPayload - Object containing all information about the event
* @param {object} eventPayload.data - The new document inserted into database, default is null if not provided
* @param {object|string} eventPayload.error - The returned error from database add request if any, default is null if not provided
* @param {number} eventPayload.primaryKey - The primaryKey value of the added document, default is zero if not provided
* @param {object} eventPayload.rawObj - The raw document object provided on dataEntity.add(doc) mehod call. Default is {} if not provided.
*/
#_triggerAddEvents({ data, error, primaryKey, rawObj}) {
const action = 'add'
this.#_foundation.triggerEvent(`collection:${action}:${this.#_entity.toLowerCase()}`, {
foundation: this.#_foundation,
entity: this.#_entity,
document: rawObj,
primaryKey,
data,
error,
})
this.triggerEvent(action, {
foundation: this.#_foundation,
entity: this.#_entity,
document: rawObj,
primaryKey,
data,
error,
})
const state = { action, data, error, document: rawObj, primaryKey }
this.#_sendStateChangeToAllOtherSessions(state)
}
/**
* @async
* @Method DataEntity.edit
* @description Edit a document on the storage
* @param {string|number} primaryKey - The primary key value of the desired document
* @param {object} doc - A valid document validated against mongoose schema
* @return {object} signature - Default methods signature format { error, data }
* @return {string|object} signature.error - Execution error
* @return {object} signature.data - Edited document
* @example
const doc = {
__id: 1,
_id: '601cb8d8623dc60000ee3c24',
name: 'Eduardo Almeida',
address: 'Av. Beira Mar. Praia do Morro, Guarapari - ES. Brazil.',
email: 'web2solucoes@gmail.com',
cards: []
}
const { data, error } = await Customer.edit(doc.__id, doc)
*/
async edit(primaryKey = null, doc = {}) {
// if (!(doc instanceof Document)) {
// return createMethodSignature('You must pass a valid JSON document as parameter to DataEntity.edit() method', null)
// }
if (Object.keys(doc).length < 1) {
return createMethodSignature('You must pass a valid JSON document as parameter to DataEntity.edit() method', null)
}
if (primaryKey === null) {
return createMethodSignature('You must pass a valid primary key value as parameter to DataEntity.edit() method', null)
}
if (typeof doc.__id !== 'number') {
return createMethodSignature('Document must have doc.__id (Integer) when calling DataEntity.edit() method', null)
}
if (typeof doc._id !== 'string') {
return createMethodSignature('Document must have doc._id (ObjectID) when calling DataEntity.edit() method', null)
}
primaryKey = +primaryKey
let data = null
let error = null
let rawObj = {}
try {
const model = new this.Model(doc, this.#_schema)
const invalid = model.validateSync()
if (invalid) {
return createMethodSignature(invalid, data)
}
rawObj = toJSON(model)
rawObj.__id = primaryKey
// bug
const response = await this.#_foundation.localDatabaseTransport
.table(this.#_entity)
.put({ ...rawObj })
// .update({ __id: primaryKey }, { ...rawObj })
data = { __id: primaryKey, ...rawObj }
/* if (response.modifiedCount === 1) {
data = { __id: primaryKey, ...rawObj }
} else {
data = null
error = {
message: 'Critical query error on update',
response
}
} */
} catch (e) {
error = e
}
this.#_triggerEditEvents({ data, error, primaryKey, rawObj })
return createMethodSignature(error, data)
}
/**
* @Method DataEntity.#_triggerEditEvents
* @description PRIVATE - Triggers all events related to 'edit document' event
* @param {object} eventPayload - Object containing all information about the event
* @param {object} eventPayload.data - The new document updated into database, default is null if not provided
* @param {object|string} eventPayload.error - The returned error from database edit request if any, default is null if not provided
* @param {number} eventPayload.primaryKey - The primaryKey value of the edited document, default is zero if not provided
* @param {object} eventPayload.rawObj - The raw document object provided on dataEntity.edit(primaryKey, doc) mehod call. Default is {} if not provided.
*/
#_triggerEditEvents({ data, error, primaryKey, rawObj }) {
const action = 'edit'
this.#_foundation.triggerEvent(
`collection:${action}:${this.#_entity.toLowerCase()}`,
{
foundation: this.#_foundation,
entity: this.#_entity,
primaryKey,
document: rawObj,
data,
error
}
)
this.triggerEvent(
action,
{
foundation: this.#_foundation,
entity: this.#_entity,
primaryKey,
document: rawObj,
data,
error
}
)
const state = { action, data, error, document: rawObj, primaryKey }
this.#_sendStateChangeToAllOtherSessions(state)
}
/**
* @async
* @Method DataEntity.delete
* @description delete a document from the storage
* @param {string|number} primaryKey - The primary key value of the desired document
* @return {object} signature - Default methods signature format { error, data }
* @return {string|object} signature.error - Execution error
* @return {object} signature.data - Deleted document
*/
async delete(primaryKey) {
primaryKey = +primaryKey
let data = null
let error = null
let rawObj = {}
try {
const __id = await this.#_foundation.localDatabaseTransport
.table(this.#_entity)
.delete(primaryKey)
// console.error({ __id })
data = { __id: primaryKey }
} catch (e) {
error = e
}
this.#_triggerDeleteEvents({ data, error, primaryKey })
return createMethodSignature(error, data)
}
/**
* @Method DataEntity.#_triggerDeleteEvents
* @description PRIVATE - Triggers all events related to 'delete document' event
* @param {object} eventPayload - Object containing all information about the event
* @param {object} eventPayload.data - A object containing the __id property of the deleted document, default is null if not provided
* @param {object|string} eventPayload.error - The returned error from database edit request if any, default is null if not provided
* @param {number} eventPayload.primaryKey - The primaryKey value of the deleted document, default is zero if not provided
*/
#_triggerDeleteEvents({ data, error, primaryKey }) {
const action = 'delete'
this.#_foundation.triggerEvent(
`collection:${action}:${this.#_entity.toLowerCase()}`,
{
foundation: this.#_foundation,
entity: this.#_entity,
primaryKey,
data,
error
}
)
this.triggerEvent(
action,
{
foundation: this.#_foundation,
entity: this.#_entity,
primaryKey,
data,
error
}
)
const state = { action, data, error, primaryKey }
this.#_sendStateChangeToAllOtherSessions(state)
}
/**
* @async
* @Method DataEntity.findById
* @description find a document from the storage by ID
* @param {string|number} primaryKey - The primary key value of the desired document
* @return {object} signature - Default methods signature format { error, data }
* @return {string|object} signature.error - Execution error
* @return {object} signature.data - Found document
*/
async findById (primaryKey) {
let data = null
let error = null
try {
primaryKey = parseInt(primaryKey)
const doc = await this.#_foundation.localDatabaseTransport
.table(this.#_entity)
.get(primaryKey)
// console.log({ __id: primaryKey, doc })
if (doc)
{
if (doc.__id === primaryKey) {
data = { __id: primaryKey, ...doc }
}
}
} catch (e) {
error = e
}
return createMethodSignature(error, data)
}
/**
* @async
* @Method DataEntity.findAll
* @summary Find all documents
* @description This method will to return all documents based on the given query. If no query is specified, it will returns all records from this collection
* @return {object} signature - Default methods signature format { error, data }
* @return {string|object} signature.error - Execution error
* @return {array} signature.data - Array of Found documents
*/
async findAll(query = {}) {
let data = null
let error = null
try {
const documents = await this.#_foundation
.localDatabaseTransport
.collection(this.#_entity)
.find(query)
.toArray()
data = documents
} catch (e) {
error = e
}
return createMethodSignature(error, data)
}
/**
* @async
* @Method DataEntity.find
* @summary find documents based on the given query and returns a paginated response
* @description This method will to return the documents based on the given query and the specified paging. If no query is specified, it will returns documents based on paging only.
* @param {object|null} query - The query object to search documents
* @param {object} pagination - Pagination object. If not provided will assume internaly set pagination.
* @param {number} pagination.offset - Offset. Default 0.
* @param {number} pagination.limit - Limit. Default 30.
* @example
User.find({
$or: [{ age: { $lt: 23, $ne: 20 } }, { lastname: { $in: ['Fox'] } }]
})
* @return {object} signature - Default methods signature format { error, data }
* @return {string|object} signature.error - Execution error
* @return {array} signature.data - Array of Found documents
*/
async find(query = {}, pagination = this.#_pagination) {
let { offset, limit } = pagination
let data = null
let error = null
try {
const documents = await this.#_foundation
.localDatabaseTransport
.collection(this.#_entity)
.find(query)
.reverse()
.offset(offset)
.limit(limit)
.toArray()
data = documents
} catch (e) {
error = e
}
return createMethodSignature(error, data)
}
/**
* @async
* @Method DataEntity.count
* @description count all documents based on the given query
* @param {object} query - The query object to count documents
* @example
User.count({
$or: [{ age: { $lt: 23, $ne: 20 } }, { lastname: { $in: ['Fox'] } }]
})
* @return {object} signature - Default methods signature format { error, data }
* @return {string|object} signature.error - Execution error
* @return {number} signature.data - Documents counter
*/
async count (query = {}) {
let data = null
let error = null
try {
const counter = await this.#_foundation
.localDatabaseTransport
.collection(this.#_entity)
.count(query)
data = counter
} catch (e) {
error = e
}
return createMethodSignature(error, data)
}
/**
* @Method DataEntity.#_listenToAllOtherSessionsStateChanges
* @summary PRIVATE - Listen to data state changes on every Application session.
* @description Listen to data state change event incoming from every other Application session and communicates to every subscriber tied to this session.
* <br><br> The application scope is the browser running the application.
* <br><br> Every tab is considered a session.
* <br> <br> Internally it triggers all events related to data change events, except if the source, the session which originated the event, is the same that is receiving the event
* <br> <br> It does not rely on network to propagate the changes.
* @example
this.#_listenToAllOtherSessionsStateChanges()
*/
#_listenToAllOtherSessionsStateChanges() {
window.addEventListener('storage', (event) => {
if (event.key === this.#_stateChangeStorageName)
{
const { key, newValue, oldValue } = event
if (newValue)
{
// console.log('DATAAPI -> got new state change', { key, newValue, oldValue })
const jsonState = JSON.parse(newValue)
const { error, data, entity, action, source, document } = jsonState
// console.error({ error, data, entity, action, source, document })
const eventObj = {
foundation: this.#_foundation,
entity: entity,
document: document,
data,
error
}
this.#_foundation.triggerEvent(`collection:${action}:${entity.toLowerCase()}`, eventObj)
this.triggerEvent(action, eventObj)
}
// oldValue
}
})
}
/**
* @Method DataEntity.#_sendStateChangeToAllOtherSessions
* @summary PRIVATE - Sends data state changes information to every other current application session.
* @description The application scope is the browser running the application. <br> Every tab is considered a session.<br> It can not rely on network.
* @param {object} state - Object containing all information about the state
* @param {object} state.data - The modified data, default is null if not provided
* @param {object|string} state.error - The returned error when trying to modify the data, default is null if not provided
* @param {object} state.document - The raw object used as value to get the new data state, default is {} if not provided
* @example
this.#_sendStateChangeToAllOtherSessions({
action: 'add',
data: {...newDocument},
error: null,
document: {...originalDocument}
})
*/
#_sendStateChangeToAllOtherSessions(state) {
// state { action: '', data: null, error: null, document: {} }
state.source = {
sessionId: this.#_foundation.tabId,
applicationId: this.#_foundation.guid,
}
state.entity = this.#_entity
const stateChange = JSON.stringify(state)
window.localStorage.setItem(this.#_stateChangeStorageName, stateChange)
window.localStorage.removeItem(this.#_stateChangeStorageName)
}
}