Merge pull request #84 from JosephJNK/master

Added support for migrations to postgres adapter
This commit is contained in:
Anatoliy Chakkaev 2012-05-16 20:23:21 -07:00
commit e1bd896292
4 changed files with 448 additions and 48 deletions

57
coverage.html Normal file

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,7 @@ var safeRequire = require('../utils').safeRequire;
*/
var pg = safeRequire('pg');
var BaseSQL = require('../sql');
var util = require('util');
exports.initialize = function initializeSchema(schema, callback) {
if (!pg) return;
@ -187,9 +188,6 @@ PG.prototype.fromDatabase = function (model, data) {
var props = this._models[model].properties;
Object.keys(data).forEach(function (key) {
var val = data[key];
if (props[key]) {
// if (props[key])
}
data[key] = val;
});
return data;
@ -269,12 +267,24 @@ PG.prototype.toFilter = function (model, filter) {
return out;
};
function getTableStatus(model, cb){
function decoratedCallback(err, data){
data.forEach(function(field){
field.Type = mapPostgresDatatypes(field.Type);
});
cb(err, data);
};
this.query('SELECT column_name as "Field", udt_name as "Type", is_nullable as "Null", column_default as "Default" FROM information_schema.COLUMNS WHERE table_name = \'' + this.table(model) + '\'', decoratedCallback);
};
PG.prototype.autoupdate = function (cb) {
var self = this;
var wait = 0;
Object.keys(this._models).forEach(function (model) {
wait += 1;
self.query('SELECT column_name as "Field", udt_name as "Type", is_nullable as "Null", column_default as "Default" FROM information_schema.COLUMNS WHERE table_name = \''+ self.table(model) + '\'', function (err, fields) {
var fields;
getTableStatus.call(self, model, function(err, fields){
if(err) console.log(err);
self.alterTable(model, fields, done);
});
});
@ -286,64 +296,173 @@ PG.prototype.autoupdate = function (cb) {
if (--wait === 0 && cb) {
cb();
}
}
};
};
PG.prototype.isActual = function(cb) {
var self = this;
var wait = 0;
changes = [];
Object.keys(this._models).forEach(function (model) {
wait += 1;
getTableStatus.call(self, model, function(err, fields){
changes = changes.concat(getPendingChanges.call(self, model, fields));
done(err, changes);
});
});
function done(err, fields) {
if (err) {
console.log(err);
}
if (--wait === 0 && cb) {
var actual = (changes.length === 0);
cb(null, actual);
}
};
};
PG.prototype.alterTable = function (model, actualFields, done) {
var self = this;
var pendingChanges = getPendingChanges.call(self, model, actualFields);
applySqlChanges.call(self, model, pendingChanges, done);
};
function getPendingChanges(model, actualFields){
var sql = [];
var self = this;
var m = this._models[model];
sql = sql.concat(getColumnsToAdd.call(self, model, actualFields));
sql = sql.concat(getPropertiesToModify.call(self, model, actualFields));
sql = sql.concat(getColumnsToDrop.call(self, model, actualFields));
return sql;
};
function getColumnsToAdd(model, actualFields){
var self = this;
var m = self._models[model];
var propNames = Object.keys(m.properties);
var sql = [];
// change/add new fields
propNames.forEach(function (propName) {
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));
var found = searchForPropertyInActual.call(self, propName, actualFields);
if(!found && propertyHasNotBeenDeleted.call(self, model, propName)){
sql.push(addPropertyToActual.call(self, model, propName));
}
});
return sql;
};
// drop columns
function addPropertyToActual(model, propName){
var self = this;
var p = self._models[model].properties[propName];
sqlCommand = 'ADD COLUMN "' + propName + '" ' + datatype(p) + " " + (propertyCanBeNull.call(self, model, propName) ? "" : " NOT NULL");
return sqlCommand;
};
function searchForPropertyInActual(propName, actualFields){
var found = false;
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 + '"');
if (f.Field === propName) {
found = f;
return;
}
});
return found;
};
function getPropertiesToModify(model, actualFields){
var self = this;
var sql = [];
var m = self._models[model];
var propNames = Object.keys(m.properties);
var found;
propNames.forEach(function (propName) {
found = searchForPropertyInActual.call(self, propName, actualFields);
if(found && propertyHasNotBeenDeleted.call(self, model, propName)){
if (datatypeChanged(propName, found)) {
sql.push(modifyDatatypeInActual.call(self, model, propName));
}
if (nullabilityChanged(propName, found)){
sql.push(modifyNullabilityInActual.call(self, model, propName));
}
}
});
if (sql.length) {
this.query('ALTER TABLE ' + this.tableEscaped(model) + ' ' + sql.join(',\n'), done);
} else {
done();
}
return sql;
function actualize(propName, oldSettings) {
function datatypeChanged(propName, oldSettings){
var newSettings = m.properties[propName];
if (newSettings && changed(newSettings, oldSettings)) {
sql.push('CHANGE COLUMN "' + propName + '" "' + propName + '" ' + self.propertySettingsSQL(model, propName));
if(!newSettings) return false;
return oldSettings.Type.toLowerCase() !== datatype(newSettings);
};
function nullabilityChanged(propName, oldSettings){
var newSettings = m.properties[propName];
if(!newSettings) return false;
var changed = false;
if (oldSettings.Null === 'YES' && (newSettings.allowNull === false || newSettings.null === false)) changed = true;
if (oldSettings.Null === 'NO' && !(newSettings.allowNull === false || newSettings.null === false)) changed = true;
return changed;
};
};
function modifyDatatypeInActual(model, propName) {
var self = this;
var sqlCommand = 'ALTER COLUMN "' + propName + '" TYPE ' + datatype(self._models[model].properties[propName]);
return sqlCommand;
};
function modifyNullabilityInActual(model, propName) {
var self = this;
var sqlCommand = 'ALTER COLUMN "' + propName + '" ';
if(propertyCanBeNull.call(self, model, propName)){
sqlCommand = sqlCommand + "DROP ";
} else {
sqlCommand = sqlCommand + "SET ";
}
sqlCommand = sqlCommand + "NOT NULL";
return sqlCommand;
};
function getColumnsToDrop(model, actualFields){
var self = this;
var sql = [];
actualFields.forEach(function (actualField) {
if (actualField.Field === 'id') return;
if (actualFieldNotPresentInModel(actualField, model)) {
sql.push('DROP COLUMN "' + actualField.Field + '"');
}
});
return sql;
function actualFieldNotPresentInModel(actualField, model){
return !(self._models[model].properties[actualField.Field]);
};
};
function applySqlChanges(model, pendingChanges, done){
var self = this;
if (pendingChanges.length) {
var thisQuery = 'ALTER TABLE ' + self.tableEscaped(model);
var ranOnce = false;
pendingChanges.forEach(function(change){
if(ranOnce) thisQuery = thisQuery + ',';
thisQuery = thisQuery + ' ' + change;
ranOnce = true;
});
thisQuery = thisQuery + ';';
self.query(thisQuery, callback);
}
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;
function callback(err, data){
if(err) console.log(err);
}
done();
};
PG.prototype.propertiesSQL = function (model) {
var self = this;
var sql = ['"id" SERIAL NOT NULL UNIQUE PRIMARY KEY'];
var sql = ['"id" SERIAL PRIMARY KEY'];
Object.keys(this._models[model].properties).forEach(function (prop) {
sql.push('"' + prop + '" ' + self.propertySettingsSQL(model, prop));
});
@ -351,10 +470,17 @@ PG.prototype.propertiesSQL = function (model) {
};
PG.prototype.propertySettingsSQL = function (model, prop) {
var p = this._models[model].properties[prop];
return datatype(p) + ' ' +
(p.allowNull === false || p['null'] === false ? 'NOT NULL' : 'NULL');
PG.prototype.propertySettingsSQL = function (model, propName) {
var self = this;
var p = self._models[model].properties[propName];
var result = datatype(p) + ' ';
if(!propertyCanBeNull.call(self, model, propName)) result = result + 'NOT NULL ';
return result;
};
function propertyCanBeNull(model, propName){
var p = this._models[model].properties[propName];
return !(p.allowNull === false || p['null'] === false);
};
function escape(val) {
@ -390,14 +516,32 @@ function escape(val) {
function datatype(p) {
switch (p.type.name) {
case 'String':
return 'VARCHAR(' + (p.limit || 255) + ')';
return 'varchar';
case 'Text':
return 'TEXT';
return 'text';
case 'Number':
return 'INTEGER';
return 'integer';
case 'Date':
return 'TIMESTAMP';
return 'timestamp';
case 'Boolean':
return 'BOOLEAN';
return 'boolean';
default:
console.log("Warning: postgres adapter does not explicitly handle type '" + p.type.name +"'");
return p.type.toLowerCase();
//TODO a default case might not be the safest thing here... postgres has a fair number of extra types though
}
}
};
function mapPostgresDatatypes(typeName) {
//TODO there are a lot of synonymous type names that should go here-- this is just what i've run into so far
switch (typeName){
case 'int4':
return 'integer';
default:
return typeName;
}
};
function propertyHasNotBeenDeleted(model, propName){
return !!this._models[model].properties[propName];
};

View File

@ -0,0 +1,67 @@
juggling = require('../index')
Schema = juggling.Schema
Text = Schema.Text
DBNAME = process.env.DBNAME || 'myapp_test' #this db must already exist and will be destroyed
DBUSER = process.env.DBUSER || 'root'
DBPASS = ''
DBENGINE = process.env.DBENGINE || 'postgres'
require('./spec_helper').init module.exports
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',
name: {type: String, default: "guest"}
credits: {type: Number, default: 0}
withBlankDatabase = (cb) ->
db = schema.settings.database = DBNAME
query 'DROP DATABASE IF EXISTS ' + db, (err) ->
query 'CREATE DATABASE ' + db, ->
schema.automigrate(cb)
it 'default values should not interfere with fully specified objects', (test)->
withBlankDatabase (err)->
test.ok !err, "error while setting up blank database"
new User()
User.create {name: "Steve", credits: 47}, (err, obj)->
console.log "error creating user: #{err}"
test.ok !err, "error occurred when saving user with all values specified"
test.ok obj.id?, 'saved object has no id'
console.log "id: #{obj.id}"
test.equals obj.name, "Steve", "User's name didn't save correctly"
test.equals obj.credits, 47, "User's credits didn't save correctly"
test.done()
it 'objects should have default values when some fields are unspecified', (test)->
User.create {credits: 2}, (err, obj)->
console.log "error creating user: #{err}"
test.ok !err, "error occurred when saving user with some values unspecified"
test.ok obj.id?, 'saved object has no id'
test.equals obj.name, "guest", "User's name didn't save correctly"
test.equals obj.credits, 2, "User's credits didn't save correctly"
User.create {name: "Jeanette Adele McKenzie"}, (err, obj)->
console.log "error creating user: #{err}"
test.ok !err, "error occurred when saving user with some values unspecified"
test.ok obj.id?, 'saved object has no id'
test.equals obj.name, "Jeanette Adele McKenzie", "User's name didn't save correctly"
test.equals obj.credits, 0, "User's credits didn't save correctly"
test.done()
it 'objects should have default values when all fields are left unspecified', (test)->
User.create {}, (err, obj)->
console.log "error creating user: #{err}"
test.ok !err, "error occurred when saving user with all values specified"
test.ok obj.id?, 'saved object has no id'
test.equals obj.name, "guest", "User's name didn't save correctly"
test.equals obj.credits, 0, "User's credits didn't save correctly"
test.done()
it 'should disconnect when done', (test)->
schema.disconnect()
test.done()

View File

@ -0,0 +1,132 @@
juggling = require('../index')
Schema = juggling.Schema
Text = Schema.Text
DBNAME = process.env.DBNAME || 'myapp_test' #this db must already exist and will be destroyed
DBUSER = process.env.DBUSER || 'root'
DBPASS = ''
DBENGINE = process.env.DBENGINE || 'postgres'
require('./spec_helper').init module.exports
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
withBlankDatabase = (cb) ->
db = schema.settings.database = DBNAME
query 'DROP DATABASE IF EXISTS ' + db, (err) ->
query 'CREATE DATABASE ' + db, cb
getColumnDescriptions = (model, cb)->
query "SELECT column_name, data_type, is_nullable, column_default FROM information_schema.COLUMNS WHERE table_name = '#{model}'", (err, res)->
if err
cb err
else
fields = {}
fields[entry.column_name] = entry for entry in res
cb err, fields
it 'should run migration', (test)->
withBlankDatabase (err)->
schema.automigrate ->
getColumnDescriptions 'User', (err, fields)->
test.deepEqual fields,
id:
column_name: 'id'
data_type: 'integer'
is_nullable: 'NO'
column_default: 'nextval(\'"User_id_seq"\'::regclass)'
email:
column_name: 'email'
data_type: 'character varying'
is_nullable: 'NO'
column_default: null
name:
column_name: 'name'
data_type: 'character varying'
is_nullable: 'YES'
column_default: null
bio:
column_name: 'bio'
data_type: 'text'
is_nullable: 'YES'
column_default: null
password:
column_name: 'password'
data_type: 'character varying'
is_nullable: 'YES'
column_default: null
birthDate:
column_name: 'birthDate'
data_type: 'timestamp without time zone'
is_nullable: 'YES'
column_default: null
pendingPeriod:
column_name: 'pendingPeriod'
data_type: 'integer'
is_nullable: 'YES'
column_default: null
createdByAdmin:
column_name: 'createdByAdmin'
data_type: 'boolean'
is_nullable: 'YES'
column_default: null
test.done()
it 'should autoupdate', (test) ->
getColumnDescriptions 'User', (err, fields)->
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, "error occurred while creating User: #{err}"
userExists (yep) ->
test.ok yep, 'userExists returned false'
User.defineProperty 'email', type: String
User.defineProperty 'createdByAdmin', type: String
User.defineProperty 'newProperty', type: Number
User.defineProperty 'pendingPeriod', false
schema.autoupdate (err) ->
getColumnDescriptions 'User', (err, fields) ->
# email should now be nullable
test.equal fields.email.is_nullable, 'YES', "email's nullability did not change to nullable"
# change type of createdByAdmin
test.equal fields.createdByAdmin.data_type, 'character varying', "createdByAdmin's data type did not change"
# new column should be added
test.ok fields.newProperty, 'New column was not added'
if fields.newProperty
test.equal fields.newProperty.data_type, 'integer', 'New column type is not integer'
# pendingPeriod should be dropped
test.ok not fields.pendingPeriod, 'pendingPeriod was not dropped'
# user should still exist
userExists (yep) ->
test.ok yep, 'user does not still exist after update'
test.done()
it 'should check actuality of schema', (test) ->
# drop column
User.schema.isActual (err, ok) ->
test.ok ok, "User is not actual before schema is modified"
User.defineProperty 'email', false
User.schema.isActual (err, ok) ->
test.ok not ok, "User should not be actual after schema is modified"
test.done()
it 'should disconnect when done', (test)->
schema.disconnect()
test.done()