fs = require 'fs'
sysPath = require 'path'
mkdirp = require 'mkdirp'
utils = require './utils'
CoreObject = require './CoreObject'
MergedRecordStore = require './MergedRecordStore'
Model = require './Model'
Class = null
###*
The main class of the `local-json-db`, representing a database with one or more layers
@since 0.0.2
@class Database
@extends CoreObject
@constructor
@example
```js
var db = new Database();
var record = db.createRecord({name: 'Huafu'});
record.age = 31;
db.updateRecord(record);
console.log(db.find('user', record.id));
// will output {id: 1, name: 'Huafu', age: 31}
db.save();
```
###
class Database extends CoreObject
###*
The base path
@since 0.0.2
@private
@property _basePath
@type String
@default "./json.db"
###
_basePath: null
###*
Configuration options
@since 0.0.2
@private
@property _config
@type Object
@default {deletedAtKey: "__deleted"}
###
_config: null
###*
Array of all layers (overlays) path
@since 0.0.2
@private
@property _layers
@type Array<String>
@default [this._baseBath]
###
_layers: null
###*
Loaded models, indexed by their bare name
@since 0.0.2
@private
@property _models
@type Object<Model>
@default null
###
_models: null
###*
Constructs a new instance of {{#crossLink "Database"}}{{/crossLink}}
@since 0.0.2
@method constructor
@param {String} [basePath="./json.db"] the base path for the db files, hosting base JSON files of any added overlay
@param {Object} [config={}] the configuration options
@param {String} [config.createdAtKey] name of the key to use as the `createdAt` flag for a record
@param {String} [config.updatedAtKey] name of the key to use as the `updatedAt` flag for a record
@param {String} [config.deletedAtKey="__deleted"] name of the key to use as the `deletedAt` flag for a record
###
constructor: (basePath = './json.db', config = {}) ->
@_basePath = sysPath.resolve basePath
@_config = utils.defaults {}, config
@_layers = [@_basePath]
# keep to null so we know if we are loaded or not, in which case adding/removing layers would be impossible
@_models = null
@lockProperties '_basePath', '_config', '_layers'
###*
Add an overlay on top of all layers (the latest is the one used first, then come the others in order until the base)
@since 0.0.2
@method addOverlay
@param {String} path the path where to read/write JSON files of records, relative to the base path
@chainable
###
addOverlay: (path) ->
@assertNotLoaded('cannot add overlay')
if utils.isArray(path)
path = sysPath.join(path...)
path = sysPath.resolve @_basePath, path
@assert path not in @_layers, "you cannot add twice the same path for 2 different overlays"
@_layers.push path
@
###*
Creates a new record in the database
@since 0.0.2
@method createRecord
@param {String} modelName name of the model
@param {Object} record attributes of the record to create
@return {Object} a copy of the newly created model with read-only `id`
###
createRecord: (modelName, record) ->
@modelFactory(modelName).create record
###*
Updates a record with the given attributes
@since 0.0.2
@method updateModel
@param {String} modelName name of the model
@param {String|Number} [id] id of the record to update, if not given it must be in `record`
@param {Object} record attributes of the record to update
@return {Object} a copy of the updated record
###
updateRecord: (modelName, id, record) ->
mdl = @modelFactory(modelName)
mdl.update.apply mdl, [].slice.call(arguments, 1)
###*
Deletes a record given its id
@since 0.0.2
@method deleteRecord
@param {String} modelName name of the model
@param {String|Number} id id of the record to delete
@return {Object} a copy of the old record which has been deleted
###
deleteRecord: (modelName, id) ->
@modelFactory(modelName).delete id
###*
Finds a record by id
@since 0.0.2
@method find
@param {String} modelName name of the model
@param {String|Number} id id of the record to find
@return {Object|undefined} copy of the record if found, else `undefined`
###
find: (modelName, id) ->
@modelFactory(modelName).find id
###*
Finds many record given a list of ids. If some records are not found, they'll just be filtered out
of the resulting array
@since 0.0.2
@method findMany
@param {String} modelName name of the model
@param {Array|String|Number} ids* id of each record to find, or one array with all record ids
@return {Array<Object>} array containing all found records
###
findMany: (modelName, ids...) ->
@modelFactory(modelName).findMany ids...
###*
Finds records using a filter (either function or set of properties to match)
@since 0.0.2
@method findQuery
@param {String} modelName name of the model
@param {Object|Function} filter attributes to match or a function used to filter records
@param {Object} [thisArg] will be used as the context to run the filter function
@return {Array<Object>} array with all records which matched
###
findQuery: (modelName, filter, thisArg) ->
@modelFactory(modelName).findQuery filter, thisArg
###*
Finds all records in the database
@since 0.0.2
@method findAll
@param {String} modelName name of the model
@return {Array<Object>} array containing all records of the given model
###
findAll: (modelName) ->
@modelFactory(modelName).findAll()
###*
Counts all records of a given model
@since 0.0.2
@method count
@param {String} modelName name of the model to count records
@return {Number} the total number of records
###
count: (modelName) ->
@modelFactory(modelName).count()
###*
Saves in the top overlay's path the records that have been created/modified or deleted
@since 0.0.2
@method save
@chainable
###
save: ->
if @isLoaded()
models = []
for own name, model of @_models
@_saveModelStore name, model
@emit 'model.store.saved', model
models.push model
@emit 'db.saved', model
@
###*
Whether the database has been loaded or not (in that case no overlay can be added)
@since 0.0.2
@method isLoaded
@return {Boolean} returns `true` if the db is loaded, else `false`
###
isLoaded: ->
Boolean(@_models)
###*
Loads the database (you don't need to call this method, it'll be automatically called when needed)
@since 0.0.2
@method load
@param {Boolean} force whether to force a reload in case it has already been loaded previously
@chainable
###
load: (force) ->
if force or not @isLoaded()
@unload()
@_models = {}
@
###*
Unloads the database and all the records. **This does NOT save anything**
@since 0.0.2
@method unload
@chainable
###
unload: ->
if @isLoaded()
for own name, model of @_models
model.destroy()
@_models = null
@
###*
Destroy and free the db object
@since 0.0.2
@method destroy
###
destroy: ->
@unload()
super
###*
Get the {{#crossLink "Model"}}{{/crossLink}} instance given a model name
@since 0.0.2
@method modelFactory
@param {String} modelName name of the model to get the instance
@return {Model} the model object
###
modelFactory: (modelName) ->
@load()
name = @_modelName(modelName)
unless (model = @_models[name])
@_models[name] = model = new Model(@, name, @_createModelStore(name))
@emit 'model.store.loaded', model
model
###*
Used to transform a model name into its store's JSON file name
@since 0.0.2
@method modelNameToFileName
@param {String} modelName name of the model
@return {String} name of the file, sanitized and normalized
###
modelNameToFileName: (modelName) ->
Model.assertValidModelName modelName
"#{ utils.kebabCase(utils.pluralize modelName) }.json"
###*
Asserts that the DB hasn't been loaded yet, or throw an error
@since 0.0.2
@method assertNotLoaded
@param {String} msg additional message to add in the error
@chainable
###
assertNotLoaded: (msg) ->
@assert not @isLoaded(), "the database is already loaded#{if msg then ", #{msg}" else ''}"
###*
Normalize a model name
@since 0.0.2
@method _modelName
@private
@param {String} name name of the model to normalize
@return {String} normalized name
###
_modelName: (name) ->
Model._modelName name
###*
Creates the store for a given model
@since 0.0.2
@private
@method _createModelStore
@param {String} name name of the model
@return {MergedRecordStore} the newly created store for the given model
###
_createModelStore: (modelName) ->
file = @modelNameToFileName @_modelName modelName
stores = []
for path in @_layers.slice().reverse()
path = sysPath.join path, file
if fs.existsSync(path)
data = fs.readFileSync path, encoding: 'utf8'
data = JSON.parse data
stores.push data
else
stores.push {config: {}, records: []}
main = stores.shift()
store = new MergedRecordStore(main.records, utils.defaults({}, @_config, main.config))
for s in stores
store.addLayer s.records, utils.defaults({}, main.config, s.config)
store
###*
Saves the store of a given model to disk
@since 0.0.2
@private
@method _saveModelStore
@param {String} name name of the model
@param {Model} model model instance
@return {String} the full path of the file that has been saved
###
_saveModelStore: (name, model) ->
file = @modelNameToFileName @_modelName name
top = @_layers[@_layers.length - 1]
path = sysPath.join top, file
store = model._store
if store.countRecords(yes) is 0
fs.unlinkSync(path) if fs.existsSync(path)
else
mkdirp.sync top
fs.writeFileSync path, JSON.stringify(store.export())
path
module.exports = Class = Database