Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.51% covered (success)
90.51%
124 / 137
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
RelationshipStatisticsService
90.51% covered (success)
90.51%
124 / 137
66.67% covered (warning)
66.67%
6 / 9
31.82
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 increment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 decrement
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mergeStatistic
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
9.03
 recalculateForContact
79.49% covered (warning)
79.49%
31 / 39
0.00% covered (danger)
0.00%
0 / 1
8.55
 recalculateAll
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 getTypeKey
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 loadStatisticsFromDatabase
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 writeStatisticsToDatabase
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
3.01
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\crm\Service;
6
7use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
8use Drupal\Core\Database\Connection;
9use Drupal\Core\Entity\EntityTypeManagerInterface;
10use Drupal\crm\Entity\ContactInterface;
11use Drupal\crm\Entity\RelationshipInterface;
12
13/**
14 * Service for managing relationship statistics on contacts.
15 *
16 * Statistics are updated directly in the database without saving the contact
17 * entity, similar to how Drupal's comment module handles comment statistics.
18 * This avoids creating new revisions when statistics change.
19 */
20class RelationshipStatisticsService implements RelationshipStatisticsInterface {
21
22  /**
23   * The base table for the relationship statistics field.
24   */
25  protected const BASE_TABLE = 'crm_contact__relationship_statistics';
26
27  /**
28   * The revision table for the relationship statistics field.
29   */
30  protected const REVISION_TABLE = 'crm_contact_revision__relationship_statistics';
31
32  /**
33   * Constructs a RelationshipStatisticsService object.
34   *
35   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
36   *   The entity type manager.
37   * @param \Drupal\Core\Database\Connection $database
38   *   The database connection.
39   * @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $cacheTagsInvalidator
40   *   The cache tags invalidator.
41   */
42  public function __construct(
43    protected EntityTypeManagerInterface $entityTypeManager,
44    protected Connection $database,
45    protected CacheTagsInvalidatorInterface $cacheTagsInvalidator,
46  ) {}
47
48  /**
49   * {@inheritdoc}
50   */
51  public function increment(ContactInterface $contact, string $type_key): void {
52    $this->mergeStatistic($contact, $type_key, 1);
53  }
54
55  /**
56   * {@inheritdoc}
57   */
58  public function decrement(ContactInterface $contact, string $type_key): void {
59    $this->mergeStatistic($contact, $type_key, -1);
60  }
61
62  /**
63   * Merges a statistic value (increment or decrement).
64   *
65   * Updates the database directly without saving the contact entity.
66   *
67   * @param \Drupal\crm\ContactInterface $contact
68   *   The contact entity.
69   * @param string $type_key
70   *   The relationship type key.
71   * @param int $delta
72   *   The amount to change (positive or negative).
73   */
74  protected function mergeStatistic(ContactInterface $contact, string $type_key, int $delta): void {
75    $contact_id = (int) $contact->id();
76    if (!$contact_id) {
77      return;
78    }
79
80    // Get contact info needed for database operations.
81    $contact_storage = $this->entityTypeManager->getStorage('crm_contact');
82    $contact_storage->resetCache([$contact_id]);
83    $contact = $contact_storage->load($contact_id);
84
85    if (!$contact || !$contact->hasField('relationship_statistics')) {
86      return;
87    }
88
89    $revision_id = (int) $contact->getRevisionId();
90    $bundle = $contact->bundle();
91
92    // Query current statistics from the database.
93    $current_statistics = $this->loadStatisticsFromDatabase($contact_id);
94
95    // Update the statistics.
96    $found = FALSE;
97    foreach ($current_statistics as $key => &$item) {
98      if ($item['value'] === $type_key) {
99        $item['count'] = (int) $item['count'] + $delta;
100        $found = TRUE;
101
102        // Remove entry if count reaches 0 or below.
103        if ($item['count'] <= 0) {
104          unset($current_statistics[$key]);
105        }
106        break;
107      }
108    }
109
110    // If not found and incrementing, add new entry.
111    if (!$found && $delta > 0) {
112      $current_statistics[] = [
113        'value' => $type_key,
114        'count' => $delta,
115      ];
116    }
117
118    // Re-index the array.
119    $current_statistics = array_values($current_statistics);
120
121    // Write updated statistics to database.
122    $this->writeStatisticsToDatabase($contact_id, $revision_id, $bundle, $current_statistics);
123
124    // Reset entity static cache so subsequent loads get fresh data.
125    $contact_storage->resetCache([$contact_id]);
126
127    // Invalidate render cache tags.
128    $this->cacheTagsInvalidator->invalidateTags(['crm_contact:' . $contact_id]);
129  }
130
131  /**
132   * {@inheritdoc}
133   */
134  public function recalculateForContact(ContactInterface $contact): void {
135    if (!$contact->hasField('relationship_statistics')) {
136      return;
137    }
138
139    $contact_id = (int) $contact->id();
140    if (!$contact_id) {
141      return;
142    }
143
144    $revision_id = (int) $contact->getRevisionId();
145    $bundle = $contact->bundle();
146
147    // Query all active relationships for this contact.
148    $relationship_storage = $this->entityTypeManager->getStorage('crm_relationship');
149    $query = $relationship_storage->getQuery()
150      ->condition('status', 1)
151      ->condition('contacts', $contact_id)
152      ->accessCheck(FALSE);
153
154    $relationship_ids = $query->execute();
155
156    if (empty($relationship_ids)) {
157      // Clear all statistics if no relationships exist.
158      $this->writeStatisticsToDatabase($contact_id, $revision_id, $bundle, []);
159      $this->entityTypeManager->getStorage('crm_contact')->resetCache([$contact_id]);
160      $this->cacheTagsInvalidator->invalidateTags(['crm_contact:' . $contact_id]);
161      return;
162    }
163
164    $relationships = $relationship_storage->loadMultiple($relationship_ids);
165    $statistics = [];
166
167    foreach ($relationships as $relationship) {
168      // Determine which position(s) this contact occupies.
169      $contacts_field = $relationship->get('contacts')->getValue();
170      $contact_a_id = $contacts_field[0]['target_id'] ?? NULL;
171      $contact_b_id = $contacts_field[1]['target_id'] ?? NULL;
172
173      if ($contact_a_id == $contact_id) {
174        $type_key = $this->getTypeKey($relationship, 'a');
175        $statistics[$type_key] = ($statistics[$type_key] ?? 0) + 1;
176      }
177
178      if ($contact_b_id == $contact_id) {
179        $type_key = $this->getTypeKey($relationship, 'b');
180        $statistics[$type_key] = ($statistics[$type_key] ?? 0) + 1;
181      }
182    }
183
184    // Convert to field values format.
185    $field_values = [];
186    foreach ($statistics as $type_key => $count) {
187      $field_values[] = [
188        'value' => $type_key,
189        'count' => $count,
190      ];
191    }
192
193    // Write to database directly.
194    $this->writeStatisticsToDatabase($contact_id, $revision_id, $bundle, $field_values);
195
196    // Reset entity static cache so subsequent loads get fresh data.
197    $this->entityTypeManager->getStorage('crm_contact')->resetCache([$contact_id]);
198
199    // Invalidate render cache tags.
200    $this->cacheTagsInvalidator->invalidateTags(['crm_contact:' . $contact_id]);
201  }
202
203  /**
204   * {@inheritdoc}
205   */
206  public function recalculateAll(int $batch_size = 100): int {
207    $contact_storage = $this->entityTypeManager->getStorage('crm_contact');
208    $total_processed = 0;
209    $last_id = 0;
210
211    do {
212      $query = $contact_storage->getQuery()
213        ->condition('id', $last_id, '>')
214        ->sort('id', 'ASC')
215        ->range(0, $batch_size)
216        ->accessCheck(FALSE);
217
218      $contact_ids = $query->execute();
219
220      if (empty($contact_ids)) {
221        break;
222      }
223
224      $contacts = $contact_storage->loadMultiple($contact_ids);
225
226      foreach ($contacts as $contact) {
227        $this->recalculateForContact($contact);
228        $last_id = $contact->id();
229        $total_processed++;
230      }
231
232      // Clear entity cache to prevent memory issues.
233      $contact_storage->resetCache($contact_ids);
234
235    } while (!empty($contact_ids));
236
237    return $total_processed;
238  }
239
240  /**
241   * {@inheritdoc}
242   */
243  public function getTypeKey(RelationshipInterface $relationship, string $position): string {
244    $bundle = $relationship->bundle();
245    $relationship_type = $this->entityTypeManager
246      ->getStorage('crm_relationship_type')
247      ->load($bundle);
248
249    if (!$relationship_type) {
250      return $bundle;
251    }
252
253    // Check if the relationship type is asymmetric.
254    $is_asymmetric = (bool) $relationship_type->get('asymmetric');
255
256    if ($is_asymmetric) {
257      return $bundle . ':' . $position;
258    }
259
260    return $bundle;
261  }
262
263  /**
264   * Loads current statistics from the database.
265   *
266   * @param int $contact_id
267   *   The contact entity ID.
268   *
269   * @return array
270   *   An array of statistics with 'value' and 'count' keys.
271   */
272  protected function loadStatisticsFromDatabase(int $contact_id): array {
273    $result = $this->database->select(self::BASE_TABLE, 's')
274      ->fields('s', ['relationship_statistics_value', 'relationship_statistics_count'])
275      ->condition('entity_id', $contact_id)
276      ->orderBy('delta')
277      ->execute();
278
279    $statistics = [];
280    foreach ($result as $row) {
281      $statistics[] = [
282        'value' => $row->relationship_statistics_value,
283        'count' => (int) $row->relationship_statistics_count,
284      ];
285    }
286
287    return $statistics;
288  }
289
290  /**
291   * Writes statistics directly to the database.
292   *
293   * Updates both the base table and the revision table for the current
294   * revision. Uses a transaction to ensure data integrity.
295   *
296   * @param int $contact_id
297   *   The contact entity ID.
298   * @param int $revision_id
299   *   The contact revision ID.
300   * @param string $bundle
301   *   The contact bundle.
302   * @param array $statistics
303   *   An array of statistics with 'value' and 'count' keys.
304   */
305  protected function writeStatisticsToDatabase(int $contact_id, int $revision_id, string $bundle, array $statistics): void {
306    $transaction = $this->database->startTransaction();
307
308    try {
309      // Delete existing rows from base table.
310      $this->database->delete(self::BASE_TABLE)
311        ->condition('entity_id', $contact_id)
312        ->execute();
313
314      // Delete existing rows from revision table for current revision.
315      $this->database->delete(self::REVISION_TABLE)
316        ->condition('entity_id', $contact_id)
317        ->condition('revision_id', $revision_id)
318        ->execute();
319
320      // Insert new rows.
321      foreach ($statistics as $delta => $item) {
322        $fields = [
323          'bundle' => $bundle,
324          'deleted' => 0,
325          'entity_id' => $contact_id,
326          'revision_id' => $revision_id,
327          'langcode' => 'und',
328          'delta' => $delta,
329          'relationship_statistics_value' => $item['value'],
330          'relationship_statistics_count' => $item['count'],
331        ];
332
333        // Insert into base table.
334        $this->database->insert(self::BASE_TABLE)
335          ->fields($fields)
336          ->execute();
337
338        // Insert into revision table.
339        $this->database->insert(self::REVISION_TABLE)
340          ->fields($fields)
341          ->execute();
342      }
343    }
344    catch (\Exception $e) {
345      $transaction->rollBack();
346      throw $e;
347    }
348  }
349
350}