/**
* @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;
}
$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();
$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') {
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;
+ }
+
}
<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>
</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>
// 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) {
$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')
$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')