Skip to content

Commit 1e31e6d

Browse files
authored
feat(data-producer): Introducing a taxonomy term autocomplete data producer. (drupal-graphql#1105)
1 parent 66d2171 commit 1e31e6d

File tree

2 files changed

+389
-0
lines changed

2 files changed

+389
-0
lines changed

graphql.api.php

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/**
4+
* @file
5+
* Hooks provided by GraphQL module.
6+
*/
7+
8+
/**
9+
* Alter the query built by the term autocomplete data producer.
10+
*
11+
* @param array $args
12+
* Input arguments of taxonomy term data producer.
13+
* @param \Drupal\Core\Database\Query\SelectInterface $query
14+
* The term autocomplete query.
15+
* @param \Drupal\Core\Database\Query\ConditionInterface $name_condition_group
16+
* The condition group matching the term name. This condition group is defined
17+
* as OR condition group which allows to cover a match in term name OR in some
18+
* other fields.
19+
*/
20+
function hook_graphql_term_autocomplete_query_alter(array $args, \Drupal\Core\Database\Query\SelectInterface $query, \Drupal\Core\Database\Query\ConditionInterface $name_condition_group): void {
21+
// Custom field on profile entity type of bundle resume has a reference to
22+
// synonyms field. Extend a query so it matches the string in term names OR in
23+
// synonyms.
24+
if ($args['entity_type'] == 'profile' && $args['entity_type'] == 'resume' && $args['field'] = 'field_custom') {
25+
$like_contains = '%' . $query->escapeLike($args['match_string']) . '%';
26+
$query->leftJoin('taxonomy_term__field_term_synonyms', 's', 's.entity_id = t.tid');
27+
// This makes the query to perform a match on term names OR synonyms.
28+
$name_condition_group->condition('s.field_term_synonyms_synonym', $like_contains, 'LIKE');
29+
}
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
<?php
2+
3+
namespace Drupal\graphql\Plugin\GraphQL\DataProducer\Taxonomy;
4+
5+
use Drupal\Core\Database\Connection;
6+
use Drupal\Core\Entity\ContentEntityTypeInterface;
7+
use Drupal\Core\Entity\EntityTypeManagerInterface;
8+
use Drupal\Core\Extension\ModuleHandlerInterface;
9+
use Drupal\Core\Field\FieldStorageDefinitionInterface;
10+
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
11+
use Drupal\field\FieldConfigInterface;
12+
use Drupal\graphql\GraphQL\Execution\FieldContext;
13+
use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase;
14+
use Drupal\taxonomy\TermStorageInterface;
15+
use Symfony\Component\DependencyInjection\ContainerInterface;
16+
17+
/**
18+
* Gets term items matching the given string in given field's vocabularies.
19+
*
20+
* @DataProducer(
21+
* id = "term_field_autocomplete",
22+
* name = @Translation("Term field autocomplete"),
23+
* description = @Translation("Returns autocomplete items matched against given string for vocabularies in given field"),
24+
* produces = @ContextDefinition("list",
25+
* label = @Translation("List of term ids matching the string.")
26+
* ),
27+
* consumes = {
28+
* "entity_type" = @ContextDefinition("string",
29+
* label = @Translation("Entity type the searchable term field is attached to")
30+
* ),
31+
* "bundle" = @ContextDefinition("string",
32+
* label = @Translation("Entity type the searchable term field is attached to")
33+
* ),
34+
* "field" = @ContextDefinition("string",
35+
* label = @Translation("Field name to search the terms on")
36+
* ),
37+
* "match_string" = @ContextDefinition("string",
38+
* label = @Translation("String to be matched"),
39+
* required = FALSE
40+
* ),
41+
* "prioritize_start_with" = @ContextDefinition("boolean",
42+
* label = @Translation("Whether terms which start with matching string should come first"),
43+
* required = FALSE,
44+
* default_value = TRUE
45+
* ),
46+
* "limit" = @ContextDefinition("integer",
47+
* label = @Translation("Number of items to be returned"),
48+
* required = FALSE,
49+
* default_value = 10
50+
* )
51+
* }
52+
* )
53+
*/
54+
class TermFieldAutocomplete extends DataProducerPluginBase implements ContainerFactoryPluginInterface {
55+
56+
/**
57+
* The default maximum number of items to be capped to prevent DDOS attacks.
58+
*/
59+
const MAX_ITEMS = 100;
60+
61+
/**
62+
* The database connection.
63+
*
64+
* @var \Drupal\Core\Database\Connection
65+
*/
66+
protected $database;
67+
68+
/**
69+
* The entity type manager.
70+
*
71+
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
72+
*/
73+
protected $entityTypeManager;
74+
75+
/**
76+
* The term storage.
77+
*
78+
* @var \Drupal\taxonomy\TermStorageInterface
79+
*/
80+
protected $termStorage;
81+
82+
/**
83+
* The term type.
84+
*
85+
* @var \Drupal\Core\Entity\ContentEntityTypeInterface
86+
*/
87+
protected $termType;
88+
89+
/**
90+
* The module handler.
91+
*
92+
* @var \Drupal\Core\Extension\ModuleHandlerInterface
93+
*/
94+
protected $moduleHandler;
95+
96+
/**
97+
* {@inheritdoc}
98+
*/
99+
public function __construct(
100+
array $configuration,
101+
$plugin_id,
102+
$plugin_definition,
103+
Connection $database,
104+
EntityTypeManagerInterface $entity_type_manager,
105+
ModuleHandlerInterface $module_handler
106+
) {
107+
parent::__construct($configuration, $plugin_id, $plugin_definition);
108+
$this->database = $database;
109+
$this->entityTypeManager = $entity_type_manager;
110+
$this->moduleHandler = $module_handler;
111+
}
112+
113+
/**
114+
* {@inheritdoc}
115+
*/
116+
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
117+
return new static(
118+
$configuration,
119+
$plugin_id,
120+
$plugin_definition,
121+
$container->get('database'),
122+
$container->get('entity_type.manager'),
123+
$container->get('module_handler')
124+
);
125+
}
126+
127+
/**
128+
* Gets the term storage.
129+
*
130+
* @return \Drupal\taxonomy\TermStorageInterface
131+
* The term storage.
132+
*/
133+
protected function getTermStorage(): TermStorageInterface {
134+
if (!isset($this->termStorage)) {
135+
/** @var \Drupal\taxonomy\TermStorageInterface $term_storage */
136+
$term_storage = $this->entityTypeManager->getStorage('taxonomy_term');
137+
$this->termStorage = $term_storage;
138+
}
139+
return $this->termStorage;
140+
}
141+
142+
/**
143+
* Gets the term type.
144+
*
145+
* @return \Drupal\Core\Entity\ContentEntityTypeInterface
146+
* The term type.
147+
*/
148+
protected function getTermType(): ContentEntityTypeInterface {
149+
if (!isset($this->termType)) {
150+
/** @var \Drupal\Core\Entity\ContentEntityTypeInterface $term_type */
151+
$term_type = $this->entityTypeManager->getDefinition('taxonomy_term');
152+
$this->termType = $term_type;
153+
}
154+
return $this->termType;
155+
}
156+
157+
/**
158+
* Gets the field config for given field of given entity type of given bundle.
159+
*
160+
* @param string $entity_type
161+
* Entity type to get the field config for.
162+
* @param string $bundle
163+
* Bundle to get the field config for.
164+
* @param string $field
165+
* Field to get the field config for.
166+
*
167+
* @return \Drupal\field\FieldConfigInterface|null
168+
* Field config for given field of given entity type of given bundle, or
169+
* null if it does not exist.
170+
*/
171+
protected function getFieldConfig(string $entity_type, string $bundle, string $field): ?FieldConfigInterface {
172+
/** @var \Drupal\Core\Config\Entity\ConfigEntityStorageInterface $field_config_storage */
173+
$field_config_storage = $this->entityTypeManager->getStorage('field_config');
174+
175+
/** @var \Drupal\field\FieldConfigInterface|null $field_config */
176+
$field_config = $field_config_storage->load($entity_type . '.' . $bundle . '.' . $field);
177+
178+
return $field_config;
179+
}
180+
181+
/**
182+
* Whether given field storage config is configured for term field.
183+
*
184+
* @param \Drupal\Core\Field\FieldStorageDefinitionInterface $field_storage_config
185+
* The field storage config to be examined.
186+
*
187+
* @return bool
188+
* True if given field storage config is configured for term field.
189+
*/
190+
public function isTermFieldStorageConfig(FieldStorageDefinitionInterface $field_storage_config): bool {
191+
// Term level field is allowed.
192+
$field_type = $field_storage_config->getType();
193+
if ($field_type == 'term_level') {
194+
return TRUE;
195+
}
196+
197+
// And term reference fields are allowed as well. Must be type of entity
198+
// reference or entity reference revision and must target taxonomy terms.
199+
if ($field_type != 'entity_reference' && $field_type != 'entity_reference_revisions') {
200+
return FALSE;
201+
}
202+
if ($field_storage_config->getSetting('target_type') != 'taxonomy_term') {
203+
return FALSE;
204+
}
205+
206+
return TRUE;
207+
}
208+
209+
/**
210+
* Gets the vocabularies configured for given field if it is a term field.
211+
*
212+
* @param string $entity_type
213+
* Entity type of the field to get the vocabularies for.
214+
* @param string $bundle
215+
* Bundle of entity type of the field to get the vocabularies for.
216+
* @param string $field
217+
* Field name to get the vocabularies for.
218+
*
219+
* @return string[]
220+
* Vocabularies configured for the field in case it is a term field, null
221+
* otherwise.
222+
*/
223+
protected function getTermFieldVocabularies(string $entity_type, string $bundle, string $field): ?array {
224+
// Load field config of given entity type of given bundle. If not obtained,
225+
// bail out.
226+
if (!$field_config = $this->getFieldConfig($entity_type, $bundle, $field)) {
227+
return NULL;
228+
}
229+
230+
// Make sure the field is configured for taxonomy terms.
231+
$field_storage_config = $field_config->getFieldStorageDefinition();
232+
if (!$this->isTermFieldStorageConfig($field_storage_config)) {
233+
return NULL;
234+
}
235+
236+
// Make sure that target vocabularies are configured.
237+
$handler_settings = $field_config->getSetting('handler_settings');
238+
if (empty($handler_settings['target_bundles'])) {
239+
return NULL;
240+
}
241+
242+
// Return list of vocabularies.
243+
return $handler_settings['target_bundles'];
244+
}
245+
246+
/**
247+
* Gets term items matched against given query for given vocabulary.
248+
*
249+
* @param string $entity_type
250+
* Entity type the searchable term field is attached to.
251+
* @param string $bundle
252+
* Bundle the searchable term field is attached to.
253+
* @param string $field
254+
* Field name to search the terms on.
255+
* @param string|null $match_string
256+
* String to be matched.
257+
* @param bool $prioritize_start_with
258+
* Whether terms which start with matching string should come first.
259+
* @param int $limit
260+
* Number of items to be returned.
261+
* @param \Drupal\graphql\GraphQL\Execution\FieldContext $context
262+
* The caching context related to the current field.
263+
*
264+
* @return array
265+
* List of matched terms.
266+
*/
267+
public function resolve(
268+
string $entity_type,
269+
string $bundle,
270+
string $field,
271+
?string $match_string,
272+
bool $prioritize_start_with,
273+
int $limit,
274+
FieldContext $context
275+
): ?array {
276+
if ($limit <= 0) {
277+
$limit = 10;
278+
}
279+
280+
if ($limit > self::MAX_ITEMS) {
281+
$limit = self::MAX_ITEMS;
282+
}
283+
284+
// Get configured vocabulary. If none is obtained, bail out.
285+
if (!$vocabularies = $this->getTermFieldVocabularies($entity_type, $bundle, $field)) {
286+
return NULL;
287+
}
288+
289+
// Base of the query selecting term names and synonyms.
290+
$query = $this->database->select('taxonomy_term_field_data', 't');
291+
$query->fields('t', ['tid']);
292+
$query->condition('t.vid', $vocabularies, 'IN');
293+
$query->range(0, $limit);
294+
295+
// Make condition matching name as OR condition group. This makes it
296+
// extendable if a module needs to cover a match in term name OR in some
297+
// other fields.
298+
$like_contains = '%' . $query->escapeLike($match_string) . '%';
299+
$name_condition_group = $query->orConditionGroup();
300+
$name_condition_group->condition('t.name', $like_contains, 'LIKE');
301+
302+
// Additional query logic for matches.
303+
if ($match_string) {
304+
// Prioritize terms starting with matching string.
305+
if ($prioritize_start_with) {
306+
// Get calculated meta weight value where terms which match the string
307+
// at the start have higher meta weight value comparing to the terms
308+
// which match the string in between.
309+
$like_starts_with = $query->escapeLike($match_string) . '%';
310+
$query->addExpression(
311+
'(t.name LIKE :like_starts_with) * 1 + (t.name LIKE :like_contains) * 0.5',
312+
'meta_weight', [
313+
':like_starts_with' => $like_starts_with,
314+
':like_contains' => $like_contains,
315+
]
316+
);
317+
318+
// Order by calculated meta weight value as a first ordering criterion.
319+
$query->orderBy('meta_weight', 'DESC');
320+
}
321+
322+
// Rest of ordering.
323+
$query->orderBy('t.weight', 'ASC');
324+
$query->orderBy('t.name', 'ASC');
325+
}
326+
327+
// Allow modules to alter the term autocomplete query.
328+
$args = [
329+
'entity_type' => $entity_type,
330+
'bundle' => $bundle,
331+
'field' => $field,
332+
'match_string' => $match_string,
333+
'prioritize_start_with' => $prioritize_start_with,
334+
'limit' => $limit,
335+
];
336+
$this->moduleHandler->alter('graphql_term_autocomplete_query', $args, $query, $name_condition_group);
337+
338+
// Add name OR condition group after query was altered. If added sooner then
339+
// condition group extensions done in alter hooks wouldn't be reflected.
340+
$query->condition($name_condition_group);
341+
342+
// Handle access on query.
343+
$query->addTag('taxonomy_term_access');
344+
345+
// Process the terms returned by term autocomplete query.
346+
$terms = [];
347+
foreach ($query->execute() as $match) {
348+
$terms[] = $match->tid;
349+
// Invalidate on term update, because it may change the name which does
350+
// not match the string anymore.
351+
$context->addCacheTags(['taxonomy_term:' . $match->tid]);
352+
}
353+
354+
$context->addCacheTags($this->getTermType()->getListCacheTags());
355+
356+
return $terms;
357+
}
358+
359+
}

0 commit comments

Comments
 (0)