Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Autocomplete] Document how to pass related entity #391

Open
tacman opened this issue Jul 11, 2022 · 11 comments
Open

[Autocomplete] Document how to pass related entity #391

tacman opened this issue Jul 11, 2022 · 11 comments

Comments

@tacman
Copy link
Contributor

tacman commented Jul 11, 2022

How can I use Autocomplete to filter based on some underlying data?

That is, I only want to autocomplete answers from a specific question.

With a regular choice field, I can do something like

$question = $options['data'];
...
'query_builder' => function($answerRepo) use ($question) { 
    return $answerRepo->createQueryBuilder('a')
      where('a.question =: question')->setParameter('question', $question);

I tried overriding query_builder in the form that calls AnswerAutoCompleteField, but that didn't work. I can't figure out how to pass data to the AnswerAutoCompleteField class to use in the query builder.

@weaverryan
Copy link
Member

Hmm. This is actually a bigger problem than just documentation - it's a case I hadn't considered.

The tricky thing is that the AJAX endpoint doesn't know anything about your current form's data. You basically just make an AJAX call to /autocomplete/answer_autocomplete and that grabs the query_builder option from your AnswerAutoCompleteField... but in this situation, the $options['data'] will be empty, as we're just grabbing AN instance or your field, but it doesn't have access to your form data.

So, to allow this (which seems reasonable), we need to be able to pass some extra information via query parameters to the Ajax endpoint, then supply this to you so you can modify the query. Possible API for this:

// AnswerAutoCompleteField.php
public function buildForm(...)
{
    // ...
    'query_context' => function() {
        return ['question' => $options['data']->getId()];
    },
    'query_builder' => function($answerRepo, array $context) { 
        return $answerRepo->createQueryBuilder('a')
          ->andWhere('a.question := question')->setParameter('question', $context['question']);
    }
}

The query_context would result in the Ajax URL being something like: /autocomplete/answer_autocomplete?question=3&query=foo. One tricky thing (not sure if it's possible yet) is that we would need to replace the normal DoctrineType query_builder normalizer with our own so that we could invoke the callable with the new $context argument.

Additionally, we should:

A) On the Ajax endpoint, if the original query_context has a question key, then guarantee there is a question query parameter available. If there is not, 400 error.
B) The $context part of the URL (at the very least) needs to be signed so that "bad users" can't just modify and start auto-completion answers from any question.

@tacman
Copy link
Contributor Author

tacman commented Jul 14, 2022

Although the documentation says to avoid passing anything in the 3rd argument, it seems natural to be able to do so -- that is, to pass the querybuilder with the custom filtering. Obviously, it's not that simple.

Adding the filtering makes me wish we could leverage API Platform, because of how powerful it is once it's configured. And it returns json, so can be called directly from stimulus.

So perhaps there could be a new UX component that is an autocomplete based on a api platform uri? Just brainstorming.

@janklan
Copy link
Contributor

janklan commented Aug 9, 2022

I'll add my two cents here, although the comment might as well go under #405. (Maybe I shouldn't have started that one, sorry).

The architectural hurdle here is the lost context at the AJAX endpoint. The solution is to write your own endpoint and do all the heavy lifting.

Does it show an opportunity for a middle-ground approach, where the UX component would provide us with a service that

  1. we autowire where appropriate (most often the custom endpoint's controller action),
  2. give it a custom-made EntityAutocompleterInterface object (see pseudo code below)
  3. and the service would take care of the rest - finding data, building a response
  4. we would just return the response generated by that service

Pseudo code of the autocompleter:

class AnswerAutocompleter implements EntityAutocompleterInterface
{
  public function __construct(private readonly Question $question) {
    // ...
  }
  
  public function createFilteredQueryBuilder(EntityRepository $repository, string $query): QueryBuilder
  {
    return $repository
      // ...
      ->andWhere('question = :question')
      ->setParameter('question', $this->question->getId())
    ;
  }
}

The autocompleter's controller action

#[Route('/autocomplete/{question}')]
controllerAction(Question $question, AutocompleteService $svc) {
  return $svc->doTheHeavyLifting(new AnswerAutocompleter($question)); 
}

Sounds like an idea?

@Cedricoss
Copy link

Cedricoss commented Apr 1, 2023

For my part, when I use the custom vision with a leftJoin, I systematically get the error

Compile Error: Cannot declare class App\Entity\User, because the name is already in use

Is this related to the same problem?

<?php
// ContactsMemberAutocompleter.php

namespace App\Autocompleter;

use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\EntityRepository;
use App\Entity\ContactsMember;
use Symfony\Component\Security\Core\Security;
use Symfony\UX\Autocomplete\EntityAutocompleterInterface;
use Symfony\Bundle\SecurityBundle\Security as SecurityUser;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('ux.entity_autocompleter', attributes: ['alias' => 'cm'])]

class ContactsMemberAutocompleter implements EntityAutocompleterInterface
{
    public function __construct(private SecurityUser $securityUser)
    {
        $this->securityUser = $securityUser;      
    }

    public function getEntityClass(): string
    {
        return ContactsMember::class;
    }

    public function createFilteredQueryBuilder(EntityRepository $repository, string $query): QueryBuilder
    {
        $user = $this->securityUser->getUser();

        $q = $repository
            // the alias "food" can be anything
            ->createQueryBuilder('ContactsMember')
            ->select('(ContactsMember.contactUser) as id, (User.lastName) as lastName, (User.firstName) as firstName, (User.email) as email, (User.alias) as alias')
            ->leftJoin('App\\Entity\\User', 'User', 'WITH', 'ContactsMember.contactUser = User.id')
            ->where('ContactsMember.user = ' . $user)
            ->andWhere('ContactsMember.contactUser != ' . $user)
            ->andWhere('ContactsMember.visible = 1')
            ->andWhere('
            User.lastName LIKE :search OR
            User.firstName LIKE :search OR
            User.email LIKE :search OR
            User.alias LIKE :search
            ')
            ->setParameter('search', '%'.$query.'%')

            // maybe do some custom filtering in all cases
            //->andWhere('food.isHealthy = :isHealthy')
            //->setParameter('isHealthy', true)
        ;
        
        return $q;

    }

    public function getLabel(object $entity): string
    {  
        return $entity->getLastName() . ' ' . $entity->getFirstName();
    }

    public function getValue(object $entity): string
    {
        return $entity->getId();
    }

    public function isGranted(Security $security): bool
    {
        // see the "security" option for details
        return true;
    }
}

@Cedricoss
Copy link

Does anyone have an idea, answer or advice?

@janklan
Copy link
Contributor

janklan commented Apr 9, 2023

Does anyone have an idea, answer or advice?

I think your question is rather unrelated to the topic. The compile error is telling you what's the problem, you'retrying to re-use an existing alias to the User entity. Here are my thoughts, without knowing the rest of your schema:

->createQueryBuilder('ContactsApplozMember')
->select('(ContactsMember.contactUser) as id, (User.lastName) as lastName, (User.firstName) as firstName, (User.email) as email, (User.alias) as alias')

You're creating the query builder with alias ContactsApplozMember but then are using ContactsMember - is the snippet above meant to work?

->leftJoin('App\Entity\User', 'User', 'WITH', 'ContactsMember.contactUser = User.id')

Do you not have ContactsMember->User association? I assume ContactsMember is an inverse side, User is the owning side. You could join like this: ->leftJoin('ContactsMember.user', 'User').

I suggest getting rid of the join in the first round to see if the query starts working. Then add the join, see if it still works. Then add the additional selects. Play with different aliases. But most importantly, try to use the join as Doctrine wants you to, if you do have that association defined. Check this out https://symfonycasts.com/screencast/symfony3-doctrine-relations/query-with-join#adding-the-join

Also your use of parenthesis in (ContactsMember.contactUser) as id is unnecessary.

Good luck debugging!

@Cedricoss
Copy link

@janklan
Sorry for this confusion of problem... But your answers helped me a lot, I managed to debug!! Thank you very much 👍

@carsonbot
Copy link

Thank you for this issue.
There has not been a lot of activity here for a while. Has this been resolved?

@carsonbot
Copy link

Friendly ping? Should this still be open? I will close if I don't hear anything.

@tacman
Copy link
Contributor Author

tacman commented May 11, 2024

This continues to be a feature I'd love to have, to replace https://github.com/tetranz/select2entity-bundle

@carsonbot carsonbot removed the Stalled label May 11, 2024
@ytilotti
Copy link

ytilotti commented May 30, 2024

Something like this @tacman & @weaverryan, no?

->add('answer', AnswerAutoCompleteField::class, [
    'autocomplete_url' => $this->router->generate('ux_entity_autocomplete', [
        'alias' => 'answer_auto_complete_field',
        'question' =>  $question->getId(),
    ]),
]),
#[AsEntityAutocompleteField]
class AnswerAutoCompleteField extends AbstractType
{
    public function __construct(protected RequestStack $request) {}

    public function configureOptions(OptionsResolver $resolver): void
    {
        $request = $this->request->getCurrentRequest();
        $resolver->setDefaults([
            'class' => Answer::class,            
            'query_builder' => function($answerRepo) use ($request) { 
                return $answerRepo->createQueryBuilder('a')
                    ->andWhere('a.question := question')
                    ->setParameter('question', $request->query->get('question'))
                ;
            },
        ]);
    }

    public function getParent(): string
    {
        return BaseEntityAutocompleteType::class;
    }
}

A better way to get the alias name answer_auto_complete_field? AsEntityAutocompleteField::shortName()?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants