Obsidian supports all common relation types. Relations are defined as methods on your model and return a relation object you can call .get() or .first() on. Eager loading via .with() is supported on all types.
A model owns a single related record. The foreign key lives on the related table.
@Table("users")
public class User extends Model {
// Explicit FK
public HasOne<Profile> profile() {
return hasOne(Profile.class, "user_id");
}
// Convention: omit the FK if it follows {model}_id
public HasOne<Profile> profile() {
return hasOne(Profile.class);
}
}
// Usage
Profile profile = user.profile().first();
A model owns multiple related records. The foreign key lives on the related table.
@Table("users")
public class User extends Model {
public HasMany<Post> posts() {
return hasMany(Post.class, "user_id");
}
}
// Retrieve
List<Post> posts = user.posts().get();
// Create a related record directly on the relation
Post post = user.posts().create(Map.of(
"title", "Hello world",
"content", "..."
));
The inverse of HasOne / HasMany. The foreign key lives on this model's table.
@Table("posts")
public class Post extends Model {
public BelongsTo<User> author() {
return belongsTo(User.class, "user_id");
}
}
// Retrieve
User author = post.author().first();
// Associate / dissociate (does not save — call save() after)
post.author().associate(user);
post.save();
post.author().dissociate(); // sets FK to null
post.save();
Many-to-many through a pivot table, with a full set of pivot management methods.
@Table("users")
public class User extends Model {
// Explicit pivot table and keys
public BelongsToMany<Role> roles() {
return belongsToMany(Role.class, "role_user", "user_id", "role_id");
}
// Convention: derives {parent}_id and {related}_id from class names
public BelongsToMany<Role> roles() {
return belongsToMany(Role.class, "role_user");
}
}
// Retrieve
List<Role> roles = user.roles().get();
// Attach / detach
user.roles().attach(roleId);
user.roles().attach(roleId, Map.of("granted_at", LocalDate.now()));
user.roles().detach(roleId);
user.roles().detachAll();
// Bulk operations
user.roles().attachMany(List.of(1, 2, 3));
user.roles().detachMany(List.of(1, 2));
// Sync — replaces the current pivot set with exactly these IDs
user.roles().sync(List.of(1, 3, 5));
// Toggle — attach if absent, detach if present
user.roles().toggle(List.of(2, 4));
// Update extra pivot columns
user.roles().updatePivot(roleId, Map.of("granted_at", LocalDate.now()));
// Read extra pivot columns
List<Role> roles = user.roles().withPivot("granted_at").get();
String grantedAt = (String) role.get("pivot_granted_at");
Access distant records through an intermediate model. Example: Country → User → Post.
@Table("countries")
public class Country extends Model {
public HasManyThrough<Post> posts() {
return hasManyThrough(
Post.class, // final model
User.class, // intermediate model
"country_id", // FK on users (users.country_id)
"user_id", // FK on posts (posts.user_id)
"id", // local key on Country
"id" // local key on User
);
}
// Convention shorthand
public HasManyThrough<Post> posts() {
return hasManyThrough(Post.class, User.class);
}
}
List<Post> posts = country.posts().get();
Polymorphic relations let a single related table belong to multiple model types via two columns — by convention {name}_type and {name}_id.
// images table: id, url, imageable_id, imageable_type
@Table("users")
public class User extends Model {
public MorphOne<Image> image() {
return morphOne(Image.class, "imageable");
}
}
@Table("teams")
public class Team extends Model {
public MorphOne<Image> image() {
return morphOne(Image.class, "imageable");
}
}
Image img = user.image().first();
// comments table: id, body, commentable_id, commentable_type
@Table("posts")
public class Post extends Model {
public MorphMany<Comment> comments() {
return morphMany(Comment.class, "commentable");
}
}
@Table("videos")
public class Video extends Model {
public MorphMany<Comment> comments() {
return morphMany(Comment.class, "commentable");
}
}
List<Comment> comments = post.comments().get();
// Create with morph columns set automatically
Comment c = post.comments().create(Map.of("body", "Great!"));
@Table("comments")
public class Comment extends Model {
public MorphTo<?> commentable() {
return morphTo("commentable", Map.of(
"Post", Post.class,
"Video", Video.class
));
}
}
// Returns the parent model regardless of its type
Model parent = comment.commentable().first();
The morphMap maps the string stored in commentable_type (e.g. "Post") to the corresponding model class. An unknown type throws a RuntimeException at runtime.
Load relations up front to avoid N+1 queries. Pass relation method names to .with():
// 3 queries total: posts + authors + comments
List<Post> posts = Model.query(Post.class)
.with("author", "comments")
.get();
// Access — no additional query fired
for (Post post : posts) {
User author = post.author().first();
List<Comment> comments = post.comments().get();
}
// Works with all relation types
List<Comment> comments = Model.query(Comment.class)
.with("commentable")
.get();
Each eager-loaded relation issues exactly one query, using a WHERE IN (...) to load all related records at once.
All relation factory methods have a short form that derives key names from class names. hasMany(Post.class) assumes user_id on the posts table; belongsTo(User.class) assumes user_id on this model's table. Pass explicit key names whenever your schema differs.