Search in parent relationship with the database driver - laravel-scout

Let's say you have this relationship: users x cats. Each user can have many cats (a "one-to-many" relationship):
class Cat extends Model
{
public function user()
{
return $this->belongsTo(User::class);
}
}
Both models (users and cats) have a name field.
Let's say we want to get all cats with bob in their names, using Laravel's Scout.
The standard solution is to add this to the Cat.php model:
// Cat.php
use Searchable;
/**
* Get the indexable data array for the model.
*
* #return array
*/
public function toSearchableArray()
{
return [
'name' => $this->name,
];
}
And we search with Cat::search('bob')->get().
The problem
The above solution works well, but what if we want to search in the relationship's fields?
What if you want to get cats owned by people with bob in their names?
If you add this to the "Cat" model:
// Cat.php
use Searchable;
/**
* Get the indexable data array for the model.
*
* #return array
*/
public function toSearchableArray()
{
return [
'name' => $this->name,
'users.name' => '', // no need to return `$this->user->name` as the database engine only uses the array keys
];
}
It won't work. You will get this exception when running Cat::search('bob')->get():
SQLSTATE[42S22]: Column not found: 1054 Unknown column 'users.name' in 'where clause'
SQL: select `cats`.* from `cats` where (`cats`.`name` like %bob% or `users`.`name` like %bob%)
Clearly, the SQL is missing the users table. But how to add it? Doing a Cat::join(...)->search('bob') will throw an exception, same for Cat::search(...)->join(...).
The question is: How to search in the parent attributes? And by "parent" I mean the "belongsTo" model.

The query method allows for modifing the search query. Use it to inject a join clause:
Cat::search('bob')->query(function ($builder) {
$builder->select('cats.*')->join('users', 'cats.user_id', '=', 'users.id');
})->get();
This generates the proper query:
SELECT `cats`.*
FROM `cats`
INNER JOIN `users` on `cats`.`genre_id` = `users`.`id`
WHERE (`cats`.`name` LIKE '%bob%' or `users`.`name` LIKE '%bob%')
ORDER BY `id` desc
EDIT: Automatically adds the JOIN clause to all searches:
If you want to search with just Cat::search('bob')->get(), without having to write ->join(...) on every call:
// Cat.php
/**
* Overrides the "search" method to inject a `join` to the relationships.
*/
use Searchable {
Searchable::search as parentSearch;
}
/**
* Perform a search against the model's indexed data.
*
* #param string $query
* #param \Closure $callback
* #return \Laravel\Scout\Builder
*/
public static function search($query = '', $callback = null)
{
return static::parentSearch($query, $callback)->query(function ($builder) {
$builder->select('cats.*')->join('users', 'cats.user_id', '=', 'users.id');
});
}

Related

How to select rows which have both items in ManyToMany relation

Let's assume i have "News" entity which has got ManyToMany "Tag" relation
class News
{
/**
* #ORM\ManyToMany(targetEntity="App\Domain\Entity\Vocabulary\Tag")
*/
private Collection $tags;
}
And i have such query:
public function getList(
array $tags = null,
): Query {
if (null !== $tags) {
$qb->andWhere('nt.id IN (:tags)');
$qb->setParameter('tags', $tags);
}
}
The problem is when i pass ["Tag1", "Tag2"] it selects news that have either the first tag or the second, but not both at the same time. How can i rewrite the query to select news which have both tags at the same time?
Some things to notice first:
For doctrine annotations it is possible to use the ::class-constant:
use App\Domain\Entity\Vocabulary\Tag;
class News
{
/**
* #ORM\ManyToMany(targetEntity=Tag::class)
*/
private Collection $tags;
}
If the $tags array is empty doctrine will throw an exception because an empty value set is invalid SQL, at least in mysql:
nt.id IN () # invalid!
Now to the problem:
With the SQL-aggregation functions COUNT and GROUP BY we can count the number of tags for all news. Together with your condition for the allowed tags, the number of tags per news must be equal to the number of tags in the tags array:
/**
* #var EntityManagerInterface
*/
private $manager;
...
/**
* #param list<Tag> $tags - Optional tag filter // "list" is a vimeo psalm annotation.
*
* #return list<News>
*/
public function getNews(array $tags = []): array
{
$qb = $this->manager
->createQueryBuilder()
->from(News::class, 'news')
->select('news')
;
if(!empty($tags)) {
$tagIds = array_unique(
array_map(static function(Tag $tag): int {
return $tag->getId();
}) // For performance reasons, give doctrine ids instead of objects.
); // Make sure duplicate tags are handled.
$qb
->join('news.tags', 'tag')
->where('tag IN (:tags)')
->setParameter('tags', $tagIds)
->addSelect('COUNT(tag) AS HIDDEN numberOfTags')
->groupBy('news')
->having('numberOfTags = :numberOfTags')
->setParameter('numberOfTags', count($tags))
;
}
return $qb
->getQuery()
->getResult()
;
}

Doctrine2 - Doctrine generating query with associated entity - InvalidFieldNameException

Yep, the title suggests: Doctrine is looking for a fieldname that's not there. That's both true and not true at the same time, though I cannot figure out how to fix it.
The full error:
File: D:\path\to\project\vendor\doctrine\dbal\lib\Doctrine\DBAL\Driver\AbstractMySQLDriver.php:71
Message: An exception occurred while executing 'SELECT DISTINCT id_2
FROM (SELECT p0_.name AS name_0, p0_.code AS code_1, p0_.id AS id_2
FROM product_statuses p0_) dctrn_result ORDER BY p0_.language_id ASC, name_0 ASC LIMIT 25
OFFSET 0':
SQLSTATE[42S22]: Column not found: 1054 Unknown column
'p0_.language_id' in 'order clause'
The query the error is caused by (from error above):
SELECT DISTINCT id_2
FROM (
SELECT p0_.name AS name_0, p0_.code AS code_1, p0_.id AS id_2
FROM product_statuses p0_
) dctrn_result
ORDER BY p0_.language_id ASC, name_0 ASC
LIMIT 25 OFFSET 0
Clearly, that query is not going to work. The ORDER BY should be in the sub-query, or else it should replace p0_ in the ORDER BY with dctrn_result and also get the language_id column in the sub-query to be returned.
The query is build using the QueryBuilder in the indexAction of a Controller in Zend Framework. All is very normal and the same function works perfectly fine when using a the addOrderBy() function for a single ORDER BY statement. In this instance I wish to use 2, first by language, then by name. But the above happens.
If someone knows a full solution to this (or maybe it's a bug?), that would be nice. Else a hint in the right direction to help me solve this issue would be greatly appreciated.
Below additional information - Entity and indexAction()
ProductStatus.php - Entity - Note the presence of language_id column
/**
* #ORM\Table(name="product_statuses")
* #ORM\Entity(repositoryClass="Hzw\Product\Repository\ProductStatusRepository")
*/
class ProductStatus extends AbstractEntity
{
/**
* #var string
* #ORM\Column(name="name", type="string", length=255, nullable=false)
*/
protected $name;
/**
* #var string
* #ORM\Column(name="code", type="string", length=255, nullable=false)
*/
protected $code;
/**
* #var Language
* #ORM\ManyToOne(targetEntity="Hzw\Country\Entity\Language")
* #ORM\JoinColumn(name="language_id", referencedColumnName="id")
*/
protected $language;
/**
* #var ArrayCollection|Product[]
* #ORM\OneToMany(targetEntity="Hzw\Product\Entity\Product", mappedBy="status")
*/
protected $products;
[Getters/Setters]
}
IndexAction - Removed parts not directly related to QueryBuilder. Added in comments showing params as they are.
/** #var QueryBuilder $qb */
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select($asParam) // 'pro'
->from($emEntity, $asParam); // Hzw\Product\Entity\ProductStatus, 'pro'
if (count($queryParams) > 0 && !is_null($query)) {
// [...] creates WHERE statement, unused in this instance
}
if (isset($orderBy)) {
if (is_array($orderBy)) {
// !!! This else is executed !!! <-----
if (is_array($orderDirection)) { // 'ASC'
// [...] other code
} else {
// $orderBy = ['language', 'name'], $orderDirection = 'ASC'
foreach ($orderBy as $orderParam) {
$qb->addOrderBy($asParam . '.' . $orderParam, $orderDirection);
}
}
} else {
// This works fine. A single $orderBy with a single $orderDirection
$qb->addOrderBy($asParam . '.' . $orderBy, $orderDirection);
}
}
================================================
UPDATE: I found the problem
The above issue is not caused by incorrect mapping or a possible bug. It's that the QueryBuilder does not automatically handle associations between entities when creating queries.
My expectation was that when an entity, such as ProductStatus above, contains the id's of the relation (i.e. language_id column), that it would be possible to use those properties in the QueryBuilder without issues.
Please see my own answer below how I fixed my functionality to be able to have a default handling of a single level of nesting (i.e. ProducStatus#language == Language, be able to use language.name as ORDER BY identifier).
Ok, after more searching around and digging into how and where this goes wrong, I found out that Doctrine does not handle relation type properties of entities during the generation of queries; or maybe does not default to using say, the primary key of an entity if nothing is specified.
In the use case of my question above, the language property is of a #ORM\ManyToOne association to the Language entity.
My use case calls for the ability to handle at lease one level of relations for default actions. So after I realized that this is not handled automatically (or with modifications such as language.id or language.name as identifiers) I decided to write a little function for it.
/**
* Adds order by parameters to QueryBuilder.
*
* Supports single level nesting of associations. For example:
*
* Entity Product
* product#name
* product#language.name
*
* Language being associated entity, but must be ordered by name.
*
* #param QueryBuilder $qb
* #param string $tableKey - short alias (e.g. 'tab' with 'table AS tab') used for the starting table
* #param string|array $orderBy - string for single orderBy, array for multiple
* #param string|array $orderDirection - string for single orderDirection (ASC default), array for multiple. Must be same count as $orderBy.
*/
public function createOrderBy(QueryBuilder $qb, $tableKey, $orderBy, $orderDirection = 'ASC')
{
if (!is_array($orderBy)) {
$orderBy = [$orderBy];
}
if (!is_array($orderDirection)) {
$orderDirection = [$orderDirection];
}
// $orderDirection is an array. We check if it's of equal length with $orderBy, else throw an error.
if (count($orderBy) !== count($orderDirection)) {
throw new \InvalidArgumentException(
$this->getTranslator()->translate(
'If you specify both OrderBy and OrderDirection as arrays, they should be of equal length.'
)
);
}
$queryKeys = [$tableKey];
foreach ($orderBy as $key => $orderParam) {
if (strpos($orderParam, '.')) {
if (substr_count($orderParam, '.') === 1) {
list($entity, $property) = explode('.', $orderParam);
$shortName = strtolower(substr($entity, 0, 3)); // Might not be unique...
$shortKey = $shortName . '_' . (count($queryKeys) + 1); // Now it's unique, use $shortKey when continuing
$queryKeys[] = $shortKey;
$shortName = strtolower(substr($entity, 0, 3));
$qb->join($tableKey . '.' . $entity, $shortName, Join::WITH);
$qb->addOrderBy($shortName . '.' . $property, $orderDirection[$key]);
} else {
throw new \InvalidArgumentException(
$this->getTranslator()->translate(
'Only single join statements are supported. Please write a custom function for deeper nesting.'
)
);
}
} else {
$qb->addOrderBy($tableKey . '.' . $orderParam, $orderDirection[$key]);
}
}
}
It by no means supports everything the QueryBuilder offers and is definitely not a final solution. But it gives a starting point and solid "default functionality" for an abstract function.

Zf2 list elements from entities doctrine 2

I have simple question,
how can I create form list elements, something like grid or this:
[x] name | image | [button]
[ ] name | image | [button]
[x] name | image | [button]
<table>
<tr><th>checkbox</th><th>name</th><th>action</th></tr>
<tr><td><input type="checkbox"></td><td>name</td><td><button>OK</td></tr>
<tr><td><input type="checkbox"></td><td>name</td><td><button>OK</td></tr>
<tr><td><input type="checkbox"></td><td>name</td><td><button>OK</td></tr>
</table>
//list entities from db, array(object,object,object)
//object = Application\Entity\Area
$areas = $this->getObjectManager()->getRepository('Application\Entity\Area')->findAll();
I used in form Zend\Form\Element\Collection but I don't know how populate collection date from db, so I had clear form.
I should do it properly and what to use?
From Doctrine you already get an iterable datatype (array). So you only need to iterate it in your view:
...
<?php foreach($this->data as $area): ?>
//your table row markup for a single entity
<?php endforeach; ?>
...
Disclaimer: I have asked a similar question, with no answer. So I would also be keen to know the 'Zend' way or if anyone is able to suggest an alternative.
The approach below seems to work for me.
ListForm.php
Add a collection to your 'list' form.
/** The collection that holds each element **/
$name = $this->getCollectionName();
$collectionElement = new \Zend\Form\Element\Collection($name);
$collectionElement->setOptions(array(
'count' => 0,
'should_create_template' => false,
'allow_add' => true
));
$this->add($collectionElement);
This collection will hold out collection element (Zend\Form\Element\Checkbox)
/** The element that should be added for each item **/
$targetElement = new \Zend\Form\Element\Checkbox('id');
$targetElement->setOptions(array(
'use_hidden_element' => false,
'checked_value' => 1,
));
$collectionElement->setTargetElement($targetElement);
Then I add a few methods to allow me to pass an ArrayCollecion to the form. For each entity in my collection I will create a new $targetElement; setting its it's checked value to the id of the entity.
/**
* addItems
*
* Add multiple items to the collection
*
* #param Doctrine\Common\Collections\Collection $items Items to add to the
* collection
*/
public function addItems(Collection $items)
{
foreach($items as $item) {
$this->addItem($item);
}
return $this;
}
/**
* addItem
*
* Add a sigle collection item
*
* #param EntityInterface $entity The entity to add to the
* element collection
*/
public function addItem(EntityInterface $item)
{
$element = $this->createNewItem($item->getId());
$this->get($this->getCollectionName())->add($element);
}
/**
* createNewItem
*
* Create a new collection item
*
* #param EntityInterface $entity The entity to create
* #return \Zend\Form\ElementInterface
*/
protected function createNewItem($id, array $options = array())
{
$element = clone $this->targetElement;
$element->setOptions(array_merge($element->getOptions(), $options));
$element->setCheckedValue($id);
return $element;
}
All that is then needed is to pass the collection to the form from within the controller action.
SomeController
public function listAction()
{
//....
$users = $objectManager->getRepository('user')->findBy(array('foo' => 'bar'));
$form = $this->getServiceLocator()->get('my_list_form');
$form->addItems($users);
//...
}
You can populate multi-select checkbox using doctrine from the database using DoctrineModule\Form\Element\ObjectMultiCheckbox as in this page:
https://github.com/doctrine/DoctrineModule/blob/master/docs/form-element.md
simply you need to pass the entity manager to the form, and then do same as in the example you can create ObjectMultiCheckbox form element...
or the other better -moro automated work- method, if you want to use the collection you need to do the mapping right (#orm\OneToMany and #orm\ManyToOne) with the area... and the create a fieldset in the form as in here...:
http://framework.zend.com/manual/2.2/en/modules/zend.form.collections.html
and add methods to the other entity to add and remove the areas as this:
public function addArea(Collection $areas)
{
foreach ($areas as $area) {
$area->setOtherEntity($this);
$this->areas->add($area);
}
}
public function removeAreas(Collection $areas)
{
foreach ($areas as $area) {
$area->setOtherEntity(null);
$this->areas->removeElement($area);
}
}
By this if you use the hydration the values will be added and removed as you select them automatically...

doctrine: is there a way to use associated entity in findBy

I have an entity 'employee' which is associated to one or more 'manager' entities.
Therefore i use a join table and an association in the employee entity as follows:
/**
* #ManyToMany(targetEntity="manager_entity")
* #JoinTable(name="manager_employees",
* joinColumns={#JoinColumn(name="emp_id", referencedColumnName="id")},
* inverseJoinColumns={#JoinColumn(name="manager_id", referencedColumnName="id", unique=true)}
* )
*/
protected $managers;
this is already working. but now i want to retrieve all employees of a specific manager.
therefore i'm asking if its possible to do something like this:
$mgr = $this->em->getRepository ( 'Entities\manager' )->findOneBy ( array (
"alias" => $this->get('alias'));
// only pseudo code - i know that $managers is a list of managers and $mgr cannot be compared to that
$empList = $this->em->getRepository('Entities\employee')->findBy(array("managers" => $mgr));
Add a function like this to your repository:
public function findByManager($managerId)
{
return $this->getEntityManager()
->createQueryBuilder()
->from('employee', 'e')
->innerJoin('e.managers m')
->where('m.Id = :managerId')
->setParameter('managerId', $managerId)
->getQuery()
->getResult();
}
And then just use:
$employee = $repository->findByManager($manager->getId());

how works createQueryBuilder and leftJoin?

I dont get how to make it work.
I have:
a table partner with fields id and name
a table partner_address with two fields: id_partner and id_address
a table address with fields id and external key id_town which references town(id)
a table town with fields id, a name, and postal_code
I want to select all partners that are in towns with specific postal_code
This query works:
SELECT p.nom, v.nom
FROM partner p
JOIN partner_address pa
ON pa.id_partner=p.id
JOIN address a
ON pa.id_address = a.id
JOIN town t
ON a.id_town=t.id
WHERE t.postal_code='13480';
Now I want to "translate" it into Doctrine 2 full syntax, following the documentation.
So I've made a custom repository:
src/Society/Bundle/MyProjectBundle/Repository/PartnerRepository.php
In this repository, I'm trying to create the corresponding function:
<?php
namespace HQF\Bundle\PizzasBundle\Repository;
use Doctrine\ORM\EntityRepository;
class PartenaireRepository extends EntityRepository
{
/**
* Get all active partners from a given postal code.
*/
public function findAllActiveByCp($cp)
{
return $this->createQueryBuilder('p')
->where('p.dateVFin IS NULL')
->andWhere('p.cp=:cp')
->addOrderBy('p.cp', 'DESC')
->setParameter('cp', $cp);
}
}
Nota: the query in the code is not the right one but this code works in another custom repository I've made, so I'm trying to start from this code.
I'm trying something like this but it doesn't work:
public function findAllActiveByCp($cp)
{
$qb = $this->createQueryBuilder('p');
return $qb
->leftJoin('partner_address pa ON pa.id_partner=p.id')
->leftJoin('address a ON pa.id_address = a.id')
->leftJoin('town t ON a.id_ville=t.id')
->where('p.dateVFin IS NULL')
->andWhere('t.cp=:cp')
->addOrderBy('t.cp', 'DESC')
->setParameter('cp', $cp);
}
I get this error:
Warning: Missing argument 2 for Doctrine\ORM\QueryBuilder::leftJoin(),
called in
/blabla/Repository/PartenaireRepository.php
on line 18 and defined in
/blabla/symfony/vendor/doctrine/orm/lib/Doctrine/ORM/QueryBuilder.php
line 767
You have to join only properties, that the selected entity have.
In first parameter of join() or leftJoin() or xxxJoin() you pass the attribute name related to selected object, and in the second - alias for joined entity.
Try similar to this:
$q = $this->em()->createQueryBuilder();
$q->select(['item', 'itemContact'])
->from('ModuleAdmin\Entity\CustomerEntity', 'item')
->leftJoin('item.contacts', 'itemContact')
->andWhere($q->expr()->like('item.name', ':customerNameStart'));
Of course, the CustomerEntity contains OneToMany relation in field contacts.
Remember, that in select statement you have to select the root entity (in my example CustomerEntity aliased as item).
Edit by Olivier Pons to add how I found out the solution, and to mark this answer as valid, because it put me on the right track, thank you Adam!
In the file PartenaireRepository.php I've used the createQueryBuilder('p') properly. Here's how to make two joins in a row, using createQueryBuilder():
class PartenaireRepository extends EntityRepository
{
/**
* Retrieval of all partners given for a given postal code.
*/
public function findAllActiveByCp($cp)
{
return $this->createQueryBuilder('p')
->leftJoin('p.adresses', 'a')
->leftJoin('a.ville', 'v')
->where('v.cp=:cp')
->setParameter('cp', $cp);
... blabla
}
}
I believe for what you're doing, you will need to provide four arguments to the leftJoin method.
->leftJoin('partner_address', 'pa', 'ON', 'pa.id_partner = p.id')
So your query builder chain should look like this
public function findAllActiveByCp($cp)
{
$qb = $this->createQueryBuilder('p');
return $qb
->leftJoin('partner_address', 'pa', 'ON', 'pa.id_partner = p.id')
->leftJoin('address', 'a', 'ON', 'pa.id_address = a.id')
->leftJoin('town', 't', 'ON', 'a.id_ville = t.id')
->where('p.dateVFin IS NULL')
->andWhere('t.cp=:cp')
->addOrderBy('t.cp', 'DESC')
->setParameter('cp', $cp)
;
}