ORM Relationships
StruxJS ORM supports One-to-One, One-to-Many, Belongs-To, and Many-to-Many relationships with eager loading, relationship existence filters, aggregate queries (withCount, withSum, withAvg), and pivot table sync operations.
Defining Relationships
Relationships are defined as methods on your Model class returning relation helper instances (hasOne, hasMany, belongsTo, belongsToMany).
1. One to One (hasOne & belongsTo)
A User model may have one Profile:
// app/Models/User.ts
import { BaseModel, HasOne } from "struxjs";
import { Profile } from "./Profile.js";
export class User extends BaseModel {
protected table = "users";
public profile(): HasOne {
return this.hasOne(Profile);
}
}Inverse relationship on the Profile model:
// app/Models/Profile.ts
import { BaseModel, BelongsTo } from "struxjs";
import { User } from "./User.js";
export class Profile extends BaseModel {
protected table = "profiles";
public user(): BelongsTo {
return this.belongsTo(User);
}
}2. One to Many (hasMany)
A User model may have many Post records:
// app/Models/User.ts
import { BaseModel, HasMany } from "struxjs";
import { Post } from "./Post.js";
export class User extends BaseModel {
protected table = "users";
public posts(): HasMany {
return this.hasMany(Post);
}
}Inverse relationship on Post:
// app/Models/Post.ts
import { BaseModel, BelongsTo } from "struxjs";
import { User } from "./User.js";
export class Post extends BaseModel {
protected table = "posts";
public author(): BelongsTo {
return this.belongsTo(User);
}
}3. Many to Many (belongsToMany)
A User model may belong to many Role records through a pivot table (role_user):
// app/Models/User.ts
import { BaseModel, BelongsToMany } from "struxjs";
import { Role } from "./Role.js";
export class User extends BaseModel {
protected table = "users";
public roles(): BelongsToMany {
return this.belongsToMany(Role);
}
}Dynamic Property Access vs. Method Call Querying
StruxJS ORM supports dynamic property lazy-loading and method call querying:
1. Dynamic Property Access (user.posts / await user.posts)
Accessing a relationship as a dynamic property returns either pre-loaded relationship models (via .with()) or automatically lazy-loads the related records when awaited:
// Eager Loaded (Pre-cached)
const user = await User.with("posts").firstOrFail();
console.log(user.posts); // Returns Collection of Post models
// Automatic Lazy Loading
const singleUser = await User.findOrFail(1);
const posts = await singleUser.posts; // Auto-fetches and caches relation2. Method Call Querying (user.posts().where().get())
Invoking a relationship method with parentheses (user.posts()) returns a relation Query Builder pre-constrained to the parent model instance. You can chain any query builder method (where, whereIn, orderBy, paginate, create, save, saveMany, get):
const user = await User.findOrFail(1);
// Filter & order related posts dynamically
const publishedPosts = await user.posts()
.where("status", "published")
.orderBy("created_at", "desc")
.get();
// Paginate related records
const paginatedPosts = await user.posts().paginate(10);
// Create related record with automatic foreign key assignment (user_id = 1)
const newPost = await user.posts().create({
title: "New Post Title",
content: "Post body content"
});
// Create multiple related records at once
const newPosts = await user.posts().createMany([
{ title: "Post 1", content: "Body 1" },
{ title: "Post 2", content: "Body 2" }
]);
// Save existing model instance under parent
const draftPost = new Post({ title: "Draft Post" });
await user.posts().save(draftPost);
// Save multiple model instances under parent at once
await user.posts().saveMany([
new Post({ title: "Draft 1" }),
new Post({ title: "Draft 2" })
]);Eager Loading (with)
Eager loading solves the N+1 query problem by fetching related models in a single optimized query.
// Fetch users with their profile and posts loaded
const users = await User.with("profile", "posts").get();
for (const user of users) {
console.log(user.profile.bio);
console.log(user.posts.count());
}Relationship Aggregations (withCount, withSum, withAvg, withMin, withMax)
Count or calculate aggregate statistics on related records directly in the main query without loading all related models:
// Count related posts and roles
const users = await User.withCount("posts", "roles").get();
for (const user of users) {
console.log(`${user.name} has ${user.posts_count} posts and ${user.roles_count} roles`);
}
// Calculate sum, avg, min, and max of related post attributes
const authors = await User.withSum("posts", "views")
.withAvg("posts", "rating")
.withMin("posts", "views")
.withMax("posts", "views")
.get();
for (const author of authors) {
console.log(`Total views: ${author.posts_sum_views}`);
console.log(`Avg rating: ${author.posts_avg_rating}`);
}Relationship Existence Filters
Filter query results based on the presence or conditions of related records.
has(relationName)
Retrieves models that have at least one related record:
// Retrieve users who have written at least one post
const activeAuthors = await User.has("posts").get();doesntHave(relationName)
Retrieves models that do NOT have any related records:
// Retrieve users who have zero posts
const newUsers = await User.doesntHave("posts").get();whereHas(relationName, callback)
Filters models where a relationship satisfies custom query conditions:
// Retrieve users with posts having more than 100 views
const popularAuthors = await User.whereHas("posts", (query) => {
query.where("views", ">", 100);
}).get();whereRelation(relationName, column, operator?, value?)
Shorthand for whereHas when filtering by a specific column condition:
// Retrieve users who have the 'admin' role
const admins = await User.whereRelation("roles", "slug", "admin").get();whereDoesntHave(relationName, callback)
Filters models where a relationship does NOT satisfy specific conditions:
// Retrieve users with no published posts
const draftAuthors = await User.whereDoesntHave("posts", (query) => {
query.where("status", "published");
}).get();Pivot Table Operations (attach, detach, sync, toggle, create)
Manage Many-to-Many pivot table relationships dynamically.
create(attributes, pivotAttributes?)
Creates a new related model in the target table and automatically attaches it to the parent in the pivot table:
const user = await User.findOrFail(1);
// Creates Role record and inserts entry into role_user pivot table
const newRole = await user.roles().create({
name: "Moderator",
slug: "moderator"
});attach(id | ids, attributes?)
Inserts new records into the pivot table:
const user = await User.findOrFail(1);
// Attach a single role ID
await user.roles().attach(2);
// Attach multiple role IDs with extra pivot table attributes
await user.roles().attach([2, 3], { assigned_by: "admin" });detach(id | ids?)
Removes records from the pivot table:
// Detach a single role ID
await user.roles().detach(2);
// Detach multiple role IDs
await user.roles().detach([2, 3]);
// Detach ALL roles from user
await user.roles().detach();sync(ids)
Synchronizes pivot table associations (detaches missing IDs and attaches new IDs):
// Ensures user ONLY has role IDs 1 and 4 in pivot table
await user.roles().sync([1, 4]);toggle(id | ids)
Toggles pivot table associations (attaches if currently missing, detaches if currently present):
// Toggles role IDs 2 and 5 for user
await user.roles().toggle([2, 5]);