Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
90.51% |
124 / 137 |
|
66.67% |
6 / 9 |
CRAP | |
0.00% |
0 / 1 |
| RelationshipStatisticsService | |
90.51% |
124 / 137 |
|
66.67% |
6 / 9 |
31.82 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| increment | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| decrement | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| mergeStatistic | |
92.59% |
25 / 27 |
|
0.00% |
0 / 1 |
9.03 | |||
| recalculateForContact | |
79.49% |
31 / 39 |
|
0.00% |
0 / 1 |
8.55 | |||
| recalculateAll | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
3 | |||
| getTypeKey | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| loadStatisticsFromDatabase | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
2 | |||
| writeStatisticsToDatabase | |
89.29% |
25 / 28 |
|
0.00% |
0 / 1 |
3.01 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Drupal\crm\Service; |
| 6 | |
| 7 | use Drupal\Core\Cache\CacheTagsInvalidatorInterface; |
| 8 | use Drupal\Core\Database\Connection; |
| 9 | use Drupal\Core\Entity\EntityTypeManagerInterface; |
| 10 | use Drupal\crm\Entity\ContactInterface; |
| 11 | use 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 | */ |
| 20 | class 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 | } |