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