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
- Path Resolution: The system parses dot-notation paths to identify relationships
- Join Collection: All necessary joins are collected from the entire criteria tree
- Join Application: Joins are applied to the query (with deduplication)
- 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 demonstrationtests/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',
});