Skip to main content

Relational Field Filters with Automatic Joins

NormalJS now supports filtering by relational field names using dot-notation. The ORM automatically generates the necessary SQL joins based on your model relationships.

Features​

  • Automatic Join Generation: No need to manually specify joins - they're created automatically
  • Field Name Mapping: Use field names (e.g., author_id.firstname) instead of table.column syntax
  • Multi-Level Joins: Chain through multiple relationships (e.g., author_id.organization_id.name)
  • Relationship Support: Works with many-to-one and one-to-many relationships
  • Logical Operators: Combine with AND, OR, and NOT logic
  • All Query Operators: Use any operator (eq, like, in, gt, etc.) with relational paths

Basic Usage​

Single-Level Join​

Filter posts by their author's firstname:

const posts = await Posts.where({
'author_id.firstname': 'Alice',
});

Generates SQL:

SELECT * FROM posts
INNER JOIN users ON posts.author_id = users.id
WHERE users.firstname = 'Alice'

Multi-Level Join​

Filter posts by their author's organization:

const posts = await Posts.where({
'author_id.organization_id.name': 'ACME Corp',
});

Generates SQL:

SELECT * FROM posts
INNER JOIN users ON posts.author_id = users.id
INNER JOIN organizations ON users.organization_id = organizations.id
WHERE organizations.name = 'ACME Corp'

One-to-Many Reverse Lookup​

Filter users by their posts' titles:

const users = await Users.where({
'posts.title': 'My First Post',
});

Generates SQL:

SELECT * FROM users
INNER JOIN posts ON users.id = posts.author_id
WHERE posts.title = 'My First Post'

Advanced Usage​

Combining Relational and Direct Filters​

const posts = await Posts.where({
'author_id.organization_id.name': 'ACME Corp',
'author_id.firstname': 'Alice',
title: { like: '%Update%' },
created_at: { gte: '2024-01-01' },
});

Using Query Operators​

All standard operators work with relational paths:

// LIKE operator
const posts = await Posts.where({
'author_id.lastname': { like: 'Sm%' },
});

// IN operator
const posts = await Posts.where({
'author_id.organization_id.country': { in: ['USA', 'UK', 'Canada'] },
});

// Comparison operators
const posts = await Posts.where({
'author_id.organization_id.id': { gt: 10 },
});

OR Logic with Relational Filters​

const posts = await Posts.where({
or: [
{ 'author_id.organization_id.name': 'ACME Corp' },
{ 'author_id.organization_id.name': 'Tech Inc' },
],
});

Nested AND/OR Logic​

const posts = await Posts.where({
and: [
{ 'author_id.organization_id.name': 'ACME Corp' },
{
or: [{ 'author_id.firstname': 'Alice' }, { 'author_id.firstname': 'Charlie' }],
},
],
});

Generates SQL:

SELECT * FROM posts
INNER JOIN users ON posts.author_id = users.id
INNER JOIN organizations ON users.organization_id = organizations.id
WHERE (
organizations.name = 'ACME Corp'
AND (users.firstname = 'Alice' OR users.firstname = 'Charlie')
)

Model Relationships​

The feature works with these relationship types:

Many-to-One​

class Posts {
static fields = {
author_id: { type: 'many-to-one', model: 'Users' },
};
}

// Filter by related model's fields
await Posts.where({ 'author_id.email': 'alice@example.com' });

One-to-Many (Reverse Lookup)​

class Users {
static fields = {
posts: { type: 'one-to-many', foreign: 'Posts.author_id' },
};
}

// Filter by related collection
await Users.where({ 'posts.title': 'My Post' });

How It Works​

  1. Path Resolution: The system parses dot-notation paths to identify relationships
  2. Join Collection: All necessary joins are collected from the entire criteria tree
  3. Join Application: Joins are applied to the query (with deduplication)
  4. Where Clauses: Finally, WHERE conditions are applied with proper table.column qualification

This two-pass approach ensures:

  • Joins are only applied once (no duplicates)
  • Joins work correctly with nested OR/AND logic
  • Column names are properly qualified to avoid ambiguity

Important Notes​

Column Name Qualification​

When joins are present, all column names in the SELECT clause are automatically qualified with their table names to prevent "ambiguous column name" errors.

Supported Relationships​

Currently supported:

  • ✅ Many-to-one (e.g., post.author_id)
  • ✅ One-to-many (e.g., user.posts)

Not yet supported:

  • ❌ Many-to-many (coming soon)

Error Handling​

If a relational path cannot be resolved (e.g., field doesn't exist or isn't a relationship), the system treats it as a regular qualified column name and lets Knex handle it:

// This will work if you have a table.column syntax
await Posts.where({ 'posts.title': 'My Post' });

// This will throw an error if author_id.nonexistent doesn't exist
await Posts.where({ 'author_id.nonexistent': 'value' });

Examples​

See the complete working examples in:

  • demo/relational-filters-demo.js - Interactive demonstration
  • tests/relational-filters.test.js - Comprehensive test suite

Run the demo:

node demo/relational-filters-demo.js

Performance​

  • Join Deduplication: Joins are automatically deduplicated if the same path is used multiple times
  • Efficient SQL: Generated SQL uses INNER JOINs for optimal query performance
  • Query Planning: Consider adding indexes on foreign key columns for better performance with large datasets

Migration from Manual Joins​

Before (manual joins):

const posts = await repo
.cnx('posts')
.join('users', 'posts.author_id', 'users.id')
.join('organizations', 'users.organization_id', 'organizations.id')
.where('organizations.name', 'ACME Corp');

After (automatic joins):

const posts = await Posts.where({
'author_id.organization_id.name': 'ACME Corp',
});

See Also​