diff --git a/lib/dbwrap.js b/lib/dbwrap.js index 059a5c2..51d67c9 100644 --- a/lib/dbwrap.js +++ b/lib/dbwrap.js @@ -1,5 +1,47 @@ '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? @@ -12,34 +54,6 @@ function wrap(db, dir, dbsMap) { dbsMap = {}; } - function lowerFirst(str) { - return str.charAt(0).toLowerCase() + str.slice(1); - } - - function snakeCase(str) { - return lowerFirst(str).replace( - /([A-Z])/g - , function ($1) { - return "_" + $1.toLowerCase(); - } - ); - } - - function camelCase(str) { - str = str.replace( - /_([a-z])/g - , function (g) { - return g[1].toUpperCase(); - } - ); - return str; - } - - function upperCamelCase(str) { - // TODO handle UTF-8 properly (use codePointAt, don't use slice) - return str.charAt(0).toUpperCase() + camelCase(str).slice(1); - } - // PRAGMA schema.table_info(table-name); // function sqlite3GetColumns(tablename, columns, cb) { @@ -152,14 +166,6 @@ function wrap(db, dir, dbsMap) { DB._indicesMap[col.name] = col; }); - function simpleParse(row) { - if (!row) { - return null; - } - - return simpleMap([row])[0] || null; - } - function simpleMap(rows) { if (!rows) { return []; @@ -173,10 +179,10 @@ function wrap(db, dir, dbsMap) { if (row.json) { obj = JSON.parse(row.json); - delete row.json; } else { obj = {}; } + delete row.json; obj[idnameCased] = row[idname]; delete row[idname]; @@ -202,6 +208,93 @@ function wrap(db, dir, dbsMap) { 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); @@ -233,36 +326,124 @@ function wrap(db, dir, dbsMap) { return PromiseA.reject(err); } - if (obj && keys.length) { - sql += 'WHERE '; - - keys.forEach(function (key, i) { - if (i !== 0) { - sql += 'AND '; - } - if (null === obj[key]) { - sql += db.escape(snakeCase(key)) + " IS null"; - } - else { - // TODO check that key is some type? ignore undefined? - sql += db.escape(snakeCase(key)) + " = '" + db.escape(obj[key]) + "'"; - } - }); + 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')); + } } - else if (null !== obj || (params && !params.limit)) { + + 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; + } if (params.orderBy) { - sql += " ORDER BY \"" + db.escape(snakeCase(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 "; + sql += " ORDER BY 'updated_at' DESC "; } else if (DB._indicesMap.created_at) { - sql += " ORDER BY \"created_at\" DESC "; + sql += " ORDER BY 'created_at' DESC "; } if (isFinite(params.limit)) { sql += " LIMIT " + parseInt(params.limit, 10); @@ -380,80 +561,6 @@ function wrap(db, dir, dbsMap) { }); }; - // pull indices from object - function strainUpdate(id, data/*, vals*/, cb, oldId) { - var fieldable = []; - var json; - 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) { - var val = 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) { - json = JSON.stringify(data); - fieldable.push("json"); - //fieldable.push("json = '" + db.escape(json) + "'"); - vals.push(json); - } - - vals.push(id); - - sql = cb(fieldable); - - if (debug) { - console.log('[masterquest-sqlite3] dbwrap.js'); - console.log(sql); - console.log(vals); - } - - while (vals.length) { - sql = sql.replace(/\?/, "'" + db.escape(vals.shift()) + "'"); - } - - return sql; - } - DB.set = function (id, obj, oldId) { obj.updatedAt = Date.now(); diff --git a/package.json b/package.json index d992a97..929a93f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "masterquest-sqlite3", - "version": "1.1.0", + "version": "1.2.0", "description": "A NoSQL / SQLite3 Hybrid. All your indices are belong to us. Master Quest.", "main": "lib/dbwrap", "scripts": {