Relations between records
In most situations you will likely see more than one DataObject
and several classes in your data model may relate
to one another. An example of this is a Player
object may have a relationship to one or more Team
or Coach
classes
and could take part in many Games
. Relations are a key part of designing and building a good data model.
Relations are built through static array definitions on a class, in the format <relationship-name> => <classname>
.
Silverstripe CMS supports a number of relationship types and each relationship type can have any number of relations.
has_one
Many-to-one and one-to-one relationships create a database-column called <relationship-name>ID
, in the example below this would be TeamID
on the Player
table.
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Player extends DataObject
{
private static $has_one = [
'Team' => Team::class,
];
// ...
}
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Team extends DataObject
{
private static $db = [
'Title' => 'Varchar',
];
private static $has_many = [
'Players' => Player::class,
];
// ...
}
This defines a one-to-many relationship called Team
which links any number of Player
records to a single Team
record. The ORM handles navigating the relationship
and provides a short syntax for accessing the related object.
Relations don't only apply to your own DataObject
models - you can make relations to core models such as File
and Image
as well:
namespace App\Model;
use SilverStripe\Assets\File;
use SilverStripe\Assets\Image;
use SilverStripe\ORM\DataObject;
class Team extends DataObject
{
private static $has_one = [
'Teamphoto' => Image::class,
'Lineup' => File::class,
];
// ...
}
At the database level, the has_one
from our example above creates a TeamID
field on the Player
table. A has_many
field does not impose any database changes. It merely injects a new method into the class to access the related records (in this case, Players()
)
$player = Player::get()->byId(1);
$team = $player->Team();
// returns a 'Team' instance.
echo $player->Team()->Title;
// returns the 'Title' column on the 'Team' or `getTitle` if it exists.
Even if the $player
record doesn't have any team record saved in its Team
relation, $player->Team()
will return a Team
object. In that case, it will be an empty record, with only default values applied. You can validate if that is the case by calling exists()
on the record (e.g. $player->Team()->exists()
).
The relationship can also be navigated in templates.
<% with $Player %>
<% if $Team.exists %>
Plays for $Team.Title
<% end_if %>
<% end_with %>
Polymorphic has_one
A has_one
relation can also be polymorphic, which allows any type of object to be associated.
This is useful where there could be many use cases for a particular data structure.
An additional column is created called <relationship-name>Class
, which along
with the <relationship-name>ID
column identifies the object.
To specify that a has_one
relation is polymorphic set the type to DataObject
.
Ideally, the associated has_many
(or belongs_to
) should be specified with "dot notation".
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Player extends DataObject
{
private static $has_many = [
'Fans' => Fan::class . '.FanOf',
];
// ...
}
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Team extends DataObject
{
private static $has_many = [
'Fans' => Fan::class . '.FanOf',
];
// ...
}
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Fan extends DataObject
{
// Generates columns FanOfID and FanOfClass
// The actual class of objects returned by $fan->FanOf() will vary
private static $has_one = [
'FanOf' => DataObject::class,
];
// ...
}
Note: The use of polymorphic relationships can affect query performance, especially on joins, and also increases the complexity of the database and necessary user code. They should be used sparingly, and only where additional complexity would otherwise be necessary. For example additional parent classes for each respective relationship, or duplication of code.
Multi-relational has_one
A single has_one
relation can be allowed to manage multiple has_many
relations - this is especially useful
in situations like adding multiple lists of links to a SiteConfig
to build a menu.
An additional column is created called <relationship-name>Relation
, along with the <relationship-name>Class
and <relationship-name>ID
columns of a normal polymorphic has_one
relation.
If you save records into the has_one
relation programatically, you must set the relation in the
<relationship-name>Relation
field, or it won't be included when you fetch the has_many
relation list.
Generally it is better to instead add the record with the has_one
relation into its corresponding has_many
relation
directly - see adding relations.
To specify that a has_one
relation is multi-relational define the relation like so:
namespace App\Model;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectSchema;
class Fan extends DataObject
{
// ...
private static array $has_one = [
'FanOf' => [
// The class here is the class for the has_one - it must be polymorphic.
'class' => DataObject::class,
// Setting this to true is what defines this has_one relation as multi-relational
DataObjectSchema::HAS_ONE_MULTI_RELATIONAL => true,
],
];
}
Multi-relational has_one
relations must be polymorphic.
It is best practice for your has_many
relations to indicate which relation they're pointing at using dot notation. For example:
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Team extends DataObject
{
// ...
private static array $has_many = [
// Notice that these are both pointing at the same has_one relation!
'CheapFans' => Fan::class . '.FanOf',
'VipFans' => Fan::class . '.FanOf',
];
}
See has_many
below for more details about has_many
relations.
belongs_to
Defines a one-to-one relationship with another object, which declares the other end of the relationship with a
corresponding has_one
. A single database column named <relationship-name>ID
will be created in the object with the
has_one
, but the belongs_to
by itself will not create a database field.
Similarly with has_many
below, dot notation can (and for best practice should) be used to explicitly specify the has_one
which refers to this relation.
This is not mandatory unless the relationship would be otherwise ambiguous.
You can use RelationValidationService
for validation of relationships. This tool will point out the relationships which may need a review. See Validating relations for more information.
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Team extends DataObject
{
private static $has_one = [
'Coach' => Coach::class,
];
}
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Coach extends DataObject
{
private static $belongs_to = [
'Team' => Team::class . '.Coach',
];
}
has_many
Defines one-to-many joins. As you can see from the previous example, $has_many
goes hand in hand with $has_one
.
When defining a has_many
relation, you must specify a has_one
relationship on the related class as well. To add a has_one
relation on core classes, yml config settings can be used:
SilverStripe\Assets\Image:
has_one:
Team: App\Model\Team
You can point multiple has_many
relations at a single has_one
relation if you use a multi-relational has_one
.
Note that in some cases you may be better off using a many_many
relation instead. Carefully consider whether you are defining a "one-to-many" or a "many-to-many" relationship.
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Team extends DataObject
{
private static $db = [
'Title' => 'Varchar',
];
private static $has_many = [
'Players' => Player::class,
];
// ...
}
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Player extends DataObject
{
private static $has_one = [
'Team' => Team::class,
];
// ...
}
Much like the has_one
relationship, has_many
can be navigated through the ORM as well. The only difference being
you will get an instance of HasManyList
rather than the object.
$team = Team::get()->first();
/** @var HasManyList $players */
$players = $team->Players();
/** @var int $numPlayers */
$numPlayers = $players->Count();
foreach ($players as $player) {
echo $player->FirstName;
}
If you're using the default scaffolded form fields with multiple has_one
relationships, you will end up with a CMS field for each relation. If you don't want these you can remove them, referring to them with as <relation-name>ID
:
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Company extends DataObject
{
// ...
public function getCMSFields()
{
$fields = parent::getCMSFields();
$fields->removeByName(['ManagerID', 'CleanerID']);
return $fields;
}
}
For more information see scaffolding for relations.
Dot notation
To specify multiple has_many
, many_many
, belongs_to
, or belongs_many_many
relationships to the same model class (and as a general best practice) you can use dot notation to distinguish them like below:
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Person extends DataObject
{
private static $has_many = [
'Managing' => Company::class . '.Manager',
'Cleaning' => Company::class . '.Cleaner',
];
// ...
}
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Company extends DataObject
{
private static $has_one = [
'Manager' => Person::class,
'Cleaner' => Person::class,
];
// ...
}
Multiple has_many
or belongs_to
relationships are okay without dot notation if they aren't linking to the same model class. Otherwise, using dot notation is required. With that said, dot notation is recommended in all cases as it makes your code more resilient to change. Adding new relationships is easier when you don't need to review and update existing ones.
You can use RelationValidationService
for validation of relationships. This tool will point out the relationships which may need a review. See Validating relations for more information.
many_many
relationships
Please specify a belongs_many_many
relationship on the related class as well in order to have the necessary accessors available on both ends. See belongs_many_many
for more information.
Defines many-to-many relationships. This type of relationship requires a new table which has an ID
column to represent the relationship itself and a column for each of the IDs of the related records.
There are two ways this relationship can be declared which are (described below) depending on how the developer wishes to manage this join table.
You can use RelationValidationService
for validation of relationships. This tool will point out the relationships which may need a review. See Validating relations for more information.
Automatic many_many
table
If you specify only a single class as the other side of the many-many relationship, then a
table will be automatically created between the two. It will be the table name of the class which declares the many_many
relationship, suffixed with the relationship name (e.g. Team_Supporters
).
The table will be created with an ID
column to represent the relationship itself and a column for each of the IDs of the related records
Extra fields on the mapping table can be created by declaring a many_many_extraFields
config to add extra columns.
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Team extends DataObject
{
private static $many_many = [
'Supporters' => Supporter::class,
];
private static $many_many_extraFields = [
'Supporters' => [
'Ranking' => 'Int',
],
];
// ...
}
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Supporter extends DataObject
{
private static $belongs_many_many = [
'Supports' => Team::class,
];
// ...
}
To ensure this many_many
is sorted by "Ranking" by default you can add this to your config:
Team_Supporters:
default_sort: 'Ranking ASC'
Team_Supporters
is the table name automatically generated for the many_many
relation in this case.
many_many
through relationship joined on a separate DataObject
If necessary, a third DataObject
class can instead be specified as the joining table,
rather than having the ORM generate an automatically scaffolded table. This has the following
advantages:
- Allows versioning of the mapping table, including support for the ownership API (see Versioning for more information).
- Allows support of other extensions on the mapping table (e.g. for subsites or localisation via fluent).
- Extra fields can easily be managed separately via the joined dataobject, even via a separate
GridField
or form.
This is declared via array syntax, with the following keys on the many_many
relation:
through
: Class name of the mapping tablefrom
: Name of thehas_one
relationship pointing back at the object declaringmany_many
to
: Name of thehas_one
relationship pointing to the object declaringbelongs_many_many
.
Just like any normal DataObject
, you can apply a default sort which will be applied when
accessing many many through relations.
Note: The through
class must not also be the name of any field or relation on the parent
or child record.
The syntax for belongs_many_many
is unchanged.
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Team extends DataObject
{
private static $many_many = [
'Supporters' => [
'through' => TeamSupporter::class,
'from' => 'Team',
'to' => 'Supporter',
],
];
// ...
}
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Supporter extends DataObject
{
// It can be useful, but not necessary, to include the reverse relation name via dot-notation
// i.e. 'Supports' => Team::class . '.Supporters'
private static $belongs_many_many = [
'Supports' => Team::class,
];
// ...
}
namespace App\Model;
use SilverStripe\ORM\DataObject;
class TeamSupporter extends DataObject
{
private static $db = [
'Ranking' => 'Int',
];
private static $has_one = [
'Team' => Team::class,
'Supporter' => Supporter::class,
];
private static $default_sort = 'Ranking ASC';
}
You can filter on the relation by the extra fields automatically, assuming they don't conflict with names of fields on other tables in the query.
$team = Team::get()->byID(1);
$supporters = $team->Supporters()->filter(['Ranking' => 1]);
If the field names conflict, you can explicitly query the field from the join record using raw SQL. There are potential security concerns to consider with this approach. See raw SQL for more information.
use SilverStripe\ORM\DataObject;
$rankingColumn = DataObject::getSchema()->sqlColumnForField(TeamSupporter::class, 'Ranking');
$team = Team::get()->byID(1);
$supporters = $team->Supporters()->where([$rankingColumn => 1]);
For records accessed in a ManyManyThroughList
, you can access the join record (e.g. for our example above a TeamSupporter
instance) by calling getJoin()
or as the $Join
property in templates.
Polymorphic many_many
Using many_many through it is possible to support polymorphic relations on the mapping table. Note, that this feature has certain limitations:
- This feature only works with many_many through
-
This feature will only allow polymorphic
many_many
, but notbelongs_many_many
.- You can have a
has_many
relation to the join table where you would normally usebelongs_many_many
, and iterate through it to collate parent records - but note that this will trigger a database query for every single record in the relation (because relations are not eager loaded by default), and filtering/etc would require additional complexity.
- You can have a
Note that this works by leveraging a polymorphic has_one
relation on the join class. See Polymorphic has_one for more information about that relation type.
For instance, this is how you would link an arbitrary object to many_many
tags.
namespace App\Model;
use SilverStripe\ORM\DataObject;
class SomeObject extends DataObject
{
// This same many_many may also exist on other classes
private static $many_many = [
'Tags' => [
'through' => TagMapping::class,
'from' => 'Parent',
'to' => 'Tag',
],
];
// ...
}
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Tag extends DataObject
{
// has_many works, but belongs_many_many will not
// note that we are explicitly declaring the join class "TagMapping" here instead of the "SomeObject" class.
private static $has_many = [
'TagMappings' => TagMapping::class,
];
/**
* Example iterator placeholder for belongs_many_many.
* This is a list of arbitrary types of objects
* @return Generator
*/
public function getTaggedObjects()
{
foreach ($this->TagMappings() as $mapping) {
yield $mapping->Parent();
}
}
}
namespace App\Model;
use SilverStripe\ORM\DataObject;
class TagMapping extends DataObject
{
private static $has_one = [
// Polymorphic has_one
'Parent' => DataObject::class,
'Tag' => Tag::class,
];
}
Using many_many
relationships
Much like has_one
and has_many
relationships, many_many
can be navigated through the ORM as well.
The only difference being you will get an instance of ManyManyList or
ManyManyThroughList returned.
$team = Team::get()->byId(1);
/** @var MayManyList|ManyManyThroughList $supporters */
$supporters = $team->Supporters();
You can add objects to the relation simply by calling add()
on the relation list:
$team = Team::get()->byId(1);
$supporter = Supporter::get()->first();
$team->Supporters()->add($supporter);
Setting many_many extra fields data
You can set the extra fields data at the same time as adding the record to the relationship list:
$team = Team::get()->byId(1);
$supporter = Supporter::get()->first();
$team->Supporters()->add($supporter, ['Ranking' => 1]);
You can also declare extra fields data later on. For regular many_many
relationships using an automatic many_many
table you can use the setExtraData()
method on the list:
$team = Team::get()->byId(1);
$supporter = Supporter::get()->first();
$team->Supporters()->add($supporter);
$team->Supporters()->setExtraData($supporter->ID, ['Ranking' => 2]);
For many_many
through relationships, just treat the join record the same as you would any other DataObject
record.
$team = Team::get()->byId(1);
$supporter = Supporter::get()->first();
$team->Supporters()->add($supporter);
$joinRecord = TeamSupporter::get()->filter(['TeamID' => $team->Id, 'SupporterID' => $supporter->ID])->first();
$joinRecord->Ranking = 2;
$joinRecord->write();
Using many_many
in templates
The relationship can also be navigated in templates.
<% with $Supporter %>
<% loop $Supports %>
Supports $Title (rank $Ranking)
<% end_loop %>
<% end_with %>
For many_many through relations, the join record can be accessed via $Join
or the actual relation name (e.g. $TeamSupporter
). This is useful if your template is class-agnostic and doesn't know specifically what relation names are used.
This also provides three ways to access the extra fields on a many_many through relation:
<% with $Supporter %>
<% loop $Supports %>
Access extrafields directly: $Ranking
Access extrafields using getJoin: $Join.Ranking
Access extrafields using the somewhat-magic join-class selector: $TeamSupporter.Ranking
<% end_loop %>
<% end_with %>
belongs_many_many
The belongs_many_many
relation represents the other side of the many_many
relationship.
When using either a basic many_many
or a many_many
through, the syntax for belongs_many_many
is the same.
To specify multiple many_many
relationships between the same classes, specify use dot notation to
distinguish them like below:
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Category extends DataObject
{
private static $many_many = [
'Products' => Product::class,
'FeaturedProducts' => Product::class,
];
// ...
}
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Product extends DataObject
{
private static $belongs_many_many = [
'Categories' => Category::class . '.Products',
'FeaturedInCategories' => Category::class . '.FeaturedProducts',
];
// ...
}
If you're unsure about whether an object should take on many_many
or belongs_many_many
,
the best way to think about it is that the object where the relationship will be edited
(i.e. via checkboxes) should contain the many_many
. For instance, in a many_many
of
Product
=> Category
, the Product
model should contain the many_many
side of the relationship, because it is much
more likely that the user will select categories for a product than vice-versa.
Eager loading
Querying nested relationships inside a loop using the ORM is prone to the N + 1 query problem. To illustrate the N + 1 query problem, imagine a scenario where there are Teams with many child Players
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Team extends DataObject
{
private static $has_many = [
'Players' => Player::class,
];
}
To retrieve teams and their players:
$teams = Team::get();
foreach ($teams as $team) {
foreach ($team->Players() as $player) {
echo $player->FirstName;
}
}
In this case the loop will execute one query to retrieve all the teams and then an additional query for each team to retrieve its players. If we have 20 teams this loop would run 21 queries - one to get all the teams and then 20 more queries to get the players for each team.
# Retrieve all the teams
# Note this is not the exact SQL that would generated it is just for demonstration
SELECT * FROM Team;
# Retrieve the players for all the teams in 20 separate queries:
SELECT * FROM Player WHERE TeamID = 1
SELECT * FROM Player WHERE TeamID = 2
SELECT * FROM Player WHERE TeamID = 3
SELECT * FROM Player WHERE TeamID = ...
The N + 1 query problem can be alleviated using eager loading which in this example will reduce this down to just two queries. We do this by passing the relationships that should be eagerly loaded to the DataList::eagerLoad()
method:
$teams = Team::get()->eagerLoad('Players');
Manipulating the eager loading query is significantly (up to ~600%) faster than Filtering an EagerLoadedlist
after the query has been made.
With eager loading now only two queries will be executed:
# Retrieve all the teams
SELECT * FROM Team
# Retrieve all the players for the teams in a single query:
SELECT * FROM Player WHERE TeamID IN (1, 2, 3, ...)
Suppose we have the following related classes:
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Team extends DataObject
{
private static $has_many = [
'Players' => Player::class,
'Fans' => Fan::class,
];
}
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Player extends DataObject
{
private static $has_one = [
'Team' => Team::class,
];
private static $many_many = [
'Games' => Game::class,
];
}
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Game extends DataObject
{
private static $has_many = [
'Officials' => Official::class,
'Sponsors' => Sponsor::class,
];
}
In this example, to eager load the Players and Fans relationships on Team, pass multiple arguments to the eagerLoad()
method:
$teams = Team::get()->eagerLoad('Players', 'Fans');
Perhaps, you may need to get all the Officials
that are related to each Game
. In this example, you can use another feature provided by the eagerLoad
method. Eager load nested relationships up to three levels deep using the dot syntax:
$teams = Team::get()->eagerLoad('Players.Games.Officials');
You can then access the nested relationships in the loop as you normally would:
foreach ($teams as $team) {
foreach ($team->Players() as $player) {
foreach ($player->Games() as $game) {
foreach ($game->Officials() as $official) {
// Everything will have been eager loaded at this point
echo $official->FirstName;
}
}
}
}
You can get the results for multiple nested relations with multiple arguments:
$teams = Team::get()->eagerLoad(
'Players.Games.Officials',
'Players.Games.Sponsors'
);
Eager loading can be used in templates. The following example assumes that $MyTeams
is an available DataList
which could be provided via a getMyTeams()
method on PageController
:
<% loop $MyTeams.eagerLoad('Players') %>
<% loop $Players %>
<p>Player first name is $FirstName</p>
<% end_loop %>
<% end_loop %>
Eager loading supports all relationship types.
Eager loading is only intended to be used in read-only scenarios such as when outputting data on the front-end of a website. When using default lazy-loading, relationship methods will return a subclass of DataList
such as HasManyList
. However when using eager-loading, an EagerLoadedList
will be returned instead. EagerLoadedList
has common methods such as filter()
, sort()
, limit()
and reverse()
available to manipulate its data, as well as some methods you'd expect on the various relation list implementations such as getExtraData()
.
Manipulating eager loading queries
There are some limitations to manipulating an EagerLoadedList
(i.e. after the query has been executed).
The main limitation is that filtering or sorting an EagerLoadedList
will be done in PHP rather than as part of the database query, since we have already loaded all its relevant data into memory.
EagerLoadedList
can't filter or sort by fields on relations using dot notation (e.g. sort('MySubRelation.Title')
won't work).
Manipulating the eager loading query is significantly (up to ~600%) faster than Filtering an EagerLoadedlist
after the query has been made.
You can avoid those problems by applying manipulations such as filtering and sorting to the eager loading query as part of your call to eagerLoad()
.
You can pass an associative array into the eagerLoad()
method, with relation chains as the keys and callbacks as the values. The callback accepts a DataList
argument, and must return a DataList
.
You can't manipulate the lists of has_one
or belongs_to
relations. This functionality is intended primarily as a way to pre-filter or pre-sort has_many
and many_many
relations.
use SilverStripe\ORM\DataList;
$teams = Team::get()->eagerLoad([
'Players' => fn (DataList $list) => $list->filter(['Age:GreaterThan' => 18]),
]);
The list passed into your callback represents the query for that relation on all of the records you're fetching. For example, the $list
variable above is a DataList
that will fetch all Player
records in the Players
relation for all Team
records (so long as they match the filter applied in the callback).
Note that each relation in the relation chain (e.g. Players
, Players.Fans
, Players.Fans.Events
) can have their own callback:
use SilverStripe\ORM\DataList;
$teams = Team::get()->eagerLoad([
'Players' => fn (DataList $list) => $list->filter(['Age:GreaterThan' => 18]),
'Players.Fans' => fn (DataList $list) => $list->filter(['Name:PartialMatch:nocase' => 'Sam']),
'Players.Fans.Events' => fn (DataList $list) => $list->filter(['DollarCost:LessThan' => 200]),
]);
If you have complex branching logic, and in some branches you want to avoid pre-filtering or perform different filtering, you can declare a different callback (or null
) for that relation chain by calling eagerLoad()
again. Note that subsequent callbacks replace previous callbacks.
use SilverStripe\ORM\DataList;
$teams = Team::get()->eagerLoad([
// Remove any callback that was previously defined for this relation chain
'Players' => fn (DataList $list) => null,
// Replace any callback that was previously defined for this relation chain.
// If you want to apply *additional* filters rather than replacing existing ones, you will
// need to declare the original filter again here.
'Players.Fans.Events' => fn (DataList $list) => $list->filter(['DollarCost:GreaterThan' => 100]),
]);
Cascading deletions
Relationships between objects can cause cascading deletions, if necessary, through configuration of the
cascade_deletes
config on the class that declares the relationship.
Declaring cascade_deletes
implies delete permissions on the listed objects.
Built-in controllers using delete operations check canDelete()
on the owner, but not on the owned object.
namespace App\Model;
use SilverStripe\ORM\DataObject;
class ParentObject extends DataObject
{
private static $has_one = [
'Child' => ChildObject::class,
];
private static $cascade_deletes = [
'Child',
];
// ...
}
In this example, when the parent object is deleted, the child specified by the has_one
relation will also be deleted.
All relation types (has_many
, many_many
, belongs_many_many
, belongs_to
, and has_one
) are supported, as are methods that return lists of objects but do not correspond to a physical database relation. In all cases the child defined in cascade_deletes
will be deleted.
Cascading deletions for many_many
relations
When the parent object is deleted, the end child specified by the cascade_deletes
will also be removed, not unlinked. To remove just the link between the two objects, use has_many
on the relation object directly to create an alternate view of the relationship which can be unlinked.
Note that this requires using the many_many
through style relation. A regular many_many
relation cannot be used this way.
For example:
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Team extends DataObject
{
// Define the many-to-many relationship using a custom relation table
private static array $many_many = [
'Supporters' => [
'through' => TeamSupporter::class,
'from' => 'Team',
'to' => 'Supporter',
],
];
// Add a different perspective of the relationship data
private static array $has_many = [
'SupportersRelation' => TeamSupporter::class . '.Team',
];
// Unlink the relationship when the parent object is deleted
private static array $cascade_deletes = [
'SupportersRelation',
];
// ...
}
This will avoid the situation where related children used by many data objects are removed from all parents because a single parent was deleted.
Cascading deletions with versions
If your object is versioned, cascade_deletes
will also act as "cascade unpublish", such that any unpublish
on a parent object will trigger unpublish on the child, similarly to how owns
causes triggered publishing.
See the versioning docs for more information on ownership.
If the child model is not versioned, cascade_deletes
will result in the child record being deleted if the parent is unpublished! Be sure to check whether both sides of the relationship are versioned before declaring cascade_deletes
.
Cascading duplications
Similar to cascade_deletes
there is also a cascade_duplicates
config which works in much the same way.
When you invoke duplicate()
on a DataObject
record,
relation names specified by this config will be duplicated
and saved against the new clone object.
Note that duplications will act differently depending on the kind of relation:
- Records in one-to-many or many-to-many relationships (e.g.
has_many
,has_one
, andbelongs_to
) will be explicitly duplicated. - Records in many-to-many (i.e.
many_many
andbelongs_many_many
) relationships will not be duplicated, but the mapping table values will instead be copied so that the original records are related to the new duplicate record.
For example:
namespace App\Model;
use SilverStripe\ORM\DataObject;
class ParentObject extends DataObject
{
private static $many_many = [
// None of the ManyManyExample records in this relationship will be duplicated.
// Instead each row in the join table will be copied, connecting the new duplicated ParentObject
// record with each of the original ManyManyExample records in the original relationship.
'ManyManyExamples' => ManyManyExample::class,
];
private static $has_many = [
// Each HasManyExample record in this relationship will be duplicated, and the new records will all have the
// duplicated ParentObject record's ID in their has_one relation ID column.
'HasManyExamples' => HasManyExample::class,
];
private static $has_one = [
// The HasOneExample record will be duplicated, and the new duplicated records will be related to one another.
'HasOneExample' => HasOneExample::class,
];
private static $cascade_duplicates = [
'ManyManyExamples',
'HasManyExamples',
'HasOneExample',
];
}
When duplicating objects you can determine which relationships (if any) should be cascade-duplicated by passing specific values to the second argument of duplicate()
.
By default (or by explicitly passing null
) this will respect the cascade_duplicates
configuration. Passing false
results in only duplicating the record for which the method is being invoked. Passing an array of relation names will act as though the passed in relation names were in the cascade_duplicates
configuration.
$parent = ParentObject::get()->first();
// Only duplicate the `$parent` record
$dupe = $parent->duplicate(relations: false);
// Duplicate the `$parent` record, and cascade duplicate the "Children" relation
// (ignoring any cascade_duplicates configuration)
$dupe = $parent->duplicate(relations: ['Children']);
The first parameter in duplicate()
is $doWrite
and determines whether the new duplicate record(s) will be written to the database. The second parameter is $relations
, and it works as described above.
Adding relations
Adding new items to a relation works the same regardless if you're editing a has_many
or a many_many
relationship. They are
encapsulated by HasManyList
and ManyManyList
, both of which provide very similar APIs (e.g. an add()
and remove()
method).
$team = Team::get()->byId(1);
// create a new supporter
$supporter = Supporter::create();
$supporter->Name = 'Foo';
$supporter->write();
// add the supporter.
$team->Supporters()->add($supporter);
Note that add()
and remove()
happen instantaneously. You don't have to call write()
on anything after using those methods.
To set what record is in a has_one
relation, just set the <relation-name>ID
field - e.g: $player->TeamID = $team->ID;
Don't forget to write the record ($player->write();
)!
Custom relations
You can use the ORM to get a filtered result list without writing any SQL. For example, this snippet gets you the
Players
relation on a team, but only containing active players.
See Filtering Results for more information.
namespace App\Model;
use SilverStripe\ORM\DataObject;
class Team extends DataObject
{
private static $has_many = [
'Players' => Player::class,
];
public function getActivePlayers()
{
return $this->Players()->filter('Status', 'Active');
}
// ...
}
Adding new records to a filtered RelationList
like in the example above doesn't automatically set the filtered
criteria on the added record - the record is added to the relation but is otherwise unaltered.
$newPlayer = Player::create(['Status' => 'Inactive']);
$newPlayer->write();
$playersList = Team()->get_one()->Players()->filter('Status', 'Active');
$playersList->add($newPlayer);
// Still returns 'Inactive'
$status = $newPlayer->Status;
Relations on unsaved objects
You can also set has_many
and many_many
relations before the DataObject
is saved. This behavior uses the
UnsavedRelationList and converts it into the correct RelationList
subclass when saving the DataObject
for the first
time.
This unsaved lists will also recursively save any unsaved objects that they contain.
As these lists are not backed by the database, most of the filtering methods on DataList
cannot be used on a list of
this type. As such, an UnsavedRelationList
should only be used for setting a relation before saving an object, not
for displaying the objects contained in the relation.
Validating relations
The RelationValidationService
can be used to check if your relations are set up according to best practices, and is very useful for debugging unexpected behaviour with relations. It is disabled by default.
To enable this service, set the following YAML configuration, which will give you validation output every time you run dev/build
.
SilverStripe\Dev\Validation\RelationValidationService:
output_enabled: true
By default, this service only inspects relations for classes which have either no namespace or a namespace beginning with App\
. You can declare your own namespace prefixes by setting the allow_rules
configuration:
SilverStripe\Dev\Validation\RelationValidationService:
allow_rules:
# using the "app" key you can override the default "App" namespace
app: 'MyApp'
# you can add any namespace declarations with-or-without keys
- 'MyOrg'
# you can declare as much of of a namespace as you want - only classes which begin with
# any one of the mentioned namespace prefixes will be allowed.
- 'AnotherOrg\MyModule'
You can also tell the service to ignore classes whose namespace starts a certain way:
SilverStripe\Dev\Validation\RelationValidationService:
deny_rules:
- 'MyApp\SpecialCases'
If you have relations that you've intentionally set up in a way that the validation service warns against, you can tell it to ignore those specific relations by setting deny_relations
config. Syntax for this is <class>.<relation>
.
SilverStripe\Dev\Validation\RelationValidationService:
deny_relations:
- 'App\Model\Player.Teams'
Validating relations outside dev/build
If you want to, you can invoke the RelationValidationService
at any time in PHP code.
use SilverStripe\Dev\Validation\RelationValidationService;
$messages = RelationValidationService::singleton()->validateRelations();
If you are doing this to debug some specific class(es), you might want to use the inspectClasses()
method, which disregards the allow_rules
, deny_rules
, and deny_relations
configuration specified above.
use SilverStripe\Dev\Validation\RelationValidationService;
$messages = RelationValidationService::singleton()->inspectClasses([Team::class, Player::class]);
Link tracking
You can control the visibility of the Link Tracking
tab by setting the show_sitetree_link_tracking
config.
This defaults to false
for most DataObject
's.
It is also possible to control the visibility of the File Tracking
tab by setting the show_file_link_tracking
config.