Laravel Eloquent Case Sensitive Relationships - php

Hi SO I'm having real issues with some Laravel Eloquent relationships which I can only guess are being caused by a case-sensitive relation and I'm hoping somebody here can help!
Here are the models that I'm having the issues with:
class DeliveryManifestLines extends Eloquent
{
protected $table = 'manifests';
public function sapDelivery()
{
return $this->hasOne('Delivery', 'DocNum', 'sap_delivery');
}
}
class Delivery extends Eloquent
{
protected $connection = 'sap';
protected $table = 'ODLN';
protected $primaryKey = 'DocNum';
public function deliveryManifest() {
return $this->belongsTo('DeliveryManifest', 'DocNum', 'sap_delivery');
}
public function address()
{
return $this->hasOne('Address', 'Address', 'ShipToCode')->where('CardCode', $this->CardCode)->where('AdresType', 'S');
}
public function geolocation()
{
return $this->hasOne('GeoLocation', 'Address', 'ShipToCode')->where('CardCode', $this->CardCode)->where('AdresType', 'S')->where('Lat', '>', 0)->where('Lng', '>', 0);
}
}
class Address extends Eloquent
{
protected $connection = 'sap';
protected $table = 'CRD1';
protected $primaryKey = 'Address';
public function delivery() {
return $this->belongsTo('Delivery', 'Address', 'ShipToCode');
}
}
Here's the code in my controller that is supposed to fetch some of the above models from the DB.
$deliveries = DeliveryManifestLines::with('sapDelivery')->where('manifest_date', $date))->get();
foreach ($deliveries as $delivery) {
$delivery->sapDelivery->load('address');
}
I'm using the "->load('address)" line as no matter what I tried I could not get eager loading to work with "sapDelivery.address"
In 99% of cases the address is loaded successfully from the DB but I have come across one case in which I am experiencing an issue that I can only think is being caused by case-sensitivity.
Using Laravel DebugBar I can see that my application is executing the following query:
SELECT * FROM [CRD1] WHERE [CardCode] = 'P437' AND [AdresType] = 'S' AND [CRD1].[Address] IN ('The Pizza Factory (topping)')
When I dump the contents of $delivery->sapDelivery in this occurrence the address relation is NULL, however, when I paste the SQL statement into my DB console and execute it manually I get the expected row returned.
The only difference I can see between this one address and the thousands of others that are working is that there is a case difference between the Address fields:
In the CRD1 table the Address field for the effected/expected row is "The Pizza Factory (Topping)" but the eloquent relationship is using AND [CRD1].[Address] IN ('The Pizza Factory (topping)') to try and find it I'm aware that SQL is case-insensitive be default but I can't think of any other reason why this one row is behaving differently to the others.
Does anybody have any other ideas as to what could be causing this issue and suggest any possible solutions or confirm either way my theory of case sensitivity being the culprit.
Many thanks!

So After giving this problem little thought over the past few months I revisited the issue today and found some very useful code on laravel.io by somebody experiencing the same issue I found myself with.
I've built on MattApril's solution to provide the least hacky way I can think of to provide a way to offer case insensitive relationships in laravel.
To achieve this you need to add a few new classes which utilise the strtolower() function to create lower case keys which allows the isset() function used in the relationships to find differently cased but matching keys:
ModelCI.php (app\Models\Eloquent\ModelCI.php)
<?php
namespace App\Models\Eloquent;
use App\Models\Eloquent\Relations\BelongsToCI;
use App\Models\Eloquent\Relations\HasManyCI;
use App\Models\Eloquent\Relations\HasOneCI;
use Illuminate\Database\Eloquent\Model;
abstract class ModelCI extends Model
{
/**
* Define a one-to-many relationship.
*
* #param string $related
* #param string $foreignKey
* #param string $localKey
*
* #return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function hasManyCI($related, $foreignKey = null, $localKey = null)
{
$foreignKey = $foreignKey ?: $this->getForeignKey();
$instance = new $related();
$localKey = $localKey ?: $this->getKeyName();
return new HasManyCI($instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey);
}
/**
* Define a one-to-one relationship.
*
* #param string $related
* #param string $foreignKey
* #param string $localKey
* #return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function hasOneCI($related, $foreignKey = null, $localKey = null)
{
$foreignKey = $foreignKey ?: $this->getForeignKey();
$instance = new $related;
$localKey = $localKey ?: $this->getKeyName();
return new HasOneCI($instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey);
}
/**
* Define an inverse one-to-one or many relationship.
*
* #param string $related
* #param string $foreignKey
* #param string $otherKey
* #param string $relation
* #return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function belongsToCI($related, $foreignKey = null, $otherKey = null, $relation = null)
{
// If no relation name was given, we will use this debug backtrace to extract
// the calling method's name and use that as the relationship name as most
// of the time this will be what we desire to use for the relationships.
if (is_null($relation))
{
list(, $caller) = debug_backtrace(false, 2);
$relation = $caller['function'];
}
// If no foreign key was supplied, we can use a backtrace to guess the proper
// foreign key name by using the name of the relationship function, which
// when combined with an "_id" should conventionally match the columns.
if (is_null($foreignKey))
{
$foreignKey = snake_case($relation).'_id';
}
$instance = new $related;
// Once we have the foreign key names, we'll just create a new Eloquent query
// for the related models and returns the relationship instance which will
// actually be responsible for retrieving and hydrating every relations.
$query = $instance->newQuery();
$otherKey = $otherKey ?: $instance->getKeyName();
return new BelongsToCI($query, $this, $foreignKey, $otherKey, $relation);
}
}
BelongsToCI.php (app\Models\Eloquent\Relations\BelongsToCI.php)
<?php namespace App\Models\Eloquent\Relations;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BelongsToCI extends BelongsTo {
/**
* Match the eagerly loaded results to their parents.
*
* #param array $models
* #param \Illuminate\Database\Eloquent\Collection $results
* #param string $relation
* #return array
*/
public function match(array $models, Collection $results, $relation)
{
$foreign = $this->foreignKey;
$other = $this->otherKey;
// First we will get to build a dictionary of the child models by their primary
// key of the relationship, then we can easily match the children back onto
// the parents using that dictionary and the primary key of the children.
$dictionary = array();
foreach ($results as $result)
{
$dictionary[strtolower($result->getAttribute($other))] = $result;
}
// Once we have the dictionary constructed, we can loop through all the parents
// and match back onto their children using these keys of the dictionary and
// the primary key of the children to map them onto the correct instances.
foreach ($models as $model)
{
if (isset($dictionary[strtolower($model->$foreign)]))
{
$model->setRelation($relation, $dictionary[strtolower($model->$foreign)]);
}
}
return $models;
}
}
HasManyCI.php (app\Models\Eloquent\Relations\HasManyCI.php)
<?php namespace App\Models\Eloquent\Relations;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\HasMany;
class HasManyCI extends HasMany {
/**
* Build model dictionary keyed by the relation's foreign key.
*
* #param \Illuminate\Database\Eloquent\Collection $results
* #return array
*/
protected function buildDictionary(Collection $results)
{
$dictionary = array();
$foreign = $this->getPlainForeignKey();
// First we will create a dictionary of models keyed by the foreign key of the
// relationship as this will allow us to quickly access all of the related
// models without having to do nested looping which will be quite slow.
foreach ($results as $result)
{
$dictionary[strtolower($result->{$foreign})][] = $result;
}
return $dictionary;
}
/**
* Match the eagerly loaded results to their many parents.
*
* #param array $models
* #param \Illuminate\Database\Eloquent\Collection $results
* #param string $relation
* #param string $type
* #return array
*/
protected function matchOneOrMany(array $models, Collection $results, $relation, $type)
{
$dictionary = $this->buildDictionary($results);
// Once we have the dictionary we can simply spin through the parent models to
// link them up with their children using the keyed dictionary to make the
// matching very convenient and easy work. Then we'll just return them.
foreach ($models as $model)
{
$key = strtolower( $model->getAttribute($this->localKey) );
if (isset($dictionary[$key]))
{
$value = $this->getRelationValue($dictionary, $key, $type);
$model->setRelation($relation, $value);
}
}
return $models;
}
}
HasOneCI.php (app\Models\Eloquent\Relations\HasOneCI.php)
<?php namespace App\Models\Eloquent\Relations;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\HasOne;
class HasOneCI extends HasOne {
/**
* Match the eagerly loaded results to their many parents.
*
* #param array $models
* #param \Illuminate\Database\Eloquent\Collection $results
* #param string $relation
* #param string $type
* #return array
*/
protected function matchOneOrMany(array $models, Collection $results, $relation, $type)
{
$dictionary = $this->buildDictionary($results);
// Once we have the dictionary we can simply spin through the parent models to
// link them up with their children using the keyed dictionary to make the
// matching very convenient and easy work. Then we'll just return them.
foreach ($models as $model)
{
$key = strtolower($model->getAttribute($this->localKey));
if (isset($dictionary[$key]))
{
$value = $this->getRelationValue($dictionary, $key, $type);
$model->setRelation($relation, $value);
}
}
return $models;
}
/**
* Build model dictionary keyed by the relation's foreign key.
*
* #param \Illuminate\Database\Eloquent\Collection $results
* #return array
*/
protected function buildDictionary(Collection $results)
{
$dictionary = array();
$foreign = strtolower($this->getPlainForeignKey());
// First we will create a dictionary of models keyed by the foreign key of the
// relationship as this will allow us to quickly access all of the related
// models without having to do nested looping which will be quite slow.
foreach ($results as $result)
{
$dictionary[$result->{$foreign}][] = $result;
}
return $dictionary;
}
}
To utilise the new classes you must define a relationship as so:
$this->belongsToCI('Model');
or
$this->hasManyCI('Model');
or
$this->hasOneCI('Model');

Eloquent uses internal associative arrays to map and link the related records to their parents and in cases with string foreign keys and difference in uppercase vs lowercase, some of the related records will not be mapped.
I recently came across the same problem and decided to wrap a solution in a composer package.
https://github.com/TishoTM/eloquent-ci-relations
After installing the composer package, you can simply use the trait inside the eloquent models without changing anything else on the relation methods within the model.
use \TishoTM\Eloquent\Concerns\HasCiRelationships;

Related

How to map a foreign key on an entity both as field and mapping in Doctrine?

Background (short version): Symfony4 application in which I use a custom data layer over doctrine entity layer so the business logic layer is not dependent on the database schema. This is not changeable for the moment.
In some cases it's easier to use have the foreign key mapped as a field:
/**
* #var string
*
* #ORM\Column(name="article_id", type="string")
*/
protected $articleId;
but in other cases for the same entity I need the relation:
/**
* #var Article
*
* #ORM\OneToOne(targetEntity="Article")
* #ORM\JoinColumn(name="article_id", referencedColumnName="id")
*/
protected $article;
to not have the same entity in 2 copies, I added the snippets above in one single class. If I hydrate the relation, everything works well ( $articleId is ignored ) but if only the $articleId is provided and $articles is null, doctrine will insert in DB null for the foreign key.
I found a possible solution: I parse the class metadata, and if in $class->associationMappings['joinColumnFieldNames'] exists fields that I also have as fields in the object, I remove the association mapping:
doctrine/orm/lib/Doctrine/ORM/UnitOfWork.php
private function doMerge($entity, array &$visited, $prevManagedCopy = null, array $assoc = [])
{
$oid = spl_object_hash($entity);
if (isset($visited[$oid])) {
$managedCopy = $visited[$oid];
if ($prevManagedCopy !== null) {
$this->updateAssociationWithMergedEntity($entity, $assoc, $prevManagedCopy, $managedCopy);
}
return $managedCopy;
}
$class = $this->em->getClassMetadata(get_class($entity));
/* Code that I added */
foreach ($class->associationMappings as $key => $mapping) {
$joinColumns = $mapping['joinColumnFieldNames'] ?? [];
if (array_intersect($joinColumns, $class->getColumnNames())) {
unset($class->associationMappings[$key]);
unset($class->reflFields[$key]);
}
}
/* .... */
}
Is it ethically to do such thing?

L5 How to use trait that hashes id but keep pivot functionality

I added hashes to my ID's using a trait. However by doing that now I can no longer use attach() or relationships.
For example this relationship does not work in my view anymore:
#foreach ($invoice->items as $item)
{{ $item->item }}
#endforeach
Here is the trait that hashes the id for me
<?php
namespace App\Traits;
use Hashids\Hashids;
use Illuminate\Database\Eloquent\Builder;
trait HashedId
{
/**
* Get the user's id as hashids.
*
* #param $value
* #return string
*/
public function getIdAttribute($value)
{
$hashids = new \Hashids\Hashids(env('APP_KEY'),10);
return $hashids->encode($value);
}
public function scopeHashId(Builder $query, $id)
{
$hashIds = new Hashids(env('APP_KEY'), 10);
$id = $hashIds->decode($id)[0];
return $query->where('id', $id);
}
}
Invoice Model:
<?php
namespace App;
use App\Traits\HashedId;
use Illuminate\Database\Eloquent\Model;
use HipsterJazzbo\Landlord\BelongsToTenants;
class Invoice extends Model
{
use BelongsToTenants;
use HashedId;
//
protected $fillable = [
'client_id',
'invoice_number',
'purchase_order',
'invoice_note',
'invoice_status',
'invoice_total',
'invoice_type',
'sub_total',
'balance_due',
'due_date',
'invoice_type',
'user_id',
];
protected $hidden = [
'user_id'
];
public function items()
{
return $this->belongsToMany('App\LineItem', 'invoice_items', 'invoice_id', 'item_id');
}
public function client()
{
return $this->belongsTo('App\Client');
}
}
I have tried doing this from a controller but it feels more like a hack than the right way to do it and I still lose the ability to use things like $invoice->attach($lineItem) or $invoice->items
//Currently I have to unhash the ids in order to save them as a pivot
$hashIds = new \Hashids\Hashids(env('APP_KEY'), 10);
$invoiceId = $hashIds->decode($request->invoice_id)[0];
$lineItemId = $hashIds->decode($request->item_id)[0];
//Should have been able to use $invoice->attach($lineItemId)
DB::table('invoice_items')->insert(
['invoice_id' => $invoiceId, 'item_id' => $lineItemId]
);
How can I continue to use $invoice->attach($lineItem) or $invoice->items from controllers while still using the trait that hashes my ids?
I've re-written the trait as follows (this assumes you're using PHP 5.6 or above):
<?php
namespace App\Traits;
use Hashids\Hashids;
use Illuminate\Database\Eloquent\Builder;
trait HashedId
{
/**
* Get model ID attribute encoded to hash ID.
*
* #return string
*/
public function getHashIdAttribute()
{
$hashIds = new Hashids(env('APP_KEY'), 10);
return $hashIds->encode($this->getKey());
}
/**
* Restrict query scope to find model by encoded hash ID.
*
* #param \Illuminate\Database\Eloquent\Builder $query
* #param integer $id
* #return \Illuminate\Database\Eloquent\Builder
*/
public function scopeHashId(Builder $query, $id)
{
$hashIds = new Hashids(env('APP_KEY'), 10);
$id = $hashIds->decode($id)[0];
return $query->where('id', $id);
}
/**
* Restrict query scope to find models by encoded hash IDs.
*
* #param \Illuminate\Database\Eloquent\Builder $query
* #param array $ids
* #return \Illuminate\Database\Eloquent\Builder
*/
public function scopeHashIds(Builder $query, ...$ids)
{
$hashIds = new Hashids(env('APP_KEY'), 10);
$ids = array_map(function ($id) use ($hashIds) {
return $hashIds->decode($id)[0];
}, $ids);
return $query->whereIn('id', $ids);
}
}
You may notice that I've renamed the accessor, getIdAttribute() to getHashIdAttribute(). You can therefore now get the hash ID of a model instance by calling $model->hash_id instead of $model->id.
This is where I think your problem was, because Laravel was expecting an integer key to be returned by $model->id, whereas it would have been getting the hash ID instead.
If after implementing the changes above you're still getting an error, can you show what the specific error is?
Just like you commented, you couldn't use attach because id is hashed cos of getIdAttribute. I would like to suggest you to use getOriginal().
For example,
$invoice->attach($lineItem->getOriginal()['id']);
I think that could be the only way to attach that.

Insert on duplicate key update with laravel 5.1 eloquent (only one query)

I want to perform insert on duplicate key update with eloquent model without making two queries, the problem is when I use updateOrCreate() it does two queries
/**
* Create or update a record matching the attributes, and fill it with values.
*
* #param array $attributes
* #param array $values
* #return static
*/
public static function updateOrCreate(array $attributes, array $values = [])
{
$instance = static::firstOrNew($attributes);
//second query
$instance->fill($values)->save();
return $instance;
}
public static function firstOrNew(array $attributes)
{
//first query
if (! is_null($instance = (new static)->newQueryWithoutScopes()->where($attributes)->first())) {
return $instance;
}
return new static($attributes);
}
Thanks :)

Laravel hasManyThrough relationship with non-default local key

My schema is as follows:
Clients (hasMany Accounts)
id
name
Accounts (hasMany Holdings, belongsTo Clients)
id (int)
account_id (string, unique key)
name
Holdings (belongsTo Accounts)
id
account_id (string)
value
holding_date... etc
So, Client hasMany Accounts hasMany Holdings. The caveat being that the local key for accounts is account_id, not just id as is expected. This is because there is a requirement for the accounts to have a string identifier. In the holdings table the foreign key is also account_id.
I have defined my relationships like so:
// Client.php
public function accounts()
{
return $this->hasMany('Account');
}
// Account.php
public function client()
{
return $this->belongsTo('Client');
}
public function holdings()
{
return $this->hasMany('Holding');
}
// Holding.php
public function account()
{
return $this->belongsTo('Account', 'account_id', 'account_id');
}
If I wanted to query all the holdings for a given client ID how would I do this? If I do something like
Client::find($id)->accounts->holdings;
I get this error:
Undefined property: Illuminate\Database\Eloquent\Relations\HasMany::$holdings
I also tried using the hasManyThrough relationship (having added the relationship to my model) but there seems to only be a way to define the foreign key, not the local key for the accounts. Any suggestions?
Assuming you have client_id on accounts table,
do this:
// Account model
public function holdings()
{
return $this->hasMany('Holding', 'account_id', 'account_id');
}
// then
$client = Client::with('accounts.holdings')->find($id);
$client->accounts // collection
->first() // or process the collecction in the loop
->holdings; // holdlings collection
HasManyThrough will work only if Account model has (or will have for that purpose) $primaryKey set to account_id instead of default id
Since account_id is not primary key of the Account model, you can't use hasManyThrough. So I suggest you do this:
$accountIds = $client->accounts()->lists('account_id');
// if it was many-to-many you would need select clause as well:
// $accountIds = $client->accounts()->select('accounts.account_id')->lists('account_id');
$holdings = Holding::whereIn('account_id', $accountIds)->get();
This way you get the Collection just like you wanted, donwside is 1 more query needed in comparison to eager loading.
You need to change your relation in Account model
// Account.php
public function client()
{
return $this->belongsTo('Client','account_id');
}
But, it more appropriate to change column name to client_id in Accounts table
I think you can use load method to get the corresponding resulting query for each accounts. Something like:
Client::find($id)->load('accounts.holdings');
This means that client_id is present in accounts and holdings has account_id as well.
PS: I am not super sure how this would work in this context. But I hope this can lead you to find the way to do it.
You'd have to override Eloquent a little bit. I just ran into something very similar with a BelongsToMany relationship. I was trying to perform a many-to-many query where the relevant local-key was not the primary key. So I extended Eloquent's BelongsToMany a little bit. Start by building an override class for the BelongsToMany relationship class:
namespace App\Overrides\Relations;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany as BaseBelongsToMany;
class BelongsToMany extends BaseBelongsToMany
{
protected $localKey;
/**
* #var array
*/
protected $customConstraints = [];
/**
* BelongsToMany constructor.
* #param Builder $query
* #param Model $parent
* #param string $table
* #param string $foreignKey
* #param string $otherKey
* #param string $relationName
* #param string $localKey
*/
public function __construct(
Builder $query,
Model $parent,
$table,
$foreignKey,
$otherKey,
$relationName = null,
$localKey = null
) {
//The local-key binding, assumed by Eloquent to be the primary key of the model, will have already been set
if ($localKey) { //If it's intended to be overridden, that value in the Query/Builder object needs updating
$this->localKey = $localKey;
$this->setLocalKey($query, $parent, $table, $foreignKey, $localKey);
}
parent::__construct($query, $parent, $table, $foreignKey, $otherKey, $relationName);
}
/**
* If a custom local-key field is defined, don't automatically assume the pivot table's foreign relationship is
* joined to the model's primary key. This method is necessary for lazy-loading.
*
* #param Builder $query
* #param Model $parent
* #param string $table
* #param string $foreignKey
* #param string $localKey
*/
public function setLocalKey(Builder $query, Model $parent, $table, $foreignKey, $localKey)
{
$qualifiedForeignKey = "$table.$foreignKey";
$bindingIndex = null;
//Search for the 'where' value currently linking the pivot table's foreign key to the model's primary key value
$query->getQuery()->wheres = collect($query->getQuery()->wheres)->map(function ($where, $index) use (
$qualifiedForeignKey,
$parent,
&$bindingIndex
) {
//Update the key value, and note the index so the corresponding binding can also be updated
if (array_get($where, 'column', '') == $qualifiedForeignKey) {
$where['value'] = $this->getKey($parent);
$bindingIndex = $index;
}
return $where;
})->toArray();
//If a binding index was discovered, updated it to reflect the value of the custom-defined local key
if (!is_null($bindingIndex)) {
$bindgings = $query->getQuery()->getBindings();
$bindgings[$bindingIndex] = $this->getKey($parent);
$query->getQuery()->setBindings($bindgings);
}
}
/**
* Get all of the primary keys for an array of models.
* Overridden so that the call to $value->getKey() is replaced with $this->getKey()
*
* #param array $models
* #param string $key
* #return array
*/
protected function getKeys(array $models, $key = null)
{
if ($key) {
return parent::getKeys($models, $key);
}
return array_unique(array_values(array_map(function ($value) use ($key) {
return $this->getKey($value);
}, $models)));
}
/**
* If a custom local-key field is defined, don't automatically assume the pivot table's foreign relationship is
* joined to the model's primary key. This method is necessary for eager-loading.
*
* #param Model $model
* #return mixed
*/
protected function getKey(Model $model)
{
return $this->localKey ? $model->getAttribute($this->localKey) : $model->getKey();
}
/**
* Set the where clause for the relation query.
* Overridden so that the call to $this->parent->getKey() is replaced with $this->getKey()
* This method is not necessary if this class is accessed through the typical flow of a Model::belongsToMany() call.
* It is necessary if it's instantiated directly.
*
* #return $this
*/
protected function setWhere()
{
$foreign = $this->getForeignKey();
$this->query->where($foreign, '=', $this->getKey($this->parent));
return $this;
}
}
Next, you'll need to make the Model class actually use it:
namespace App\Overrides\Traits;
use App\Overrides\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\Relation;
/**
* Intended for use inside classes that extend Illuminate\Database\Eloquent\Model
*
* Class RelationConditions
* #package App\Overrides\Traits
*/
trait CustomConstraints
{
/**
* Intercept the Eloquent Model method and return a custom relation object instead
*
* {#inheritdoc}
*/
public function belongsToMany($related, $table = null, $foreignKey = null, $otherKey = null, $relation = null, $localKey = null)
{
//Avoid having to reproduce parent logic here by asking the returned object for its original parameter values
$base = parent::belongsToMany($related, $table, $foreignKey, $otherKey, $relation);
//The base action will have already applied the appropriate constraints, so don't re-add them here
return Relation::noConstraints(function () use ($base, $localKey) {
//These methods do the same thing, but got renamed
$foreignKeyName = version_compare(app()->version(), '5.3', '>=')
? $base->getQualifiedForeignKeyName()
: $base->getForeignKey();
$relatedKeyName = version_compare(app()->version(), '5.3', '>=')
? $base->getQualifiedRelatedKeyName()
: $base->getOtherKey();
return new BelongsToMany(
$base->getQuery(),
$base->getParent(),
$base->getTable(),
last(explode('.', $foreignKeyName)),
last(explode('.', $relatedKeyName)),
$base->getRelationName(),
$localKey
);
});
}
}
Use this trait inside your model class, and you now have the ability to add a 6th argument that specifies what local-key to use, rather than automatically assume the primary one.

Doctrine orderBy annotation. Ordering Entity associations based on an associated Entity.

Say have the following entities with a Symfony app.
class List
{
/**
* #ORM\OneToMany(targetEntity="ListItem", mappedBy="list")
* #ORM\OrderBy({"category.title" = "ASC"})
*/
protected $listItems;
}
class ListItem
{
/**
* #ORM\ManyToOne(targetEntity="List", inversedBy="listItems")
*/
protected $list;
/**
* #ORM\ManyToOne(targetEntity="Category", inversedBy="listItems")
*/
protected $category;
}
class Category
{
/**
* #ORM\OneToMany(targetEntity="ListItem", mappedBy="cateogory")
*/
protected $listItems;
protected $title;
}
The orderBy argument, category.title unfortunately will not work in doctrine. My understanding is that the most common solution is to store an extra property on the ListItem Entity such as $categoryTitle and using this new field in the orderBy annotation. For example;
class List
{
/**
* #ORM\OneToMany(targetEntity="ListItem", mappedBy="list")
* #ORM\OrderBy({"categoryTitle" = "ASC"})
*/
protected $listItems;
}
class ListItem
{
// --
protected $categoryTitle
}
The problem with this approach is the extra overhead of keeping this $categoryTitle, up to date through set methods and/or listeners, and obviously the denormalization of database data.
Is there a method I can use to order this association with doctrine, without degrading the quality of my database?
To solve this issue, I added the following method to the abstractEntity that all of our entities extend, and therefore all entities can sort their collections.
The following code has hasn't under gone any tests, but it should be a good starting point for anyone that might have this issue in future.
/**
* This method will change the order of elements within a Collection based on the given method.
* It preserves array keys to avoid any direct access issues but will order the elements
* within the array so that iteration will be done in the requested order.
*
* #param string $property
* #param array $calledMethods
*
* #return $this
* #throws \InvalidArgumentException
*/
public function orderCollection($property, $calledMethods = array())
{
/** #var Collection $collection */
$collection = $this->$property;
// If we have a PersistentCollection, make sure it is initialized, then unwrap it so we
// can edit the underlying ArrayCollection without firing the changed method on the
// PersistentCollection. We're only going in and changing the order of the underlying ArrayCollection.
if ($collection instanceOf PersistentCollection) {
/** #var PersistentCollection $collection */
if (false === $collection->isInitialized()) {
$collection->initialize();
}
$collection = $collection->unwrap();
}
if (!$collection instanceOf ArrayCollection) {
throw new InvalidArgumentException('First argument of orderCollection must reference a PersistentCollection|ArrayCollection within $this.');
}
$uaSortFunction = function($first, $second) use ($calledMethods) {
// Loop through $calledMethods until we find a orderable difference
foreach ($calledMethods as $callMethod => $order) {
// If no order was set, swap k => v values and set ASC as default.
if (false == in_array($order, array('ASC', 'DESC')) ) {
$callMethod = $order;
$order = 'ASC';
}
if (true == is_string($first->$callMethod())) {
// String Compare
$result = strcasecmp($first->$callMethod(), $second->$callMethod());
} else {
// Numeric Compare
$difference = ($first->$callMethod() - $second->$callMethod());
// This will convert non-zero $results to 1 or -1 or zero values to 0
// i.e. -22/22 = -1; 0.4/0.4 = 1;
$result = (0 != $difference) ? $difference / abs($difference): 0;
}
// 'Reverse' result if DESC given
if ('DESC' == $order) {
$result *= -1;
}
// If we have a result, return it, else continue looping
if (0 !== (int) $result) {
return (int) $result;
}
}
// No result, return 0
return 0;
};
// Get the values for the ArrayCollection and sort it using the function
$values = $collection->getValues();
uasort($values, $uaSortFunction);
// Clear the current collection values and reintroduce in new order.
$collection->clear();
foreach ($values as $key => $item) {
$collection->set($key, $item);
}
return $this;
}
This method then could then be called somewhat like the below to solve the original question
$list->orderCollection('listItems', array('getCategory' => 'ASC', 'getASecondPropertyToSortBy' => 'DESC'))

Categories