'use strict'; function lowerFirst(str) { return str.charAt(0).toLowerCase() + str.substr(1); } function snakeCase(str) { return lowerFirst(str).replace(/([A-Z])/g, function (match) { return "_" + match.toLowerCase(); }); } function camelCase(str) { return str.replace(/_([a-z])/g, function (match) { return match[1].toUpperCase(); }); } function upperCamelCase(str) { var camel = camelCase(str); return camel.charAt(0).toUpperCase() + camel.substr(1); } var searchConditions = { '=': true, '==': true, '!=': true, '<>': true, '<': true, '<=': true, '!<': true, '>': true, '>=': true, '!>': true, 'IS': true, 'IS NOT': true, 'IN': true, 'NOT IN': true, 'LIKE': true, 'NOT LIKE': true, 'GLOB': true, 'NOT GLOB': true, 'BETWEEN': true, 'NOT BETWEEN': true, }; function wrap(db, dir, dbsMap) { // TODO if I put a failure right here, // why doesn't the unhandled promise rejection fire? var PromiseA = require('bluebird'); var promises = []; var earr = []; var debug = false; if (!dbsMap) { dbsMap = {}; } // PRAGMA schema.table_info(table-name); // function sqlite3GetColumns(tablename, columns, cb) { var sql = "PRAGMA table_info(" + db.escape(tablename) + ")"; db.all(sql, earr, function (err, result) { if (err) { console.error('[Error] query columns'); console.error(err.stack); cb(err); return; } if (debug) { console.log('sqlite3 rows 0'); console.log(result); } function alterTable() { var column = columns.pop(); var sql; if (!column) { cb(null); return; } if ((result.rows||result).some(function (row) { return (row.column_name || row.name) === snakeCase(column.name); })) { alterTable(); return; } sql = "ALTER TABLE " + db.escape(tablename) + " ADD COLUMN " + db.escape(column.name) + " " + db.escape(column.type) + " DEFAULT null" ; if (debug) { console.log('sqlite3 1'); console.log(sql); } db.all(sql, earr, function (err, results) { if (err) { console.error("[Error] add column '" + tablename + "'"); console.error(sql); console.error(err.stack || new Error('stack').stack); cb(err); return; } if (debug) { console.log('sqlite3 rows 1'); console.log(results); } alterTable(); }); } columns = columns.slice(0); alterTable(); }); } function normalizeColumn(col, i, arr) { if ('string' === typeof col) { col = arr[i] = { name: col, type: 'text' }; } if (!col.type) { col.type = 'text'; } col.type = col.type.toLowerCase(); // oh postgres... col.name = snakeCase(col.name); return col; } function createTable(dir) { if (!dir.modelname && !dir.tablename) { throw new Error('Please specify dir.modelname'); } if (!dir.tablename) { dir.tablename = snakeCase(dir.modelname); } if (!dir.modelname) { dir.modelname = upperCamelCase(dir.tablename); } if (!dir.indices) { dir.indices = []; } var DB = {}; var tablename = (db.escape(dir.tablename || 'data')); var idname = (db.escape(dir.idname || 'id')); var idnameCased = (camelCase(dir.idname || 'id')); dir.indices.forEach(normalizeColumn); DB._indices = dir.indices; DB._indicesMap = {}; DB._indices.forEach(function (col) { DB._indicesMap[col.name] = col; }); function simpleMap(rows) { if (!rows) { return []; } var results = rows.map(function (row, i) { // set up for garbage collection rows[i] = null; var obj; if (row.json) { obj = JSON.parse(row.json); } else { obj = {}; } delete row.json; obj[idnameCased] = row[idname]; delete row[idname]; Object.keys(row).forEach(function (fieldname) { // Ideally it shouldn't be possible to overriding a former proper column, // but when a new indexable field is added, the old value is still in json // TODO one-time upgrade of all rows when a new column is added if (null === row[fieldname] || 'undefined' === typeof row[fieldname] || '' === row[fieldname]) { obj[camelCase(fieldname)] = row[fieldname] || obj[camelCase(fieldname)] || row[fieldname]; } else { obj[camelCase(fieldname)] = row[fieldname]; } }); return obj; }); // set up for garbage collection rows.length = 0; rows = null; return results; } function simpleParse(row) { if (!row) { return null; } return simpleMap([row])[0] || null; } // pull indices from object function strainUpdate(id, data/*, vals*/, cb, oldId) { var fieldable = []; var sql; var vals = []; ['hasOne', 'hasMany', 'hasAndBelongsToMany', 'belongsTo', 'belongsToMany'].forEach(function (relname) { var rels = dir[relname]; if (!rels) { return; } if (!Array.isArray(rels)) { rels = [rels]; } // don't save relationships rels.forEach(function (colname) { delete data[colname]; delete data[camelCase(colname)]; // TODO placehold relationships on find / get? // data[camelCase(colname)] = null; }); }); dir.indices.forEach(function (col) { // We prioritze the raw name rather than the camelCase name because it's not in the object // we give for retrieved entries, so if it's present then the user put it there themselves. var val = data[col.name] || data[camelCase(col.name)]; //if (col.name in data) if ('undefined' !== typeof val) { /* fieldable.push( db.escape(snakeCase(col.name)) + " = '" + db.escape(val) + "'" ); */ fieldable.push(db.escape(snakeCase(col.name))); vals.push(val); } delete data[col.name]; delete data[camelCase(col.name)]; }); if (!oldId) { delete data[idnameCased]; } if (!fieldable.length || Object.keys(data).length) { vals.push(JSON.stringify(data)); } else { vals.push(null); } fieldable.push('json'); vals.push(id); sql = cb(fieldable); if (debug) { console.log('[masterquest-sqlite3] dbwrap.js'); console.log(sql); console.log(vals); } vals.forEach(function (val) { if (null === val || 'number' === typeof val) { sql = sql.replace('?', String(val)); } else { sql = sql.replace('?', "'" + db.escape(val) + "'"); } }); return sql; } DB.migrate = function (columns) { columns.forEach(normalizeColumn); return new PromiseA(function (resolve, reject) { sqlite3GetColumns(tablename, columns, function (err) { if (err) { reject(err); return; } resolve(); }); }); }; DB.find = function (obj, params) { var err; var sql = 'SELECT * FROM \'' + tablename + '\' '; var keys = obj && Object.keys(obj); if (obj) { Object.keys(obj).forEach(function (key) { if (undefined === obj[key]) { err = new Error("'" + key + "' was `undefined'. For security purposes you must explicitly set the value to null or ''"); } }); } if (err) { return PromiseA.reject(err); } if (params && params.limit) { params.limit = parseInt(params.limit, 10); // remember to check for the case of NaN if (!params.limit || params.limit <= 0) { return PromiseA.reject(new Error('limit must be a positive integer')); } } if (obj && keys.length) { var conditions = keys.map(function (key) { var dbKey = db.escape(snakeCase(key)); var value = obj[key]; if (null === value) { return dbKey + ' IS NULL'; } var split, cmd; if (typeof value === 'string') { value = value.trim(); if (['IS NULL', 'IS NOT NULL'].indexOf(value.toUpperCase()) !== -1) { return dbKey + ' ' + value.toUpperCase(); } split = value.split(' '); if (searchConditions[split[0].toUpperCase()]) { cmd = split[0].toUpperCase(); value = split.slice(1).join(' '); } else if (searchConditions[split.slice(0, 2).join(' ').toUpperCase()]) { cmd = split.slice(0, 2).join(' ').toUpperCase(); value = split.slice(2).join(' '); } // If we were given something like "BEGINS WITH 'something quoted'" we don't want // to include the quotes (we'll quote it again later) so we strip them out here. if (cmd) { value = value.replace(/^(['"])(.*)\1$/, '$2'); } } if (typeof value === 'object') { cmd = value.condition || value.relation || value.cmd; value = value.value; if (!cmd || !value) { err = new Error("'"+key+"' was an object, but missing condition and/or value"); return; } if (typeof cmd !== 'string' || !searchConditions[cmd.toUpperCase()]) { err = new Error("'"+key+"' tried to use invalid condition '"+cmd+"'"); return; } else { cmd = cmd.toUpperCase(); } } if (!cmd) { cmd = '='; } // The IN condition is special in that we can't quote the value as a single value, // so it requires a little more logic to actually work and still be sanitary. if (cmd === 'IN' || cmd === 'NOT IN') { if (typeof value === 'string') { value = value.split((params || {}).seperator || /[\s,]+/); } if (!Array.isArray(value)) { err = new Error("'"+key+"' has invalid value for use with 'IN'"); return; } value = value.map(function (val) { return "'"+db.escape(val)+"'"; }); return dbKey + ' ' + cmd + ' (' + value.join(',') + ')'; } // The BETWEEN condition is also special for the same reason as IN if (cmd === 'BETWEEN' || cmd === 'NOT BETWEEN') { if (typeof value === 'string') { value = value.split((params || {}).seperator || /[\s,]+(AND\s+)?/i); } if (!Array.isArray(value) || value.length !== 2) { err = new Error("'"+key+"' has invalid value for use with 'BETWEEN'"); return; } value = value.map(function (val) { return "'"+db.escape(val)+"'"; }); return dbKey + ' ' + cmd + ' ' + value.join(' AND '); } // If we are supposed to compare to another field then make sure the name is correct, // and that we don't try to quote the name. if (typeof value === 'string' && /^[a-zA-Z0-9_]*$/.test(value)) { var snake = snakeCase(value); if (dir.indices.some(function (col) { return snake === col.name; })) { return dbKey + ' ' + cmd + ' ' + snake; } } return dbKey + ' ' + cmd + " '" + db.escape(value) + "'"; }); if (err) { return PromiseA.reject(err); } sql += 'WHERE ' + conditions.join(' AND '); } else if (null !== obj || !(params && params.limit)) { return PromiseA.reject(new Error("to find all you must explicitly specify find(null, { limit: <> })")); } if (params) { if (typeof params.orderByDesc === 'string' && !params.orderBy) { params.orderBy = params.orderByDesc; params.orderByDesc = true; } // IMPORTANT: " is not the same to sqlite as '. // // " is exact and necessary if (params.orderBy) { sql += " ORDER BY \"" + db.escape(snakeCase(params.orderBy)) + "\" "; if (params.orderByDesc) { sql += "DESC "; } } else if (DB._indicesMap.updated_at) { sql += " ORDER BY \"updated_at\" DESC "; } else if (DB._indicesMap.created_at) { sql += " ORDER BY \"created_at\" DESC "; } if (isFinite(params.limit)) { sql += " LIMIT " + parseInt(params.limit, 10); } } return db.allAsync(sql, []).then(simpleMap); }; DB.get = function (id) { var sql = "SELECT * FROM " + tablename + " WHERE " + idname + " = ?"; var values = [id]; return db.getAsync(sql, values).then(function (rows) { if (Array.isArray(rows)) { if (!rows.length) { return null; } return rows[0] || null; } return rows; }).then(simpleParse); }; DB.upsert = function (id, data, oldId) { if (!data) { data = id; id = data[idnameCased]; } return DB.set(oldId || id, data, oldId).then(function (result) { var success = result.changes >= 1; if (success) { return result; } return DB.create(id, data); }); }; DB.save = function (data, oldId) { if (!data[idnameCased] && !oldId) { // NOTE saving the id both in the object and the id for now data[idnameCased] = require('uuid').v4(); return DB.create(data[idnameCased], data).then(function (/*stats*/) { //data._rowid = stats.id; return data; }); } return DB.set(oldId || data[idnameCased], data, oldId).then(function (result) { var success = result.changes >= 1; if (success) { return result; } else { //console.log('[debug result of set]', result.sql); delete result.sql; } return null; }); }; DB.create = function (id, obj) { if (!obj) { obj = id; id = obj[idnameCased]; } if (!id) { return PromiseA.reject(new Error("no id supplied")); } obj.createdAt = Date.now(); obj.updatedAt = Date.now(); return new PromiseA(function (resolve, reject) { var json = JSON.stringify(obj); var data = JSON.parse(json); var sql; // removes known fields from data sql = strainUpdate(id, data, function sqlTpl(fieldable) { return "INSERT INTO " + tablename + " (" + fieldable.join(', ') + ", " + idname + ")" //+ " VALUES ('" + vals.join("', '") + "')" + " VALUES (" + fieldable.map(function () { return '?'; }).join(", ") + ", ?)" ; }); //console.log('[debug] DB.create() sql:', sql); db.run(sql, [], function (err) { if (err) { reject(err); return; } // NOTE changes is 1 even if the value of the updated record stays the same // (PostgreSQL would return 0 in that case) // thus if changes is 0 then it failed, otherwise it succeeded /* console.log('[log db wrapper insert]'); console.log(this); // sql, lastID, changes console.log(this.sql); console.log('insert lastID', this.lastID); // sqlite's internal rowId console.log('insert changes', this.changes); */ //this.id = id; resolve(this); }); }); }; DB.set = function (id, obj, oldId) { obj.updatedAt = Date.now(); var json = JSON.stringify(obj); var data = JSON.parse(json); return new PromiseA(function (resolve, reject) { function sqlTpl(fieldable) { // this will always at least have one fieldable value: json return "UPDATE " + tablename + " SET " + (fieldable.join(' = ?, ') + " = ?") + " WHERE " + idname + " = ?" ; } //var vals = []; // removes known fields from data data.updated_at = Date.now(); var sql = strainUpdate(id, data/*, vals*/, sqlTpl, oldId); //console.log('[debug] DB.set() sql:', sql); db.run(sql, /*vals*/[], function (err) { //console.log('[debug] error:', err); if (err) { reject(err); return; } // it isn't possible to tell if the update succeeded or failed // only if the update resulted in a change or not /* console.log('[log db wrapper set]'); console.log(this); // sql, lastID, changes console.log(this.sql); console.log('update lastID', this.lastID); // always 0 (except on INSERT) console.log('update changes', this.changes); */ resolve(this); }); }); }; DB.destroy = function (id) { if ('object' === typeof id) { id = id[idnameCased]; } return new PromiseA(function (resolve, reject) { var sql = "DELETE FROM " + tablename + " WHERE " + idname + " = ?"; var values = [id]; db.run(sql, values, function (err) { if (err) { reject(err); return; } // it isn't possible to tell if the update succeeded or failed // only if the update resulted in a change or not /* console.log('[log db wrapper delete]'); console.log(this); // sql, lastID, changes console.log(this.sql); console.log('delete lastID', this.lastID); // always 0 (except on INSERT) console.log('delete changes', this.changes); */ resolve(this); }); }); }; DB._db = db; return new PromiseA(function (resolve, reject) { var indexable = [idname + ' TEXT']; var sql; dir.indices.forEach(function (col) { if ('string' === typeof col) { col = { name: col, type: 'TEXT' }; } if (!col.type) { col.type = 'TEXT'; } indexable.push( db.escape(snakeCase(col.name)) + ' ' + db.escape(col.type) ); }); indexable.push('json TEXT'); sql = "CREATE TABLE IF NOT EXISTS '" + tablename + "' " + "(" + indexable.join(', ') + ", PRIMARY KEY(" + idname + "))" ; db.runAsync(sql).then(function () { sqlite3GetColumns(tablename, dir.indices, function (err) { if (err) { console.error('[Error] dbwrap get columns'); console.error(err.stack); reject(err); return; } resolve(DB); }); }, reject); }); } if (!db.__masterquest_init) { db.__masterquest_init = true; db = PromiseA.promisifyAll(db); db.__masterquest_init = true; db.escape = function (str) { // TODO? literals for true,false,null // error on undefined? if (undefined === str) { str = ''; } return String(str).replace(/'/g, "''"); }; if (dir && dir.verbose || db.verbose) { console.log('Getting Verbose up in here'); db.on('trace', function (str) { console.log('SQL:', str); }); db.on('profile', function (sql, ms) { console.log('Profile:', ms); }); } } dbsMap.sql = db; dir.forEach(function (dir) { // TODO if directive is the same as existing dbsMap, skip it promises.push(createTable(dir).then(function (dbw) { dbsMap[dir.modelname] = dbw; return dbw; })); }); return PromiseA.all(promises).then(function (/*dbs*/) { return dbsMap; }); } module.exports.wrap = wrap;