Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow other base tables for api4-based smart groups #17003

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 22 additions & 22 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 Down Expand Up @@ -715,50 +715,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 @@ -802,11 +803,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;

/**
* @var array
*/
Expand All @@ -82,6 +84,8 @@ public function __construct($apiGet) {
$this->limit = $apiGet->getLimit();
$this->offset = $apiGet->getOffset();
$this->having = $apiGet->getHaving();
// 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 @@ -158,8 +162,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 @@ -752,7 +752,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 @@ -761,6 +762,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 @@ -771,10 +781,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