Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
49.22% covered (danger)
49.22%
63 / 128
33.33% covered (danger)
33.33%
5 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContactSearch
49.22% covered (danger)
49.22%
63 / 128
33.33% covered (danger)
33.33%
5 / 15
106.84
0.00% covered (danger)
0.00%
0 / 1
 create
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 access
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 findResults
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 prepareResults
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 updateIndex
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
3
 indexContact
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 indexClear
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markForReindex
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 indexStatus
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 buildConfigurationForm
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 submitConfigurationForm
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHelp
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\crm\Plugin\Search;
6
7use Drupal\Core\Access\AccessibleInterface;
8use Drupal\Core\Access\AccessResult;
9use Drupal\Core\Config\Config;
10use Drupal\Core\Database\Connection;
11use Drupal\Core\Database\Query\PagerSelectExtender;
12use Drupal\Core\Database\StatementInterface;
13use Drupal\Core\Entity\EntityTypeManagerInterface;
14use Drupal\Core\Extension\ModuleHandlerInterface;
15use Drupal\Core\Form\FormStateInterface;
16use Drupal\Core\Render\RendererInterface;
17use Drupal\Core\Session\AccountInterface;
18use Drupal\Core\StringTranslation\TranslatableMarkup;
19use Drupal\crm\CrmContactInterface;
20use Drupal\search\Attribute\Search;
21use Drupal\search\Plugin\ConfigurableSearchPluginBase;
22use Drupal\search\Plugin\SearchIndexingInterface;
23use Drupal\search\SearchIndexInterface;
24use Drupal\search\SearchQuery;
25use Symfony\Component\DependencyInjection\ContainerInterface;
26
27/**
28 * Handles searching for contact entities using the Search module index.
29 */
30#[Search(
31  id: 'crm_contact_search',
32  title: new TranslatableMarkup('Contacts'),
33)]
34class ContactSearch extends ConfigurableSearchPluginBase implements AccessibleInterface, SearchIndexingInterface {
35
36  /**
37   * The current database connection.
38   *
39   * @var \Drupal\Core\Database\Connection
40   */
41  protected Connection $database;
42
43  /**
44   * The replica database connection.
45   *
46   * @var \Drupal\Core\Database\Connection
47   */
48  protected Connection $databaseReplica;
49
50  /**
51   * The entity type manager.
52   *
53   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
54   */
55  protected EntityTypeManagerInterface $entityTypeManager;
56
57  /**
58   * The module handler.
59   *
60   * @var \Drupal\Core\Extension\ModuleHandlerInterface
61   */
62  protected ModuleHandlerInterface $moduleHandler;
63
64  /**
65   * A config object for 'search.settings'.
66   *
67   * @var \Drupal\Core\Config\Config
68   */
69  protected Config $searchSettings;
70
71  /**
72   * The current user.
73   *
74   * @var \Drupal\Core\Session\AccountInterface
75   */
76  protected AccountInterface $currentUser;
77
78  /**
79   * The Renderer service to format the contact.
80   *
81   * @var \Drupal\Core\Render\RendererInterface
82   */
83  protected RendererInterface $renderer;
84
85  /**
86   * The search index.
87   *
88   * @var \Drupal\search\SearchIndexInterface
89   */
90  protected SearchIndexInterface $searchIndex;
91
92  /**
93   * {@inheritdoc}
94   */
95  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
96    return new static(
97      $configuration,
98      $plugin_id,
99      $plugin_definition,
100      $container->get('database'),
101      $container->get('entity_type.manager'),
102      $container->get('module_handler'),
103      $container->get('config.factory')->get('search.settings'),
104      $container->get('renderer'),
105      $container->get('current_user'),
106      $container->get('database.replica'),
107      $container->get('search.index')
108    );
109  }
110
111  /**
112   * Constructs a ContactSearch object.
113   *
114   * @param array $configuration
115   *   A configuration array containing information about the plugin instance.
116   * @param string $plugin_id
117   *   The plugin ID for the plugin instance.
118   * @param mixed $plugin_definition
119   *   The plugin implementation definition.
120   * @param \Drupal\Core\Database\Connection $database
121   *   The current database connection.
122   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
123   *   The entity type manager.
124   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
125   *   The module handler.
126   * @param \Drupal\Core\Config\Config $search_settings
127   *   A config object for 'search.settings'.
128   * @param \Drupal\Core\Render\RendererInterface $renderer
129   *   The renderer.
130   * @param \Drupal\Core\Session\AccountInterface $current_user
131   *   The current user.
132   * @param \Drupal\Core\Database\Connection $database_replica
133   *   The replica database connection.
134   * @param \Drupal\search\SearchIndexInterface $search_index
135   *   The search index.
136   */
137  public function __construct(
138    array $configuration,
139    $plugin_id,
140    $plugin_definition,
141    Connection $database,
142    EntityTypeManagerInterface $entity_type_manager,
143    ModuleHandlerInterface $module_handler,
144    Config $search_settings,
145    RendererInterface $renderer,
146    AccountInterface $current_user,
147    Connection $database_replica,
148    SearchIndexInterface $search_index,
149  ) {
150    $this->database          = $database;
151    $this->databaseReplica   = $database_replica;
152    $this->entityTypeManager = $entity_type_manager;
153    $this->moduleHandler     = $module_handler;
154    $this->searchSettings    = $search_settings;
155    $this->renderer          = $renderer;
156    $this->currentUser       = $current_user;
157    $this->searchIndex       = $search_index;
158
159    parent::__construct($configuration, $plugin_id, $plugin_definition);
160
161    $this->addCacheTags(['crm_contact_list']);
162  }
163
164  /**
165   * {@inheritdoc}
166   */
167  public function access($operation = 'view', ?AccountInterface $account = NULL, $return_as_object = FALSE) {
168    // Allow access if user can view contacts OR has admin permission.
169    $result = AccessResult::allowedIfHasPermission($account, 'view any crm_contact')
170      ->orIf(AccessResult::allowedIfHasPermission($account, 'administer crm'));
171    return $return_as_object ? $result : $result->isAllowed();
172  }
173
174  /**
175   * {@inheritdoc}
176   */
177  public function getType() {
178    return $this->getPluginId();
179  }
180
181  /**
182   * {@inheritdoc}
183   */
184  public function execute() {
185    if ($this->isSearchExecutable()) {
186      $results = $this->findResults();
187
188      if ($results) {
189        return $this->prepareResults($results);
190      }
191    }
192
193    return [];
194  }
195
196  /**
197   * Queries to find search results, and sets status messages.
198   *
199   * This method can assume that $this->isSearchExecutable() has already been
200   * checked and returned TRUE.
201   *
202   * @return \Drupal\Core\Database\StatementInterface|null
203   *   Results from search query execute() method, or NULL if the search
204   *   failed.
205   */
206  protected function findResults(): ?StatementInterface {
207    $keys = $this->keywords;
208
209    // Build matching conditions.
210    $query = $this->databaseReplica
211      ->select('search_index', 'i')
212      ->extend(SearchQuery::class)
213      ->extend(PagerSelectExtender::class);
214    $query->join('crm_contact', 'c', '[c].[id] = [i].[sid]');
215    $query->searchExpression($keys, $this->getPluginId());
216
217    // Only search published contacts for non-admins.
218    if (!$this->currentUser->hasPermission('administer crm')) {
219      $query->condition('c.status', 1);
220    }
221
222    // Run the query. Need to add fields and groupBy for SearchQuery to work.
223    // Also explicitly select sid for loading entities later.
224    $find = $query
225      ->fields('i', ['sid', 'langcode'])
226      ->groupBy('i.langcode')
227      ->groupBy('i.sid')
228      ->limit(10)
229      ->execute();
230
231    // Check query status and set messages if needed.
232    $status = $query->getStatus();
233
234    if ($status & SearchQuery::NO_POSITIVE_KEYWORDS) {
235      return NULL;
236    }
237
238    return $find;
239  }
240
241  /**
242   * Prepares search results for rendering.
243   *
244   * @param \Drupal\Core\Database\StatementInterface $found
245   *   Results found from a successful search query execute() method.
246   *
247   * @return array
248   *   Array of search result item render arrays (empty array if no results).
249   */
250  protected function prepareResults(StatementInterface $found): array {
251    $results = [];
252
253    $contact_storage = $this->entityTypeManager->getStorage('crm_contact');
254    $keys            = $this->keywords;
255
256    foreach ($found as $item) {
257      /** @var \Drupal\crm\CrmContactInterface $contact */
258      $contact = $contact_storage->load($item->sid);
259
260      if (!$contact) {
261        continue;
262      }
263
264      // Render the contact for snippet generation.
265      $contact_render = $this->entityTypeManager->getViewBuilder('crm_contact');
266      $build          = $contact_render->view($contact, 'search_result');
267      unset($build['#theme']);
268
269      $rendered = $this->renderer->renderInIsolation($build);
270      $this->addCacheableDependency($contact);
271
272      /** @var \Drupal\crm\CrmContactTypeInterface $type */
273      $type = $this->entityTypeManager->getStorage('crm_contact_type')->load($contact->bundle());
274
275      $result = [
276        'link'    => $contact->toUrl('canonical', ['absolute' => TRUE])->toString(),
277        'type'    => $type->label(),
278        'title'   => $contact->label(),
279        'contact' => $contact,
280        'score'   => $item->calculated_score,
281        'snippet' => search_excerpt($keys, $rendered, $item->langcode ?? 'en'),
282      ];
283
284      $results[] = $result;
285    }
286
287    return $results;
288  }
289
290  /**
291   * {@inheritdoc}
292   */
293  public function updateIndex(): void {
294    // Interpret the cron limit setting as the maximum number of contacts to
295    // index per cron run.
296    $limit = (int) $this->searchSettings->get('index.cron_limit');
297
298    $query = $this->databaseReplica->select('crm_contact', 'c');
299    $query->addField('c', 'id');
300    $query->leftJoin('search_dataset', 'sd', '[sd].[sid] = [c].[id] AND [sd].[type] = :type', [':type' => $this->getPluginId()]);
301    $query->addExpression('CASE MAX([sd].[reindex]) WHEN NULL THEN 0 ELSE 1 END', 'ex');
302    $query->addExpression('MAX([sd].[reindex])', 'ex2');
303    $query->condition(
304      $query->orConditionGroup()
305        ->where('[sd].[sid] IS NULL')
306        ->condition('sd.reindex', 0, '<>')
307    );
308    $query->orderBy('ex', 'DESC')
309      ->orderBy('ex2')
310      ->orderBy('c.id')
311      ->groupBy('c.id')
312      ->range(0, $limit);
313
314    $ids = $query->execute()->fetchCol();
315    if (!$ids) {
316      return;
317    }
318
319    $contact_storage = $this->entityTypeManager->getStorage('crm_contact');
320    $words           = [];
321    try {
322      foreach ($contact_storage->loadMultiple($ids) as $contact) {
323        $words += $this->indexContact($contact);
324      }
325    }
326    finally {
327      $this->searchIndex->updateWordWeights($words);
328    }
329  }
330
331  /**
332   * Indexes a single contact.
333   *
334   * @param \Drupal\crm\CrmContactInterface $contact
335   *   The contact to index.
336   *
337   * @return array
338   *   An array of words to update after indexing.
339   */
340  protected function indexContact(CrmContactInterface $contact): array {
341    $words = [];
342
343    // Build text content for indexing.
344    // Start with the contact label/name as highest priority.
345    $text = '<h1>' . htmlspecialchars($contact->label() ?? '') . '</h1>';
346
347    // Try to render the contact entity for additional content.
348    try {
349      $contact_render = $this->entityTypeManager->getViewBuilder('crm_contact');
350      $build          = $contact_render->view($contact, 'search_index');
351      unset($build['#theme']);
352      $rendered = $this->renderer->renderInIsolation($build);
353      $text .= ' ' . $rendered;
354    }
355    catch (\Exception $e) {
356      // If rendering fails, just use the label.
357    }
358
359    // Update index, using search index "type" equal to the plugin ID.
360    $words += $this->searchIndex->index($this->getPluginId(), $contact->id(), 'en', $text, FALSE);
361
362    return $words;
363  }
364
365  /**
366   * {@inheritdoc}
367   */
368  public function indexClear(): void {
369    // All ContactSearch pages share a common search index "type" equal to
370    // the plugin ID.
371    $this->searchIndex->clear($this->getPluginId());
372  }
373
374  /**
375   * {@inheritdoc}
376   */
377  public function markForReindex(): void {
378    // All ContactSearch pages share a common search index "type" equal to
379    // the plugin ID.
380    $this->searchIndex->markForReindex($this->getPluginId());
381  }
382
383  /**
384   * {@inheritdoc}
385   */
386  public function indexStatus(): array {
387    $total     = $this->database->query('SELECT COUNT(*) FROM {crm_contact}')->fetchField();
388    $remaining = $this->database->query(
389      "SELECT COUNT(DISTINCT [c].[id]) FROM {crm_contact} [c] LEFT JOIN {search_dataset} [sd] ON [sd].[sid] = [c].[id] AND [sd].[type] = :type WHERE [sd].[sid] IS NULL OR [sd].[reindex] <> 0",
390      [':type' => $this->getPluginId()]
391    )->fetchField();
392
393    return ['remaining' => $remaining, 'total' => $total];
394  }
395
396  /**
397   * {@inheritdoc}
398   */
399  public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
400    // Contact search currently has no configuration options.
401    // This method can be extended in the future to add options like:
402    // - Ranking factors for different fields
403    // - Search result display options
404    // - Filter options by contact type.
405    return $form;
406  }
407
408  /**
409   * {@inheritdoc}
410   */
411  public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void {
412    // No configuration to save currently.
413    // This will be implemented when configuration options are added.
414  }
415
416  /**
417   * {@inheritdoc}
418   */
419  public function getHelp(): array {
420    $help = [
421      'list' => [
422        '#theme' => 'item_list',
423        '#items' => [
424          $this->t('Contact search looks for contact names, email addresses, telephone numbers, and addresses. Example: john@example.com would match contacts with that email address.'),
425          $this->t('You can use * as a wildcard within your keyword. Example: john* would match john, johnson, and johnathon.'),
426          $this->t('The search will find matches in contact names as well as their associated contact details (emails, telephones, and addresses).'),
427        ],
428      ],
429    ];
430
431    return $help;
432  }
433
434}