TokenProcessor - Allow defining Smarty variables which are opulated from tokens
authorTim Otten <totten@civicrm.org>
Wed, 1 Sep 2021 04:21:41 +0000 (21:21 -0700)
committerTim Otten <totten@civicrm.org>
Wed, 1 Sep 2021 06:17:31 +0000 (23:17 -0700)
Overview
--------

This allow more interoperability between Smarty expressions and tokens.  For
example, suppose one had a contribution-related message that could use the
Smarty variable `$theInvoiceId` and/or the token `{contribution.invoice_id}`.
This revision allows the Smarty variable to function as an alias for the token.

Before
------

The caller would need to precompute Smarty values, eg

```php
$theInvoiceId = civicrm_api4('Contribution', 'get', [
  'select' => 'invoice_id',
  'where' => [['id', '=', $contributionId]]
]);
$p = new TokenProcessor($this->dispatcher, [
  'controller' => __CLASS__,
  'schema' => ['contributionId'],
  'smarty' => TRUE,
]);
$p->addMessage('example', 'Invoice #{$theInvoiceId}!', 'text/plain');
$p->addRow(['contributionId' => 123]);
```

After
-----

The caller can declare a Smarty=>Token alias and leverage token data-loader.

```php
$p = new TokenProcessor($this->dispatcher, [
  'controller' => __CLASS__,
  'schema' => ['contributionId'],
  'smarty' => TRUE,
  'smartyTokenAlias' => [
    'theInvoiceId' => 'contribution.invoice_id',
  ],
]);
$p->addMessage('example', 'Invoice #{$theInvoiceId}!', 'text/plain');
$p->addRow(['contributionId' => 123]);
```

Comments
--------

The target token must be populated via `civi.token.eval` (e.g `$e->token('foo', 'bar', 'value')`).
This would work with `CRM_*_Tokens`.  But if the token is evaluted by other means (eg
`CRM_Utils_Token::replaceGreetingTokens()`), then it won't currently be resolved.

Civi/Token/TokenCompatSubscriber.php
Civi/Token/TokenProcessor.php
tests/phpunit/Civi/Token/TokenProcessorTest.php

index 13a1136460cd86981c7211c1f6a7dbdc967a604f..9d745c14890829a1d62df951846604f9d4542a7e 100644 (file)
@@ -25,11 +25,37 @@ class TokenCompatSubscriber implements EventSubscriberInterface {
    */
   public static function getSubscribedEvents() {
     return [
-      'civi.token.eval' => 'onEvaluate',
+      'civi.token.eval' => [
+        ['setupSmartyAliases', 1000],
+        ['onEvaluate'],
+      ],
       'civi.token.render' => 'onRender',
     ];
   }
 
+  /**
+   * Interpret the variable `$context['smartyTokenAlias']` (e.g. `mySmartyField' => `tkn_entity.tkn_field`).
+   *
+   * We need to ensure that any tokens like `{tkn_entity.tkn_field}` are hydrated, so
+   * we pretend that they are in use.
+   *
+   * @param \Civi\Token\Event\TokenValueEvent $e
+   */
+  public function setupSmartyAliases(TokenValueEvent $e) {
+    $aliasedTokens = [];
+    foreach ($e->getRows() as $row) {
+      $aliasedTokens = array_unique(array_merge($aliasedTokens,
+        array_values($row->context['smartyTokenAlias'] ?? [])));
+    }
+
+    $fakeMessage = implode('', array_map(function ($f) {
+      return '{' . $f . '}';
+    }, $aliasedTokens));
+
+    $proc = $e->getTokenProcessor();
+    $proc->addMessage('TokenCompatSubscriber.aliases', $fakeMessage, 'text/plain');
+  }
+
   /**
    * Load token data.
    *
@@ -130,7 +156,19 @@ class TokenCompatSubscriber implements EventSubscriberInterface {
     }
 
     if ($useSmarty) {
-      $e->string = \CRM_Utils_String::parseOneOffStringThroughSmarty($e->string);
+      $smartyVars = [];
+      foreach ($e->context['smartyTokenAlias'] ?? [] as $smartyName => $tokenName) {
+        // Note: $e->row->tokens resolves event-based tokens (eg CRM_*_Tokens). But if the target token relies on the
+        // above bits (replaceGreetingTokens=>replaceContactTokens=>replaceHookTokens) then this lookup isn't sufficient.
+        $smartyVars[$smartyName] = \CRM_Utils_Array::pathGet($e->row->tokens, explode('.', $tokenName));
+      }
+      \CRM_Core_Smarty::singleton()->pushScope($smartyVars);
+      try {
+        $e->string = \CRM_Utils_String::parseOneOffStringThroughSmarty($e->string);
+      }
+      finally {
+        \CRM_Core_Smarty::singleton()->popScope();
+      }
     }
   }
 
index d434a1a13919e1c95e2786c39c4760f8783a8049..61438d20921af6cc84acfdda807888bbab2f04e9 100644 (file)
@@ -49,6 +49,8 @@ class TokenProcessor {
    *
    *   - controller: string, the class which is managing the mail-merge.
    *   - smarty: bool, whether to enable smarty support.
+   *   - smartyTokenAlias: array, Define Smarty variables that are populated
+   *      based on token-content. Ex: ['theInvoiceId' => 'contribution.invoice_id']
    *   - contactId: int, the main person/org discussed in the message.
    *   - contact: array, the main person/org discussed in the message.
    *     (Optional for performance tweaking; if omitted, will load
index 2a6d3f7e54541ab90a3fac2f06a5b4cdf51acb20..26e0da7e7fc2477060f93e110c873fcdc7eb6e87 100644 (file)
@@ -415,4 +415,72 @@ class TokenProcessorTest extends \CiviUnitTestCase {
     $this->assertEquals(2, $loops);
   }
 
+  /**
+   * This defines a compatibility mechanism wherein an old Smarty expression can
+   * be evaluated based on a newer token expression.
+   *
+   * Ex: $tokenContext['oldSmartyVar'] = 'new_entity.new_field';
+   */
+  public function testSmartyTokenAlias_Contribution() {
+    $first = $this->contributionCreate(['contact_id' => $this->individualCreate(), 'receive_date' => '2010-01-01', 'invoice_id' => 100, 'trxn_id' => 1000]);
+    $second = $this->contributionCreate(['contact_id' => $this->individualCreate(), 'receive_date' => '2011-02-02', 'invoice_id' => 200, 'trxn_id' => 1]);
+    $this->dispatcher->addSubscriber(new TokenCompatSubscriber());
+    $this->dispatcher->addSubscriber(new \CRM_Contribute_Tokens());
+
+    $p = new TokenProcessor($this->dispatcher, [
+      'controller' => __CLASS__,
+      'schema' => ['contributionId'],
+      'smarty' => TRUE,
+      'smartyTokenAlias' => [
+        'theInvoiceId' => 'contribution.invoice_id',
+      ],
+    ]);
+    $p->addMessage('example', 'Invoice #{$theInvoiceId}!', 'text/plain');
+    $p->addRow(['contributionId' => $first]);
+    $p->addRow(['contributionId' => $second]);
+    $p->evaluate();
+
+    $outputs = [];
+    foreach ($p->getRows() as $row) {
+      $outputs[] = $row->render('example');
+    }
+    $this->assertEquals('Invoice #100!', $outputs[0]);
+    $this->assertEquals('Invoice #200!', $outputs[1]);
+  }
+
+  ///**
+  // * This defines a compatibility mechanism wherein an old Smarty expression can
+  // * be evaluated based on a newer token expression.
+  // *
+  // * The following example doesn't work because the handling of greeting+contact
+  // * tokens still use a special override (TokenCompatSubscriber::onRender).
+  // *
+  // * Ex: $tokenContext['oldSmartyVar'] = 'new_entity.new_field';
+  // */
+  //  public function testSmartyTokenAlias_Contact() {
+  //    $alice = $this->individualCreate(['first_name' => 'Alice']);
+  //    $bob = $this->individualCreate(['first_name' => 'Bob']);
+  //    $this->dispatcher->addSubscriber(new TokenCompatSubscriber());
+  //
+  //    $p = new TokenProcessor($this->dispatcher, [
+  //      'controller' => __CLASS__,
+  //      'schema' => ['contactId'],
+  //      'smarty' => TRUE,
+  //      'smartyTokenAlias' => [
+  //        'myFirstName' => 'contact.first_name',
+  //      ],
+  //    ]);
+  //    $p->addMessage('example', 'Hello {$myFirstName}!', 'text/plain');
+  //    $p->addRow(['contactId' => $alice]);
+  //    $p->addRow(['contactId' => $bob]);
+  //    $p->evaluate();
+  //
+  //    $outputs = [];
+  //    foreach ($p->getRows() as $row) {
+  //      $outputs[] = $row->render('example');
+  //    }
+  //    $this->assertEquals('Hello Alice!', $outputs[0]);
+  //    $this->assertEquals('Hello Bob!', $outputs[1]);
+  //  }
+
 }