diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..3ca1979 --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["es2015"], + "plugins": ["transform-runtime"] +} diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..2a8faf1 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +node_modules/** +dist/** diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..9c54f10 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,22 @@ +module.exports = { + root: true, + parserOptions: { + sourceType: 'module', + ecmaFeatures: { + experimentalObjectRestSpread: true + } + }, + env: { + browser: true, + node: true + }, + extends: 'standard', + 'rules': { + // allow paren-less arrow functions + 'arrow-parens': 0, + // allow async-await + 'generator-star-spacing': 0, + // allow debugger during development + 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0aa35d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +node_modules +.idea diff --git a/README.md b/README.md index 7d2ccd5..297ec94 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,110 @@ -# dexie-relationships -Dexie relationship plugin +Dexie.js relationship plugin +======== + +Dexie.js is a wrapper library for indexedDB - the standard database in the browser. + +Dexie relationship plugin provides an API to ease the loading of relational data from foreign tables + +Installation +======== + +npm: +``` +npm install dexie-relationships --save +``` + +bower: +``` +bower install dexie-relationships --save +``` + +API Example +======== + +#### Schema +Note the use of `->` which sets the foreign keys. + +```javascript +db.version(1).stores({ + projects: '++id', + project_settings: '++id, project_id -> projects.id', + project_members: '++id, project_id -> projects.id' +}) +``` + +#### Seed the data + +```javascript +db.projects.add({name: 'Project #1'}) +db.projects.add({name: 'Project #2'}) + +db.project_settings.add({name: 'Setting #1', project_id: 1}) +db.project_settings.add({name: 'Setting #2', project_id: 2}) +db.project_settings.add({name: 'Setting #3', project_id: 1}) + +db.project_members.add({name: 'Member #1', project_id: 1}) +db.project_members.add({name: 'Member #2', project_id: 2}) +db.project_members.add({name: 'Member #3', project_id: 1}) +``` + +#### Usage + +```javascript +db.with({ + 'settings': 'project_settings', + 'members': 'project_members' +}).then(rows => console.log(rows)) +``` + +#### Result + +```json +[ + { + "name":"Project #1", + "id":1, + "settings":[ + { + "name":"Setting #1", + "project_id":1, + "id":1 + }, + { + "name":"Setting #3", + "project_id":1, + "id":3 + } + ], + "members":[ + { + "name":"Member #1", + "project_id":1, + "id":1 + }, + { + "name":"Member #3", + "project_id":1, + "id":3 + } + ] + }, + { + "name":"Project #2", + "id":2, + "settings":[ + { + "name":"Setting #2", + "project_id":2, + "id":2 + } + ], + "members":[ + { + "name":"Member #2", + "project_id":2, + "id":2 + } + ] + } +] +``` diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..079d4a5 --- /dev/null +++ b/bower.json @@ -0,0 +1,15 @@ +{ + "name": "dexie-relationships", + "description": "Dexie relationship plugin", + "main": "dist/dexie-relationships.js", + "authors": [ + "Ignas Bernotas" + ], + "license": "MIT", + "keywords": [ + "dexie", + "relationships", + "plugin" + ], + "homepage": "https://github.com/ignasbernotas/dexie-relationships" +} diff --git a/dist/dexie-relationships.js b/dist/dexie-relationships.js new file mode 100644 index 0000000..beecbee --- /dev/null +++ b/dist/dexie-relationships.js @@ -0,0 +1,122 @@ +'use strict'; + +var _promise = require('babel-runtime/core-js/promise'); + +var _promise2 = _interopRequireDefault(_promise); + +var _keys = require('babel-runtime/core-js/object/keys'); + +var _keys2 = _interopRequireDefault(_keys); + +var _dexie = require('dexie'); + +var _dexie2 = _interopRequireDefault(_dexie); + +var _schemaParser = require('./schema-parser'); + +var _schemaParser2 = _interopRequireDefault(_schemaParser); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +var Relationships = function Relationships(db) { + /** + * Iterate through all items and collect related records + * + * @param relationships + * + * @returns {Dexie.Promise} + */ + db.Table.prototype.with = function (relationships) { + return this.toCollection().with(relationships); + }; + + /** + * Iterate through all items and collect related records + * + * @param relationships + * + * @returns {Dexie.Promise} + */ + db.Collection.prototype.with = function (relationships) { + var self = this; + var baseTable = this._ctx.table.name; + var databaseTables = db._allTables; + + // this holds tables that have foreign keys pointing at the current table + var usableForeignTables = {}; + + // validate target tables and add them into our usable tables object + (0, _keys2.default)(relationships).forEach(function (column) { + var table = relationships[column]; + + if (!databaseTables.hasOwnProperty(table)) { + throw new Error('Relationship table ' + table + ' doesn\'t exist.'); + } + + if (!databaseTables[table].schema.hasOwnProperty('foreignKeys')) { + throw new Error('Relationship table ' + table + ' doesn\'t have foreign keys set.'); + } + + // remove the foreign keys that don't link to the base table + var columns = databaseTables[table].schema.foreignKeys.filter(function (column) { + return column.targetTable === baseTable; + }); + + if (columns.length > 0) { + usableForeignTables[table] = { + column: column, + foreign: columns[0] + }; + } + }); + + return new _dexie2.default.Promise(function (resolve) { + self.toArray().then(function (rows) { + var queue = []; + + // loop through all rows and collect all data from the related table + rows.forEach(function (row) { + var tables = (0, _keys2.default)(usableForeignTables); + + tables.forEach(function (table) { + var relatedTable = usableForeignTables[table]; + + var promise = databaseTables[table].where(relatedTable.foreign.index).equals(row[relatedTable.foreign.targetIndex]).toArray().then(function (relations) { + row[relatedTable.column] = relations; + }); + + queue.push(promise); + }); + }); + + // we need to wait until all data is retrieved + // once it's there we can resolve the promise + _promise2.default.all(queue).then(function () { + resolve(rows); + }); + }); + }); + }; + + db.Version.prototype._parseStoresSpec = _dexie2.default.override(db.Version.prototype._parseStoresSpec, function (parseStoresSpec) { + return function (storesSpec, outDbSchema) { + var parser = new _schemaParser2.default(storesSpec); + + var foreignKeys = parser.getForeignKeys(); + // call the original method + var rv = parseStoresSpec.call(this, parser.getCleanedSchema(), outDbSchema); + + // set foreign keys into database table objects + // to use later in 'with' method + (0, _keys2.default)(outDbSchema).forEach(function (table) { + if (foreignKeys.hasOwnProperty(table)) { + outDbSchema[table].foreignKeys = foreignKeys[table]; + } + }); + + return rv; + }; + }); +}; + +_dexie2.default.addons.push(Relationships); diff --git a/package.json b/package.json new file mode 100644 index 0000000..d5ea1e9 --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "dexie-relationships", + "version": "1.0.0", + "description": "Dexie relationship plugin", + "main": "dist/dexie-relationships.js", + "directories": { + "test": "tests" + }, + "dependencies": { + }, + "devDependencies": { + "babel-cli": "^6.16.0", + "babel-loader": "^6.2.5", + "babel-preset-es2015": "^6.16.0", + "chai": "^3.5.0", + "eslint": "^3.8.1", + "eslint-config-standard": "^6.2.0", + "eslint-plugin-promise": "^3.3.0", + "eslint-plugin-standard": "^2.0.1", + "mocha": "^3.1.2", + "webpack": "^1.13.2" + }, + "scripts": { + "build": "babel --presets es2015 src/index.js -o dist/dexie-relationships.js", + "test": "npm run eslint && npm run mocha", + "eslint": "eslint src", + "mocha": "./node_modules/.bin/mocha --compilers js:babel-core/register --reporter spec" + }, + "babel": { + "presets": ["es2015"] + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ignasbernotas/dexie-relationships.git" + }, + "keywords": [ + "dexie", + "relationships", + "plugin" + ], + "author": "Ignas Bernotas", + "license": "MIT", + "bugs": { + "url": "https://github.com/ignasbernotas/dexie-relationships/issues" + }, + "homepage": "https://github.com/ignasbernotas/dexie-relationships#readme" +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..0e3894e --- /dev/null +++ b/src/index.js @@ -0,0 +1,107 @@ +import Dexie from 'dexie' +import SchemaParser from './schema-parser' + +const Relationships = (db) => { + /** + * Iterate through all items and collect related records + * + * @param relationships + * + * @returns {Dexie.Promise} + */ + db.Table.prototype.with = function (relationships) { + return this.toCollection().with(relationships) + } + + /** + * Iterate through all items and collect related records + * + * @param relationships + * + * @returns {Dexie.Promise} + */ + db.Collection.prototype.with = function (relationships) { + const self = this + const baseTable = this._ctx.table.name + const databaseTables = db._allTables + + // this holds tables that have foreign keys pointing at the current table + let usableForeignTables = {} + + // validate target tables and add them into our usable tables object + Object.keys(relationships).forEach((column) => { + let table = relationships[column] + + if (!databaseTables.hasOwnProperty(table)) { + throw new Error('Relationship table ' + table + ' doesn\'t exist.') + } + + if (!databaseTables[table].schema.hasOwnProperty('foreignKeys')) { + throw new Error('Relationship table ' + table + ' doesn\'t have foreign keys set.') + } + + // remove the foreign keys that don't link to the base table + let columns = databaseTables[table].schema.foreignKeys.filter(column => column.targetTable === baseTable) + + if (columns.length > 0) { + usableForeignTables[table] = { + column: column, + foreign: columns[0] + } + } + }) + + return new Dexie.Promise((resolve) => { + self.toArray().then(rows => { + let queue = [] + + // loop through all rows and collect all data from the related table + rows.forEach((row) => { + let tables = Object.keys(usableForeignTables) + + tables.forEach(table => { + let relatedTable = usableForeignTables[table] + + let promise = databaseTables[table] + .where(relatedTable.foreign.index) + .equals(row[relatedTable.foreign.targetIndex]) + .toArray() + .then(relations => { + row[relatedTable.column] = relations + }) + + queue.push(promise) + }) + }) + + // we need to wait until all data is retrieved + // once it's there we can resolve the promise + Promise.all(queue).then(() => { + resolve(rows) + }) + }) + }) + } + + db.Version.prototype._parseStoresSpec = Dexie.override( + db.Version.prototype._parseStoresSpec, + parseStoresSpec => function (storesSpec, outDbSchema) { + const parser = new SchemaParser(storesSpec) + + let foreignKeys = parser.getForeignKeys() + // call the original method + let rv = parseStoresSpec.call(this, parser.getCleanedSchema(), outDbSchema) + + // set foreign keys into database table objects + // to use later in 'with' method + Object.keys(outDbSchema).forEach(table => { + if (foreignKeys.hasOwnProperty(table)) { + outDbSchema[table].foreignKeys = foreignKeys[table] + } + }) + + return rv + }) +} + +Dexie.addons.push(Relationships) diff --git a/src/schema-parser.js b/src/schema-parser.js new file mode 100644 index 0000000..248d7cc --- /dev/null +++ b/src/schema-parser.js @@ -0,0 +1,59 @@ +class SchemaParser { + + /** + * Schema parser + * + * @param schema + */ + constructor (schema) { + this.schema = schema + } + + /** + * Extracts foreign keys from the schema + * + * @returns Object + */ + getForeignKeys () { + let foreignKeys = {} + + Object.keys(this.schema).forEach(table => { + let indexes = this.schema[table].split(',') + + foreignKeys[table] = indexes + .filter(idx => idx.indexOf('->') !== -1) + .map(idx => { + // split the column and foreign table info + let [column, target] = idx.split('->').map(x => x.trim()) + + return { + index: column, + targetTable: target.split('.')[0], + targetIndex: target.split('.')[1] + } + }) + }) + + return foreignKeys + } + + /** + * Get schema without the foreign key definitions + * + * @returns Object + */ + getCleanedSchema () { + let schema = {} + + Object.keys(this.schema).forEach(table => { + let indexes = this.schema[table].split(',') + + // Remove foreign keys syntax before calling the original method + schema[table] = indexes.map(idx => idx.split('->')[0].trim()).join(',') + }) + + return schema + } +} + +export default SchemaParser diff --git a/test/schema.js b/test/schema.js new file mode 100644 index 0000000..a65d76c --- /dev/null +++ b/test/schema.js @@ -0,0 +1,36 @@ +import assert from 'assert' +import SchemaParser from '../src/schema-parser' + +describe('SchemaParser', function () { + let schema = { + parent: 'id, name', + child: 'id, name, parent_id -> parent.id' + } + let parser = new SchemaParser(schema) + + describe('getCleanedSchema', function () { + it('should return table list without foreign keys', function () { + let expected = { + 'parent': 'id,name', + 'child': 'id,name,parent_id' + } + + assert.deepEqual(expected, parser.getCleanedSchema()) + }) + }) + + describe('getForeignKeys', function () { + it('should return table foreign keys', function () { + let expected = { + parent: [], + child: [{ + index: 'parent_id', + targetTable: 'parent', + targetIndex: 'id' + }] + } + + assert.deepEqual(expected, parser.getForeignKeys()) + }) + }) +}) diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..d05b32d --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,25 @@ +var path = require('path'); +var webpack = require('webpack'); + +module.exports = { + entry: './src/index.js', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'dexie-relationships.js' + }, + module: { + loaders: [ + { + test: /\.js$/, + loader: 'babel-loader', + query: { + presets: ['es2015'] + } + } + ] + }, + stats: { + colors: true + }, + devtool: 'source-map' +};