Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"stroustrup"
],
"max-depth": "off",
"max-nested-callbacks": "off",
"no-warning-comments": "off"
},
"extends": "xo-space"
Expand Down
165 changes: 113 additions & 52 deletions lib/sqlutil.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ export class Db {
});
});
}

enableForeignKeys() {
return this.run('PRAGMA foreign_keys = ON;');
}
}


Expand Down Expand Up @@ -952,6 +956,25 @@ export class Table {
}
}

if (this.schema.foreignKeys) {
this.schema.foreignKeys.forEach(foreignKey => {
let referencesTables = Object.keys(foreignKey.references);
if (referencesTables.length !== 1) {
throw new Error('Invalid foreign key description');
}
let referencesTable = referencesTables[0];
let referencesColumns = foreignKey.references[referencesTable];
if (!_.isArray(referencesColumns)) {
referencesColumns = [referencesColumns];
}

let fromColumns = _.isArray(foreignKey.from)
? foreignKey.from : [foreignKey.from];

columnDescriptions.push(`FOREIGN KEY(${fromColumns.join(', ')}) REFERENCES ${referencesTable}(${referencesColumns.join(', ')})`);
});
}

if (this.schema.primaryKey) {
const columnListing = this.schema.primaryKey.join(',');
columnDescriptions.push(`PRIMARY KEY (${columnListing})`);
Expand Down Expand Up @@ -981,63 +1004,101 @@ export class Table {
}

_recreateTableIfNeeded() {
let sql = `PRAGMA TABLE_INFO(${this.schema.name})`;
return this.db.all(sql).then(oldColumns => {
let namedOldColumns = _.keyBy(oldColumns, 'name');
let columnsInBoth = _.intersection(Object.keys(this.schema.columns), Object.keys(namedOldColumns));

// Check if there are any changes to the columns.
let recreateTable = (columnsInBoth.length !== oldColumns.length)
|| (columnsInBoth.length !== Object.keys(this.schema.columns).length);

if (!recreateTable) {
// Check if the types and column parameters match
recreateTable = columnsInBoth.map(columnName => {
let thisColumn = this.schema.columns[columnName];
let oldColumn = namedOldColumns[columnName];

let thisNotNull = Boolean(thisColumn.notNull)
&& !thisColumn.primaryKey;
let thisDefaultValue = _.isUndefined(thisColumn.defaultValue)
? null : thisColumn.defaultValue;

// Note: oldcolumn.notnull is the name from the pragma query, it is
// not camelcased and returned as an interger (0 or 1)
let oldNotNull = Boolean(oldColumn.notnull);

// TODO: Better type conversions for oldDefaultValue
let oldDefaultValue = oldColumn.dflt_value;
if (oldDefaultValue) {
switch (oldColumn.type) {
case 'INTEGER':
oldDefaultValue = Number.parseInt(oldDefaultValue, 10);
break;
let columnsInOldAndNew = () => {
let sql = `PRAGMA TABLE_INFO(${this.schema.name})`;
return this.db.all(sql).then(oldColumns => {
let namedOldColumns = _.keyBy(oldColumns, 'name');
return _.intersection(Object.keys(this.schema.columns), Object.keys(namedOldColumns));
});
};

default:
break;
let columnsHaveChanged = () => {
let sql = `PRAGMA TABLE_INFO(${this.schema.name})`;
return this.db.all(sql).then(oldColumns => {
let namedOldColumns = _.keyBy(oldColumns, 'name');
let columnsInBoth = _.intersection(Object.keys(this.schema.columns), Object.keys(namedOldColumns));

// Check if there are any changes to the columns.
let recreateTable = (columnsInBoth.length !== oldColumns.length)
|| (columnsInBoth.length !== Object.keys(this.schema.columns).length);

if (!recreateTable) {
// Check if the types and column parameters match
recreateTable = columnsInBoth.map(columnName => {
let thisColumn = this.schema.columns[columnName];
let oldColumn = namedOldColumns[columnName];

let thisNotNull = Boolean(thisColumn.notNull)
&& !thisColumn.primaryKey;
let thisDefaultValue = _.isUndefined(thisColumn.defaultValue)
? null : thisColumn.defaultValue;

// Note: oldcolumn.notnull is the name from the pragma query, it is
// not camelcased and returned as an interger (0 or 1)
let oldNotNull = Boolean(oldColumn.notnull);

// TODO: Better type conversions for oldDefaultValue
let oldDefaultValue = oldColumn.dflt_value;
if (oldDefaultValue) {
switch (oldColumn.type) {
case 'INTEGER':
oldDefaultValue = Number.parseInt(oldDefaultValue, 10);
break;

default:
break;
}
}
}

return ((thisColumn.type !== oldColumn.type)
|| (thisNotNull !== oldNotNull)
|| (thisDefaultValue !== oldDefaultValue));
}).reduce((x, y) => x || y, false);
}
return ((thisColumn.type !== oldColumn.type)
|| (thisNotNull !== oldNotNull)
|| (thisDefaultValue !== oldDefaultValue));
}).reduce((x, y) => x || y, false);
}

// Recreate the tables if required
if (recreateTable) {
// TODO: Better random naming
let backupTableName = `${this.schema.name}_backup`;
return recreateTable;
});
};

let sql = `ALTER TABLE ${this.schema.name} RENAME TO ${backupTableName}`;
return this.db.run(sql).then(() => this.createTable())
.then(() => {
let joinedFiledsInBoth = columnsInBoth.join(', ');
return this.db.run(`INSERT INTO ${this.schema.name} (${joinedFiledsInBoth}) SELECT ${joinedFiledsInBoth} FROM ${backupTableName}`);
})
.then(() => this.db.run(`DROP TABLE ${backupTableName}`));
}
});
let foreignKeysHaveChanged = () => {
let sql = `PRAGMA foreign_key_list(${this.schema.name})`;
return this.db.all(sql).then(oldForeignKeys => {
let foreignKeys = this.schema.foreignKeys || [];
let recreateTable = (oldForeignKeys.length !== foreignKeys.length);

// Check if the foreign key from/references have changed
for (let i = 0; i < oldForeignKeys.length; i++) {
let oldForeignKey = oldForeignKeys[i];
// Find matching new foreign key
let newForeignKey = _.find(foreignKeys, foreignKey => {
return foreignKey.from === oldForeignKey.from;
});
recreateTable = recreateTable || !newForeignKey
|| _.has(newForeignKey.references, newForeignKey.table)
|| newForeignKey.references[newForeignKey.table] !== oldForeignKey.to;
}

return recreateTable;
});
};

return Promise.join(columnsHaveChanged(), foreignKeysHaveChanged(),
(recreate1, recreate2) => {
// Recreate the tables if required
if (recreate1 || recreate2) {
return columnsInOldAndNew().then(copyColumns => {
let backupTableName = `backup_${this.schema.name}_${Date.now()}`;

let sql = `ALTER TABLE ${this.schema.name} RENAME TO ${backupTableName}`;
return this.db.run(sql).then(() => this.createTable())
.then(() => {
let joinedFiledsInBoth = copyColumns.join(', ');
return this.db.run(`INSERT INTO ${this.schema.name} (${joinedFiledsInBoth}) SELECT ${joinedFiledsInBoth} FROM ${backupTableName}`);
})
.then(() => this.db.run(`DROP TABLE ${backupTableName}`));
});
}
});
}

createWriteStream(options = {}) {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
},
"scripts": {
"build": "babel --compact true --minified --source-maps false -d dist/ lib",
"build:debug": "babel -d dist/ lib",
"lint": "eslint lib test",
"test": "mocha --compilers js:babel-core/register test",
"prepublish": "npm run build"
Expand Down
34 changes: 34 additions & 0 deletions test/sqlutil.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,40 @@ describe('sqlutil', () => {
return expect(indexTable.createTable()).to.eventually.be.rejected;
});

it('should be possible to have foreign keys between tables', () => {
return db.enableForeignKeys().then(() => {
let parentTable = new sqlutil.Table(db, {
name: 'parent',
columns: {
id: {type: sqlutil.DataType.INTEGER, primaryKey: true},
name: {type: sqlutil.DataType.TEXT, unique: true}
}
});

let childTable = new sqlutil.Table(db, {
name: 'child',
columns: {
id: {type: sqlutil.DataType.INTEGER, primaryKey: true},
value: {type: sqlutil.DataType.TEXT, unique: true},
parentId: {type: sqlutil.DataType.INTEGER}
},
foreignKeys: [
{from: 'parentId', references: {parent: 'id'}}
]
});

return Promise.all([
parentTable.createTable(),
childTable.createTable()
]).then(() => {
// Insert a parent value
return parentTable.insert({id: 1, name: 'Banan'})
.then(() => childTable.insert({parentId: 1, value: 'Apa'}))
.then(() => childTable.insert({parentId: 1, value: 'Ape'}));
});
});
});

it('should be possible to retrieve single rows from the table', () => {
return table.find({name: 'key1'}).get().then(row => {
assert.isObject(row);
Expand Down