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