SearchKit - Add "EXCLUDE" join type, to search for entities that do not have a relati...
authorColeman Watts <coleman@civicrm.org>
Sun, 21 Mar 2021 20:32:54 +0000 (16:32 -0400)
committerColeman Watts <coleman@civicrm.org>
Sun, 21 Mar 2021 21:37:27 +0000 (17:37 -0400)
Civi/Api4/Generic/DAOGetAction.php
Civi/Api4/Query/Api4SelectQuery.php
ang/api4Explorer/Explorer.js
ext/search/CRM/Search/Upgrader.php
ext/search/ang/crmSearchAdmin/compose/criteria.html
ext/search/ang/crmSearchAdmin/crmSearchAdmin.component.js
tests/phpunit/api/v4/Action/FkJoinTest.php

index 055eb72370715efab6280adaa71535b4ac731e2a..778cc3de40b71bbaa387febff1ee0edb95fd66d3 100644 (file)
@@ -164,16 +164,16 @@ class DAOGetAction extends AbstractGetAction {
 
   /**
    * @param string $entity
-   * @param bool $required
+   * @param string|bool $type
    * @param string $bridge
    * @param array ...$conditions
    * @return DAOGetAction
    */
-  public function addJoin(string $entity, bool $required = FALSE, $bridge = NULL, ...$conditions): DAOGetAction {
+  public function addJoin(string $entity, $type = 'LEFT', $bridge = NULL, ...$conditions): DAOGetAction {
     if ($bridge) {
       array_unshift($conditions, $bridge);
     }
-    array_unshift($conditions, $entity, $required);
+    array_unshift($conditions, $entity, $type);
     $this->join[] = $conditions;
     return $this;
   }
index c97d07fb78d570ea195b18eb6d2b86f899c4d625..efaba5c29645878f93b87fc652f01e1e9d9dbb54 100644 (file)
@@ -595,7 +595,18 @@ class Api4SelectQuery {
       $alias = $alias ? \CRM_Utils_String::munge($alias, '_', 256) : strtolower($entity);
       // First item in the array is a boolean indicating if the join is required (aka INNER or LEFT).
       // The rest are join conditions.
-      $side = array_shift($join) ? 'INNER' : 'LEFT';
+      $side = array_shift($join);
+      // If omitted, supply default (LEFT); and legacy support for boolean values
+      if (!is_string($side)) {
+        $side = $side ? 'INNER' : 'LEFT';
+      }
+      if (!in_array($side, ['INNER', 'LEFT', 'EXCLUDE'])) {
+        throw new \API_Exception("Illegal value for join side: '$side'.");
+      }
+      if ($side === 'EXCLUDE') {
+        $side = 'LEFT';
+        $this->api->addWhere("$alias.id", 'IS NULL');
+      }
       // Add all fields from joined entity to spec
       $joinEntityGet = \Civi\API\Request::create($entity, 'get', ['version' => 4, 'checkPermissions' => $this->getCheckPermissions()]);
       $joinEntityFields = $joinEntityGet->entityFields();
index 87ad2fe32f2bd44422e35ec26088be77533019aa..f1918b7e40c8df2c8dd82978f8b90616f45ff277 100644 (file)
@@ -56,7 +56,7 @@
     $scope.loading = false;
     $scope.controls = {};
     $scope.langs = ['php', 'js', 'ang', 'cli'];
-    $scope.joinTypes = [{k: false, v: 'FALSE (LEFT JOIN)'}, {k: true, v: 'TRUE (INNER JOIN)'}];
+    $scope.joinTypes = [{k: 'LEFT', v: 'LEFT JOIN'}, {k: 'INNER', v: 'INNER JOIN'}, {k: 'EXCLUDE', v: 'EXCLUDE'}];
     $scope.bridgeEntities = _.filter(schema, function(entity) {return _.includes(entity.type, 'EntityBridge');});
     $scope.code = {
       php: [
               $timeout(function() {
                 if (field) {
                   if (name === 'join') {
-                    $scope.params[name].push([field + ' AS ' + _.snakeCase(field), false]);
+                    $scope.params[name].push([field + ' AS ' + _.snakeCase(field), 'LEFT']);
                     ctrl.buildFieldList();
                   }
                   else if (typeof objectParams[name] === 'undefined') {
index f8fbc76f22b9bc2222fbfda666b31496dfe59a62..3f6de4d5eea7139877b9977cdb41c362a4e6298c 100644 (file)
@@ -110,4 +110,27 @@ class CRM_Search_Upgrader extends CRM_Search_Upgrader_Base {
     return TRUE;
   }
 
+  /**
+   * Upgrade 1003 - update APIv4 join syntax in saved searches
+   * @return bool
+   */
+  public function upgrade_1003() {
+    $this->ctx->log->info('Applying 1003 - update APIv4 join syntax in saved searches.');
+    $savedSearches = \Civi\Api4\SavedSearch::get(FALSE)
+      ->addSelect('id', 'api_params')
+      ->addWhere('api_params', 'IS NOT NULL')
+      ->execute();
+    foreach ($savedSearches as $savedSearch) {
+      foreach ($savedSearch['api_params']['join'] ?? [] as $i => $join) {
+        $savedSearch['api_params']['join'][$i][1] = empty($join[1]) ? 'LEFT' : 'INNER';
+      }
+      if (!empty($savedSearch['api_params']['join'])) {
+        \Civi\Api4\SavedSearch::update(FALSE)
+          ->setValues($savedSearch)
+          ->execute();
+      }
+    }
+    return TRUE;
+  }
+
 }
index 3185d4cb8613734a75a69603d997f60f02e14a4e..7eb92ef116171e3f4ff83adfdfeb523b98478a26 100644 (file)
@@ -3,9 +3,8 @@
     <div ng-if=":: $ctrl.paramExists('join')">
       <fieldset ng-repeat="join in $ctrl.savedSearch.api_params.join">
         <div class="form-inline">
-          <label for="crm-search-join-{{ $index }}">{{:: ts('With') }}</label>
+          <select class="form-control" ng-model="join[1]" ng-change="$ctrl.changeJoinType(join)" ng-options="o.k as o.v for o in ::joinTypes" ></select>
           <input id="crm-search-join-{{ $index }}" class="form-control huge" ng-model="join[0]" crm-ui-select="{placeholder: ' ', data: getJoinEntities}" disabled >
-          <select class="form-control" ng-model="join[1]" ng-options="o.k as o.v for o in ::joinTypes" ></select>
           <button type="button" class="btn btn-xs btn-danger-outline" ng-click="$ctrl.removeJoin($index)" title="{{:: ts('Remove join') }}">
             <i class="crm-i fa-trash" aria-hidden="true"></i>
           </button>
@@ -16,7 +15,8 @@
       </fieldset>
       <fieldset>
         <div class="form-inline">
-          <input id="crm-search-add-join" class="form-control crm-action-menu fa-plus huge" ng-model="controls.join" crm-ui-select="{placeholder: ts('With'), data: getJoinEntities}" ng-change="addJoin()"/>
+          <select class="form-control" ng-model="controls.joinType" ng-options="o.k as o.v for o in ::joinTypes" ></select>
+          <input id="crm-search-add-join" class="form-control crm-action-menu fa-plus huge" ng-model="controls.join" crm-ui-select="{placeholder: ts('Entity'), data: getJoinEntities}" ng-change="addJoin()"/>
         </div>
       </fieldset>
     </div>
index a26a5b4066ab309e8e6b61791818b0cd56050396..feac4a41e15dd2db18b16c4cc530e1c975e049a1 100644 (file)
       // Have the filters (WHERE, HAVING, GROUP BY, JOIN) changed?
       this.stale = true;
 
-      $scope.controls = {tab: 'compose'};
-      $scope.joinTypes = [{k: false, v: ts('Optional')}, {k: true, v: ts('Required')}];
+      $scope.controls = {tab: 'compose', joinType: 'LEFT'};
+      $scope.joinTypes = [
+        {k: 'LEFT', v: ts('With (optional)')},
+        {k: 'INNER', v: ts('With (required)')},
+        {k: 'EXCLUDE', v: ts('Without')},
+      ];
       // Try to create a sensible list of entities one might want to search for,
       // excluding those whos primary purpose is to provide joins or option lists to other entities
       var primaryEntities = _.filter(CRM.crmSearchAdmin.schema, function(entity) {
             ctrl.savedSearch.api_params.join = ctrl.savedSearch.api_params.join || [];
             var join = searchMeta.getJoin($scope.controls.join),
               entity = searchMeta.getEntity(join.entity),
-              params = [$scope.controls.join, false];
+              params = [$scope.controls.join, $scope.controls.joinType || 'LEFT'];
             _.each(_.cloneDeep(join.conditions), function(condition) {
               params.push(condition);
             });
               params.push(condition);
             });
             ctrl.savedSearch.api_params.join.push(params);
-            if (entity.label_field) {
+            if (entity.label_field && $scope.controls.joinType !== 'EXCLUDE') {
               ctrl.savedSearch.api_params.select.push(join.alias + '.' + entity.label_field);
             }
             loadFieldOptions();
       this.removeJoin = function(index) {
         var alias = searchMeta.getJoin(ctrl.savedSearch.api_params.join[index][0]).alias;
         ctrl.clearParam('join', index);
+        removeJoinStuff(alias);
+      };
+
+      function removeJoinStuff(alias) {
         _.remove(ctrl.savedSearch.api_params.select, function(item) {
           var pattern = new RegExp('\\b' + alias + '\\.');
           return pattern.test(item.split(' AS ')[0]);
           return clauseUsesJoin(clause, alias);
         });
         _.eachRight(ctrl.savedSearch.api_params.join, function(item, i) {
-          if (searchMeta.getJoin(item[0]).alias.indexOf(alias) === 0) {
+          var joinAlias = searchMeta.getJoin(item[0]).alias;
+          if (joinAlias !== alias && joinAlias.indexOf(alias) === 0) {
             ctrl.removeJoin(i);
           }
         });
+      }
+
+      this.changeJoinType = function(join) {
+        if (join[1] === 'EXCLUDE') {
+          removeJoinStuff(searchMeta.getJoin(join[0]).alias);
+        }
       };
 
       $scope.changeGroupBy = function(idx) {
index faed114075b0a7ba9fe2649fab001018862c8f50..fe5f4e8363642f66b87a57d51f96eac561be2086 100644 (file)
@@ -113,6 +113,14 @@ class FkJoinTest extends UnitTestCase {
     $this->assertEquals('US', $contacts[0]['address.country.iso_code']);
   }
 
+  public function testExcludeJoin() {
+    $contacts = Contact::get(FALSE)
+      ->addJoin('Address AS address', 'EXCLUDE', ['id', '=', 'address.contact_id'], ['address.location_type_id', '=', 1])
+      ->addSelect('id')
+      ->execute()->column('id');
+    $this->assertNotContains($this->getReference('test_contact_1')['id'], $contacts);
+  }
+
   public function testJoinToTheSameTableTwice() {
     $cid1 = Contact::create(FALSE)
       ->addValue('first_name', 'Aaa')
@@ -137,8 +145,8 @@ class FkJoinTest extends UnitTestCase {
     $contacts = Contact::get(FALSE)
       ->addSelect('id', 'first_name', 'any_email.email', 'any_email.location_type_id:name', 'any_email.is_primary', 'primary_email.email')
       ->setJoin([
-        ['Email AS any_email', TRUE, NULL],
-        ['Email AS primary_email', FALSE, ['primary_email.is_primary', '=', TRUE]],
+        ['Email AS any_email', 'INNER', NULL],
+        ['Email AS primary_email', 'LEFT', ['primary_email.is_primary', '=', TRUE]],
       ])
       ->addWhere('id', 'IN', [$cid1, $cid2, $cid3])
       ->addOrderBy('any_email.id')