Merge pull request #21744 from civicrm/5.42
[civicrm-core.git] / CRM / Core / EntityTokens.php
1 <?php
2
3 /*
4 +--------------------------------------------------------------------+
5 | Copyright CiviCRM LLC. All rights reserved. |
6 | |
7 | This work is published under the GNU AGPLv3 license with some |
8 | permitted exceptions and without any warranty. For full license |
9 | and copyright information, see https://civicrm.org/licensing |
10 +--------------------------------------------------------------------+
11 */
12
13 use Civi\Token\AbstractTokenSubscriber;
14 use Civi\Token\Event\TokenRegisterEvent;
15 use Civi\Token\Event\TokenValueEvent;
16 use Civi\Token\TokenRow;
17 use Civi\ActionSchedule\Event\MailingQueryEvent;
18 use Civi\Token\TokenProcessor;
19
20 /**
21 * Class CRM_Core_EntityTokens
22 *
23 * Parent class for generic entity token functionality.
24 *
25 * WARNING - this class is highly likely to be temporary and
26 * to be consolidated with the TokenTrait and / or the
27 * AbstractTokenSubscriber in future. It is being used to clarify
28 * functionality but should NOT be used from outside of core tested code.
29 */
30 class CRM_Core_EntityTokens extends AbstractTokenSubscriber {
31
32 /**
33 * @var array
34 */
35 protected $prefetch = [];
36
37 /**
38 * Register the declared tokens.
39 *
40 * @param \Civi\Token\Event\TokenRegisterEvent $e
41 * The registration event. Add new tokens using register().
42 */
43 public function registerTokens(TokenRegisterEvent $e) {
44 if (!$this->checkActive($e->getTokenProcessor())) {
45 return;
46 }
47 foreach ($this->getAllTokens() as $name => $label) {
48 if (!in_array($name, $this->getHiddenTokens(), TRUE)) {
49 $e->register([
50 'entity' => $this->entity,
51 'field' => $name,
52 'label' => $label,
53 ]);
54 }
55 }
56 }
57
58 /**
59 * @inheritDoc
60 * @throws \CRM_Core_Exception
61 */
62 public function evaluateToken(TokenRow $row, $entity, $field, $prefetch = NULL) {
63 $this->prefetch = (array) $prefetch;
64 $fieldValue = $this->getFieldValue($row, $field);
65 if (is_array($fieldValue)) {
66 // eg. role_id for participant would be an array here.
67 $fieldValue = implode(',', $fieldValue);
68 }
69
70 if ($this->isPseudoField($field)) {
71 if (!empty($fieldValue)) {
72 // If it's set here it has already been loaded in pre-fetch.
73 return $row->format('text/plain')->tokens($entity, $field, (string) $fieldValue);
74 }
75 // Once prefetch is fully standardised we can remove this - as long
76 // as tests pass we should be fine as tests cover this.
77 $split = explode(':', $field);
78 return $row->tokens($entity, $field, $this->getPseudoValue($split[0], $split[1], $this->getFieldValue($row, $split[0])));
79 }
80 if ($this->isCustomField($field)) {
81 $prefetchedValue = $this->getCustomFieldValue($this->getFieldValue($row, 'id'), $field);
82 if ($prefetchedValue) {
83 return $row->format('text/html')->tokens($entity, $field, $prefetchedValue);
84 }
85 return $row->customToken($entity, \CRM_Core_BAO_CustomField::getKeyID($field), $this->getFieldValue($row, 'id'));
86 }
87 if ($this->isMoneyField($field)) {
88 return $row->format('text/plain')->tokens($entity, $field,
89 \CRM_Utils_Money::format($fieldValue, $this->getCurrency($row)));
90 }
91 if ($this->isDateField($field)) {
92 try {
93 return $row->format('text/plain')
94 ->tokens($entity, $field, new DateTime($fieldValue));
95 }
96 catch (Exception $e) {
97 Civi::log()->info('invalid date token');
98 }
99 }
100 $row->format('text/plain')->tokens($entity, $field, (string) $fieldValue);
101 }
102
103 /**
104 * Metadata about the entity fields.
105 *
106 * @var array
107 */
108 protected $fieldMetadata = [];
109
110 /**
111 * Get the entity name for api v4 calls.
112 *
113 * @return string
114 */
115 protected function getApiEntityName(): string {
116 return '';
117 }
118
119 /**
120 * Get the entity alias to use within queries.
121 *
122 * The default has a double underscore which should prevent any
123 * ambiguity with an existing table name.
124 *
125 * @return string
126 */
127 protected function getEntityAlias(): string {
128 return $this->getApiEntityName() . '__';
129 }
130
131 /**
132 * Get the name of the table this token class can extend.
133 *
134 * The default is based on the entity but some token classes,
135 * specifically the event class, latch on to other tables - ie
136 * the participant table.
137 */
138 public function getExtendableTableName(): string {
139 return CRM_Core_DAO_AllCoreTables::getTableForEntityName($this->getApiEntityName());
140 }
141
142 /**
143 * Get the relevant bao name.
144 */
145 public function getBAOName(): string {
146 return CRM_Core_DAO_AllCoreTables::getFullName($this->getApiEntityName());
147 }
148
149 /**
150 * Get an array of fields to be requested.
151 *
152 * @return string[]
153 */
154 public function getReturnFields(): array {
155 return array_keys($this->getBasicTokens());
156 }
157
158 /**
159 * Get all the tokens supported by this processor.
160 *
161 * @return array|string[]
162 * @throws \API_Exception
163 */
164 protected function getAllTokens(): array {
165 $basicTokens = $this->getBasicTokens();
166 foreach (array_keys($basicTokens) as $fieldName) {
167 // The goal is to be able to render more complete tokens
168 // (eg. actual booleans, field names, raw ids) for a more
169 // advanced audiences - ie those using conditionals
170 // and to specify that audience in the api that retrieves.
171 // But, for now, let's not advertise, given that most of these fields
172 // aren't really needed even once...
173 if ($this->isBooleanField($fieldName)) {
174 unset($basicTokens[$fieldName]);
175 }
176 }
177 foreach ($this->getBespokeTokens() as $token) {
178 $basicTokens[$token['name']] = $token['title'];
179 }
180 return array_merge($basicTokens, $this->getPseudoTokens(), CRM_Utils_Token::getCustomFieldTokens($this->getApiEntityName()));
181 }
182
183 /**
184 * Is the given field a boolean field.
185 *
186 * @param string $fieldName
187 *
188 * @return bool
189 */
190 public function isBooleanField(string $fieldName): bool {
191 return $this->getFieldMetadata()[$fieldName]['data_type'] === 'Boolean';
192 }
193
194 /**
195 * Is the given field a date field.
196 *
197 * @param string $fieldName
198 *
199 * @return bool
200 */
201 public function isDateField(string $fieldName): bool {
202 return in_array($this->getFieldMetadata()[$fieldName]['data_type'], ['Timestamp', 'Date'], TRUE);
203 }
204
205 /**
206 * Is the given field a pseudo field.
207 *
208 * @param string $fieldName
209 *
210 * @return bool
211 */
212 public function isPseudoField(string $fieldName): bool {
213 return strpos($fieldName, ':') !== FALSE;
214 }
215
216 /**
217 * Is the given field a custom field.
218 *
219 * @param string $fieldName
220 *
221 * @return bool
222 */
223 public function isCustomField(string $fieldName) : bool {
224 return (bool) \CRM_Core_BAO_CustomField::getKeyID($fieldName);
225 }
226
227 /**
228 * Is the given field a date field.
229 *
230 * @param string $fieldName
231 *
232 * @return bool
233 */
234 public function isMoneyField(string $fieldName): bool {
235 return $this->getFieldMetadata()[$fieldName]['data_type'] === 'Money';
236 }
237
238 /**
239 * Get the metadata for the available fields.
240 *
241 * @return array
242 */
243 protected function getFieldMetadata(): array {
244 if (empty($this->fieldMetadata)) {
245 try {
246 // Tests fail without checkPermissions = FALSE
247 $this->fieldMetadata = (array) civicrm_api4($this->getApiEntityName(), 'getfields', ['checkPermissions' => FALSE], 'name');
248 }
249 catch (API_Exception $e) {
250 $this->fieldMetadata = [];
251 }
252 }
253 return $this->fieldMetadata;
254 }
255
256 /**
257 * Get pseudoTokens - it tokens that reflect the name or label of a pseudoconstant.
258 *
259 * @internal - this function will likely be made protected soon.
260 *
261 * @return array
262 */
263 public function getPseudoTokens(): array {
264 $return = [];
265 foreach (array_keys($this->getBasicTokens()) as $fieldName) {
266 if ($this->isAddPseudoTokens($fieldName)) {
267 $fieldLabel = $this->fieldMetadata[$fieldName]['input_attrs']['label'] ?? $this->fieldMetadata[$fieldName]['label'];
268 $return[$fieldName . ':label'] = $fieldLabel;
269 $return[$fieldName . ':name'] = ts('Machine name') . ': ' . $fieldLabel;
270 }
271 if ($this->isBooleanField($fieldName)) {
272 $return[$fieldName . ':label'] = $this->getFieldMetadata()[$fieldName]['title'];
273 }
274 }
275 return $return;
276 }
277
278 /**
279 * Get any tokens with custom calculation.
280 */
281 protected function getBespokeTokens(): array {
282 return [];
283 }
284
285 /**
286 * Is this a field we should add pseudo-tokens to?
287 *
288 * Pseudo-tokens allow access to name and label fields - e.g
289 *
290 * {contribution.contribution_status_id:name} might resolve to 'Completed'
291 *
292 * @param string $fieldName
293 */
294 public function isAddPseudoTokens($fieldName): bool {
295 if (in_array($fieldName, $this->getCurrencyFieldName())) {
296 // 'currency' is manually added to the skip list as an anomaly.
297 // name & label aren't that suitable for 'currency' (symbol, which
298 // possibly maps to 'abbr' would be) and we can't gather that
299 // from the metadata as yet.
300 return FALSE;
301 }
302 if ($this->getFieldMetadata()[$fieldName]['type'] === 'Custom') {
303 // If we remove this early return then we get that extra nuanced goodness
304 // and support for the more portable v4 style field names
305 // on custom fields - where labels or names can be returned.
306 // At present the gap is that the metadata for the label is not accessed
307 // and tests failed on the enotice and we don't have a clear plan about
308 // v4 style custom tokens - but medium term this IF will probably go.
309 return FALSE;
310 }
311 return (bool) ($this->getFieldMetadata()[$fieldName]['options'] || !empty($this->getFieldMetadata()[$fieldName]['suffixes']));
312 }
313
314 /**
315 * Get the value for the relevant pseudo field.
316 *
317 * @param string $realField e.g contribution_status_id
318 * @param string $pseudoKey e.g name
319 * @param int|string $fieldValue e.g 1
320 *
321 * @return string
322 * Eg. 'Completed' in the example above.
323 *
324 * @internal function will likely be protected soon.
325 */
326 public function getPseudoValue(string $realField, string $pseudoKey, $fieldValue): string {
327 if ($pseudoKey === 'name') {
328 $fieldValue = (string) CRM_Core_PseudoConstant::getName($this->getBAOName(), $realField, $fieldValue);
329 }
330 if ($pseudoKey === 'label') {
331 $fieldValue = (string) CRM_Core_PseudoConstant::getLabel($this->getBAOName(), $realField, $fieldValue);
332 }
333 return (string) $fieldValue;
334 }
335
336 /**
337 * @param \Civi\Token\TokenRow $row
338 * @param string $field
339 * @return string|int
340 */
341 protected function getFieldValue(TokenRow $row, string $field) {
342 $entityName = $this->getEntityName();
343 if (isset($row->context[$entityName][$field])) {
344 return $row->context[$entityName][$field];
345 }
346
347 $actionSearchResult = $row->context['actionSearchResult'];
348 $aliasedField = $this->getEntityAlias() . $field;
349 if (isset($actionSearchResult->{$aliasedField})) {
350 return $actionSearchResult->{$aliasedField};
351 }
352 $entityID = $row->context[$this->getEntityIDField()];
353 if ($field === 'id') {
354 return $entityID;
355 }
356 return $this->prefetch[$entityID][$field] ?? '';
357 }
358
359 /**
360 * Class constructor.
361 */
362 public function __construct() {
363 $tokens = $this->getAllTokens();
364 parent::__construct($this->getEntityName(), $tokens);
365 }
366
367 /**
368 * Check if the token processor is active.
369 *
370 * @param \Civi\Token\TokenProcessor $processor
371 *
372 * @return bool
373 */
374 public function checkActive(TokenProcessor $processor) {
375 return (!empty($processor->context['actionMapping'])
376 // This makes the 'schema context compulsory - which feels accidental
377 // since recent discu
378 && $processor->context['actionMapping']->getEntity()) || in_array($this->getEntityIDField(), $processor->context['schema']);
379 }
380
381 /**
382 * Alter action schedule query.
383 *
384 * @param \Civi\ActionSchedule\Event\MailingQueryEvent $e
385 */
386 public function alterActionScheduleQuery(MailingQueryEvent $e): void {
387 if ($e->mapping->getEntity() !== $this->getExtendableTableName()) {
388 return;
389 }
390 foreach ($this->getReturnFields() as $token) {
391 $e->query->select('e.' . $token . ' AS ' . $this->getEntityAlias() . $token);
392 }
393 }
394
395 /**
396 * Get tokens to be suppressed from the widget.
397 *
398 * Note this is expected to be an interim function. Now we are no
399 * longer working around the parent function we can just define them once...
400 * with metadata, in a future refactor.
401 */
402 protected function getHiddenTokens(): array {
403 return [];
404 }
405
406 /**
407 * Get tokens supporting the syntax we are migrating to.
408 *
409 * In general these are tokens that were not previously supported
410 * so we can add them in the preferred way or that we have
411 * undertaken some, as yet to be written, db update.
412 *
413 * See https://lab.civicrm.org/dev/core/-/issues/2650
414 *
415 * @return string[]
416 * @throws \API_Exception
417 */
418 public function getBasicTokens(): array {
419 $return = [];
420 foreach ($this->getExposedFields() as $fieldName) {
421 // Custom fields are still added v3 style - we want to keep v4 naming 'unpoluted'
422 // for now to allow us to consider how to handle names vs labels vs values
423 // and other raw vs not raw options.
424 if ($this->getFieldMetadata()[$fieldName]['type'] !== 'Custom') {
425 $return[$fieldName] = $this->getFieldMetadata()[$fieldName]['title'];
426 }
427 }
428 return $return;
429 }
430
431 /**
432 * Get entity fields that should be exposed as tokens.
433 *
434 * @return string[]
435 *
436 */
437 protected function getExposedFields(): array {
438 $return = [];
439 foreach ($this->getFieldMetadata() as $field) {
440 if (!in_array($field['name'], $this->getSkippedFields(), TRUE)) {
441 $return[] = $field['name'];
442 }
443 }
444 return $return;
445 }
446
447 /**
448 * Get entity fields that should not be exposed as tokens.
449 *
450 * @return string[]
451 */
452 public function getSkippedFields(): array {
453 // tags is offered in 'case' & is one of the only fields that is
454 // 'not a real field' offered up by case - seems like an oddity
455 // we should skip at the top level for now.
456 $fields = ['contact_id', 'tags'];
457 if (!CRM_Campaign_BAO_Campaign::isCampaignEnable()) {
458 $fields[] = 'campaign_id';
459 }
460 return $fields;
461 }
462
463 /**
464 * @return string
465 */
466 protected function getEntityName(): string {
467 return CRM_Core_DAO_AllCoreTables::convertEntityNameToLower($this->getApiEntityName());
468 }
469
470 public function getEntityIDField(): string {
471 return $this->getEntityName() . 'Id';
472 }
473
474 public function prefetch(TokenValueEvent $e): ?array {
475 $entityIDs = $e->getTokenProcessor()->getContextValues($this->getEntityIDField());
476 if (empty($entityIDs)) {
477 return [];
478 }
479 $select = $this->getPrefetchFields($e);
480 $result = (array) civicrm_api4($this->getApiEntityName(), 'get', [
481 'checkPermissions' => FALSE,
482 // Note custom fields are not yet added - I need to
483 // re-do the unit tests to support custom fields first.
484 'select' => $select,
485 'where' => [['id', 'IN', $entityIDs]],
486 ], 'id');
487 return $result;
488 }
489
490 public function getCurrencyFieldName() {
491 return [];
492 }
493
494 /**
495 * Get the currency to use for formatting money.
496 * @param $row
497 *
498 * @return string
499 */
500 public function getCurrency($row): string {
501 if (!empty($this->getCurrencyFieldName())) {
502 return $this->getFieldValue($row, $this->getCurrencyFieldName()[0]);
503 }
504 return CRM_Core_Config::singleton()->defaultCurrency;
505 }
506
507 /**
508 * Get the fields required to prefetch the entity.
509 *
510 * @param \Civi\Token\Event\TokenValueEvent $e
511 *
512 * @return array
513 * @throws \API_Exception
514 */
515 public function getPrefetchFields(TokenValueEvent $e): array {
516 $allTokens = array_keys($this->getAllTokens());
517 $requiredFields = array_intersect($this->getActiveTokens($e), $allTokens);
518 if (empty($requiredFields)) {
519 return [];
520 }
521 $requiredFields = array_merge($requiredFields, array_intersect($allTokens, array_merge(['id'], $this->getCurrencyFieldName())));
522 foreach ($this->getDependencies() as $field => $required) {
523 if (in_array($field, $this->getActiveTokens($e), TRUE)) {
524 foreach ((array) $required as $key) {
525 $requiredFields[] = $key;
526 }
527 }
528 }
529 return $requiredFields;
530 }
531
532 /**
533 * Get fields which need to be returned to render another token.
534 *
535 * @return array
536 */
537 public function getDependencies(): array {
538 return [];
539 }
540
541 /**
542 * Get the apiv4 style custom field name.
543 *
544 * @param int $id
545 *
546 * @return string
547 */
548 protected function getCustomFieldName(int $id): string {
549 foreach ($this->getFieldMetadata() as $key => $field) {
550 if (($field['custom_field_id'] ?? NULL) === $id) {
551 return $key;
552 }
553 }
554 }
555
556 /**
557 * @param $entityID
558 * @param string $field eg. 'custom_1'
559 *
560 * @return array|string|void|null $mixed
561 *
562 * @throws \CRM_Core_Exception
563 */
564 protected function getCustomFieldValue($entityID, string $field) {
565 $id = str_replace('custom_', '', $field);
566 $value = $this->prefetch[$entityID][$this->getCustomFieldName($id)] ?? NULL;
567 if ($value !== NULL) {
568 return CRM_Core_BAO_CustomField::displayValue($value, $id);
569 }
570 }
571
572 }