diff --git a/lib/KnormRelations.js b/lib/KnormRelations.js index 283d277..bea8449 100644 --- a/lib/KnormRelations.js +++ b/lib/KnormRelations.js @@ -9,24 +9,12 @@ const addReference = (references, field, reference) => { references[toModel.name][field.name] = field; }; -const addReferenceByFunction = (references, func, { name, column }) => { - let resolvedReferences = func(); - resolvedReferences = isArray(resolvedReferences) - ? resolvedReferences - : [resolvedReferences]; - - resolvedReferences.forEach(reference => { - // add a pseudo-field to avoid having to overwrite field.references - const field = { name, column, references: reference }; - addReference(references, field, reference); - }); -}; - const mapReferencesByReferencedField = (references, fromModel) => { return Object.values(references).reduce((referencesByTo, from) => { const references = isArray(from.references) ? from.references : [from.references]; + references.forEach(reference => { if (reference.model.name === fromModel.name) { const to = reference.name; @@ -112,18 +100,41 @@ class KnormRelations { updateQuery(knorm) { const { Query, Model, Field } = knorm; + const addReferenceByFunction = ( + references, + func, + { name, column, type, model } + ) => { + let resolvedReferences = func(); + resolvedReferences = isArray(resolvedReferences) + ? resolvedReferences + : [resolvedReferences]; + + resolvedReferences.forEach(reference => { + // create a new field to avoid overwriting field.references + const field = new Field({ + name, + column, + type, + model, + references: reference + }); + addReference(references, field, reference); + }); + }; + class RelationsQuery extends Query { constructor(model) { super(model); // TODO: only initialize parsedRows when needed this.parsedRows = new Map(); - // TODO: move this to base model default options + // TODO: move these to base model default options this.options.ensureUniqueField = true; this.config.references = model.config.references; this.config.referenceFunctions = model.config.referenceFunctions; } - addJoin(type, joins, options) { + addJoin(joinType, joins, options) { if (!isArray(joins)) { joins = [joins]; } @@ -136,8 +147,7 @@ class KnormRelations { join = join.query; } - join.options.joinType = type; - join.setOptions(options); + join.setOptions(Object.assign({}, options, { joinType })); this.options.joins.push(join); }); @@ -145,6 +155,10 @@ class KnormRelations { return this; } + joinType(joinType) { + return this.setOption('joinType', joinType); + } + leftJoin(queries, options) { return this.addJoin('leftJoin', queries, options); } @@ -153,6 +167,10 @@ class KnormRelations { return this.addJoin('innerJoin', queries, options); } + join(queries, options) { + return this.addJoin('join', queries, options); + } + // TODO: require setting `as` when configuring references as(as) { return this.setOption('as', as); @@ -164,58 +182,66 @@ class KnormRelations { return this.addOption('on', field); } - prepareOn() { - const join = this; - const parent = this.parent; - const joinReferences = Object.assign({}, join.config.references); - const parentReferences = Object.assign({}, parent.config.references); + via(query, options) { + if (query.prototype instanceof Model) { + query = query.query; + } - Object.entries(join.config.referenceFunctions).forEach( - ([fieldName, referenceFunction]) => { - const field = join.config.fields[fieldName]; - addReferenceByFunction(joinReferences, referenceFunction, field); - } - ); + query.setOptions(Object.assign({ joinType: 'leftJoin' }, options)); + + return this.setOption('via', query); + } + + asJoinQuery(asJoinQuery = true) { + return this.setOption('asJoinQuery', !!asJoinQuery); + } + + getQueryReferences(query) { + const references = Object.assign({}, query.config.references); - Object.entries(parent.config.referenceFunctions).forEach( + Object.entries(query.config.referenceFunctions).forEach( ([fieldName, referenceFunction]) => { - const field = parent.config.fields[fieldName]; - addReferenceByFunction(parentReferences, referenceFunction, field); + const field = query.config.fields[fieldName]; + addReferenceByFunction(references, referenceFunction, field); } ); - if ( - !parentReferences[join.model.name] && - !joinReferences[parent.model.name] - ) { - throw new Query.QueryError( - `${parent.model.name}: there are no references to \`${ - join.model.name - }\`` - ); - } + return references; + } - const isReverseJoin = !!parentReferences[join.model.name]; - const toModel = isReverseJoin ? join.model : parent.model; - const mergedReferences = Object.assign( - {}, - parentReferences[join.model.name], - joinReferences[parent.model.name] - ); - const mergedReferencesReversed = mapReferencesByReferencedField( - mergedReferences, - toModel - ); - let references = []; + getFieldReferences( + fromQuery, + toQuery, + on = [], + allReferences, + allReferencesByReferencedField + ) { + let fieldReferences; + const onFields = []; + + on.forEach(field => { + if (typeof field === 'object' && !(field instanceof Field)) { + if (field[fromQuery.model.name]) { + onFields.push(field[fromQuery.model.name]); + } else if (field[toQuery.model.name]) { + onFields.push(field[toQuery.model.name]); + } + } else { + onFields.push(field); + } + }); - if (this.options.on) { - this.options.on.forEach(field => { + if (onFields.length) { + fieldReferences = []; + onFields.forEach(field => { if (field instanceof Field) { - if (field.model === parent.model) { - if (mergedReferences[field.name]) { - references.push(mergedReferences[field.name]); + if (field.model === fromQuery.model) { + if (allReferences[field.name]) { + fieldReferences.push(allReferences[field.name]); } else { - references.push(...mergedReferencesReversed[field.name]); + fieldReferences.push( + ...allReferencesByReferencedField[field.name] + ); } return; } @@ -224,37 +250,74 @@ class KnormRelations { field = field.name; } - if (mergedReferencesReversed[field]) { - references.push(...mergedReferencesReversed[field]); + if (allReferencesByReferencedField[field]) { + fieldReferences.push(...allReferencesByReferencedField[field]); } else { - references.push(mergedReferences[field]); + fieldReferences.push(allReferences[field]); } }); } else { - references = Object.values(mergedReferences); + fieldReferences = Object.values(allReferences); + } + + return fieldReferences; + } + + formatOn(fromQuery, toQuery, on) { + const fromReferences = this.getQueryReferences(fromQuery); + const toReferences = this.getQueryReferences(toQuery); + + if ( + !fromReferences[toQuery.model.name] && + !toReferences[fromQuery.model.name] + ) { + throw new Query.QueryError( + `${fromQuery.model.name}: there are no references to \`${ + toQuery.model.name + }\`` + ); } - return references.reduce((columns, field) => { + const reversed = !!fromReferences[toQuery.model.name]; + const toModel = reversed ? toQuery.model : fromQuery.model; + const allReferences = Object.assign( + {}, + fromReferences[toQuery.model.name], + toReferences[fromQuery.model.name] + ); + const allReferencesByReferencedField = mapReferencesByReferencedField( + allReferences, + toModel + ); + const fieldReferences = this.getFieldReferences( + fromQuery, + toQuery, + on, + allReferences, + allReferencesByReferencedField + ); + + return fieldReferences.reduce((columns, field) => { const fromColumn = field.column; - const references = isArray(field.references) + const referencedFields = isArray(field.references) ? field.references : [field.references]; - references.forEach(reference => { + referencedFields.forEach(reference => { if (reference.model.name === toModel.name) { const toColumn = reference.column; - let from; - let to; + let formattedFromColumn; + let formattedToColumn; - if (isReverseJoin) { - from = join.formatColumn(toColumn); - to = parent.formatColumn(fromColumn); + if (reversed) { + formattedFromColumn = toQuery.formatColumn(toColumn); + formattedToColumn = fromQuery.formatColumn(fromColumn); } else { - from = join.formatColumn(fromColumn); - to = parent.formatColumn(toColumn); + formattedFromColumn = toQuery.formatColumn(fromColumn); + formattedToColumn = fromQuery.formatColumn(toColumn); } - columns[from] = to; + columns[formattedFromColumn] = formattedToColumn; } }); @@ -263,15 +326,68 @@ class KnormRelations { } // TODO: v2: do not rely on `getTable` auto-aliasing (@knorm/knorm v2) + async prepareOn(sql, fromQuery, toQuery, joinType, on) { + const table = toQuery.getTable(); + + if (typeof joinType === 'object') { + if (joinType[fromQuery.model.name]) { + joinType = joinType[fromQuery.model.name]; + } else if (joinType[toQuery.model.name]) { + joinType = joinType[toQuery.model.name]; + } else { + joinType = 'leftJoin'; + } + } + + sql[joinType](table, this.formatOn(fromQuery, toQuery, on)); + + return sql; + } + async prepareJoin(sql, options) { - const method = this.options.joinType || 'leftJoin'; - sql[method](this.getTable(), this.prepareOn()); + const from = this.parent; + const to = this; + const via = this.getOption('via'); + + if (via) { + const joinType = via.getOption('joinType'); + + let firstOn; + let secondOn; + firstOn = secondOn = via.getOption('on'); + + if (from.model === to.model && !firstOn) { + const references = this.getQueryReferences(via); + if (references[from.model.name]) { + const referenceFields = Object.values( + references[from.model.name] + ); + firstOn = [referenceFields[0]]; + secondOn = [referenceFields[1]]; + } + } + + sql = await this.prepareOn(sql, from, via, joinType, firstOn); + sql = await this.prepareOn(sql, via, to, joinType, secondOn); + } else { + const joinType = to.getOption('joinType'); + const on = to.getOption('on'); + sql = await this.prepareOn(sql, from, to, joinType, on); + } + + const asJoinQuery = this.getOption('asJoinQuery'); + + if (asJoinQuery) { + this.setOption('ensureUniqueField', false); + } else { + // select all fields if none have been selected for the join + this.ensureFields(); + } - this.ensureFields(); return this.prepareSql(sql, options); } - ensureUniqueField(to) { + ensureUniqueField(toQuery) { const aliases = Object.keys(this.options.fields); const fields = Object.values(this.options.fields); let unique; @@ -288,7 +404,7 @@ class KnormRelations { if (!unique) { throw new Query.QueryError( `${this.model.name}: cannot join \`${ - to.model.name + toQuery.model.name }\` with no primary or unique fields selected` ); } @@ -363,8 +479,13 @@ class KnormRelations { } parseRow(row) { + const parsedRow = super.parseRow(row); + if (this.getOption('asJoinQuery')) { + return parsedRow; + } + if (this.options.joins) { this.options.joins.forEach(join => { const as = join.options.as; @@ -411,8 +532,6 @@ class KnormRelations { } } - RelationsQuery.prototype.join = RelationsQuery.prototype.innerJoin; - knorm.Query = knorm.Model.Query = RelationsQuery; } diff --git a/package-lock.json b/package-lock.json index 8901b55..190d249 100644 --- a/package-lock.json +++ b/package-lock.json @@ -332,6 +332,30 @@ "lodash": "^4.17.4" } }, + "@sinonjs/commons": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.3.0.tgz", + "integrity": "sha512-j4ZwhaHmwsCb4DlDOIWnI5YyKDNMoNThsmwEpfHx6a1EpsGZ9qYLxP++LMlmBRjtGptGHFsGItJ768snllFWpA==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/formatio": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.1.0.tgz", + "integrity": "sha512-ZAR2bPHOl4Xg6eklUGpsdiIJ4+J1SNag1DHHrG/73Uz/nVwXqjgUtRPLoS+aVyieN9cSbc0E4LsU984tWcDyNg==", + "dev": true, + "requires": { + "@sinonjs/samsam": "^2 || ^3" + } + }, + "@sinonjs/samsam": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-2.1.3.tgz", + "integrity": "sha512-8zNeBkSKhU9a5cRNbpCKau2WWPfan+Q2zDlcXvXyhn9EsMqgYs4qzo0XHNVlXC6ABQL8fT6nV+zzo5RTHJzyXw==", + "dev": true + }, "JSONStream": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", @@ -387,7 +411,7 @@ }, "ansi-escapes": { "version": "3.1.0", - "resolved": "http://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.1.0.tgz", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.1.0.tgz", "integrity": "sha512-UgAb8H9D41AQnu/PbWlCofQVcnV4Gs2bBJi9eZPxfU/hgglFh3SMDMENRIqdr7H6XFnXdoknctFByVsCOotTVw==", "dev": true }, @@ -824,7 +848,7 @@ }, "callsites": { "version": "0.2.0", - "resolved": "http://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz", "integrity": "sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo=", "dev": true }, @@ -3065,6 +3089,12 @@ "array-includes": "^3.0.3" } }, + "just-extend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.0.2.tgz", + "integrity": "sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw==", + "dev": true + }, "kind-of": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", @@ -3299,6 +3329,12 @@ "integrity": "sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg==", "dev": true }, + "lolex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-3.0.0.tgz", + "integrity": "sha512-hcnW80h3j2lbUfFdMArd5UPA/vxZJ+G8vobd+wg3nVEQA0EigStbYcrG030FJxL6xiDDPEkoMatV9xIh5OecQQ==", + "dev": true + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3707,6 +3743,27 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "nise": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/nise/-/nise-1.4.8.tgz", + "integrity": "sha512-kGASVhuL4tlAV0tvA34yJYZIVihrUt/5bDwpp4tTluigxUr2bBlJeDXmivb6NuEdFkqvdv/Ybb9dm16PSKUhtw==", + "dev": true, + "requires": { + "@sinonjs/formatio": "^3.1.0", + "just-extend": "^4.0.2", + "lolex": "^2.3.2", + "path-to-regexp": "^1.7.0", + "text-encoding": "^0.6.4" + }, + "dependencies": { + "lolex": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.5.tgz", + "integrity": "sha512-l9x0+1offnKKIzYVjyXU2SiwhXDLekRzKyhnbyldPHvC7BvLPVpdNUNR2KeMAiCN2D/kLNttZgQD5WjSxuBx3Q==", + "dev": true + } + } + }, "node-emoji": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.8.1.tgz", @@ -6914,7 +6971,6 @@ "version": "0.1.4", "bundled": true, "dev": true, - "optional": true, "requires": { "kind-of": "^3.0.2", "longest": "^1.0.1", @@ -7239,8 +7295,7 @@ "is-buffer": { "version": "1.1.6", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "is-builtin-module": { "version": "1.0.0", @@ -7324,7 +7379,6 @@ "version": "3.2.2", "bundled": true, "dev": true, - "optional": true, "requires": { "is-buffer": "^1.1.5" } @@ -7371,8 +7425,7 @@ "longest": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "lru-cache": { "version": "4.1.3", @@ -7638,8 +7691,7 @@ "repeat-string": { "version": "1.6.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "require-directory": { "version": "2.1.1", @@ -8224,7 +8276,7 @@ }, "os-tmpdir": { "version": "1.0.2", - "resolved": "http://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "dev": true }, @@ -8413,6 +8465,23 @@ "integrity": "sha1-v8zcjfWxLcUsi0PsONGNcsBLqW0=", "dev": true }, + "path-to-regexp": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", + "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", + "dev": true, + "requires": { + "isarray": "0.0.1" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + } + } + }, "path-type": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", @@ -8848,7 +8917,7 @@ }, "require-uncached": { "version": "1.0.3", - "resolved": "http://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz", + "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz", "integrity": "sha1-Tg1W1slmL9MeQwEcS5WqSZVUIdM=", "dev": true, "requires": { @@ -9090,6 +9159,34 @@ "pkg-conf": "^2.1.0" } }, + "sinon": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-7.1.1.tgz", + "integrity": "sha512-iYagtjLVt1vN3zZY7D8oH7dkjNJEjLjyuzy8daX5+3bbQl8gaohrheB9VfH1O3L6LKuue5WTJvFluHiuZ9y3nQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.2.0", + "@sinonjs/formatio": "^3.0.0", + "@sinonjs/samsam": "^2.1.2", + "diff": "^3.5.0", + "lodash.get": "^4.4.2", + "lolex": "^3.0.0", + "nise": "^1.4.6", + "supports-color": "^5.5.0", + "type-detect": "^4.0.8" + }, + "dependencies": { + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, "slash": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", @@ -9426,7 +9523,7 @@ }, "string_decoder": { "version": "1.1.1", - "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "requires": { @@ -9524,6 +9621,12 @@ "integrity": "sha512-j4samMCQCP5+6Il9/cxCqBd3x4vvlLeVdoyGex0KixPKl4F8LpNbDSC6NDhjianZgUngElRr9UI1ryZqJDhwGg==", "dev": true }, + "text-encoding": { + "version": "0.6.4", + "resolved": "http://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", + "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=", + "dev": true + }, "text-extensions": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", @@ -9683,6 +9786,12 @@ "prelude-ls": "~1.1.2" } }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, "uglify-js": { "version": "3.4.9", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.9.tgz", @@ -9762,6 +9871,12 @@ "proxyquire": "2.1.0" } }, + "unexpected-sinon": { + "version": "10.10.1", + "resolved": "https://registry.npmjs.org/unexpected-sinon/-/unexpected-sinon-10.10.1.tgz", + "integrity": "sha512-B/EhfBW0uoD6K13ywX2r7MTkb4ZxC1aqJ5nYhTkHEyj5njhYjWeK84bQ2tTEm7+8/W5xqiMeIHMfeVOeG3imLQ==", + "dev": true + }, "union-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", diff --git a/package.json b/package.json index 2f59ad8..22136c3 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,10 @@ "prettier": "1.15.3", "proxyquire": "2.1.0", "semantic-release": "15.12.5", + "sinon": "7.1.1", "unexpected": "10.39.2", - "unexpected-knex": "1.3.0" + "unexpected-knex": "1.3.0", + "unexpected-sinon": "10.10.1" }, "directories": { "lib": "lib", diff --git a/test/KnormRelations.spec.js b/test/KnormRelations.spec.js index d070882..16028a8 100644 --- a/test/KnormRelations.spec.js +++ b/test/KnormRelations.spec.js @@ -4,9 +4,11 @@ const knormPostgres = require('@knorm/postgres'); const KnormRelations = require('../lib/KnormRelations'); const knormRelations = require('../'); const knex = require('./lib/knex'); +const sinon = require('sinon'); const expect = require('unexpected') .clone() .use(require('unexpected-knex')) + .use(require('unexpected-sinon')) .addAssertion( ' to be fulfilled with sorted rows [exhaustively] satisfying ', (expect, subject, value) => { @@ -93,6 +95,27 @@ describe('KnormRelations', () => { receiverId: { type: 'integer', references: User.fields.id } }; + class Group extends Model {} + Group.table = 'group'; + Group.fields = { + name: { type: 'string', required: true } + }; + + class GroupMembership extends Model {} + GroupMembership.table = 'group_membership'; + GroupMembership.fields = { + name: 'string', + userId: { type: 'integer', references: User.fields.id }, + groupId: { type: 'integer', references: Group.fields.id } + }; + + class Friendship extends Model {} + Friendship.table = 'friendship'; + Friendship.fields = { + userId: { type: 'integer', references: User.fields.id }, + friendId: { type: 'integer', references: User.fields.id } + }; + before(async () => { await knex.schema.createTable(User.table, table => { table.increments(); @@ -127,6 +150,33 @@ describe('KnormRelations', () => { .references('id') .inTable(User.table); }); + await knex.schema.createTable(Group.table, table => { + table.increments(); + table.string('name').notNullable(); + }); + await knex.schema.createTable(GroupMembership.table, table => { + table.increments(); + table.string('name'); + table + .integer('user_id') + .references('id') + .inTable(User.table); + table + .integer('group_id') + .references('id') + .inTable(Group.table); + }); + await knex.schema.createTable(Friendship.table, table => { + table.increments(); + table + .integer('user_id') + .references('id') + .inTable(User.table); + table + .integer('friend_id') + .references('id') + .inTable(User.table); + }); await User.insert([ { id: 1, name: 'User 1', confirmed: null }, @@ -141,6 +191,9 @@ describe('KnormRelations', () => { }); after(async () => { + await knex.schema.dropTable(Friendship.table); + await knex.schema.dropTable(GroupMembership.table); + await knex.schema.dropTable(Group.table); await knex.schema.dropTable(Message.table); await knex.schema.dropTable(Image.table); await knex.schema.dropTable(ImageCategory.table); @@ -1415,20 +1468,30 @@ describe('KnormRelations', () => { }); describe("with an 'innerJoin' configured", () => { - it('returns the instances with matching data in the joined table (inner join)', async () => { + it('returns the instances with matching data in the joined table (INNER JOIN)', async () => { const query = new Query(User).innerJoin(new Query(Image)); + const spy = sinon.spy(query, 'query'); await expect( query.fetch(), 'to be fulfilled with sorted rows satisfying', [new User({ id: 1, name: 'User 1', image: [new Image({ id: 1 })] })] ); + await expect(spy, 'to have calls satisfying', () => { + spy( + expect.it( + 'when passed as parameter to', + sql => sql.toString(), + 'to contain', + ' INNER JOIN ' + ) + ); + }); }); it("resolves with an empty array if the join doesn't match any rows", async () => { const query = new Query(User) .where({ id: 2 }) .innerJoin(new Query(Image)); - await expect( query.fetch(), 'to be fulfilled with sorted rows exhaustively satisfying', @@ -1438,20 +1501,28 @@ describe('KnormRelations', () => { }); describe("with a 'join' configured", () => { - it('returns the instances with matching data in the joined table (inner join)', async () => { + it('returns the instances with matching data in the joined table (JOIN)', async () => { const query = new Query(User).join(new Query(Image)); + const spy = sinon.spy(query, 'query'); await expect( query.fetch(), 'to be fulfilled with sorted rows satisfying', [new User({ id: 1, name: 'User 1', image: [new Image({ id: 1 })] })] ); + await expect(spy, 'to have calls satisfying', () => { + spy( + expect.it( + 'when passed as parameter to', + sql => sql.toString(), + 'to contain', + ' JOIN ' + ) + ); + }); }); it("resolves wih an empty array if the join doesn't match any rows", async () => { - const query = new Query(User) - .where({ id: 2 }) - .innerJoin(new Query(Image)); - + const query = new Query(User).where({ id: 2 }).join(new Query(Image)); await expect( query.fetch(), 'to be fulfilled with sorted rows exhaustively satisfying', @@ -1460,6 +1531,1011 @@ describe('KnormRelations', () => { }); }); + describe('with `via` configured', () => { + before(async () => { + await User.insert([ + { id: 3, name: 'User 3' }, + { id: 4, name: 'User 4' } + ]); + await Group.insert([ + { id: 1, name: 'Group 1' }, + { id: 2, name: 'Group 2' } + ]); + await GroupMembership.insert([ + { id: 1, userId: 1, groupId: 1 }, + { id: 2, userId: 2, groupId: 2 }, + { id: 3, userId: 3, groupId: 2 } + ]); + }); + + after(async () => { + await GroupMembership.delete(); + await Group.delete(); + await User.delete({ where: User.where.in({ id: [3, 4] }) }); + }); + + it('rejects if a model has no references to the join query', async () => { + await expect( + new Query(User) + .leftJoin(new Query(Image).via(new Query(GroupMembership))) + .fetch(), + 'to be rejected with error satisfying', + new Query.QueryError( + 'GroupMembership: there are no references to `Image`' + ) + ); + await expect( + new Query(Image) + .leftJoin(new Query(User).via(new Query(GroupMembership))) + .fetch(), + 'to be rejected with error satisfying', + new Query.QueryError( + 'Image: there are no references to `GroupMembership`' + ) + ); + }); + + it('uses the join query for a many-to-many relation', async () => { + const query = new Query(User).leftJoin( + new Query(Group).via(new Query(GroupMembership)).as('groups') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows exhaustively satisfying', + [ + new User({ + id: 1, + name: 'User 1', + confirmed: null, + creator: null, + groups: [new Group({ id: 1, name: 'Group 1' })] + }), + new User({ + id: 2, + name: 'User 2', + confirmed: true, + creator: null, + groups: [new Group({ id: 2, name: 'Group 2' })] + }), + new User({ + id: 3, + name: 'User 3', + confirmed: null, + creator: null, + groups: [new Group({ id: 2, name: 'Group 2' })] + }), + new User({ + id: 4, + name: 'User 4', + confirmed: null, + creator: null, + groups: null + }) + ] + ); + }); + + describe('with `on` configured', () => { + class OtherGroupMembership extends GroupMembership {} + OtherGroupMembership.fields = { + name: { + type: 'string', + references: [User.fields.name, Group.fields.name] + } + }; + + it('supports join fields as strings', async () => { + const query = new Query(User).leftJoin( + new Query(Group) + .via( + new Query(OtherGroupMembership).on({ + User: 'userId', + Group: 'groupId' + }) + ) + .as('groups') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, groups: [new Group({ id: 1 })] }), + new User({ id: 2, groups: [new Group({ id: 2 })] }), + new User({ id: 3, groups: [new Group({ id: 2 })] }), + new User({ id: 4, groups: null }) + ] + ); + }); + + it('supports join fields as Field instances', async () => { + const query = new Query(User).leftJoin( + new Query(Group) + .via( + new Query(OtherGroupMembership).on({ + User: GroupMembership.fields.userId, + Group: GroupMembership.fields.groupId + }) + ) + .as('groups') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, groups: [new Group({ id: 1 })] }), + new User({ id: 2, groups: [new Group({ id: 2 })] }), + new User({ id: 3, groups: [new Group({ id: 2 })] }), + new User({ id: 4, groups: null }) + ] + ); + }); + + it('supports join fields as Field instances from the other model', async () => { + const query = new Query(User).leftJoin( + new Query(Group) + .via( + new Query(OtherGroupMembership).on({ + User: User.fields.id, + Group: Group.fields.id + }) + ) + .as('groups') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, groups: [new Group({ id: 1 })] }), + new User({ id: 2, groups: [new Group({ id: 2 })] }), + new User({ id: 3, groups: [new Group({ id: 2 })] }), + new User({ id: 4, groups: null }) + ] + ); + }); + + it('supports configuring join fields for the first side of the relation', async () => { + class OtherGroupMembership extends GroupMembership {} + OtherGroupMembership.fields = { + name: { + type: 'string', + references: User.fields.name + } + }; + const query = new Query(User).leftJoin( + new Query(Group) + .via( + new Query(OtherGroupMembership).on({ User: User.fields.id }) + ) + .as('groups') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, groups: [new Group({ id: 1 })] }), + new User({ id: 2, groups: [new Group({ id: 2 })] }), + new User({ id: 3, groups: [new Group({ id: 2 })] }), + new User({ id: 4, groups: null }) + ] + ); + }); + + it('supports configuring join fields for the second side of the relation', async () => { + class OtherGroupMembership extends GroupMembership {} + OtherGroupMembership.fields = { + name: { + type: 'string', + references: Group.fields.name + } + }; + const query = new Query(User).leftJoin( + new Query(Group) + .via( + new Query(OtherGroupMembership).on({ Group: Group.fields.id }) + ) + .as('groups') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, groups: [new Group({ id: 1 })] }), + new User({ id: 2, groups: [new Group({ id: 2 })] }), + new User({ id: 3, groups: [new Group({ id: 2 })] }), + new User({ id: 4, groups: null }) + ] + ); + }); + + // this is tested under self-referencing many-to-many joins + // it('supports configuring a single join field for both sides of the relation'); + }); + + describe('with a model instead of a query instance', () => { + it('creates a join query from the model', async () => { + const query = new Query(User).leftJoin( + new Query(Group).via(GroupMembership).as('groups') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, groups: [new Group({ id: 1 })] }), + new User({ id: 2, groups: [new Group({ id: 2 })] }), + new User({ id: 3, groups: [new Group({ id: 2 })] }), + new User({ id: 4, groups: null }) + ] + ); + }); + + it('supports setting join query options via an object', async () => { + const query = new Query(User).leftJoin( + new Query(Group) + .via(GroupMembership, { + on: { User: 'userId', Group: 'groupId' } + }) + .as('groups') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, groups: [new Group({ id: 1 })] }), + new User({ id: 2, groups: [new Group({ id: 2 })] }), + new User({ id: 3, groups: [new Group({ id: 2 })] }), + new User({ id: 4, groups: null }) + ] + ); + }); + }); + + describe('with `joinType` onfigured', () => { + it('allows configuring the join type for the first side of the join', async () => { + const query = new Query(User).leftJoin( + new Query(Group) + .via(GroupMembership, { joinType: { User: 'innerJoin' } }) + .as('groups') + ); + const spy = sinon.spy(query, 'query'); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, groups: [new Group({ id: 1 })] }), + new User({ id: 2, groups: [new Group({ id: 2 })] }), + new User({ id: 3, groups: [new Group({ id: 2 })] }) + ] + ); + await expect(spy, 'to have calls satisfying', () => { + spy( + expect.it( + 'when passed as parameter to', + sql => sql.toString(), + 'to satisfy', + stringSql => + expect( + stringSql, + 'to contain', + 'INNER JOIN "group_membership"' + ).and('to contain', 'LEFT JOIN "group"') + ) + ); + }); + }); + + it('allows configuring the join type for the second side of the join', async () => { + const query = new Query(User).leftJoin( + new Query(Group) + .via(GroupMembership, { joinType: { Group: 'innerJoin' } }) + .as('groups') + ); + const spy = sinon.spy(query, 'query'); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, groups: [new Group({ id: 1 })] }), + new User({ id: 2, groups: [new Group({ id: 2 })] }), + new User({ id: 3, groups: [new Group({ id: 2 })] }) + ] + ); + await expect(spy, 'to have calls satisfying', () => { + spy( + expect.it( + 'when passed as parameter to', + sql => sql.toString(), + 'to satisfy', + stringSql => + expect( + stringSql, + 'to contain', + 'LEFT JOIN "group_membership"' + ).and('to contain', 'INNER JOIN "group"') + ) + ); + }); + }); + + it('allows configuring a different join type for each side of the join', async () => { + const query = new Query(User).leftJoin( + new Query(Group) + .via(GroupMembership, { + joinType: { User: 'innerJoin', Group: 'leftJoin' } + }) + .as('groups') + ); + const spy = sinon.spy(query, 'query'); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, groups: [new Group({ id: 1 })] }), + new User({ id: 2, groups: [new Group({ id: 2 })] }), + new User({ id: 3, groups: [new Group({ id: 2 })] }) + ] + ); + await expect(spy, 'to have calls satisfying', () => { + spy( + expect.it( + 'when passed as parameter to', + sql => sql.toString(), + 'to satisfy', + stringSql => + expect( + stringSql, + 'to contain', + 'INNER JOIN "group_membership"' + ).and('to contain', 'LEFT JOIN "group"') + ) + ); + }); + }); + + it('allows configuring a single join type for both sides of the join', async () => { + const query = new Query(User).leftJoin( + new Query(Group) + .via(GroupMembership, { joinType: 'innerJoin' }) + .as('groups') + ); + const spy = sinon.spy(query, 'query'); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, groups: [new Group({ id: 1 })] }), + new User({ id: 2, groups: [new Group({ id: 2 })] }), + new User({ id: 3, groups: [new Group({ id: 2 })] }) + ] + ); + await expect(spy, 'to have calls satisfying', () => { + spy( + expect.it( + 'when passed as parameter to', + sql => sql.toString(), + 'to satisfy', + stringSql => + expect( + stringSql, + 'to contain', + 'INNER JOIN "group_membership"' + ).and('to contain', 'INNER JOIN "group"') + ) + ); + }); + }); + }); + + describe('for self-referencing many-to-many relations', () => { + before(async () => { + await Friendship.insert([ + { id: 1, userId: 1, friendId: 2 }, + { id: 2, userId: 2, friendId: 1 } + ]); + }); + + after(async () => { + await Friendship.delete(); + }); + + it('works without requiring join fields configured via `on`', async () => { + const query = new Query(User).leftJoin( + new Query(User).via(new Query(Friendship)).as('friends') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows exhaustively satisfying', + [ + new User({ + id: 1, + name: 'User 1', + confirmed: null, + creator: null, + friends: [ + new User({ + id: 2, + name: 'User 2', + confirmed: true, + creator: null + }) + ] + }), + new User({ + id: 2, + name: 'User 2', + confirmed: true, + creator: null, + friends: [ + new User({ + id: 1, + name: 'User 1', + confirmed: null, + creator: null + }) + ] + }), + new User({ + id: 3, + name: 'User 3', + confirmed: null, + creator: null, + friends: null + }), + new User({ + id: 4, + name: 'User 4', + confirmed: null, + creator: null, + friends: null + }) + ] + ); + }); + + it('works when references are configured with reference functions', async () => { + class OtherFriendship extends Friendship {} + OtherFriendship.fields = { + userId: { + type: 'integer', + references() { + return User.fields.id; + } + }, + friendId: { + type: 'integer', + references() { + return User.fields.id; + } + } + }; + const query = new Query(User).leftJoin( + new Query(User).via(new Query(OtherFriendship)).as('friends') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, friends: [new User({ id: 2 })] }), + new User({ id: 2, friends: [new User({ id: 1 })] }), + new User({ id: 3, friends: null }), + new User({ id: 4, friends: null }) + ] + ); + }); + + it('works when multiple references are configured', async () => { + class OtherFriendship extends Friendship {} + OtherFriendship.fields = { + userId: { + type: 'integer', + references: [User.fields.id, Group.fields.id] + }, + friendId: { + type: 'integer', + references: [User.fields.id, Group.fields.id] + } + }; + const query = new Query(User).leftJoin( + new Query(User).via(new Query(OtherFriendship)).as('friends') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, friends: [new User({ id: 2 })] }), + new User({ id: 2, friends: [new User({ id: 1 })] }), + new User({ id: 3, friends: null }), + new User({ id: 4, friends: null }) + ] + ); + }); + + it('supports configuring a join field for the first side of the relation', async () => { + const query = new Query(User).leftJoin( + new Query(User) + .via(new Query(Friendship)) + .on({ User: Friendship.fields.userId }) + .as('friends') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, friends: [new User({ id: 2 })] }), + new User({ id: 2, friends: [new User({ id: 1 })] }), + new User({ id: 3 }), + new User({ id: 4 }) + ] + ); + }); + + it('supports configuring a join join field for the second side of the relation', async () => { + const query = new Query(User).leftJoin( + new Query(User) + .via(new Query(Friendship)) + .on({ User: Friendship.fields.friendId }) + .as('friends') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, friends: [new User({ id: 2 })] }), + new User({ id: 2, friends: [new User({ id: 1 })] }), + new User({ id: 3 }), + new User({ id: 4 }) + ] + ); + }); + + it('supports configuring a single join field for both sides of the relation', async () => { + const query = new Query(User).leftJoin( + new Query(User) + .via(new Query(Friendship)) + .on('id') + .as('friends') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new User({ id: 1, friends: [new User({ id: 2 })] }), + new User({ id: 2, friends: [new User({ id: 1 })] }), + new User({ id: 3 }), + new User({ id: 4 }) + ] + ); + }); + }); + + describe('for an `innerJoin`', () => { + // because there's no actual join between the two queries + it('resolves with the same data as for a `leftJoin`', async () => { + const query = new Query(User).innerJoin( + new Query(Group).via(new Query(GroupMembership)).as('groups') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows exhaustively satisfying', + [ + new User({ + id: 1, + name: 'User 1', + confirmed: null, + creator: null, + groups: [new Group({ id: 1, name: 'Group 1' })] + }), + new User({ + id: 2, + name: 'User 2', + confirmed: true, + creator: null, + groups: [new Group({ id: 2, name: 'Group 2' })] + }), + new User({ + id: 3, + name: 'User 3', + confirmed: null, + creator: null, + groups: [new Group({ id: 2, name: 'Group 2' })] + }), + new User({ + id: 4, + name: 'User 4', + confirmed: null, + creator: null, + groups: null + }) + ] + ); + }); + }); + + describe('for a `join`', () => { + // because there's no actual join between the two queries + it('resolves with the same data as for a `leftJoin`', async () => { + const query = new Query(User).join( + new Query(Group).via(new Query(GroupMembership)).as('groups') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows exhaustively satisfying', + [ + new User({ + id: 1, + name: 'User 1', + confirmed: null, + creator: null, + groups: [new Group({ id: 1, name: 'Group 1' })] + }), + new User({ + id: 2, + name: 'User 2', + confirmed: true, + creator: null, + groups: [new Group({ id: 2, name: 'Group 2' })] + }), + new User({ + id: 3, + name: 'User 3', + confirmed: null, + creator: null, + groups: [new Group({ id: 2, name: 'Group 2' })] + }), + new User({ + id: 4, + name: 'User 4', + confirmed: null, + creator: null, + groups: null + }) + ] + ); + }); + }); + + describe('for reverse-references', () => { + it('resolves with the correct data', async () => { + const query = new Query(Group).leftJoin( + new Query(User) + .via(new Query(GroupMembership)) + .orderBy('id') + .as('users') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows exhaustively satisfying', + [ + new Group({ + id: 1, + name: 'Group 1', + users: [ + new User({ + id: 1, + name: 'User 1', + confirmed: null, + creator: null + }) + ] + }), + new Group({ + id: 2, + name: 'Group 2', + users: [ + new User({ + id: 2, + name: 'User 2', + confirmed: true, + creator: null + }), + new User({ + id: 3, + name: 'User 3', + confirmed: null, + creator: null + }) + ] + }) + ] + ); + }); + + describe('with `on` configured', () => { + class OtherGroupMembership extends GroupMembership {} + OtherGroupMembership.fields = { + name: { + type: 'string', + references: [User.fields.name, Group.fields.name] + } + }; + + it('supports join fields as strings', async () => { + const query = new Query(Group).leftJoin( + new Query(User) + .via( + new Query(OtherGroupMembership).on({ + User: 'userId', + Group: 'groupId' + }) + ) + .orderBy('id') + .as('users') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new Group({ id: 1, users: [new User({ id: 1 })] }), + new Group({ + id: 2, + users: [new User({ id: 2 }), new User({ id: 3 })] + }) + ] + ); + }); + + it('supports join fields as Field instances', async () => { + const query = new Query(Group).leftJoin( + new Query(User) + .via( + new Query(OtherGroupMembership).on({ + User: GroupMembership.fields.userId, + Group: GroupMembership.fields.groupId + }) + ) + .orderBy('id') + .as('users') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new Group({ id: 1, users: [new User({ id: 1 })] }), + new Group({ + id: 2, + users: [new User({ id: 2 }), new User({ id: 3 })] + }) + ] + ); + }); + + it('supports join fields as Field instances from the other model', async () => { + const query = new Query(Group).leftJoin( + new Query(User) + .via( + new Query(OtherGroupMembership).on({ + User: User.fields.id, + Group: Group.fields.id + }) + ) + .orderBy('id') + .as('users') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new Group({ id: 1, users: [new User({ id: 1 })] }), + new Group({ + id: 2, + users: [new User({ id: 2 }), new User({ id: 3 })] + }) + ] + ); + }); + + it('supports configuring join fields for the first side of the relation', async () => { + class OtherGroupMembership extends GroupMembership {} + OtherGroupMembership.fields = { + name: { + type: 'string', + references: User.fields.name + } + }; + const query = new Query(Group).leftJoin( + new Query(User) + .via( + new Query(OtherGroupMembership).on({ User: User.fields.id }) + ) + .orderBy('id') + .as('users') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new Group({ id: 1, users: [new User({ id: 1 })] }), + new Group({ + id: 2, + users: [new User({ id: 2 }), new User({ id: 3 })] + }) + ] + ); + }); + + it('supports configuring join fields for the second side of the relation', async () => { + class OtherGroupMembership extends GroupMembership {} + OtherGroupMembership.fields = { + name: { + type: 'string', + references: Group.fields.name + } + }; + const query = new Query(Group).leftJoin( + new Query(User) + .via( + new Query(OtherGroupMembership).on({ + Group: Group.fields.id + }) + ) + .orderBy('id') + .as('users') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new Group({ id: 1, users: [new User({ id: 1 })] }), + new Group({ + id: 2, + users: [new User({ id: 2 }), new User({ id: 3 })] + }) + ] + ); + }); + }); + + describe('with `joinType` configured', () => { + it('allows configuring the join type for the first side of the join', async () => { + const query = new Query(Group).leftJoin( + new Query(User) + .via(GroupMembership, { joinType: { Group: 'innerJoin' } }) + .orderBy('id') + .as('users') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new Group({ id: 1, users: [new User({ id: 1 })] }), + new Group({ + id: 2, + users: [new User({ id: 2 }), new User({ id: 3 })] + }) + ] + ); + }); + + it('allows configuring the join type for the second side of the join', async () => { + const query = new Query(Group).leftJoin( + new Query(User) + .via(GroupMembership, { joinType: { User: 'innerJoin' } }) + .orderBy('id') + .as('users') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new Group({ id: 1, users: [new User({ id: 1 })] }), + new Group({ + id: 2, + users: [new User({ id: 2 }), new User({ id: 3 })] + }) + ] + ); + }); + + it('allows configuring a different join type for each side of the join', async () => { + const query = new Query(Group).leftJoin( + new Query(User) + .via(GroupMembership, { + joinType: { Group: 'innerJoin', User: 'leftJoin' } + }) + .orderBy('id') + .as('users') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new Group({ id: 1, users: [new User({ id: 1 })] }), + new Group({ + id: 2, + users: [new User({ id: 2 }), new User({ id: 3 })] + }) + ] + ); + }); + + it('allows configuring a single join type for both sides of the join', async () => { + const query = new Query(Group).leftJoin( + new Query(User) + .via(GroupMembership, { joinType: 'innerJoin' }) + .orderBy('id') + .as('users') + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows satisfying', + [ + new Group({ id: 1, users: [new User({ id: 1 })] }), + new Group({ + id: 2, + users: [new User({ id: 2 }), new User({ id: 3 })] + }) + ] + ); + }); + }); + }); + }); + + describe.only('with `asJoinQuery` configured', () => { + before(async () => { + await User.insert([ + { id: 3, name: 'User 3' }, + { id: 4, name: 'User 4' } + ]); + await Group.insert([ + { id: 1, name: 'Group 1' }, + { id: 2, name: 'Group 2' } + ]); + await GroupMembership.insert([ + { id: 1, userId: 1, groupId: 1 }, + { id: 2, userId: 2, groupId: 2 }, + { id: 3, userId: 3, groupId: 2 } + ]); + }); + + after(async () => { + await GroupMembership.delete(); + await Group.delete(); + await User.delete({ where: User.where.in({ id: [3, 4] }) }); + }); + + it('uses the query as a join query', async () => { + const query = new Query(User).leftJoin( + new Query(GroupMembership) + .leftJoin(new Query(Group).as('groups')) + .asJoinQuery(true) + ); + await expect( + query.fetch(), + 'to be fulfilled with sorted rows exhaustively satisfying', + [ + new User({ + id: 1, + name: 'User 1', + confirmed: null, + creator: null, + groups: [new Group({ id: 1, name: 'Group 1' })] + }), + new User({ + id: 2, + name: 'User 2', + confirmed: true, + creator: null, + groups: [new Group({ id: 2, name: 'Group 2' })] + }), + new User({ + id: 3, + name: 'User 3', + confirmed: null, + creator: null, + groups: [new Group({ id: 2, name: 'Group 2' })] + }), + new User({ + id: 4, + name: 'User 4', + confirmed: null, + creator: null, + groups: null + }) + ] + ); + }); + }); + describe('with multiple references configured', () => { let OtherUser; let OtherImage;