Skip to content

Commit

Permalink
TypeORM adapter: Support conditions fields with nested relations
Browse files Browse the repository at this point in the history
Add support for fields with nested relations (multiple levels) in the
typeORM adapter.

The other adapters treat these fields as simple fields and they don't
join the relations.

Fix issue stalniy#32 for TypeORM adapter.
  • Loading branch information
Claudio Catterina authored and ccatterina committed Aug 26, 2022
1 parent ad01437 commit 7ea5cbf
Show file tree
Hide file tree
Showing 7 changed files with 172 additions and 24 deletions.
37 changes: 36 additions & 1 deletion packages/sql/spec/mikro-orm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ describe('Condition interpreter for MikroORM', () => {
'where ((`projects`.`name` = ? and `projects`.`active` = ?))'
].join(' '))
})

it('should treat fields with nested relations as simple fields', () => {
const condition = new CompoundCondition('and', [
new FieldCondition('eq', 'projects.reviews.rating', 5),
new FieldCondition('eq', 'projects.active', true),
])
const query = interpret(condition, orm.em.createQueryBuilder(User).select([]))

expect(query.getQuery()).to.equal([
'select *',
'from `user` as `e0`',
'inner join `project` as `projects` on `e0`.`id` = `projects`.`user_id`',
'where ((`projects.reviews.rating` = ? and `projects`.`active` = ?))'
].join(' '))
})
})

async function configureORM() {
Expand All @@ -79,10 +94,20 @@ async function configureORM() {
public id: number,
public name: string,
public user: User,
public reviews: Collection<Review>,
public active: boolean
) {}
}

class Review {
constructor(
public id: number,
public rating: number,
public comment: string,
public project: Project
) {}
}

const UserSchema = new EntitySchema({
class: User,
properties: {
Expand All @@ -101,12 +126,22 @@ async function configureORM() {
id: { type: 'number', primary: true },
name: { type: 'string' },
user: { type: 'User', reference: 'm:1' },
reviews: { type: 'Review', reference: '1:m', inversedBy: 'project' },
active: { type: 'boolean' }
}
})
const ReviewSchema = new EntitySchema({
class: Review,
properties: {
id: { type: 'number', primary: true },
rating: { type: 'number' },
project: { type: 'Project', reference: 'm:1' },
comment: { type: 'string' }
}
})

const orm = await MikroORM.init({
entities: [UserSchema, ProjectSchema],
entities: [UserSchema, ProjectSchema, ReviewSchema],
dbName: ':memory:',
type: 'sqlite',
})
Expand Down
37 changes: 36 additions & 1 deletion packages/sql/spec/objection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ describe('Condition interpreter for Objection', () => {
where ("projects"."name" = 'test' and "projects"."active" = true)
`.trim())
})

it('should treat fields with nested relations as simple fields', () => {
const condition = new CompoundCondition('and', [
new FieldCondition('eq', 'projects.reviews.rating', 5),
new FieldCondition('eq', 'projects.active', true),
])
const query = interpret(condition, User.query())

expect(query.toKnexQuery().toString()).to.equal(linearize`
select "users".*
from "users"
inner join "projects" on "projects"."user_id" = "users"."id"
where ("projects.reviews.rating" = 5 and "projects"."active" = true)
`.trim())
})
})

function configureORM() {
Expand Down Expand Up @@ -88,10 +103,30 @@ function configureORM() {
modelClass: User,
join: { from: 'users.id', to: 'projects.user_id' },
active: Boolean
},
projects: {
relation: Model.HasManyRelation,
modelClass: Review,
join: { from: 'projects.id', to: 'review.project_id' }
}
}
}
}

class Review extends Model {
static tableName = 'reviews'

static get relationMappings() {
return {
project: {
relation: Model.BelongsToOneRelation,
modelClass: Project,
join: { from: 'projects.id', to: 'review.project_id' },
active: Boolean
}
}
}
}

return { User, Project }
return { User, Project, Review }
}
21 changes: 21 additions & 0 deletions packages/sql/spec/sequelize.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,27 @@ describe('Condition interpreter for Sequelize', () => {
])
expect(query.where.val).to.equal('(`projects`.`name` = \'test\' and `projects`.`active` = 1)')
})

it('should treat fields with nested relations as simple fields', () => {
const condition = new CompoundCondition('and', [
new FieldCondition('eq', 'projects.reviews.rating', 5),
new FieldCondition('eq', 'projects.active', true),
])
const query = interpret(condition, User)

expect(query.include).to.deep.equal([
{ association: 'projects', required: true },
])
expect(query.where.val).to.equal('(`projects.reviews.rating` = 5 and `projects`.`active` = 1)')
})
})

function configureORM() {
const sequelize = new Sequelize('sqlite::memory:')

class User extends Model {}
class Project extends Model {}
class Review extends Model {}

User.init({
name: { type: DataTypes.STRING },
Expand All @@ -62,6 +76,13 @@ function configureORM() {
active: { type: DataTypes.BOOLEAN }
}, { sequelize, modelName: 'project' })

Review.init({
rating: { type: DataTypes.INTEGER },
comment: { type: DataTypes.STRING },
}, { sequelize, modelName: 'review' })

Project.hasMany(Review)
Review.belongsTo(Project)
Project.belongsTo(User)
User.hasMany(Project)

Expand Down
51 changes: 47 additions & 4 deletions packages/sql/spec/typeorm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,23 @@ describe('Condition interpreter for TypeORM', () => {
].join(' '))
expect(query.getParameters()).to.eql({ 0: 'test', 1: true })
})

it('should use multiple join to handle deep relations', () => {
const condition = new CompoundCondition('and', [
new FieldCondition('eq', 'projects.reviews.rating', 5),
new FieldCondition('eq', 'projects.active', true),
])
const query = interpret(condition, conn.createQueryBuilder(User, 'u'))

expect(query.getQuery()).to.equal([
'SELECT "u"."id" AS "u_id", "u"."name" AS "u_name"',
'FROM "user" "u"',
'INNER JOIN "project" "projects" ON "projects"."userId"="u"."id"',
' INNER JOIN "review" "projects_reviews" ON "projects_reviews"."projectId"="projects"."id"',
'WHERE ("projects_reviews"."rating" = :0 and "projects"."active" = :1)'
].join(' '))
expect(query.getParameters()).to.eql({ 0: 5, 1: true })
})
})

async function configureORM() {
Expand All @@ -95,9 +112,17 @@ async function configureORM() {
id!: number
name!: string
user!: User
reviews!: Review[]
active!: boolean
}

class Review {
id!: number
rating!: number
comment!: string
project!: Project
}

const UserSchema = new EntitySchema<User>({
name: 'User',
target: User,
Expand All @@ -110,7 +135,7 @@ async function configureORM() {
target: 'Project',
type: 'one-to-many',
inverseSide: 'user'
}
},
}
})

Expand All @@ -123,15 +148,33 @@ async function configureORM() {
active: { type: 'boolean' }
},
relations: {
user: { target: 'User', type: 'many-to-one' }
user: { target: 'User', type: 'many-to-one' },
reviews: {
target: 'Review',
type: 'one-to-many',
inverseSide: 'project'
},
}
})

const ReviewSchema = new EntitySchema<Review>({
name: 'Review',
target: Review,
columns: {
id: { primary: true, type: 'int', generated: true },
comment: { type: 'varchar' },
rating: { type: 'int' }
},
relations: {
project: { target: 'Project', type: 'many-to-one' }
}
})

const conn = await createConnection({
type: 'sqlite',
database: ':memory:',
entities: [UserSchema, ProjectSchema]
entities: [UserSchema, ProjectSchema, ReviewSchema]
})

return { User, Project, conn }
return { User, Project, Review, conn }
}
8 changes: 5 additions & 3 deletions packages/sql/src/interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export class Query {
return this._rootAlias + this._localField(name);
}

const relationNameIndex = name.indexOf('.');
const relationNameIndex = name.lastIndexOf('.');

if (relationNameIndex === -1) {
return this._rootAlias + this._localField(name);
Expand All @@ -65,7 +65,8 @@ export class Query {
return this._rootAlias + this._localField(name);
}

this._joins.add(relationName);
relationName.split('.').forEach(r => this._joins.add(r));

return this._foreignField(field, relationName);
}

Expand All @@ -74,7 +75,8 @@ export class Query {
}

private _foreignField(field: string, relationName: string) {
return `${this.options.escapeField(relationName)}.${this.options.escapeField(field)}`;
const relationLastAlias = relationName.split('.').slice(-1)[0];
return `${this.options.escapeField(relationLastAlias)}.${this.options.escapeField(field)}`;
}

param(value: unknown) {
Expand Down
4 changes: 3 additions & 1 deletion packages/sql/src/lib/objection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
} from '../index';

function joinRelation(relationName: string, query: QueryBuilder<Model>) {
if (!query.modelClass().getRelation(relationName)) {
try {
query.modelClass().getRelation(relationName);
} catch (e) {
return false;
}

Expand Down
38 changes: 24 additions & 14 deletions packages/sql/src/lib/typeorm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,37 @@ import {
createDialects
} from '../index';

function joinRelation<Entity>(relationName: string, query: SelectQueryBuilder<Entity>) {
const meta = query.expressionMap.mainAlias!.metadata;
const joinAlreadyExists = query.expressionMap.joinAttributes
.some(j => j.alias.name === relationName);

if (joinAlreadyExists) {
return true;
function joinRelation<Entity>(relation: string, query: SelectQueryBuilder<Entity>) {
const relationParts = relation.split('.');
let meta = query.expressionMap.mainAlias!.metadata;

// eslint-disable-next-line no-restricted-syntax
for (const part of relationParts) {
const relationData = meta.findRelationWithPropertyPath(part);
if (!relationData) {
return false;
}
meta = relationData.inverseRelation!.entityMetadata;
}

const relation = meta.findRelationWithPropertyPath(relationName);
if (relation) {
query.innerJoin(`${query.alias}.${relationName}`, relationName);
return true;
}
relationParts.forEach((part, i) => {
const alias = (i > 0) ? relationParts.slice(0, i).join('_') : query.expressionMap.mainAlias!.name;
const nextJoinAlias = relationParts.slice(0, i + 1).join('_');
if (!query.expressionMap.joinAttributes.some(j => j.alias.name === nextJoinAlias)) {
query.innerJoin(`${alias}.${part}`, nextJoinAlias);
}
});
return true;
}

return false;
function foreignField<Entity>(field: string, relationName: string) {
return `${relationName.replace(/\./g, '_')}.${field}`;
}

const dialects = createDialects({
joinRelation,
paramPlaceholder: index => `:${index - 1}`
paramPlaceholder: index => `:${index - 1}`,
foreignField
});

// eslint-disable-next-line no-multi-assign
Expand Down

0 comments on commit 7ea5cbf

Please sign in to comment.