diff --git a/.eslintrc b/.eslintrc index 709bea6..5a5b799 100644 --- a/.eslintrc +++ b/.eslintrc @@ -16,6 +16,7 @@ "stroustrup" ], "max-depth": "off", + "max-nested-callbacks": "off", "no-warning-comments": "off" }, "extends": "xo-space" diff --git a/lib/sqlutil.js b/lib/sqlutil.js index 91b0886..380fe6e 100644 --- a/lib/sqlutil.js +++ b/lib/sqlutil.js @@ -163,6 +163,10 @@ export class Db { }); }); } + + enableForeignKeys() { + return this.run('PRAGMA foreign_keys = ON;'); + } } @@ -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})`); @@ -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 = {}) { diff --git a/package.json b/package.json index 45381b9..d06cabc 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/test/sqlutil.js b/test/sqlutil.js index f89bf0a..e56b1e2 100644 --- a/test/sqlutil.js +++ b/test/sqlutil.js @@ -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);