sworm
Version:
a lightweight write-only ORM for MSSQL, MySQL, PostgreSQL, Oracle, Sqlite 3
710 lines (599 loc) • 19.1 kB
JavaScript
var crypto = require("crypto");
var _ = require("underscore");
var mssqlDriver = require("./mssqlDriver");
var pgDriver = require("./pgDriver");
var mysqlDriver = require("./mysqlDriver");
var oracleDriver = require("./oracleDriver");
var sqliteDriver = require("./sqliteDriver");
var websqlDriver = require("./websqlDriver");
var debug = require("debug")("sworm");
var debugResults = require("debug")("sworm:results");
var redactConfig = require('./redactConfig');
var urlUtils = require('url')
var unescape = require('./unescape')
function fieldsForObject(obj) {
return Object.keys(obj).filter(function (key) {
var value = obj[key];
return value instanceof Date
|| unescape.isUnescape(value)
|| value instanceof Buffer
|| !(
value === null
|| value === undefined
|| value instanceof Object
);
});
}
function foreignFieldsForObject(obj) {
return Object.keys(obj).filter(function (key) {
if (/^_/.test(key) && key !== obj._meta.id) {
return false;
} else {
var value = obj[key];
return !(value instanceof Date) && !(unescape.isUnescape(value)) && !(value instanceof Buffer) && value instanceof Object;
}
});
}
function mapDefinition (graphDefinition) {
var isOneToMany = graphDefinition instanceof Array
var model = isOneToMany? graphDefinition[0]: graphDefinition
var foreignFields = foreignFieldsForObject(model)
foreignFields.forEach(function (field) {
var foreign = model[field]
model[field] = mapDefinition(foreign)
})
return {
model: model,
isOneToMany: isOneToMany,
identityMap: {}
}
}
function graphify(definition, rows) {
var map = mapDefinition([definition])
function loadEntity (row, map) {
var fields = fieldsForObject(map.model)
var foreignFields = foreignFieldsForObject(map.model)
if (!map.model.hasIdentity()) {
throw new Error('expected definition for ' + map.model._meta.table + ' to have id')
}
var idField = map.model.identity()
if (!row.hasOwnProperty(idField)) {
throw new Error('expected ' + map.model._meta.table + '.' + map.model._meta.id + ' to be present in results as ' + idField)
}
var id = row[idField]
if (id !== null) {
var entity = map.identityMap[id]
if (!entity) {
entity = map.identityMap[id] = map.model._meta.model({}, {saved: true})
fields.forEach(function (field) {
entity[field] = row[map.model[field]]
})
}
foreignFields.forEach(function (field) {
var foreign = map.model[field]
var foreignEntity = loadEntity(row, foreign)
if (foreign.isOneToMany) {
var array = entity[field]
if (!array) {
array = entity[field] = []
}
var existing = array.find(function (item) {
return item.identity() === foreignEntity.identity()
})
if (foreignEntity && !existing) {
array.push(foreignEntity)
}
} else if (foreignEntity && !entity[field]) {
foreignEntity.setForeignKeyField(false)
entity[field] = foreignEntity
}
})
entity.setNotChanged()
return entity
}
}
var results = []
var resultsSet = new Set()
rows.forEach(function (row) {
var entity = loadEntity(row, map)
if (!resultsSet.has(entity)) {
results.push(entity)
resultsSet.add(entity)
}
})
return results
}
function insertStatement(obj, keys) {
var fields = keys.join(', ');
var values = keys.map(function (key) { return '@' + key; }).join(', ');
if (!fields.length) {
if (obj._meta.db.driver.insertEmpty) {
return obj._meta.db.driver.insertEmpty(obj._meta);
} else {
return 'insert into ' + obj._meta.table + ' default values';
}
} else {
if (obj._meta.db.driver.insertStatement) {
return obj._meta.db.driver.insertStatement(obj._meta, fields, values)
} else {
return 'insert into ' + obj._meta.table + ' (' + fields + ') values (' + values + ')';
}
}
}
function insert(obj) {
return obj._meta.db.whenConnected(function () {
var keys = fieldsForObject(obj);
var statementString = insertStatement(obj, keys);
var params = _.pick(obj, keys);
if (obj._meta.db.driver.outputIdKeys && !obj._meta.compoundKey) {
params = _.extend(params, obj._meta.db.driver.outputIdKeys(obj._meta.idType));
}
return obj._meta.db.query(statementString, params, {
insert: !obj._meta.compoundKey,
statement: obj._meta.compoundKey,
id: obj._meta.id
}).then(function (result) {
obj.setSaved();
if (!obj._meta.compoundKey) {
obj[obj._meta.id] = result.id;
}
return obj.setNotChanged();
});
});
}
function update(obj) {
var keys = fieldsForObject(obj).filter(function (key) {
return key !== obj._meta.id;
});
var assignments = keys.map(function (key) {
return key + ' = @' + key;
}).join(', ');
var whereClause;
if (!obj.hasIdentity()) {
throw new Error(obj._meta.table + ' entity must have ' + obj._meta.id + ' to be updated');
}
if (obj._meta.compoundKey) {
keys.push.apply(keys, obj._meta.id);
whereClause = obj._meta.id.map(function (key) {
return key + ' = @' + key;
}).join(' and ');
} else {
keys.push(obj._meta.id);
whereClause = obj._meta.id + ' = @' + obj._meta.id;
}
var statementString = 'update ' + obj._meta.table + ' set ' + assignments + ' where ' + whereClause;
return obj._meta.db.query(statementString, _.pick(obj, keys), {statement: true}).then(function(result) {
if (result.changes == 0) {
throw new Error(obj._meta.table + ' entity with ' + obj._meta.id + ' = ' + obj.identity() + ' not found to update')
} else {
return obj.setNotChanged();
}
});
}
function foreignField(obj, field) {
var v = obj[field];
if (typeof v == 'function') {
var value = obj[field](obj);
if (value && !(value instanceof Array)) {
throw new Error('functions must return arrays of entities: ' + obj._meta.table + '.' + field)
}
obj[field] = value;
return value;
} else {
return v;
}
}
function saveManyToOne(obj, field, options) {
var value = obj[field]
if (value && !(value instanceof Array || typeof value === 'function' || value._foreignKeyField !== undefined)) {
return value.save(options).then(function () {
var foreignId =
obj._meta.foreignKeyFor ?
obj._meta.foreignKeyFor(field) :
field + '_id';
if (!value._meta.compoundKey) {
obj[foreignId] = value.identity();
}
});
}
}
function saveManyToOnes(obj, options) {
return Promise.all(foreignFieldsForObject(obj).map(function (field) {
return saveManyToOne(obj, field, options);
}));
}
function saveOneToMany(obj, field, options) {
var items = foreignField(obj, field);
if (items instanceof Array) {
return Promise.all(items.map(function (item) {
return item.save(options);
}));
} else if (items && items._foreignKeyField !== undefined) {
if (items._foreignKeyField) {
items[items._foreignKeyField] = obj
}
return items.save()
}
}
function saveOneToManys(obj, options) {
return Promise.all(foreignFieldsForObject(obj).map(function (field) {
return saveOneToMany(obj, field, options);
}));
}
function hash(obj) {
var h = crypto.createHash('md5');
var fields = fieldsForObject(obj).map(function (field) {
return [field, obj[field]];
});
h.update(JSON.stringify(fields));
return h.digest('hex');
}
var rowBase = function() {
return {
save: function(options) {
this._meta.db.ensureConfigured();
var self = this;
var forceUpdate = options && options.hasOwnProperty('update')? options.update: false;
var forceInsert = options && options.hasOwnProperty('insert')? options.insert: false;
var force = options && options.hasOwnProperty('force')? options.force: forceInsert || forceUpdate;
var waitForOneToManys;
var oneToManyPromises;
if (typeof options == 'object' && options.hasOwnProperty('oneToManyPromises')) {
waitForOneToManys = false;
oneToManyPromises = options.oneToManyPromises;
} else {
waitForOneToManys = true;
oneToManyPromises = [];
}
if (!self._saving) {
var saving = saveManyToOnes(this, {oneToManyPromises: oneToManyPromises}).then(function () {
if (self.changed() || force) {
var writePromise = self.saved() || forceUpdate ? update(self) : insert(self);
return writePromise.then(function () {
oneToManyPromises.push(saveOneToManys(self, {oneToManyPromises: oneToManyPromises}))
});
} else {
oneToManyPromises.push(saveOneToManys(self, {oneToManyPromises: oneToManyPromises}))
}
}).then(function (value) {
self.setSaving(false);
return value;
}, function (error) {
self.setSaving(false);
throw error;
})
self.setSaving(saving)
}
oneToManyPromises.push(self._saving)
function waitForPromises () {
if (oneToManyPromises.length) {
var promises = oneToManyPromises.slice()
oneToManyPromises.length = 0
return Promise.all(promises).then(waitForPromises)
} else {
return Promise.resolve()
}
}
if (waitForOneToManys) {
return waitForPromises()
} else {
return self._saving;
}
},
changed: function() {
return !this._hash || this._hash !== hash(this);
},
identity: function () {
if (this.hasIdentity()) {
if (this._meta.compoundKey) {
var self = this;
return this._meta.id.map(function (id) {
return self[id];
});
} else {
return this[this._meta.id];
}
}
},
hasIdentity: function () {
if (this._meta.compoundKey) {
var self = this;
return this._meta.id.every(function (id) {
return self.hasOwnProperty(id) && !!self[id]
});
} else {
return this.hasOwnProperty(this._meta.id) && !!this[this._meta.id]
}
},
saved: function() {
return this._saved;
},
setSaving: function(saving) {
if (saving) {
Object.defineProperty(this, "_saving", {
value: saving,
configurable: true
});
} else {
delete this._saving;
}
},
setNotChanged: function() {
if (this._hash) {
this._hash = hash(this);
return this._hash;
} else {
return Object.defineProperty(this, "_hash", {
value: hash(this),
writable: true
});
}
},
setSaved: function() {
if (!this._saved) {
return Object.defineProperty(this, "_saved", {
value: true
});
}
},
setForeignKeyField: function (foreignKeyField) {
return Object.defineProperty(this, "_foreignKeyField", {
value: foreignKeyField
});
},
insert: function () {
return this.save({insert: true})
},
update: function () {
return this.save({update: true})
},
upsert: function () {
if (this.hasIdentity()) {
return this.update()
} else {
return this.insert()
}
}
};
}();
function isModelMeta(value, key) {
return typeof value !== 'function' || key === 'foreignKeyFor';
}
exports.db = function(config) {
var db = {
log: config && config.log,
config: config,
model: function(modelConfig) {
var proto = _.omit(modelConfig, isModelMeta);
proto._meta = _.extend({
id: 'id'
}, _.pick(modelConfig, isModelMeta));
proto._meta.db = this;
var id = proto._meta.id;
proto._meta.compoundKey = id == false || id instanceof Array;
var modelPrototype = _.extend(Object.create(rowBase), proto);
function model(obj, options) {
var saved = typeof options == 'object' && options.hasOwnProperty('saved')? options.saved: false;
var modified = typeof options == 'object' && options.hasOwnProperty('modified')? options.modified: false;
var foreignKeyField = typeof options == 'object' && options.hasOwnProperty('foreignKeyField')? options.foreignKeyField: undefined;
var row = _.extend(Object.create(modelPrototype), obj);
if (saved) {
row.setSaved();
if (!modified) {
row.setNotChanged();
}
}
if (foreignKeyField !== undefined) {
row.setForeignKeyField(foreignKeyField)
}
return row;
}
proto._meta.model = model
model.query = function() {
var self = this;
return db.query.apply(db, arguments).then(function (entities) {
return entities.map(function (e) {
return self(e, {saved: true});
});
});
};
modelPrototype.queryGraph = function() {
var self = this;
return db.query.apply(db, arguments).then(function (entities) {
return graphify(self, entities)
});
};
return model;
},
query: function(_query, _params, _options) {
var self = this;
var queryParams = unescape.interpolate(_query, _params)
var query = queryParams.query
var params = queryParams.params
var options = _options || {}
return this.whenConnected(function () {
var command = options.insert
? self.driver.insert(query, params, options)
: self.driver.query(query, params, options)
return command.then(function (results) {
self.logResults(query, params, results, options);
return results;
}, function (e) {
self.logError(query, params, e);
throw e;
});
});
},
queryGraph: function(graphDefinition, query, params, options) {
var queryArgs = Array.prototype.slice.call(arguments, 1)
return db.query.apply(db, queryArgs).then(function (entities) {
return graphify(graphDefinition, entities)
});
},
statement: function(query, params, options) {
options = _.extend({statement: true}, options);
return this.query(query, params, options);
},
whenConnected: function(fn) {
if (this.runningBeginSession) {
return fn();
} else {
return this.connect().then(fn);
}
},
logError: function(query, params, error) {
debug(query, params, error);
},
logResults: function(query, params, results, options) {
if (typeof this.log == 'function') {
return this.log(query, params, results, options);
} else {
if (params) {
debug(query, params);
} else {
debug(query);
}
if (options.insert) {
return debugResults('id = ' + results.id);
} else if (options.statement) {
return debugResults('rows affected = ' + results.changes);
} else if (!options.statement && results) {
return debugResults(results);
}
}
},
ensureConfigured: function() {
if (!this.config) {
throw new Error('sworm has not been configured to a database, use db.connect(config) or sworm.db(config)');
}
},
connected: false,
connect: function (config, fn) {
if (typeof config === 'function') {
fn = config;
config = undefined;
}
if (config) {
this.config = config;
}
if (typeof this.config == 'string') {
this.config = configFromUrl(this.config)
}
var self = this;
if (this.connection) {
return this.connection;
}
this.ensureConfigured();
debug('connecting to', redactConfig(this.config));
var driver = {
mssql: mssqlDriver,
pg: pgDriver,
mysql: mysqlDriver,
oracle: oracleDriver,
sqlite: sqliteDriver,
websql: websqlDriver
}[this.config.driver];
if (!driver) {
throw new Error("no such driver: `" + this.config.driver + "'");
}
this.driver = driver();
this.connection = this.driver.connect(this.config).then(function () {
debug('connected to', redactConfig(self.config));
self.connected = true;
if (self.config.setupSession) {
self.runningBeginSession = true;
return Promise.resolve(self.config.setupSession(self)).then(function (result) {
self.runningBeginSession = false;
return result;
}, function (error) {
self.runningBeginSession = false;
throw error;
});
}
});
if (!fn) {
return this.connection;
} else {
return this.connection.then(function () {
return fn();
}).then(function (result) {
return self.close().then(function () {
return result;
});
}, function (error) {
return self.close().then(function () {
throw error;
});
});
}
},
transaction: function (options, fn) {
var self = this;
if (typeof options === 'function') {
fn = options;
options = undefined;
}
return this.begin(options).then(function() {
return fn();
}).then(function(r) {
return self.commit().then(function() { return r; });
}, function(e) {
return self.rollback().then(function() { throw e; });
});
},
begin: function (options) {
if (this.driver.begin) {
return this.driver.begin(options);
} else {
return this.statement('begin' + (options? ' ' + options: ''));
}
},
commit: function () {
if (this.driver.commit) {
return this.driver.commit();
} else {
return this.statement('commit');
}
},
rollback: function () {
if (this.driver.rollback) {
return this.driver.rollback();
} else {
return this.statement('rollback');
}
},
close: function() {
var self = this;
if (this.driver) {
return this.driver.close().then(function () {
self.connected = false;
});
} else {
return Promise.resolve();
}
}
};
return db;
};
exports.unescape = unescape
exports.escape = function(value) {
if (typeof value == 'string') {
return "'" + value.replace(/'/g, "''") + "'"
} else {
return value
}
}
function configFromUrl(url) {
var isBrowser = typeof window !== 'undefined'
var parsedUrl = urlUtils.parse(url)
var protocol = parsedUrl.protocol? parsedUrl.protocol.replace(/:$/, ''): (isBrowser? 'websql': 'sqlite')
var driver = {
postgres: 'pg',
file: 'sqlite',
mssql: 'mssql'
}[protocol] || protocol
return {
driver: driver,
url: url
}
}