jaguardb
Version:
The simplest in-process database for Node.js that could possible work.
422 lines (369 loc) • 12.6 kB
JavaScript
var path = require('path');
var fs = require('fs');
var util = require('./jaguarUtil');
var JaguarDb = function(options) {
this.dbPath = null;
this.indexFile = null;
this.indexData = { nextId: 1, indexes: [], documents: [] };
this.log = function(message) {};
if(options != null && options.logging === true) {
this.log = function(message) {
console.log('jaguarDb: %s', message);
}
}
}
// ----------------------------------
// Connect to a database
// ----------------------------------
JaguarDb.prototype.connect = function(dbPath, cb) {
this.log('Connecting to: ' + dbPath);
this.dbPath = dbPath;
this.indexFile = path.join(this.dbPath, 'index.json');
var _this = this;
// Check if the path exists and it's indeed a directory.
fs.stat(dbPath, function(err, stat) {
if (err) {
if(err.code == 'ENOENT') {
_this.log('Creating directory ' + _this.dbPath)
fs.mkdirSync(_this.dbPath); // FYI: blocking call
}
else {
cb(err);
return;
}
}
else {
if(!stat.isDirectory()) {
cb(_this.dbPath + " exists but it's not a folder!");
return;
}
}
_this._loadIndexData(_this, cb);
});
}
// Internal method.
// Loads the "index.json" file to memory.
JaguarDb.prototype._loadIndexData = function(_this, cb) {
fs.exists(_this.indexFile, function(exists) {
if (exists) {
fs.readFile(_this.indexFile, function(err, data) {
if(err) {
_this.log('Index file already exists, but could not be read.');
cb(err);
}
else {
_this.log('Index file read');
_this.indexData = JSON.parse(data);
cb(null);
}
});
}
else {
// create index file
_this.log('Creating index file: ' + _this.indexFile);
fs.writeFile(_this.indexFile, JSON.stringify(_this.indexData), function(err) {
if (err) {
_this.log('ERROR', 'Could not create index file. Error: ' + err);
cb(err);
}
else {
_this.log('Index file created');
cb(null);
}
});
}
});
}
// ----------------------------------------------------
// Inserts a new document in the database
// data is an object with the values to save.
// Notes:
// All values in this object are saved to the database.
// An _id value will always be assigned to the data object
// before saving it (even if the object comes with one.)
// ----------------------------------------------------
JaguarDb.prototype.insert = function(data, cb) {
this.log('About to insert');
data._id = this.indexData.nextId;
this.indexData.nextId++;
// update index
var indexes = this.indexData.indexes;
var indexDoc = { _id: data._id};
for(var i=0; i<indexes.length; i++) {
var indexField = indexes[i];
indexDoc[indexField] = data[indexField];
}
this.indexData.documents.push(indexDoc);
// Use blocking-write to make sure the document file
// is created before we update the index.
// Also use blocking-write to update the index.
// Should change these to non-blocking once
// I figure out how to prevent conflicts.
try {
var documentFile = path.join(this.dbPath, data._id.toString() + '.json');
fs.writeFileSync(documentFile, JSON.stringify(data));
this.log('Document inserted: ' + documentFile);
fs.writeFileSync(this.indexFile, JSON.stringify(this.indexData));
this.log('Index file updated');
cb(null, data);
}
catch(err) {
this.log('Error inserting:' + err);
cb(err);
}
}
// ----------------------------------------------------
// Updates an existing document in the database
// data is an object with the values to save.
// Notes:
// All values in this object are saved to the database.
// The data object must come with an _id value and it must
// match with an existing document.
// ----------------------------------------------------
JaguarDb.prototype.update = function(data, cb) {
var i;
this.log('About to update');
if(data._id === undefined) {
cb('No _id was found on document');
return;
}
// find the document to update on the index
var indexDoc = null;
var documents = this.indexData.documents;
for(i=0; i<documents.length; i++) {
if(documents[i]._id === data._id) {
indexDoc = documents[i];
break;
}
}
if(indexDoc === null) {
cb("The _id to update [" + data._id + "] was not found.");
return;
}
// update the document in the index
var indexes = this.indexData.indexes;
for(i=0; i<indexes.length; i++) {
var indexField = indexes[i];
indexDoc[indexField] = data[indexField];
}
var documentFile = path.join(this.dbPath, data._id.toString() + '.json');
var _this = this;
fs.writeFile(this.indexFile, JSON.stringify(this.indexData), function(err) {
if (err) {
_this.log('ERROR', 'Could not update index file. Error: ' + err);
cb(err);
}
else {
_this.log('Index file updated');
// save full document
fs.writeFile(documentFile, JSON.stringify(data), function(err) {
if (err) {
_this.log('ERROR', 'Could not update document. Error: ' + err);
cb(err);
}
else {
_this.log('Document updated: '+ documentFile);
cb(null, data);
}
});
}
});
}
// ----------------------------------
// Find documents in the database
//
// Query is an object with the fields and values that will be
// used to filter which documents will be selected.
// query = {fieldA: 'some value'}
//
// Fields is an object with the fields that will be selected.
// fields = {fieldA: 1, fieldB: 1, fieldX: 1}
//
// cb is a callback that will be called with the following
// arguments: (err, documents)
//
// ----------------------------------
JaguarDb.prototype.find = function(query, fields, cb) {
query = query || {}; // default to select all documents
fields = fields || {}; // default to select all fields
var isFindAll = util.isEmptyObject(query);
if(isFindAll) {
this._getAll(fields, cb);
return;
}
this._getSome(query, fields, cb);
}
// Internal method.
// Fetches all documents in the database.
// See find() for more information.
//
// Notes:
// This method blocks if the fields selected are not in the indices!
// Eventually I want to make it async.
JaguarDb.prototype._getAll = function(fields, cb) {
var i, _id, file, text, document;
var documents = this.indexData.documents;
var indexes = this.indexData.indexes;
var selectFields = Object.getOwnPropertyNames(fields);
var foundDocs = [];
var isCoveredFields = false;
if(!util.isEmptyObject(selectFields)) {
isCoveredFields = util.isCoveredQuery(indexes, selectFields);
}
if(isCoveredFields) {
// We've got all the data that we need in the indexes.
this.log('getAll (covered query)');
for(i=0; i<documents.length; i++) {
document = documents[i];
foundDocs.push(document);
}
util.projectFields(foundDocs, fields, cb);
return;
}
// Worse case scenario. Read the full document for all
// documents because the fields requested are not in
// the index.
this.log('getAll (full table scan)');
for(i=0; i<documents.length; i++) {
_id = documents[i]._id;
file = path.join(this.dbPath, _id.toString() + '.json');
text = fs.readFileSync(file); // Blocking call
document = JSON.parse(text);
foundDocs.push(document);
}
util.projectFields(foundDocs, fields, cb);
}
// Internal method.
// Fetches a subset of the documents in the database.
// See find() for more information.
//
// Notes:
// This method blocks if the fields selected or the field
// to query by are not in the indices!
// Eventually I want to make it async.
//
// Only exact matches on queries are supported
// (i.e. field = 'value')
// Other types of queries are NOT supported yet.
// (i.e. field != value or field >= 'value')
JaguarDb.prototype._getSome = function(query, fields, cb) {
var _id, file, text, document, i;
var documents = this.indexData.documents;
var indexes = this.indexData.indexes;
var queryFields = Object.getOwnPropertyNames(query);
var selectFields = Object.getOwnPropertyNames(fields);
var foundDocs = [];
var isCoveredQuery = util.isCoveredQuery(indexes, queryFields);
var isCoveredFields = util.isCoveredQuery(indexes, selectFields);
if(isCoveredQuery && isCoveredFields) {
// Query and selection can be satisfied with the index alone
// and therefore we don't need to read full documents at all!
this.log('getSome (covered query)');
for(i=0; i<documents.length; i++) {
document = documents[i];
if(util.isMatch(document, queryFields, query)) {
foundDocs.push(document);
}
}
util.projectFields(foundDocs, fields, cb);
return;
}
if(isCoveredQuery) {
// Query can be satisfied with the indexes but not
// the data selection. Read the full document only
// for those documents that meet the query criteria.
this.log('getSome (partial table scan)')
for(i=0; i<documents.length; i++) {
document = documents[i];
if(util.isMatch(document, queryFields, query)) {
// read the full document
_id = documents[i]._id;
this.log('reading full document %s', _id);
file = path.join(this.dbPath, _id.toString() + '.json');
text = fs.readFileSync(file); // Blocking call
document = JSON.parse(text);
foundDocs.push(document);
}
}
util.projectFields(foundDocs, fields, cb);
return;
}
// Worse case scenario. Read the full document for all
// documents because the query cannot be satisfied
// with the indexes.
this.log('getSome (full table scan)')
for(i=0; i<documents.length; i++) {
_id = documents[i]._id;
file = path.join(this.dbPath, _id.toString() + '.json');
text = fs.readFileSync(file); // Blocking call
document = JSON.parse(text);
if(util.isMatch(document, queryFields, query)) {
foundDocs.push(document);
}
}
util.projectFields(foundDocs, fields, cb);
}
// ----------------------------------------
// Find one document in the database by Id.
// All the fields of the document are read.
// ----------------------------------------
JaguarDb.prototype.findById = function(id, cb) {
// Go straight after the file with the document
// information (i.e. don't even bother looking
// at the index.)
var file = path.join(this.dbPath, id.toString() + '.json');
fs.readFile(file, function(err, text) {
if(err) {
if(err.code === 'ENOENT') {
cb(null, null); // document not found
}
else {
cb(err); // a true other error
}
}
else {
var document = JSON.parse(text);
cb(null, document);
}
});
}
JaguarDb.prototype.findByIdSync = function(id) {
// Go straight after the file with the document
// information (i.e. don't even bother looking
// at the index.)
var file = path.join(this.dbPath, id.toString() + '.json');
if(!fs.existsSync(file)) {
return null;
}
var text = fs.readFileSync(file);
var document = JSON.parse(text);
return document;
}
// ----------------------------------
// Create an index
// Field is a string with the name of the field to index.
// When force is true the index will be recreated if already
// exists. When force is false the index will only be created
// if it does not exist already.
// ----------------------------------
JaguarDb.prototype.ensureIndexSync = function(field, force) {
if(this.indexData.indexes.indexOf(field) == -1) {
this.indexData.indexes.push(field);
}
else {
// index is already been created
if(force !== true) {
return;
}
}
this.log("Populating index [" + field + "]...");
for(i=0; i<this.indexData.documents.length; i++) {
var indexDoc = this.indexData.documents[i];
var doc = this.findByIdSync(indexDoc._id);
indexDoc[field] = doc[field];
}
this.log("Saving index [" + field + "]...");
fs.writeFileSync(this.indexFile, JSON.stringify(this.indexData));
this.log("Index created.")
}
exports.JaguarDb = JaguarDb;