Skip to content

Commit

Permalink
Allow other base tables for api4-based smart groups
Browse files Browse the repository at this point in the history
  • Loading branch information
colemanw committed Apr 7, 2020
1 parent 7fe7cfa commit 2b83dfa
Show file tree
Hide file tree
Showing 5 changed files with 75 additions and 32 deletions.
48 changes: 23 additions & 25 deletions CRM/Contact/BAO/GroupContactCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -461,13 +461,17 @@ public static function load(&$group, $force = FALSE) {
$customClass = NULL;
if ($savedSearchID) {
$ssParams = CRM_Contact_BAO_SavedSearch::getSearchParams($savedSearchID);
$groupID = CRM_Utils_Type::escape($groupID, 'Integer');

$excludeClause = "NOT IN (
SELECT contact_id FROM civicrm_group_contact
WHERE civicrm_group_contact.status = 'Removed'
AND civicrm_group_contact.group_id = $groupID )";

if (!empty($ssParams['api_entity'])) {
$mainCol = 'a';
$sql = self::getApiSQL($savedSearchID, $ssParams);
$sql = self::getApiSQL($ssParams, $excludeClause);
}
else {
$mainCol = 'contact_a';
// CRM-7021 rectify params to what proximity search expects if there is a value for prox_distance
if (!empty($ssParams)) {
CRM_Contact_BAO_ProximityQuery::fixInputParams($ssParams);
Expand All @@ -478,12 +482,8 @@ public static function load(&$group, $force = FALSE) {
else {
$sql = self::getQueryObjectSQL($savedSearchID, $ssParams);
}
$sql['from'] .= " AND contact_a.id $excludeClause";
}
$groupID = CRM_Utils_Type::escape($groupID, 'Integer');
$sql['from'] .= " AND $mainCol.id NOT IN (
SELECT contact_id FROM civicrm_group_contact
WHERE civicrm_group_contact.status = 'Removed'
AND civicrm_group_contact.group_id = $groupID ) ";
}

if (!empty($sql['select'])) {
Expand All @@ -508,9 +508,7 @@ public static function load(&$group, $force = FALSE) {
if (empty($contactQuery['select']) || empty($contactQuery['from'])) {
continue;
}
if (CRM_Core_DAO::singleValueQuery("SELECT COUNT(*) {$contactQuery['from']}") > 0) {
CRM_Core_DAO::executeQuery("INSERT IGNORE INTO $tempTable (group_id, contact_id) {$contactQuery['select']} {$contactQuery['from']}");
}
CRM_Core_DAO::executeQuery("INSERT IGNORE INTO $tempTable (group_id, contact_id) {$contactQuery['select']} {$contactQuery['from']}");
}

if ($group->children) {
Expand Down Expand Up @@ -718,50 +716,51 @@ public static function invalidateGroupContactCache($groupID) {
}

/**
* @param $savedSearchID
* @param array $savedSearch
* @param string $excludeClause
* @return array
* @throws API_Exception
* @throws \Civi\API\Exception\NotImplementedException
* @throws CRM_Core_Exception
*/
protected static function getApiSQL($savedSearchID, array $savedSearch): array {
$apiParams = ['select' => ['id'], 'checkPermissions' => FALSE] + $savedSearch['api_params'];
protected static function getApiSQL(array $savedSearch, string $excludeClause): array {
$apiParams = $savedSearch['api_params'] + ['select' => ['id'], 'checkPermissions' => FALSE];
list($select) = explode(' AS ', $apiParams['select'][0]);
$apiParams['select'][0] = $select . ' AS smart_group_contact_id';
$api = \Civi\API\Request::create($savedSearch['api_entity'], 'get', $apiParams);
$query = new \Civi\Api4\Query\Api4SelectQuery($api);
$query->forceSelectId = FALSE;
$query->getQuery()->having('smart_group_contact_id ' . $excludeClause);
$sql = $query->getSql();
return [
'select' => substr($sql, 0, strpos($sql, 'FROM')),
'from' => substr($sql, strpos($sql, 'FROM')),
'select' => substr($sql, 0, strpos($sql, "\nFROM ")),
'from' => substr($sql, strpos($sql, "\nFROM ")),
];
}

/**
* Get sql from a custom search.
*
* We split it up and store custom class
* so temp tables are not destroyed if they are used
*
* @param int $savedSearchID
* @param array $ssParams
*
* @return array
* @throws \Exception
*/
protected static function getCustomSearchSQL($savedSearchID, array $ssParams): array {
// if custom search

// we split it up and store custom class
// so temp tables are not destroyed if they are used
// hence customClass is defined above at top of function
$customClass = CRM_Contact_BAO_SearchCustom::customClass($ssParams['customSearchID'], $savedSearchID);
$searchSQL = $customClass->contactIDs();
$searchSQL = str_replace('ORDER BY contact_a.id ASC', '', $searchSQL);
if (strpos($searchSQL, 'WHERE') === FALSE) {
$searchSQL .= " WHERE ( 1 ) ";
}
$sql = [
return [
'select' => substr($searchSQL, 0, strpos($searchSQL, 'FROM')),
'from' => substr($searchSQL, strpos($searchSQL, 'FROM')),
];
return $sql;
}

/**
Expand Down Expand Up @@ -805,11 +804,10 @@ protected static function getQueryObjectSQL($savedSearchID, array $ssParams): ar
FALSE, FALSE,
FALSE, TRUE
);
$sql = [
return [
'select' => $sqlParts['select'],
'from' => "{$sqlParts['from']} {$sqlParts['where']} {$sqlParts['having']} {$sqlParts['group_by']}",
];
return $sql;
}

}
7 changes: 5 additions & 2 deletions Civi/Api4/Query/Api4SelectQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ class Api4SelectQuery extends SelectQuery {
*/
public $groupBy = [];

public $forceSelectId = TRUE;

/**
* @param \Civi\Api4\Generic\DAOGetAction $apiGet
*/
Expand All @@ -76,6 +78,8 @@ public function __construct($apiGet) {
$this->orderBy = $apiGet->getOrderBy();
$this->limit = $apiGet->getLimit();
$this->offset = $apiGet->getOffset();
// Always select ID of main table unless grouping is used
$this->forceSelectId = !$this->groupBy;
if ($apiGet->getDebug()) {
$this->debugOutput =& $apiGet->_debugOutput;
}
Expand Down Expand Up @@ -151,8 +155,7 @@ protected function buildSelectClause() {
return;
}
else {
// Always select ID (unless we're doing groupBy).
if (!$this->groupBy) {
if ($this->forceSelectId) {
$this->select = array_merge(['id'], $this->select);
}

Expand Down
2 changes: 1 addition & 1 deletion ang/api4Explorer/Explorer.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ <h1 crm-page-title>
</span>
<input class="form-control api4-index" type="search" ng-model="index" ng-mouseenter="help('index', paramDoc('$index'))" ng-mouseleave="help()" placeholder="{{ ts('Index') }}" />
<button class="btn btn-success pull-right" crm-icon="fa-bolt" ng-disabled="!entity || !action || loading" ng-click="execute()" ng-mouseenter="help(ts('Execute'), executeDoc())" ng-mouseleave="help()">{{ ts('Execute') }}</button>
<button class="btn btn-primary pull-right" crm-icon="fa-save" ng-show="perm.editGroups && entity === 'Contact' && action === 'get'" ng-click="save()" ng-mouseenter="help(ts('Save smart group'), saveDoc())" ng-mouseleave="help()">{{ ts('Save...') }}</button>
<button class="btn btn-primary pull-right" crm-icon="fa-save" ng-show="perm.editGroups && action === 'get'" ng-click="save()" ng-mouseenter="help(ts('Save smart group'), saveDoc())" ng-mouseleave="help()">{{ ts('Save...') }}</button>
</div>
</div>
<div class="panel-body">
Expand Down
15 changes: 13 additions & 2 deletions ang/api4Explorer/Explorer.js
Original file line number Diff line number Diff line change
Expand Up @@ -703,7 +703,8 @@
$scope.saveDoc = function() {
return {
description: ts('Save API call as a smart group.'),
comment: ts('Allows you to create a SavedSearch containing the WHERE clause of this API call.'),
comment: ts('Create a SavedSearch using these API params to populate a smart group.') +
'\n\n' + ts('NOTE: you must select contact id as the only field.')
};
};

Expand All @@ -712,6 +713,15 @@
writeCode();

$scope.save = function() {
$scope.params.limit = $scope.params.offset = 0;
if ($scope.params.chain.length) {
CRM.alert(ts('Smart groups are not compatible with API chaining.'), ts('Error'), 'error', {expires: 5000});
return;
}
if ($scope.params.select.length !== 1 || !_.includes($scope.params.select[0], 'id')) {
CRM.alert(ts('To create a smart group, the API must select contact id and no other fields.'), ts('Error'), 'error', {expires: 5000});
return;
}
var model = {
title: '',
description: '',
Expand All @@ -722,10 +732,11 @@
params: JSON.parse(angular.toJson($scope.params))
};
model.params.version = 4;
delete model.params.select;
delete model.params.chain;
delete model.params.debug;
delete model.params.limit;
delete model.params.offset;
delete model.params.orderBy;
delete model.params.checkPermissions;
var options = CRM.utils.adjustDialogDefaults({
width: '500px',
Expand Down
35 changes: 33 additions & 2 deletions tests/phpunit/api/v4/Entity/SavedSearchTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@

use api\v4\UnitTestCase;
use Civi\Api4\Contact;
use Civi\Api4\Email;

/**
* @group headless
*/
class SavedSearchTest extends UnitTestCase {

public function testApi4SmartGroup() {
public function testContactSmartGroup() {
$in = Contact::create()->setCheckPermissions(FALSE)->addValue('first_name', 'yes')->addValue('do_not_phone', TRUE)->execute()->first();
$out = Contact::create()->setCheckPermissions(FALSE)->addValue('first_name', 'no')->addValue('do_not_phone', FALSE)->execute()->first();

Expand All @@ -44,12 +45,42 @@ public function testApi4SmartGroup() {
],
],
'chain' => [
'group' => ['Group', 'create', ['values' => ['title' => 'Hello Test', 'saved_search_id' => '$id']], 0],
'group' => ['Group', 'create', ['values' => ['title' => 'Contact Test', 'saved_search_id' => '$id']], 0],
],
])->first();

// Oops we don't have an api4 syntax yet for selecting contacts in a group.
$ins = civicrm_api3('Contact', 'get', ['group' => $savedSearch['group']['name'], 'options' => ['limit' => 0]]);
$this->assertEquals(1, count($ins['values']));
$this->assertArrayHasKey($in['id'], $ins['values']);
$this->assertArrayNotHasKey($out['id'], $ins['values']);
}

public function testEmailSmartGroup() {
$in = Contact::create()->setCheckPermissions(FALSE)->addValue('first_name', 'yep')->execute()->first();
$out = Contact::create()->setCheckPermissions(FALSE)->addValue('first_name', 'nope')->execute()->first();
$email = uniqid() . '@' . uniqid();
Email::create()->setCheckPermissions(FALSE)->addValue('email', $email)->addValue('contact_id', $in['id'])->execute();

$savedSearch = civicrm_api4('SavedSearch', 'create', [
'values' => [
'api_entity' => 'Email',
'api_params' => [
'version' => 4,
'select' => ['contact_id'],
'where' => [
['email', '=', $email],
],
],
],
'chain' => [
'group' => ['Group', 'create', ['values' => ['title' => 'Email Test', 'saved_search_id' => '$id']], 0],
],
])->first();

// Oops we don't have an api4 syntax yet for selecting contacts in a group.
$ins = civicrm_api3('Contact', 'get', ['group' => $savedSearch['group']['name'], 'options' => ['limit' => 0]]);
$this->assertEquals(1, count($ins['values']));
$this->assertArrayHasKey($in['id'], $ins['values']);
$this->assertArrayNotHasKey($out['id'], $ins['values']);
}
Expand Down

0 comments on commit 2b83dfa

Please sign in to comment.