Skip to content

Associations through join models

A :through association reaches a second model by way of a join model. A user subscribes to magazines through subscriptions; the subscription is a real model with its own row.

Setup

The join model belongs to both sides:

1
2
3
4
5
6
class Subscription is Model {
  submethod BUILD {
    self.belongs-to: user     => class-name => 'User';
    self.belongs-to: magazine => class-name => 'Magazine';
  }
}

Each side declares the direct has-many to the join model, then a second has-many that travels through it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class User is Model {
  submethod BUILD {
    self.has-many: subscriptions => class-name => 'Subscription';
    self.has-many: magazines     => %(through => :subscriptions, class-name => 'Magazine');
  }
}

class Magazine is Model {
  submethod BUILD {
    self.has-many: subscriptions => class-name => 'Subscription';
    self.has-many: users         => %(through => :subscriptions, class-name => 'User');
  }
}

The join table carries the two foreign keys:

1
2
3
4
5
self.create-table: 'subscriptions', [
  user     => { :reference },
  magazine => { :reference },
];
self.add-index: 'subscriptions', <user_id magazine_id> => { :unique };

Reading through the join

Create the join rows, then read straight through:

1
2
3
4
5
my $user = User.create({fname => 'Greg'});
my $mag  = Magazine.create({title => 'Mad'});
Subscription.create({user => $user, magazine => $mag});

$user.magazines.first.id;   # $mag.id

The collection behaves like any other: .elems, .map, .grep.

1
2
$user.magazines.elems;
$user.magazines.map(*.attrs<title>).sort;

Eager loading a through association

preload loads the whole graph in batches and also caches the intermediate join collection, so the join rows are there too:

1
2
3
4
5
my @users = User.where({}).preload(:magazines).all;
my $alice = @users.first({ .attrs<fname> eq 'Alice' });

$alice.magazines.elems;                 # no extra query
$alice.subscriptions.elems;             # the join rows are cached as well

See Eager loading for preload vs includes vs eager-load.

has-one :through

The singular form works the same way. A user has one account through their profile:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Profile is Model {
  submethod BUILD {
    self.belongs-to: user    => class-name => 'User';
    self.belongs-to: account => %(class-name => 'Account', optional => True);
  }
}

class User is Model {
  submethod BUILD {
    self.has-one: account => %(through => :profile);
  }
}
1
2
3
4
my $account = Account.create({name => 'gdonald'});
Profile.create({user => $user, account => $account, bio => 'Raku enthusiast'});

User.find($user.id).account.id;   # $account.id

When no join row exists, the singular accessor returns an undefined value:

1
User.find($other.id).account.defined;   # False