I'm trying to make a bidirectional ManyToMany relationship on my Tag model, but I ran into this "issue".
My model looks like this:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Tag extends Model
{
protected $table = 'tags';
public $timestamps = false;
public function tags()
{
return $this->belongsToMany(Tag::class, 'tag_tag', 'tag_one_id', 'tag_two_id');
}
}
So at the moment let's say I have Tag1 and Tag2 in my tags table, and then I'll relate the Tag2 with the Tag1.
Now my pivot table will look like this:
+----+------------+------------+
| id | tag_one_id | tag_two_id |
+----+------------+------------+
| 1 | 1 | 2 |
+----+------------+------------+
When I try this code:
$tag = Tag::find(1);
$tag->tags()->get();
I get the Tag2 instance, and it is correct.
But when I try to run this code:
$tag = Tag::find(2);
$tag->tags()->get();
I would like to receive the Tag1 instance, but I don't.
Is it possible to get it done with Laravel default Eloquent using just one method on the model?
I found a solution and I solved it like this.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Tag extends Model
{
/**
* #inheritdoc
*
* #var string
*/
protected $table = 'tags';
/**
* #inheritdoc
*
* #var bool
*/
public $timestamps = false;
/*
|--------------------------------------------------------------------------
| RELATIONS
|--------------------------------------------------------------------------
*/
/**
* Every tag can contain many related tags (TagOne has many TagTwo).
*
* #return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
protected function tagsOneTwo()
{
return $this->belongsToMany(Tag::class, 'tag_tag', 'tag_one_id', 'tag_two_id');
}
/**
* Every tag can contain many related tags (TagTwo has many TagOne).
*
* #return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
protected function tagsTwoOne()
{
return $this->belongsToMany(Tag::class, 'tag_tag', 'tag_two_id', 'tag_one_id');
}
/**
* This method returns a collection with all the tags related with this tag.
* It is not a real relation, but emulates it.
*
* #return \Illuminate\Database\Eloquent\Collection
*/
public function tags()
{
return $this->tagsOneTwo()->get()->merge($this->tagsTwoOne()->get())->unique('id');
}
/*
|--------------------------------------------------------------------------
| FUNCTIONS
|--------------------------------------------------------------------------
*/
/**
* Function to relate two tags together.
*
* #param Tag $tag
* #return void;
*/
public function attach(Tag $tag)
{
if ($this->tags()->contains('id', $tag->getKey())) {
return;
}
$this->tagsOneTwo()->attach($tag->getKey());
}
/**
* Function to un-relate two tags.
*
* #param Tag $tag
* #return void;
*/
public function detach(Tag $tag)
{
if ($this->tags()->contains('id', $tag->getKey())) {
// Detach the relationship in both ways.
$this->tagsOneTwo()->detach($tag->getKey());
$this->tagsTwoOne()->detach($tag->getKey());
}
}
/*
|--------------------------------------------------------------------------
| ACCESORS
|--------------------------------------------------------------------------
*/
/**
* Let access the related tags like if it was preloaded ($tag->tags).
*
* #return mixed
*/
public function getTagsAttribute()
{
if (! array_key_exists('tags', $this->relations)) {
$this->setRelation('tags', $this->tags());
};
return $this->getRelation('tags');
}
}
Why it's not working?
It's not working because in the relationship you added in Tag model is defined to work for one way. But not the reverse way.
It would work if we could have defined two method called tags() as below:
public function tags()
{
return $this->belongsToMany(Tag::class, 'tag_tag', 'tag_one_id', 'tag_two_id');
}
//and
public function tags()
{
return $this->belongsToMany(Tag::class, 'tag_tag', 'tag_two_id', 'tag_one_id');
}
Unfortunately, it's not possible.
So, what can be possible solution
One possible solution can be, don't touch the relationship. Instead if you somehow can manage to insert two relationship for these relationship then it will work.
For example:
+----+------------+------------+
| id | tag_one_id | tag_two_id |
+----+------------+------------+
| 1 | 1 | 2 |
+----+------------+------------+
| 1 | 2 | 1 |
+----+------------+------------+
This is the solution coming out of my mind right now. There might be a better solution too.
It's possible. You would have to pass a parameter to the tags() method and modify the primary/foreign key fields on the relationship using that parameter. That might be an total pain to have to deal with though and it would almost certainly be less of a headache to just make a second relationship method. It would just end up looking like this:
public function tags($tag1 = 'tag_one_id', $tag2 = 'tag_two_id')
{
return $this->belongsToMany(Tag::class, 'tag_tag', $tag1, $tag2);
}
and then you would just have to modify the values when you need Tag::find(2)->tags('tag_two_id', 'tag_one_id')
This can be eager loaded as described here: https://laracasts.com/discuss/channels/general-discussion/eager-load-with-parameters
Your use case might be a lot more complicated than your post suggests, which might make this more reasonable. As it is I would consider a few of the other options.
Related
I have next models:
class Polling extends Model
{
/**
* #return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function participants()
{
return $this->belongsToMany(Participant::class, 'participant_poll', 'poll_id');
}
/**
* #return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function results()
{
return $this->belongsToMany(Participant::class, 'poll_results', 'poll_id');
}
}
class Participant extends Model
{
public function polls()
{
return $this->belongsToMany(Polling::class);
}
public function results()
{
return $this->belongsToMany(Polling::class);
}
}
poll_results - pivot table have structure: id, poll_id, participant_id.
I need view next table:
№|participant.name|Count vote|
1|Mike |15 |
2|................|10 |
..............................
Count vote get pivot table poll_results.
Help please, write query.
$poll = Polling::first();
$poll->participants()->get();
You may want to use withCount() method.
If you want to count the number of results from a relationship without actually loading them you may use the withCount method, which will place a {relation}_count column on your resulting models
Your query would look like this one:
Participant::withCount('polls')->get();
This will add new property to results called polls_count
Is it possible to set a model's relationship dynamically? For example, I have model Page, and I want to add relationship banners() to it without actually changing its file? So does something like this exist:
Page::createRelationship('banners', function(){
$this->hasMany('banners');
});
Or something similar? As they are fetched using the magic methods anyway, perhaps I can add the relationship dynamically?
Thanks!
I've added a package for this i-rocky/eloquent-dynamic-relation
In case anyone still looking for a solution , here is one. If you think it's a bad idea, let me know.
trait HasDynamicRelation
{
/**
* Store the relations
*
* #var array
*/
private static $dynamic_relations = [];
/**
* Add a new relation
*
* #param $name
* #param $closure
*/
public static function addDynamicRelation($name, $closure)
{
static::$dynamic_relations[$name] = $closure;
}
/**
* Determine if a relation exists in dynamic relationships list
*
* #param $name
*
* #return bool
*/
public static function hasDynamicRelation($name)
{
return array_key_exists($name, static::$dynamic_relations);
}
/**
* If the key exists in relations then
* return call to relation or else
* return the call to the parent
*
* #param $name
*
* #return mixed
*/
public function __get($name)
{
if (static::hasDynamicRelation($name)) {
// check the cache first
if ($this->relationLoaded($name)) {
return $this->relations[$name];
}
// load the relationship
return $this->getRelationshipFromMethod($name);
}
return parent::__get($name);
}
/**
* If the method exists in relations then
* return the relation or else
* return the call to the parent
*
* #param $name
* #param $arguments
*
* #return mixed
*/
public function __call($name, $arguments)
{
if (static::hasDynamicRelation($name)) {
return call_user_func(static::$dynamic_relations[$name], $this);
}
return parent::__call($name, $arguments);
}
}
Add this trait in your model as following
class MyModel extends Model {
use HasDynamicRelation;
}
Now you can use the following method to add new relationships
MyModel::addDynamicRelation('some_relation', function(MyModel $model) {
return $model->hasMany(SomeRelatedModel::class);
});
As of laravel 7, dynamic relationship is officially supported. You can use the Model::resolveRelationUsing() method.
https://laravel.com/docs/7.x/eloquent-relationships#dynamic-relationships
you can use macro call for your dynamic relation like this:
you should write this code in your service provider boot method.
\Illuminate\Database\Eloquent\Builder::macro('yourRelation', function () {
return $this->getModel()->belongsTo('class');
});
You have to have something in mind, an Eloquent relationship is a model of a relational database relatioship (i.e. MySQL).
So, I came with two approaches.
The good
If you want to achieve a full-featured Eloquent relationship with indexes and foreing keys in the database, you probably want to alter the SQL tables dynamically.
For example, supossing you have all your models created and don't want to create them dynamically, you only have to alter the Page table, add a new field called "banner_id", index it and reference to "banner_id" field on Banner table.
Then you have to write down and support for the RDBMS you will work with.
After that, you may want to include support for migrations. If it's the case, you may store in the database these table alterations for further rollbacks.
Now, for the Eloquent support part, you may look at Eloquent Model Class.
See that, for each kind of relation, you have a subyacent model (all can be found here, which is in fact what you are returning in relatioship methods:
public function hasMany($related, $foreignKey = null, $localKey = null)
{
$foreignKey = $foreignKey ?: $this->getForeignKey();
$instance = new $related;
$localKey = $localKey ?: $this->getKeyName();
return new HasMany($instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey);
}
So you have to define a method in your model that accepts the type of relation and the model, creates a new HasMany (in case hasMany was the desired relationship) instance, and then returns it.
It's little bit complicated, and so you can use:
The easy
You can create a intermediate model (i.e. PageRelationship) that stores all the relationships between Page and other Models. A possible table schema could be:
+-------------+---------+------------------+-------------+
| relation_id | page_id | foreign_model_id | model_class |
+-------------+---------+------------------+-------------+
| 1 | 2 | 225 | Banner |
| 2 | 2 | 223 | Banner |
| 3 | 2 | 12 | Button |
+-------------+---------+------------------+-------------+
Then you can retrieve all dynamically relative models to a given Page. The problem here is that you don't actually have any real RDBMS relation between Models and Pages, so you may have to make multiple and heavy queries for loading related Models, and, what's worse, you have to manage yourself database consistency (i.e., deleting or updating the "225" Banner should also remove or update the row in page_relationship_table). Reverse relationships will be a headache too.
Conclusion
If the project is big, it depends on that, and you can't make a model that implements other models via inheritance or so, you should use the good approach. Otherwise, you should rethink you app design and then decide to choose or not second approach.
Just in case anyone is looking for a Laravel 8 answer:
Let's say I define my relationships in a single method of my model:
public function relationships()
{
return [
'user' => $this->belongsTo(User::class, 'user_id'),
];
}
Now, in my app service provider, I can use the resolveRelationUsing method. I've done this by iterating through the models folder and checking all models which contain the aforementioned method:
foreach ((new Filesystem)->allFiles(app_path('Models')) as $file) {
$namespace = 'App\\Models\\' . str_replace(['/', '.php'], ['\\', ''], $file->getRelativePathname());
$class = app($namespace);
if (method_exists($class, 'relationships')) {
foreach ($class->relationships() as $key => $relationship) {
$class->resolveRelationUsing($key, function () use ($class, $key) {
return $class->relationships()[$key];
});
}
}
}
I have a Question Eloquent Model, a Course Eloquent Model, a University Eloquent Model. A One to Many relationship exists between the University and the Course. A Many to Many relationship exists between the Question and the Course. The Three models are shown below:
Question Model
namespace App;
use Illuminate\Database\Eloquent\Model;
class Question extends Model
{
/**
* The database table that the Model uses
* #var string
*/
protected $table = "questions";
/**
* The fields that are mass assignable
* #var array
*/
protected $fillable = ['title','body','images','quality_score','deactivated','creator_id','reviewer_id'];
/**
* Images is stored as serialized json.
* So we cast it to a PHP array.
* See: http://laravel.com/docs/5.1/eloquent-mutators#attribute-casting
*/
protected $casts = [
'images' => 'array',
];
public function courses(){
return $this->belongsToMany('App\Course');
}
}
Course Model
namespace App;
use Illuminate\Database\Eloquent\Model;
class Course extends Model
{
/**
* The database table used by the model
* #var string
*/
protected $table = "courses";
/**
* The fields that can be mass assigned
* #var array
*/
protected $fillable = ['name', 'instructor', 'acronym', 'university_id', 'creator_id', 'reviewer_id'];
/**
* There exists a many to one relationship between the Course and User
* This user is the creator of the course
*
* #method void
*
*/
public function creator(){
return $this->hasOne('App\User','creator_id');
}
/**
* There exists a many to one relationship between the Course and User
* This user is the reviewer of the course
* The reviewer of the Course will always be an admin
* If an Admin is the creator, then the reviewer is also the same admin
*
* #method void
*/
public function reviewer(){
return $this->hasOne('App\User','reviewer_id');
}
/**
* There exists a one to many relationship between the University and the Course
* This university is where the course is held
* Courses may float i.e. not be associated to any university
*
* #method void
*/
public function university(){
return $this->belongsTo('App\University');
}
/**
* This method is an accessor. It automatically changes the acronym to be all capitals
* regardless of how it is stored in the database.
* See: http://laravel.com/docs/5.1/eloquent-mutators#accessors-and-mutators
* #param $value (String from Database)
* #return string (Capitalized String)
*/
public function getAcronymAttribute($value){
return strtoupper($value);
}
}
University Model
namespace App;
use Illuminate\Database\Eloquent\Model;
class University extends Model
{
/**
* The database table used by the model
* #var string
*/
protected $table = "universities";
/**
* The fields that can be mass assigned
* name = Name of the University (Example: University of Illinois at Urbana Champaign)
* acronym = Acronym of the University (Example: UIUC)
* creator_id = Id of User that created the University
* reviewer_id = Id of User that reviewed and approved the University
*
* Universities will not be displayed to users without admin role unless they have been reviewed.
*
* #var array
*/
protected $fillable = ['name','acronym','creator_id','reviewer_id'];
/**
* This method is an accessor. It automatically changes the acronym to be all capitals
* regardless of how it is stored in the database.
* See: http://laravel.com/docs/5.1/eloquent-mutators#accessors-and-mutators
* #param $value (String from Database)
* #return string (Capitalized String)
*/
public function getAcronymAttribute($value){
return strtoupper($value);
}
}
On my home page I am showing a list of questions and allow filters for course and university. The controller method is shown here:
public function getHome(Request $request){
/**
* Eager Load with Course and University
*/
$questions = Question::with('courses.university')->get();
/*
* Filter Questions to remove unwanted entries based on course id
*/
if($request->has('course_id') && $request->input('course_id') != -1){
$questions = $questions->filter(function($question) use ($request){
foreach($question->courses as $course){
if ($course->id == $request->input('course_id')){
return true;
}
}
});
}
/*
* Filter Questions to remove unwanted entries based on university id
*/
if($request->has('university_id') && $request->input('university_id') != -1){
$questions = $questions->filter(function($question) use ($request){
foreach($question->courses as $course){
if ($course->university->id == $request->input('university_id')){
return true;
}
}
});
}
/*
* Return the Welcome View with Pagination on the Questions Displayed
* List of Courses and List of Universities
*/
return view('welcome',[
'questions' => $questions,
'courses' => Course::all(),
'universities' => University::all(),
'selected_university_id' => $request->input('university_id',-1),
'selected_course_id' => $request->input('course_id',-1)
]);
}
What I am doing above is returning all the questions from the database and them combing them through to remove all the ones that don't match the filters. This is obviously quite inefficient. I want to use Nested Eager Loading with constraints except I am having a lot of trouble defining what the constraint would look like. Further, I want to use server side paginate to make the client experience better on lower speed internet connections.
Here is one of my attempts:
$questions = Question::with(['courses.university' => function($query) use ($request){
if($request->has('university_id') && $request->input('university_id') != -1) {
$query->where('id', $request->input('university_id'));
}
if($request->has('course_id') && $request->input('course_id') != -1){
$query->where('courses.id',$request->input('course_id'));
}
}])->paginate(10);
This works fine when I don't have any filters.
When I do have a university_id defined, I get the error: Trying to get property of non-object (View: /var/www/testing.com/resources/views/welcome.blade.php)
When I do have a course_id defined, I get the error: SQLSTATE[42S22]: Column not found: 1054 Unknown column 'courses.id' in 'where clause' (SQL: select * from universities where universities.id in (1, 2) and courses.id = 1)
I expected the error when I have course_id defined (because I took a blind stab at the first argument of the $query->where method.
I am looking for help in defining the Nested Eager Loading Constraints.
I found the solution in a medium article. The solution works for later versions of laravel as it uses whereHas.
// If you want to put the constraint on the second relation
$questions = Question::with(['courses' => function($query) use($request){
return $query->whereHas('university', function($inner_query) use($request){
return $inner_query->where('id', $request->input('university_id'));
});
}, 'courses.university'])->paginate(10);
For your case, a simple whereHas ought to do the trick.
$questions = Question::whereHas('courses', function($query) use ($request){
return $query->where('university_id', $request->input('university_id'));
})->with(['courses.university'])->paginate(10);
I would also recommend using when clauses to reduce the amount of code.
$questions = Question::when(($request->has('course_id') && $request->input('course_id') != -1), function ($query) use($request){
return $query->where('course_id', $request->input('course_id'));
})->when($request->has('university_id') && $request->input('university_id') != -1, function ($outer_query) use($request){
return $outer_query->whereHas('courses', function($query) use($request){
return $query->where('university_id', $request->input('university_id'));
})->with(['courses.university']);
})->with(['courses.university'])->paginate(10);
I'm making a data filter and I need to filter a column with json saved data. The table structure is similar to this:
____________________________________
| id | name | tags |
|____|_________|___________________|
| 1 | example | ["3","4","5","6"] |
|____|_________|___________________|
Any idea how to filter this column by tags using Eloquent ORM included with Laravel?
Use the getter get<Column>Attribute:
class YourModel extends Eloquent {
public function getTagsAttribute($value)
{
return json_decode($value);
}
}
You could use mySql JSON_CONTAINS function since it is optimized on the DB level. Unfortunately Eloquent doesn't (?) provide wrappers for functions like this so you can fall back to plain SQL and create your models from the results.
This is an example that returns a Collection of Models:
class myModel extends Eloquent {
protected $casts = [
'json_column' => 'array', // this is JSON type in the DB
];
/**
* Returns a Collection of 'myModel' that have $search value
* stored in the 'json_column' array
*
* #param string $search
* #return \Illuminate\Database\Eloquent\Collection
*/
public static function findInJsonColumn($search){
$data = \DB::select("SELECT * FROM table_name WHERE JSON_CONTAINS(json_column, '[\"$search\"]')");
return self::hydrate($data);
}
}
class YourModel extends Eloquent {
public function getTagsAttribute($value)
{
parent::where($value)->get();
}
}
I'm wondering what's the best, the cleanest and the most simply way to work with many-to-many relations in Doctrine2.
Let's assume that we've got an album like Master of Puppets by Metallica with several tracks. But please note the fact that one track might appears in more that one album, like Battery by Metallica does - three albums are featuring this track.
So what I need is many-to-many relationship between albums and tracks, using third table with some additional columns (like position of the track in specified album). Actually I have to use, as Doctrine's documentation suggests, a double one-to-many relation to achieve that functionality.
/** #Entity() */
class Album {
/** #Id #Column(type="integer") */
protected $id;
/** #Column() */
protected $title;
/** #OneToMany(targetEntity="AlbumTrackReference", mappedBy="album") */
protected $tracklist;
public function __construct() {
$this->tracklist = new \Doctrine\Common\Collections\ArrayCollection();
}
public function getTitle() {
return $this->title;
}
public function getTracklist() {
return $this->tracklist->toArray();
}
}
/** #Entity() */
class Track {
/** #Id #Column(type="integer") */
protected $id;
/** #Column() */
protected $title;
/** #Column(type="time") */
protected $duration;
/** #OneToMany(targetEntity="AlbumTrackReference", mappedBy="track") */
protected $albumsFeaturingThisTrack; // btw: any idea how to name this relation? :)
public function getTitle() {
return $this->title;
}
public function getDuration() {
return $this->duration;
}
}
/** #Entity() */
class AlbumTrackReference {
/** #Id #Column(type="integer") */
protected $id;
/** #ManyToOne(targetEntity="Album", inversedBy="tracklist") */
protected $album;
/** #ManyToOne(targetEntity="Track", inversedBy="albumsFeaturingThisTrack") */
protected $track;
/** #Column(type="integer") */
protected $position;
/** #Column(type="boolean") */
protected $isPromoted;
public function getPosition() {
return $this->position;
}
public function isPromoted() {
return $this->isPromoted;
}
public function getAlbum() {
return $this->album;
}
public function getTrack() {
return $this->track;
}
}
Sample data:
Album
+----+--------------------------+
| id | title |
+----+--------------------------+
| 1 | Master of Puppets |
| 2 | The Metallica Collection |
+----+--------------------------+
Track
+----+----------------------+----------+
| id | title | duration |
+----+----------------------+----------+
| 1 | Battery | 00:05:13 |
| 2 | Nothing Else Matters | 00:06:29 |
| 3 | Damage Inc. | 00:05:33 |
+----+----------------------+----------+
AlbumTrackReference
+----+----------+----------+----------+------------+
| id | album_id | track_id | position | isPromoted |
+----+----------+----------+----------+------------+
| 1 | 1 | 2 | 2 | 1 |
| 2 | 1 | 3 | 1 | 0 |
| 3 | 1 | 1 | 3 | 0 |
| 4 | 2 | 2 | 1 | 0 |
+----+----------+----------+----------+------------+
Now I can display a list of albums and tracks associated to them:
$dql = '
SELECT a, tl, t
FROM Entity\Album a
JOIN a.tracklist tl
JOIN tl.track t
ORDER BY tl.position ASC
';
$albums = $em->createQuery($dql)->getResult();
foreach ($albums as $album) {
echo $album->getTitle() . PHP_EOL;
foreach ($album->getTracklist() as $track) {
echo sprintf("\t#%d - %-20s (%s) %s\n",
$track->getPosition(),
$track->getTrack()->getTitle(),
$track->getTrack()->getDuration()->format('H:i:s'),
$track->isPromoted() ? ' - PROMOTED!' : ''
);
}
}
The results are what I'm expecting, ie: a list of albums with their tracks in appropriate order and promoted ones being marked as promoted.
The Metallica Collection
#1 - Nothing Else Matters (00:06:29)
Master of Puppets
#1 - Damage Inc. (00:05:33)
#2 - Nothing Else Matters (00:06:29) - PROMOTED!
#3 - Battery (00:05:13)
So what's wrong?
This code demonstrates what's wrong:
foreach ($album->getTracklist() as $track) {
echo $track->getTrack()->getTitle();
}
Album::getTracklist() returns an array of AlbumTrackReference objects instead of Track objects. I can't create proxy methods cause what if both, Album and Track would have getTitle() method? I could do some extra processing within Album::getTracklist() method but what's the most simply way to do that? Am I forced do write something like that?
public function getTracklist() {
$tracklist = array();
foreach ($this->tracklist as $key => $trackReference) {
$tracklist[$key] = $trackReference->getTrack();
$tracklist[$key]->setPosition($trackReference->getPosition());
$tracklist[$key]->setPromoted($trackReference->isPromoted());
}
return $tracklist;
}
// And some extra getters/setters in Track class
EDIT
#beberlei suggested to use proxy methods:
class AlbumTrackReference {
public function getTitle() {
return $this->getTrack()->getTitle()
}
}
That would be a good idea but I'm using that "reference object" from both sides: $album->getTracklist()[12]->getTitle() and $track->getAlbums()[1]->getTitle(), so getTitle() method should return different data based on the context of invocation.
I would have to do something like:
getTracklist() {
foreach ($this->tracklist as $trackRef) { $trackRef->setContext($this); }
}
// ....
getAlbums() {
foreach ($this->tracklist as $trackRef) { $trackRef->setContext($this); }
}
// ...
AlbumTrackRef::getTitle() {
return $this->{$this->context}->getTitle();
}
And that's not a very clean way.
I've opened a similar question in the Doctrine user mailing list and got a really simple answer;
consider the many to many relation as an entity itself, and then you realize you have 3 objects, linked between them with a one-to-many and many-to-one relation.
http://groups.google.com/group/doctrine-user/browse_thread/thread/d1d87c96052e76f7/436b896e83c10868#436b896e83c10868
Once a relation has data, it's no more a relation !
From $album->getTrackList() you will alwas get "AlbumTrackReference" entities back, so what about adding methods from the Track and proxy?
class AlbumTrackReference
{
public function getTitle()
{
return $this->getTrack()->getTitle();
}
public function getDuration()
{
return $this->getTrack()->getDuration();
}
}
This way your loop simplifies considerably, aswell as all other code related to looping the tracks of an album, since all methods are just proxied inside AlbumTrakcReference:
foreach ($album->getTracklist() as $track) {
echo sprintf("\t#%d - %-20s (%s) %s\n",
$track->getPosition(),
$track->getTitle(),
$track->getDuration()->format('H:i:s'),
$track->isPromoted() ? ' - PROMOTED!' : ''
);
}
Btw You should rename the AlbumTrackReference (for example "AlbumTrack"). It is clearly not only a reference, but contains additional logic. Since there are probably also Tracks that are not connected to an album but just available through a promo-cd or something this allows for a cleaner separation also.
Nothing beats a nice example
For people looking for a clean coding example of an one-to-many/many-to-one associations between the 3 participating classes to store extra attributes in the relation check this site out:
nice example of one-to-many/many-to-one associations between the 3 participating classes
Think about your primary keys
Also think about your primary key. You can often use composite keys for relationships like this. Doctrine natively supports this. You can make your referenced entities into ids.
Check the documentation on composite keys here
I think I would go with #beberlei's suggestion of using proxy methods. What you can do to make this process simpler is to define two interfaces:
interface AlbumInterface {
public function getAlbumTitle();
public function getTracklist();
}
interface TrackInterface {
public function getTrackTitle();
public function getTrackDuration();
}
Then, both your Album and your Track can implement them, while the AlbumTrackReference can still implement both, as following:
class Album implements AlbumInterface {
// implementation
}
class Track implements TrackInterface {
// implementation
}
/** #Entity whatever */
class AlbumTrackReference implements AlbumInterface, TrackInterface
{
public function getTrackTitle()
{
return $this->track->getTrackTitle();
}
public function getTrackDuration()
{
return $this->track->getTrackDuration();
}
public function getAlbumTitle()
{
return $this->album->getAlbumTitle();
}
public function getTrackList()
{
return $this->album->getTrackList();
}
}
This way, by removing your logic that is directly referencing a Track or an Album, and just replacing it so that it uses a TrackInterface or AlbumInterface, you get to use your AlbumTrackReference in any possible case. What you will need is to differentiate the methods between the interfaces a bit.
This won't differentiate the DQL nor the Repository logic, but your services will just ignore the fact that you're passing an Album or an AlbumTrackReference, or a Track or an AlbumTrackReference because you've hidden everything behind an interface :)
Hope this helps!
First, I mostly agree with beberlei on his suggestions. However, you may be designing yourself into a trap. Your domain appears to be considering the title to be the natural key for a track, which is likely the case for 99% of the scenarios you come across. However, what if Battery on Master of the Puppets is a different version (different length, live, acoustic, remix, remastered, etc) than the version on The Metallica Collection.
Depending on how you want to handle (or ignore) that case, you could either go beberlei's suggested route, or just go with your proposed extra logic in Album::getTracklist(). Personally, I think the extra logic is justified to keep your API clean, but both have their merit.
If you do wish to accommodate my use case, you could have Tracks contain a self referencing OneToMany to other Tracks, possibly $similarTracks. In this case, there would be two entities for the track Battery, one for The Metallica Collection and one for Master of the Puppets. Then each similar Track entity would contain a reference to each other. Also, that would get rid of the current AlbumTrackReference class and eliminate your current "issue". I do agree that it is just moving the complexity to a different point, but it is able to handle a usecase it wasn't previously able to.
You ask for the "best way" but there is no best way. There are many ways and you already discovered some of them. How you want to manage and/or encapsulate association management when using association classes is entirely up to you and your concrete domain, noone can show you a "best way" I'm afraid.
Apart from that, the question could be simplified a lot by removing Doctrine and relational databases from the equation. The essence of your question boils down to a question about how to deal with association classes in plain OOP.
I was getting from a conflict with join table defined in an association class ( with additional custom fields ) annotation and a join table defined in a many-to-many annotation.
The mapping definitions in two entities with a direct many-to-many relationship appeared to result in the automatic creation of the join table using the 'joinTable' annotation. However the join table was already defined by an annotation in its underlying entity class and I wanted it to use this association entity class's own field definitions so as to extend the join table with additional custom fields.
The explanation and solution is that identified by FMaz008 above. In my situation, it was thanks to this post in the forum 'Doctrine Annotation Question'. This post draws attention to the Doctrine documentation regarding ManyToMany Uni-directional relationships. Look at the note regarding the approach of using an 'association entity class' thus replacing the many-to-many annotation mapping directly between two main entity classes with a one-to-many annotation in the main entity classes and two 'many-to-one' annotations in the associative entity class. There is an example provided in this forum post Association models with extra fields:
public class Person {
/** #OneToMany(targetEntity="AssignedItems", mappedBy="person") */
private $assignedItems;
}
public class Items {
/** #OneToMany(targetEntity="AssignedItems", mappedBy="item") */
private $assignedPeople;
}
public class AssignedItems {
/** #ManyToOne(targetEntity="Person")
* #JoinColumn(name="person_id", referencedColumnName="id")
*/
private $person;
/** #ManyToOne(targetEntity="Item")
* #JoinColumn(name="item_id", referencedColumnName="id")
*/
private $item;
}
This really useful example. It lacks in the documentation doctrine 2.
Very thank you.
For the proxies functions can be done :
class AlbumTrack extends AlbumTrackAbstract {
... proxy method.
function getTitle() {}
}
class TrackAlbum extends AlbumTrackAbstract {
... proxy method.
function getTitle() {}
}
class AlbumTrackAbstract {
private $id;
....
}
and
/** #OneToMany(targetEntity="TrackAlbum", mappedBy="album") */
protected $tracklist;
/** #OneToMany(targetEntity="AlbumTrack", mappedBy="track") */
protected $albumsFeaturingThisTrack;
The solution is in the documentation of Doctrine. In the FAQ you can see this :
http://docs.doctrine-project.org/en/2.1/reference/faq.html#how-can-i-add-columns-to-a-many-to-many-table
And the tutorial is here :
http://docs.doctrine-project.org/en/2.1/tutorials/composite-primary-keys.html
So you do not anymore do a manyToMany but you have to create an extra Entity and put manyToOne to your two entities.
ADD for #f00bar comment :
it's simple, you have just to to do something like this :
Article 1--N ArticleTag N--1 Tag
So you create an entity ArticleTag
ArticleTag:
type: entity
id:
id:
type: integer
generator:
strategy: AUTO
manyToOne:
article:
targetEntity: Article
inversedBy: articleTags
fields:
# your extra fields here
manyToOne:
tag:
targetEntity: Tag
inversedBy: articleTags
I hope it helps
Unidirectional. Just add the inversedBy:(Foreign Column Name) to make it Bidirectional.
# config/yaml/ProductStore.dcm.yml
ProductStore:
type: entity
id:
product:
associationKey: true
store:
associationKey: true
fields:
status:
type: integer(1)
createdAt:
type: datetime
updatedAt:
type: datetime
manyToOne:
product:
targetEntity: Product
joinColumn:
name: product_id
referencedColumnName: id
store:
targetEntity: Store
joinColumn:
name: store_id
referencedColumnName: id
I hope it helps.
See you.
You may be able to achieve what you want with Class Table Inheritance where you change AlbumTrackReference to AlbumTrack:
class AlbumTrack extends Track { /* ... */ }
And getTrackList() would contain AlbumTrack objects which you could then use like you want:
foreach($album->getTrackList() as $albumTrack)
{
echo sprintf("\t#%d - %-20s (%s) %s\n",
$albumTrack->getPosition(),
$albumTrack->getTitle(),
$albumTrack->getDuration()->format('H:i:s'),
$albumTrack->isPromoted() ? ' - PROMOTED!' : ''
);
}
You will need to examine this throughly to ensure you don't suffer performance-wise.
Your current set-up is simple, efficient, and easy to understand even if some of the semantics don't quite sit right with you.
While getting all album tracks form inside album class, you'll generate one more query for one more record. That's because of proxy method. There's another example of my code (see last post in topic): http://groups.google.com/group/doctrine-user/browse_thread/thread/d1d87c96052e76f7/436b896e83c10868#436b896e83c10868
Is there any other method to resolve that? Isn't a single join a better solution?
Here is the solution as described in the Doctrine2 Documentation
<?php
use Doctrine\Common\Collections\ArrayCollection;
/** #Entity */
class Order
{
/** #Id #Column(type="integer") #GeneratedValue */
private $id;
/** #ManyToOne(targetEntity="Customer") */
private $customer;
/** #OneToMany(targetEntity="OrderItem", mappedBy="order") */
private $items;
/** #Column(type="boolean") */
private $payed = false;
/** #Column(type="boolean") */
private $shipped = false;
/** #Column(type="datetime") */
private $created;
public function __construct(Customer $customer)
{
$this->customer = $customer;
$this->items = new ArrayCollection();
$this->created = new \DateTime("now");
}
}
/** #Entity */
class Product
{
/** #Id #Column(type="integer") #GeneratedValue */
private $id;
/** #Column(type="string") */
private $name;
/** #Column(type="decimal") */
private $currentPrice;
public function getCurrentPrice()
{
return $this->currentPrice;
}
}
/** #Entity */
class OrderItem
{
/** #Id #ManyToOne(targetEntity="Order") */
private $order;
/** #Id #ManyToOne(targetEntity="Product") */
private $product;
/** #Column(type="integer") */
private $amount = 1;
/** #Column(type="decimal") */
private $offeredPrice;
public function __construct(Order $order, Product $product, $amount = 1)
{
$this->order = $order;
$this->product = $product;
$this->offeredPrice = $product->getCurrentPrice();
}
}