Relations

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.

HasOne

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();
HasMany

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", "..."
));
BelongsTo

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();
BelongsToMany

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");
HasManyThrough

Access distant records through an intermediate model. Example: CountryUserPost.

@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

Polymorphic relations let a single related table belong to multiple model types via two columns — by convention {name}_type and {name}_id.

MorphOne — polymorphic HasOne
// 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();
MorphMany — polymorphic HasMany
// 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!"));
MorphTo — polymorphic inverse
@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.

Eager loading

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.

💡 Convention over configuration

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.