This commit is contained in:
Anatoliy Chakkaev 2012-12-14 18:01:44 +04:00
commit 2eed9a6e89
8 changed files with 845 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

7
.travis.yml Normal file
View File

@ -0,0 +1,7 @@
language: node_js
node_js:
- 0.6
- 0.8
- 0.9
before_script:
- "mysql -e 'create database myapp_test;'"

57
README.md Normal file
View File

@ -0,0 +1,57 @@
## JugglingDB-MySQL [![Build Status](https://travis-ci.org/1602/jugglingdb-mysql.png)](https://travis-ci.org/1602/jugglingdb-mysql)
PostgreSQL adapter for JugglingDB.
## Usage
To use it you need `jugglingdb@0.2.x`.
1. Setup dependencies in `package.json`:
```json
{
...
"dependencies": {
"jugglingdb": "0.2.x",
"jugglingdb-mysql": "latest"
},
...
}
```
2. Use:
```javascript
var Schema = require('jugglingbd').Schema;
var schema = new Schema('mysql', {
database: 'myapp_test',
username: 'root'
});
```
## Running tests
npm test
## MIT License
Copyright (C) 2012 by Anatoliy Chakkaev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

1
index.js Normal file
View File

@ -0,0 +1 @@
module.exports = require('./lib/mysql.js');

534
lib/mysql.js Normal file
View File

@ -0,0 +1,534 @@
/**
* Module dependencies
*/
var mysql = require('mysql');
var jdb = require('jugglingdb');
exports.initialize = function initializeSchema(schema, callback) {
if (!mysql) return;
var s = schema.settings;
schema.client = mysql.createConnection({
host: s.host || 'localhost',
port: s.port || 3306,
user: s.username,
password: s.password,
debug: s.debug,
socketPath: s.socketPath
});
schema.adapter = new MySQL(schema.client);
schema.adapter.schema = schema;
// schema.client.query('SET TIME_ZONE = "+04:00"', callback);
schema.client.query('USE `' + s.database + '`', function (err) {
if (err && err.message.match(/^unknown database/i)) {
var dbName = s.database;
schema.client.query('CREATE DATABASE ' + dbName, function (error) {
if (!error) {
schema.client.query('USE ' + s.database, callback);
} else {
throw error;
}
});
} else callback();
});
};
/**
* MySQL adapter
*/
function MySQL(client) {
this._models = {};
this.client = client;
}
require('util').inherits(MySQL, jdb.BaseSQL);
MySQL.prototype.query = function (sql, callback) {
if (!this.schema.connected) {
return this.schema.on('connected', function () {
this.query(sql, callback);
}.bind(this));
}
var client = this.client;
var time = Date.now();
var log = this.log;
if (typeof callback !== 'function') throw new Error('callback should be a function');
this.client.query(sql, function (err, data) {
if (err && err.message.match(/^unknown database/i)) {
var dbName = err.message.match(/^unknown database '(.*?)'/i)[1];
client.query('CREATE DATABASE ' + dbName, function (error) {
if (!error) {
client.query(sql, callback);
} else {
callback(err);
}
});
return;
}
if (log) log(sql, time);
callback(err, data);
});
};
/**
* Must invoke callback(err, id)
*/
MySQL.prototype.create = function (model, data, callback) {
var fields = this.toFields(model, data);
var sql = 'INSERT INTO ' + this.tableEscaped(model);
if (fields) {
sql += ' SET ' + fields;
} else {
sql += ' VALUES ()';
}
this.query(sql, function (err, info) {
callback(err, info && info.insertId);
});
};
MySQL.prototype.updateOrCreate = function (model, data, callback) {
var mysql = this;
var fieldsNames = [];
var fieldValues = [];
var combined = [];
var props = this._models[model].properties;
Object.keys(data).forEach(function (key) {
if (props[key] || key === 'id') {
var k = '`' + key + '`';
var v;
if (key !== 'id') {
v = mysql.toDatabase(props[key], data[key]);
} else {
v = data[key];
}
fieldsNames.push(k);
fieldValues.push(v);
if (key !== 'id') combined.push(k + ' = ' + v);
}
});
var sql = 'INSERT INTO ' + this.tableEscaped(model);
sql += ' (' + fieldsNames.join(', ') + ')';
sql += ' VALUES (' + fieldValues.join(', ') + ')';
sql += ' ON DUPLICATE KEY UPDATE ' + combined.join(', ');
this.query(sql, function (err, info) {
if (!err && info && info.insertId) {
data.id = info.insertId;
}
callback(err, data);
});
};
MySQL.prototype.toFields = function (model, data) {
var fields = [];
var props = this._models[model].properties;
Object.keys(data).forEach(function (key) {
if (props[key]) {
fields.push('`' + key.replace(/\./g, '`.`') + '` = ' + this.toDatabase(props[key], data[key]));
}
}.bind(this));
return fields.join(',');
};
function dateToMysql(val) {
return val.getUTCFullYear() + '-' +
fillZeros(val.getUTCMonth() + 1) + '-' +
fillZeros(val.getUTCDate()) + ' ' +
fillZeros(val.getUTCHours()) + ':' +
fillZeros(val.getUTCMinutes()) + ':' +
fillZeros(val.getUTCSeconds());
function fillZeros(v) {
return v < 10 ? '0' + v : v;
}
}
MySQL.prototype.toDatabase = function (prop, val) {
if (val === null) return 'NULL';
if (val.constructor.name === 'Object') {
var operator = Object.keys(val)[0]
val = val[operator];
if (operator === 'between') {
return this.toDatabase(prop, val[0]) +
' AND ' +
this.toDatabase(prop, val[1]);
} else if (operator == 'inq' || operator == 'nin') {
if (!(val.propertyIsEnumerable('length')) && typeof val === 'object' && typeof val.length === 'number') { //if value is array
for (var i = 0; i < val.length; i++) {
val[i] = this.client.escape(val[i]);
}
return val.join(',');
} else {
return val;
}
}
}
if (!prop) return val;
if (prop.type.name === 'Number') return val;
if (prop.type.name === 'Date') {
if (!val) return 'NULL';
if (!val.toUTCString) {
val = new Date(val);
}
return '"' + dateToMysql(val) + '"';
}
if (prop.type.name == "Boolean") return val ? 1 : 0;
return this.client.escape(val.toString());
};
MySQL.prototype.fromDatabase = function (model, data) {
if (!data) return null;
var props = this._models[model].properties;
Object.keys(data).forEach(function (key) {
var val = data[key];
if (props[key]) {
if (props[key].type.name === 'Date' && val !== null) {
val = new Date(val.toString().replace(/GMT.*$/, 'GMT'));
}
}
data[key] = val;
});
return data;
};
MySQL.prototype.escapeName = function (name) {
return '`' + name.replace(/\./g, '`.`') + '`';
};
MySQL.prototype.all = function all(model, filter, callback) {
var sql = 'SELECT * FROM ' + this.tableEscaped(model);
var self = this;
var props = this._models[model].properties;
if (filter) {
if (filter.where) {
sql += ' ' + buildWhere(filter.where);
}
if (filter.order) {
sql += ' ' + buildOrderBy(filter.order);
}
if (filter.limit) {
sql += ' ' + buildLimit(filter.limit, filter.offset || 0);
}
}
this.query(sql, function (err, data) {
if (err) {
return callback(err, []);
}
callback(null, data.map(function (obj) {
return self.fromDatabase(model, obj);
}));
}.bind(this));
return sql;
function buildWhere(conds) {
var cs = [];
Object.keys(conds).forEach(function (key) {
var keyEscaped = '`' + key.replace(/\./g, '`.`') + '`'
var val = self.toDatabase(props[key], conds[key]);
if (conds[key] === null) {
cs.push(keyEscaped + ' IS NULL');
} else if (conds[key].constructor.name === 'Object') {
var condType = Object.keys(conds[key])[0];
var sqlCond = keyEscaped;
if ((condType == 'inq' || condType == 'nin') && val.length == 0) {
cs.push(condType == 'inq' ? 0 : 1);
return true;
}
switch (condType) {
case 'gt':
sqlCond += ' > ';
break;
case 'gte':
sqlCond += ' >= ';
break;
case 'lt':
sqlCond += ' < ';
break;
case 'lte':
sqlCond += ' <= ';
break;
case 'between':
sqlCond += ' BETWEEN ';
break;
case 'inq':
sqlCond += ' IN ';
break;
case 'nin':
sqlCond += ' NOT IN ';
break;
case 'neq':
sqlCond += ' != ';
break;
}
sqlCond += (condType == 'inq' || condType == 'nin') ? '(' + val + ')' : val;
cs.push(sqlCond);
} else {
cs.push(keyEscaped + ' = ' + val);
}
});
if (cs.length === 0) {
return '';
}
return 'WHERE ' + cs.join(' AND ');
}
function buildOrderBy(order) {
if (typeof order === 'string') order = [order];
return 'ORDER BY ' + order.join(', ');
}
function buildLimit(limit, offset) {
return 'LIMIT ' + (offset ? (offset + ', ' + limit) : limit);
}
};
MySQL.prototype.autoupdate = function (cb) {
var self = this;
var wait = 0;
Object.keys(this._models).forEach(function (model) {
wait += 1;
self.query('SHOW FIELDS FROM ' + self.tableEscaped(model), function (err, fields) {
self.query('SHOW INDEXES FROM ' + self.tableEscaped(model), function (err, indexes) {
if (!err && fields.length) {
self.alterTable(model, fields, indexes, done);
} else {
self.createTable(model, done);
}
});
});
});
function done(err) {
if (err) {
console.log(err);
}
if (--wait === 0 && cb) {
cb();
}
}
};
MySQL.prototype.isActual = function (cb) {
var ok = false;
var self = this;
var wait = 0;
Object.keys(this._models).forEach(function (model) {
wait += 1;
self.query('SHOW FIELDS FROM ' + model, function (err, fields) {
self.query('SHOW INDEXES FROM ' + model, function (err, indexes) {
self.alterTable(model, fields, indexes, done, true);
});
});
});
function done(err, needAlter) {
if (err) {
console.log(err);
}
ok = ok || needAlter;
if (--wait === 0 && cb) {
cb(null, !ok);
}
}
};
MySQL.prototype.alterTable = function (model, actualFields, actualIndexes, done, checkOnly) {
var self = this;
var m = this._models[model];
var propNames = Object.keys(m.properties).filter(function (name) {
return !!m.properties[name];
});
var indexNames = m.settings.indexes ? Object.keys(m.settings.indexes).filter(function (name) {
return !!m.settings.indexes[name];
}) : [];
var sql = [];
var ai = {};
if (actualIndexes) {
actualIndexes.forEach(function (i) {
var name = i.Key_name;
if (!ai[name]) {
ai[name] = {
info: i,
columns: []
};
}
ai[name].columns[i.Seq_in_index - 1] = i.Column_name;
});
}
var aiNames = Object.keys(ai);
// change/add new fields
propNames.forEach(function (propName) {
if (propName === 'id') return;
var found;
actualFields.forEach(function (f) {
if (f.Field === propName) {
found = f;
}
});
if (found) {
actualize(propName, found);
} else {
sql.push('ADD COLUMN `' + propName + '` ' + self.propertySettingsSQL(model, propName));
}
});
// drop columns
actualFields.forEach(function (f) {
var notFound = !~propNames.indexOf(f.Field);
if (f.Field === 'id') return;
if (notFound || !m.properties[f.Field]) {
sql.push('DROP COLUMN `' + f.Field + '`');
}
});
// remove indexes
aiNames.forEach(function (indexName) {
if (indexName === 'id' || indexName === 'PRIMARY') return;
if (indexNames.indexOf(indexName) === -1 && !m.properties[indexName] || m.properties[indexName] && !m.properties[indexName].index) {
sql.push('DROP INDEX `' + indexName + '`');
} else {
// first: check single (only type and kind)
if (m.properties[indexName] && !m.properties[indexName].index) {
// TODO
return;
}
// second: check multiple indexes
var orderMatched = true;
if (indexNames.indexOf(indexName) !== -1) {
m.settings.indexes[indexName].columns.split(/,\s*/).forEach(function (columnName, i) {
if (ai[indexName].columns[i] !== columnName) orderMatched = false;
});
}
if (!orderMatched) {
sql.push('DROP INDEX `' + indexName + '`');
delete ai[indexName];
}
}
});
// add single-column indexes
propNames.forEach(function (propName) {
var i = m.properties[propName].index;
if (!i) {
return;
}
var found = ai[propName] && ai[propName].info;
if (!found) {
var type = '';
var kind = '';
if (i.type) {
type = 'USING ' + i.type;
}
if (i.kind) {
// kind = i.kind;
}
if (kind && type) {
sql.push('ADD ' + kind + ' INDEX `' + propName + '` (`' + propName + '`) ' + type);
} else {
sql.push('ADD ' + kind + ' INDEX `' + propName + '` ' + type + ' (`' + propName + '`) ');
}
}
});
// add multi-column indexes
indexNames.forEach(function (indexName) {
var i = m.settings.indexes[indexName];
var found = ai[indexName] && ai[indexName].info;
if (!found) {
var type = '';
var kind = '';
if (i.type) {
type = 'USING ' + i.kind;
}
if (i.kind) {
kind = i.kind;
}
if (kind && type) {
sql.push('ADD ' + kind + ' INDEX `' + indexName + '` (' + i.columns + ') ' + type);
} else {
sql.push('ADD ' + kind + ' INDEX ' + type + ' `' + indexName + '` (' + i.columns + ')');
}
}
});
if (sql.length) {
var query = 'ALTER TABLE ' + self.tableEscaped(model) + ' ' + sql.join(',\n');
if (checkOnly) {
done(null, true, {statements: sql, query: query});
} else {
this.query(query, done);
}
} else {
done();
}
function actualize(propName, oldSettings) {
var newSettings = m.properties[propName];
if (newSettings && changed(newSettings, oldSettings)) {
sql.push('CHANGE COLUMN `' + propName + '` `' + propName + '` ' + self.propertySettingsSQL(model, propName));
}
}
function changed(newSettings, oldSettings) {
if (oldSettings.Null === 'YES' && (newSettings.allowNull === false || newSettings.null === false)) return true;
if (oldSettings.Null === 'NO' && !(newSettings.allowNull === false || newSettings.null === false)) return true;
if (oldSettings.Type.toUpperCase() !== datatype(newSettings)) return true;
return false;
}
};
MySQL.prototype.propertiesSQL = function (model) {
var self = this;
var sql = ['`id` INT(11) NOT NULL AUTO_INCREMENT UNIQUE PRIMARY KEY'];
Object.keys(this._models[model].properties).forEach(function (prop) {
if (prop === 'id') return;
sql.push('`' + prop + '` ' + self.propertySettingsSQL(model, prop));
});
return sql.join(',\n ');
};
MySQL.prototype.propertySettingsSQL = function (model, prop) {
var p = this._models[model].properties[prop];
return datatype(p) + ' ' +
(p.allowNull === false || p['null'] === false ? 'NOT NULL' : 'NULL');
};
function datatype(p) {
var dt = '';
switch (p.type.name) {
default:
case 'String':
case 'JSON':
dt = 'VARCHAR(' + (p.limit || 255) + ')';
break;
case 'Text':
dt = 'TEXT';
break;
case 'Number':
dt = 'INT(' + (p.limit || 11) + ')';
break;
case 'Date':
dt = 'DATETIME';
break;
case 'Boolean':
dt = 'TINYINT(1)';
break;
}
return dt;
}

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "jugglingdb-mysql",
"version": "0.0.1",
"description": "MySQL adapter for JugglingDB",
"main": "index.js",
"scripts": {
"test": "nodeunit test/mysql.js test/migration.coffee"
},
"dependencies": {
"jugglingdb": "= 0.2.x",
"mysql": ">= 2.0.0-alpha3"
},
"devDependencies": {
"nodeunit": "latest"
},
"repository": {
"type": "git",
"url": "https://github.com/1602/jugglingdb-mysql.git"
},
"author": "Anatoliy Chakkaev <mail@anatoliy.in>",
"license": "MIT"
}

212
test/migration.coffee Normal file
View File

@ -0,0 +1,212 @@
juggling = require('jugglingdb')
Schema = juggling.Schema
Text = Schema.Text
DBNAME = process.env.DBNAME || 'myapp_test'
DBUSER = process.env.DBUSER || 'root'
DBPASS = ''
DBENGINE = process.env.DBENGINE || 'mysql'
schema = new Schema DBENGINE, database: '', username: DBUSER, password: DBPASS
schema.log = (q) -> console.log q
query = (sql, cb) ->
schema.adapter.query sql, cb
User = schema.define 'User',
email: { type: String, null: false, index: true }
name: String
bio: Text
password: String
birthDate: Date
pendingPeriod: Number
createdByAdmin: Boolean
, indexes:
index1:
columns: 'email, createdByAdmin'
withBlankDatabase = (cb) ->
db = schema.settings.database = DBNAME
query 'DROP DATABASE IF EXISTS ' + db, (err) ->
query 'CREATE DATABASE ' + db, (err) ->
query 'USE '+ db, cb
getFields = (model, cb) ->
query 'SHOW FIELDS FROM ' + model, (err, res) ->
if err
cb err
else
fields = {}
res.forEach (field) -> fields[field.Field] = field
cb err, fields
getIndexes = (model, cb) ->
query 'SHOW INDEXES FROM ' + model, (err, res) ->
console.log(arguments)
if err
console.log err
cb err
else
indexes = {}
res.forEach (index) ->
indexes[index.Key_name] = index if parseInt(index.Seq_in_index, 10) == 1
cb err, indexes
it = (name, testCases) ->
module.exports[name] = testCases
it 'should run migration', (test) ->
withBlankDatabase (err) ->
schema.automigrate ->
getFields 'User', (err, fields) ->
test.deepEqual fields,
id:
Field: 'id'
Type: 'int(11)'
Null: 'NO'
Key: 'PRI'
Default: null
Extra: 'auto_increment'
email:
Field: 'email'
Type: 'varchar(255)'
Null: 'NO'
Key: ''
Default: null
Extra: ''
name:
Field: 'name'
Type: 'varchar(255)'
Null: 'YES'
Key: ''
Default: null
Extra: ''
bio:
Field: 'bio'
Type: 'text'
Null: 'YES'
Key: ''
Default: null
Extra: ''
password:
Field: 'password'
Type: 'varchar(255)'
Null: 'YES'
Key: ''
Default: null
Extra: ''
birthDate:
Field: 'birthDate'
Type: 'datetime'
Null: 'YES'
Key: ''
Default: null
Extra: ''
pendingPeriod:
Field: 'pendingPeriod'
Type: 'int(11)'
Null: 'YES'
Key: ''
Default: null
Extra: ''
createdByAdmin:
Field: 'createdByAdmin'
Type: 'tinyint(1)'
Null: 'YES'
Key: ''
Default: null
Extra: ''
test.done()
it 'should autoupgrade', (test) ->
userExists = (cb) ->
query 'SELECT * FROM User', (err, res) ->
cb(not err and res[0].email == 'test@example.com')
User.create email: 'test@example.com', (err, user) ->
test.ok not err
userExists (yep) ->
test.ok yep
User.defineProperty 'email', type: String
User.defineProperty 'name', type: String, limit: 50
User.defineProperty 'newProperty', type: Number
User.defineProperty 'pendingPeriod', false
schema.autoupdate (err) ->
getFields 'User', (err, fields) ->
# change nullable for email
test.equal fields.email.Null, 'YES', 'Email is not null'
# change type of name
test.equal fields.name.Type, 'varchar(50)', 'Name is not varchar(50)'
# add new column
test.ok fields.newProperty, 'New column was not added'
if fields.newProperty
test.equal fields.newProperty.Type, 'int(11)', 'New column type is not int(11)'
# drop column
test.ok not fields.pendingPeriod, 'drop column'
# user still exists
userExists (yep) ->
test.ok yep
test.done()
it 'should check actuality of schema', (test) ->
# drop column
User.schema.isActual (err, ok) ->
test.ok ok, 'schema is actual'
User.defineProperty 'email', false
User.schema.isActual (err, ok) ->
test.ok not ok, 'schema is not actual'
test.done()
it 'should add single-column index', (test) ->
User.defineProperty 'email', type: String, index: { kind: 'FULLTEXT', type: 'HASH'}
User.schema.autoupdate (err) ->
return console.log(err) if err
getIndexes 'User', (err, ixs) ->
test.ok ixs.email && ixs.email.Column_name == 'email'
console.log(ixs)
test.equal ixs.email.Index_type, 'BTREE', 'default index type'
test.done()
it 'should change type of single-column index', (test) ->
User.defineProperty 'email', type: String, index: { type: 'BTREE' }
User.schema.isActual (err, ok) ->
test.ok ok, 'schema is actual'
User.schema.autoupdate (err) ->
return console.log(err) if err
getIndexes 'User', (err, ixs) ->
test.ok ixs.email && ixs.email.Column_name == 'email'
test.equal ixs.email.Index_type, 'BTREE'
test.done()
it 'should remove single-column index', (test) ->
User.defineProperty 'email', type: String, index: false
User.schema.autoupdate (err) ->
return console.log(err) if err
getIndexes 'User', (err, ixs) ->
test.ok !ixs.email
test.done()
it 'should update multi-column index when order of columns changed', (test) ->
User.schema.adapter._models.User.settings.indexes.index1.columns = 'createdByAdmin, email'
User.schema.isActual (err, ok) ->
test.ok not ok, 'schema is not actual'
User.schema.autoupdate (err) ->
return console.log(err) if err
getIndexes 'User', (err, ixs) ->
test.equals ixs.index1.Column_name, 'createdByAdmin'
test.done()
it 'test', (test) ->
User.defineProperty 'email', type: String, index: true
User.schema.autoupdate (err) ->
User.schema.autoupdate (err) ->
User.schema.autoupdate (err) ->
test.done()
it 'should disconnect when done', (test) ->
schema.disconnect()
test.done()

10
test/mysql.js Normal file
View File

@ -0,0 +1,10 @@
var jdb = require('jugglingdb'),
Schema = jdb.Schema,
test = jdb.test,
schema = new Schema(__dirname + '/..', {
database: 'myapp_test',
username: 'root'
});
test(module.exports, schema);