diff --git a/lib/convertor.js b/lib/convertor.js index dd1ca1e..581bede 100644 --- a/lib/convertor.js +++ b/lib/convertor.js @@ -23,9 +23,9 @@ const isLogicOp = key => isOp(key) && _.includes(logicOps, key); const isCompOp = key => isOp(key) && _.includes(_.keys(compOps), key); class MongoToKnex { - constructor(options = {}) { + constructor(options = {}, config = {}) { this.tableName = options.tableName; - this.joins = []; + this.config = config; } processWhereType(mode, op, value) { @@ -40,25 +40,85 @@ class MongoToKnex { return 'andWhere'; } - processField(field, op) { - const fieldParts = field.split('.'); + /** + * Determine if statement lives on parent table or if statement refers to a relation. + */ + processStatement(column, op, value) { + const columnParts = column.split('.'); + + // CASE: `posts.status` -> where "posts" is the parent table + if (columnParts[0] === this.tableName) { + return { + column: column, + value: value, + isRelation: false + }; + } + + // CASE: relation? + if (columnParts.length > 1) { + debug(columnParts); - if (fieldParts[0] === this.tableName) { - // If we have the right table already, return - return field; - } else if (fieldParts.length > 1) { - // If we have a different table, that should be a join - // Store the OP because an IN is different - this.joins.push({table: fieldParts[0], op}); + const table = columnParts[0]; + const relation = this.config.relations[table]; - return field; + if (!relation) { + throw new Error('Can\'t find relation in config object.'); + } + + return { + column: columnParts[1], + operator: op, + value: value, + config: relation, + isRelation: true + }; } - return this.tableName + '.' + field; + // CASE: fallback, `status=draft` -> `posts.status`=draft + return { + column: this.tableName + '.' + column, + value: value, + isRelation: false + }; } /** - * Simple comparison on the parent table. + * @TODO: This implementation serves currently only one use case: + * + * - OR conjunctions for many-to-many relations + */ + buildRelationQuery(qb, relation) { + debug(`(buildRelationQuery)`); + + if (debugExtended.enabled) { + debugExtended(`(buildRelationQuery) ${JSON.stringify(relation)}`); + } + + if (relation.config.type === 'manyToMany') { + if (isCompOp(relation.operator)) { + const comp = compOps[relation.operator] || '='; + + // CASE: post.id IN (SELECT ...) + qb.where(`${this.tableName}.id`, 'IN', function () { + return this + .select(`${relation.config.join_table}.${relation.config.join_from}`) + .from(`${relation.config.join_table}`) + .innerJoin(`${relation.config.tableName}`, `${relation.config.tableName}.id`, '=', `${relation.config.join_table}.${relation.config.join_to}`) + .where(`${relation.config.tableName}.${relation.column}`, comp, relation.value); + }); + } else { + debug('unknown operator'); + } + + return; + } + + debug('not implemented'); + } + + /** + * Determines if statement is a simple where comparison on the parent table or if the statement is a relation query. * * e.g. * @@ -66,34 +126,44 @@ class MongoToKnex { * `where column != value` * `where column > value` */ - buildComparison(qb, mode, field, op, value) { + buildComparison(qb, mode, statement, op, value) { const comp = compOps[op] || '='; const whereType = this.processWhereType(mode, op, value); - field = this.processField(field, op); + const processedStatement = this.processStatement(statement, op, value); + + debug(`(buildComparison) isRelation: ${processedStatement.isRelation}`); - debug(`(buildComparison) whereType: ${whereType}, field: ${field}, op: ${op}, comp: ${comp}, value: ${value}`); - qb[whereType](field, comp, value); + if (processedStatement.isRelation) { + return this.buildRelationQuery(qb, processedStatement); + } + + debug(`(buildComparison) whereType: ${whereType}, statement: ${statement}, op: ${op}, comp: ${comp}, value: ${value}`); + qb[whereType](processedStatement.column, comp, processedStatement.value); } /** * {author: 'carl'} */ - buildWhereClause(qb, mode, field, sub) { - debug(`(buildWhereClause) mode: ${mode}, field: ${field}`); + buildWhereClause(qb, mode, statement, sub) { + debug(`(buildWhereClause) mode: ${mode}, statement: ${statement}`); if (debugExtended.enabled) { debugExtended(`(buildWhereClause) ${JSON.stringify(sub)}`); } + // CASE sub is an atomic value, we use "eq" as default operator if (!_.isObject(sub)) { - this.buildComparison(qb, mode, field, '$eq', sub); - } else { - _.forIn(sub, (value, op) => { - if (isCompOp(op)) { - this.buildComparison(qb, mode, field, op, value); - } - }); + return this.buildComparison(qb, mode, statement, '$eq', sub); } + + // CASE: sub is an object, contains statements and operators + _.forIn(sub, (value, op) => { + if (isCompOp(op)) { + this.buildComparison(qb, mode, statement, op, value); + } else { + debug('unknown operator'); + } + }); } /** @@ -153,10 +223,10 @@ class MongoToKnex { } } -module.exports = function convertor(qb, mongoJSON) { +module.exports = function convertor(qb, mongoJSON, config) { const mongoToKnex = new MongoToKnex({ tableName: qb._single.table - }); + }, config); mongoToKnex.processJSON(qb, mongoJSON); diff --git a/test/integration/relations.test.js b/test/integration/relations.test.js index de7297f..8d79a82 100644 --- a/test/integration/relations.test.js +++ b/test/integration/relations.test.js @@ -5,13 +5,17 @@ const convertor = require('../../lib/convertor'); /* eslint-disable no-console*/ +// @TODO: the config object is not designed yet. const makeQuery = query => convertor(knex('posts'), query, { relations: { tags: { + tableName: 'tags', type: 'manyToMany', join_table: 'posts_tags', join_from: 'post_id', - join_to: 'tag_id' + join_to: 'tag_id', + // @TODO: tag -> tags.slug + aliases: {} }, authors: { type: 'oneToMany', @@ -22,19 +26,12 @@ const makeQuery = query => convertor(knex('posts'), query, { // Integration tests build a test database and // check that we get the exact data we expect from each query -describe.skip('Relations', function () { - before(utils.db.setup(() => { - // Do things afterwards in a callback - })); - +describe('Relations', function () { + before(utils.db.setup()); after(utils.db.teardown()); - describe('One-to-Many', function () { - // Use a named file - beforeEach(utils.db.init('test-fixture', () => { - // could do stuff after - })); - + describe.skip('One-to-Many', function () { + beforeEach(utils.db.init('relations1')); afterEach(utils.db.reset()); it('can match array in (single value)', function (done) { @@ -86,82 +83,64 @@ describe.skip('Relations', function () { }); }); - describe('Many-to-Many: Simple Cases', function () { - // Use the default file named after the describe block, would be many-to-many-simple-cases - beforeEach(utils.db.init()); - - afterEach(utils.db.reset()); - - it('can match array in (single value)', function (done) { - const queryJSON = {'tags.slug': {$in: ['animal']}}; - - // Use the queryJSON to build a query - const query = makeQuery(queryJSON); - - // Check any intermediate values - console.log(query.toQuery()); - - // Perform the query against the DB - query.select() - .then((result) => { - console.log(result); - - result.should.be.an.Array().with.lengthOf(3); - - // Check we get the right data - // result.should.do.something; - - done(); - }) - .catch(done); - }); - - it('can match array in (multiple values)', function (done) { - const queryJSON = {'tags.id': {$in: [2, 3]}}; - - // Use the queryJSON to build a query - const query = makeQuery(queryJSON); - - // Check any intermediate values - console.log('query', query.toQuery()); - - // Perform the query against the DB - query.select() - .then((result) => { - console.log(result); - - result.should.be.an.Array().with.lengthOf(4); + describe('Many-to-Many', function () { + before(utils.db.init('relations1')); - // Check we get the right data - // result.should.do.something; + describe('OR', function () { + it('tags.slug IN (animal)', function () { + const mongoJSON = { + 'tags.slug': { + $in: ['animal'] + } + }; - done(); - }) - .catch(done); - }); + const query = makeQuery(mongoJSON); - it('can match array in (multiple values with x, y and xy)', function (done) { - const queryJSON = {'tags.id': {$in: [1, 2]}}; + return query + .select() + .then((result) => { + result.should.be.an.Array().with.lengthOf(3); + }); + }); - // Use the queryJSON to build a query - const query = makeQuery(queryJSON); + it('featured:true AND tags.slug IN (animal)', function () { + const mongoJSON = { + $and: [ + { + featured: true + }, + { + 'tags.slug': { + $in: ['animal'] + } + } + ] + }; - // Check any intermediate values - console.log('query', query.toQuery()); + const query = makeQuery(mongoJSON); - // Perform the query against the DB - query.select() - .then((result) => { - console.log(result); + return query + .select() + .then((result) => { + result.should.be.an.Array().with.lengthOf(2); + }); + }); - result.should.be.an.Array().with.lengthOf(5); + it('tags.id IN (2,3)', function () { + const mongoJSON = { + 'tags.id': { + $in: [2, 3] + } + }; - // Check we get the right data - // result.should.do.something; + const query = makeQuery(mongoJSON); - done(); - }) - .catch(done); + return query + .select() + .then((result) => { + result.should.be.an.Array().with.lengthOf(4); + }); + }); }); }); diff --git a/test/integration/suite1/fixtures/test-fixture.json b/test/integration/suite1/fixtures/relations1.json similarity index 100% rename from test/integration/suite1/fixtures/test-fixture.json rename to test/integration/suite1/fixtures/relations1.json diff --git a/test/unit/convertor.test.js b/test/unit/convertor.test.js index 8d2d859..1741f5a 100644 --- a/test/unit/convertor.test.js +++ b/test/unit/convertor.test.js @@ -282,27 +282,3 @@ describe('Logical Groups', function () { }); }); }); - -describe('Relations', function () { - describe('IN with array of objects', function () { - it('can match array in (single value)', function () { - runQuery({'tags.slug': {$in: ['video']}}) - .should.eql('select * from `posts` where `tags`.`slug` in (\'video\')'); - }); - - it('can match array in (multiple values)', function () { - runQuery({'tags.slug': {$in: ['video', 'audio']}}) - .should.eql('select * from `posts` where `tags`.`slug` in (\'video\', \'audio\')'); - }); - - it('can match array NOT in (single value)', function () { - runQuery({'tags.slug': {$nin: ['video']}}) - .should.eql('select * from `posts` where `tags`.`slug` not in (\'video\')'); - }); - - it('can match array NOT in (multiple values)', function () { - runQuery({'tags.slug': {$nin: ['video', 'audio']}}) - .should.eql('select * from `posts` where `tags`.`slug` not in (\'video\', \'audio\')'); - }); - }); -}); diff --git a/test/utils/db/lifecycle.js b/test/utils/db/lifecycle.js index 69069b0..e205d79 100644 --- a/test/utils/db/lifecycle.js +++ b/test/utils/db/lifecycle.js @@ -63,8 +63,9 @@ module.exports.setup = name => function innerSetup() { * `init('fixtures.json')` */ module.exports.init = (suiteName, fixtureFileName) => { - if (!suiteName) { - suiteName = fixtureFileName; + if (!fixtureFileName) { + fixtureFileName = suiteName; + suiteName = null; } return function innerInit() {