Skip to content
Snippets Groups Projects
Unverified Commit 2beecf9e authored by colemanw's avatar colemanw Committed by GitHub
Browse files

Merge pull request #20764 from colemanw/multiContactRefJoins

SearchKit - Add ability to join on multi-select ContactRef fields
parents a1a47673 96ff473c
Branches
Tags
No related merge requests found
Showing
with 310 additions and 67 deletions
......@@ -224,7 +224,10 @@ trait DAOActionTrait {
formatCheckBoxField($value, 'custom_' . $field['id'], $this->getEntityName());
}
if ($field['data_type'] === 'ContactReference' && !is_numeric($value)) {
// Match contact id to strings like "user_contact_id"
// FIXME handle arrays for multi-value contact reference fields, etc.
if ($field['data_type'] === 'ContactReference' && is_string($value) && !is_numeric($value)) {
// FIXME decouple from v3 API
require_once 'api/v3/utils.php';
$value = \_civicrm_api3_resolve_contactID($value);
if ('unknown-user' === $value) {
......
......@@ -990,6 +990,7 @@ class Api4SelectQuery {
return;
}
$lastLink = array_pop($joinPath);
$previousLink = array_pop($joinPath);
// Custom field names are already prefixed
$isCustom = $lastLink instanceof CustomGroupJoinable;
......@@ -1000,7 +1001,15 @@ class Api4SelectQuery {
// Cache field info for retrieval by $this->getField()
foreach ($lastLink->getEntityFields() as $fieldObject) {
$fieldArray = $fieldObject->toArray();
$fieldArray['sql_name'] = '`' . $lastLink->getAlias() . '`.`' . $fieldArray['column_name'] . '`';
// Set sql name of field, using column name for real joins
if (!$lastLink->getSerialize()) {
$fieldArray['sql_name'] = '`' . $lastLink->getAlias() . '`.`' . $fieldArray['column_name'] . '`';
}
// For virtual joins on serialized fields, the callback function will need the sql name of the serialized field
// @see self::renderSerializedJoin()
else {
$fieldArray['sql_name'] = '`' . $previousLink->getAlias() . '`.`' . $lastLink->getBaseColumn() . '`';
}
$this->addSpecField($prefix . $fieldArray['name'], $fieldArray);
}
}
......@@ -1019,6 +1028,27 @@ class Api4SelectQuery {
}
}
/**
* Performs a virtual join with a serialized field using FIND_IN_SET
*
* @param array $field
* @return string
*/
public static function renderSerializedJoin(array $field): string {
$sep = \CRM_Core_DAO::VALUE_SEPARATOR;
$id = CoreUtil::getInfoItem($field['entity'], 'primary_key')[0];
$searchFn = "FIND_IN_SET(`{$field['table_name']}`.`$id`, REPLACE({$field['sql_name']}, '$sep', ','))";
return "(
SELECT GROUP_CONCAT(
`{$field['column_name']}`
ORDER BY $searchFn
SEPARATOR '$sep'
)
FROM `{$field['table_name']}`
WHERE $searchFn
)";
}
/**
* @return FALSE|string
*/
......
......@@ -70,6 +70,11 @@ class Joinable {
*/
protected $entity;
/**
* @var int
*/
protected $serialize;
/**
* @var bool
*/
......@@ -231,6 +236,24 @@ class Joinable {
return $this;
}
/**
* @return int|NULL
*/
public function getSerialize():? int {
return $this->serialize;
}
/**
* @param int|NULL $serialize
*
* @return $this
*/
public function setSerialize(?int $serialize) {
$this->serialize = $serialize;
return $this;
}
/**
* @return int
*/
......@@ -280,6 +303,14 @@ class Joinable {
/** @var \Civi\Api4\Service\Spec\SpecGatherer $gatherer */
$gatherer = \Civi::container()->get('spec_gatherer');
$spec = $gatherer->getSpec($this->entity, 'get', FALSE);
// Serialized fields require a specialized join
if ($this->serialize) {
foreach ($spec as $field) {
// The callback function expects separated values as output
$field->setSerialize(\CRM_Core_DAO::SERIALIZE_SEPARATOR_TRIMMED);
$field->setSqlRenderer(['Civi\Api4\Query\Api4SelectQuery', 'renderSerializedJoin']);
}
}
return $spec;
}
......
......@@ -73,6 +73,11 @@ class Joiner {
if ($link->isDeprecated()) {
\CRM_Core_Error::deprecatedWarning("Deprecated join alias '$alias' used in APIv4 get. Should be changed to '{$alias}_id'");
}
// Serialized joins are rendered by Api4SelectQuery::renderSerializedJoin
if ($link->getSerialize()) {
// Virtual join, don't actually add this table
break;
}
$bao = $joinEntity ? CoreUtil::getBAOFromApiName($joinEntity) : NULL;
$conditions = $link->getConditionsForJoin($baseTableAlias);
......
......@@ -109,7 +109,7 @@ class SchemaMapBuilder {
}
$fieldData = \CRM_Utils_SQL_Select::from('civicrm_custom_field f')
->join('custom_group', 'INNER JOIN civicrm_custom_group g ON g.id = f.custom_group_id')
->select(['g.name as custom_group_name', 'g.table_name', 'g.is_multiple', 'f.name', 'f.data_type', 'label', 'column_name', 'option_group_id'])
->select(['g.name as custom_group_name', 'g.table_name', 'g.is_multiple', 'f.name', 'f.data_type', 'label', 'column_name', 'option_group_id', 'serialize'])
->where('g.extends IN (@entity)', ['@entity' => $customInfo['extends']])
->where('g.is_active')
->where('f.is_active')
......@@ -140,6 +140,9 @@ class SchemaMapBuilder {
if ($fieldData->data_type === 'ContactReference') {
$joinable = new Joinable('civicrm_contact', 'id', $fieldData->name);
if ($fieldData->serialize) {
$joinable->setSerialize((int) $fieldData->serialize);
}
$customTable->addTableLink($fieldData->column_name, $joinable);
}
}
......
......@@ -2,6 +2,7 @@
namespace Civi\AfformAdmin;
use Civi\Api4\Utils\CoreUtil;
use CRM_AfformAdmin_ExtensionUtil as E;
class AfformAdminMeta {
......@@ -104,7 +105,26 @@ class AfformAdminMeta {
}
$params['values']['state_province_id'] = \Civi::settings()->get('defaultContactStateProvince');
}
return (array) civicrm_api4($entityName, 'getFields', $params, 'name');
$fields = (array) civicrm_api4($entityName, 'getFields', $params);
// Add implicit joins to search fields
if ($params['action'] === 'search') {
foreach (array_reverse($fields, TRUE) as $index => $field) {
if (!empty($field['fk_entity']) && !$field['options']) {
$fkLabelField = CoreUtil::getInfoItem($field['fk_entity'], 'label_field');
if ($fkLabelField) {
// Add the label field from the other entity to this entity's list of fields
$newField = civicrm_api4($field['fk_entity'], 'getFields', [
'where' => [['name', '=', $fkLabelField]],
])->first();
$newField['name'] = $field['name'] . '.' . $newField['name'];
$newField['label'] = $field['label'] . ' ' . $newField['label'];
array_splice($fields, $index, 0, [$newField]);
}
}
}
}
return array_column($fields, NULL, 'name');
}
/**
......
......@@ -83,24 +83,7 @@ class AfformMetadataInjector {
*/
private static function fillFieldMetadata($entityName, $action, \DOMElement $afField) {
$fieldName = $afField->getAttribute('name');
// For explicit joins, strip the alias off the field name
if (strpos($entityName, ' AS ')) {
[$entityName, $alias] = explode(' AS ', $entityName);
$fieldName = preg_replace('/^' . preg_quote($alias . '.', '/') . '/', '', $fieldName);
}
$params = [
'action' => $action,
'where' => [['name', '=', $fieldName]],
'select' => ['label', 'input_type', 'input_attrs', 'help_pre', 'help_post', 'options', 'fk_entity'],
'loadOptions' => ['id', 'label'],
// If the admin included this field on the form, then it's OK to get metadata about the field regardless of user permissions.
'checkPermissions' => FALSE,
];
if (in_array($entityName, \CRM_Contact_BAO_ContactType::basicTypes(TRUE))) {
$params['values'] = ['contact_type' => $entityName];
$entityName = 'Contact';
}
$fieldInfo = civicrm_api4($entityName, 'getFields', $params)->first();
$fieldInfo = self::getField($entityName, $fieldName, $action);
// Merge field definition data with whatever's already in the markup.
$deep = ['input_attrs'];
if ($fieldInfo) {
......@@ -160,6 +143,48 @@ class AfformMetadataInjector {
}
}
/**
* @param string $entityName
* @param string $fieldName
* @param string $action
* @return array|NULL
*/
private static function getField(string $entityName, string $fieldName, string $action):? array {
// For explicit joins, strip the alias off the field name
if (strpos($entityName, ' AS ')) {
[$entityName, $alias] = explode(' AS ', $entityName);
$fieldName = preg_replace('/^' . preg_quote($alias . '.', '/') . '/', '', $fieldName);
}
$namesToMatch = [$fieldName];
// Also match base field if this is an implicit join
if ($action === 'get' && strpos($fieldName, '.')) {
$namesToMatch[] = substr($fieldName, 0, strrpos($fieldName, '.'));
}
$params = [
'action' => $action,
'where' => [['name', 'IN', $namesToMatch]],
'select' => ['name', 'label', 'input_type', 'input_attrs', 'help_pre', 'help_post', 'options', 'fk_entity'],
'loadOptions' => ['id', 'label'],
// If the admin included this field on the form, then it's OK to get metadata about the field regardless of user permissions.
'checkPermissions' => FALSE,
];
if (in_array($entityName, \CRM_Contact_BAO_ContactType::basicTypes(TRUE))) {
$params['values'] = ['contact_type' => $entityName];
$entityName = 'Contact';
}
$fields = civicrm_api4($entityName, 'getFields', $params);
$field = $originalField = $fields->first();
// If this is an implicit join, get new field from fk entity
if ($field['name'] !== $fieldName && $field['fk_entity']) {
$params['where'] = [['name', '=', substr($fieldName, 1 + strrpos($fieldName, '.'))]];
$field = civicrm_api4($field['fk_entity'], 'getFields', $params)->first();
if ($field) {
$field['label'] = $originalField['label'] . ' ' . $field['label'];
}
}
return $field;
}
/**
* Determines name of the api entity based on the field name prefix
*
......
......@@ -213,10 +213,22 @@ class Run extends \Civi\Api4\Generic\AbstractAction {
// Array is either associative `OP => VAL` or sequential `IN (...)`
if (is_array($value)) {
$value = array_filter($value, [$this, 'hasValue']);
// Use IN if array does not contain operators as keys
// If array does not contain operators as keys, assume array of values
if (array_diff_key($value, array_flip(CoreUtil::getOperators()))) {
$clause[] = [$fieldName, 'IN', $value];
// Use IN for regular fields
if (empty($field['serialize'])) {
$clause[] = [$fieldName, 'IN', $value];
}
// Use an OR group of CONTAINS for array fields
else {
$orGroup = [];
foreach ($value as $val) {
$orGroup[] = [$fieldName, 'CONTAINS', $val];
}
$clause[] = ['OR', $orGroup];
}
}
// Operator => Value array
else {
foreach ($value as $operator => $val) {
$clause[] = [$fieldName, $operator, $val];
......
......@@ -132,7 +132,7 @@ class Admin {
foreach ($schema as &$entity) {
if ($entity['searchable'] !== 'bridge') {
foreach (array_reverse($entity['fields'], TRUE) as $index => $field) {
if (!empty($field['fk_entity']) && !$field['options'] && empty($field['serialize']) && !empty($schema[$field['fk_entity']]['label_field'])) {
if (!empty($field['fk_entity']) && !$field['options'] && !empty($schema[$field['fk_entity']]['label_field'])) {
$isCustom = strpos($field['name'], '.');
// Custom fields: append "Contact ID" to original field label
if ($isCustom) {
......
......@@ -674,18 +674,11 @@
if (info.fn && info.fn.name === 'COUNT') {
return value;
}
// Output user-facing name/label fields as a link, if possible
if (info.field && info.field.fieldName === searchMeta.getEntity(info.field.entity).label_field && !info.fn && typeof value === 'string') {
var link = getEntityUrl(row, info);
if (link) {
return '<a href="' + _.escape(link.url) + '" title="' + _.escape(link.title) + '">' + formatFieldValue(info.field, value) + '</a>';
}
}
return formatFieldValue(info.field, value);
return formatFieldValue(row, info, value);
};
// Attempts to construct a view url for a given entity
function getEntityUrl(row, info) {
function getEntityUrl(row, info, index) {
var entity = searchMeta.getEntity(info.field.entity),
path = _.result(_.findWhere(entity.paths, {action: 'view'}), 'path');
// Only proceed if the path metadata exists for this entity
......@@ -699,27 +692,28 @@
if (fieldName === 'id' && info.field.name !== info.field.fieldName) {
fieldName = info.field.name.substr(0, info.field.name.lastIndexOf('.'));
}
fieldName = prefix + fieldName;
if (row[fieldName]) {
replacements.push(row[fieldName]);
var replacement = row[prefix + fieldName];
if (replacement) {
replacements.push(_.isArray(replacement) ? replacement[index] : replacement);
}
});
// Only proceed if the row contains all the necessary data to resolve tokens
if (tokens.length === replacements.length) {
_.each(tokens, function(token, index) {
path = path.replace(token, replacements[index]);
_.each(tokens, function(token, key) {
path = path.replace(token, replacements[key]);
});
return {url: CRM.url(path), title: path.title};
}
}
}
function formatFieldValue(field, value) {
var type = field.data_type,
result = value;
function formatFieldValue(row, info, value, index) {
var type = info.field.data_type,
result = value,
link;
if (_.isArray(value)) {
return _.map(value, function(val) {
return formatFieldValue(field, val);
return _.map(value, function(val, idx) {
return formatFieldValue(row, info, val, idx);
}).join(', ');
}
if (value && (type === 'Date' || type === 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value)) {
......@@ -731,6 +725,13 @@
else if (type === 'Money' && typeof value === 'number') {
result = CRM.formatMoney(value);
}
// Output user-facing name/label fields as a link, if possible
if (info.field.fieldName === searchMeta.getEntity(info.field.entity).label_field && !info.fn) {
link = getEntityUrl(row, info, index || 0);
}
if (link) {
return '<a href="' + _.escape(link.url) + '" title="' + _.escape(link.title) + '">' + _.escape(result) + '</a>';
}
return _.escape(result);
}
......
......@@ -8,22 +8,23 @@
// Replace tokens keyed to rowData.
// If rowMeta is provided, values will be formatted; if omitted, raw values will be provided.
function replaceTokens(str, rowData, rowMeta) {
function replaceTokens(str, rowData, rowMeta, index) {
if (!str) {
return '';
}
_.each(rowData, function(value, key) {
if (str.indexOf('[' + key + ']') >= 0) {
var column = rowMeta && _.findWhere(rowMeta, {key: key}),
replacement = column ? formatRawValue(column, value) : value;
val = column ? formatRawValue(column, value) : value,
replacement = angular.isArray(val) ? val[index || 0] : val;
str = str.replace(new RegExp(_.escapeRegExp('[' + key + ']', 'g')), replacement);
}
});
return str;
}
function getUrl(link, rowData) {
var url = replaceTokens(link, rowData);
function getUrl(link, rowData, index) {
var url = replaceTokens(link, rowData, null, index);
if (url.slice(0, 1) !== '/' && url.slice(0, 4) !== 'http') {
url = CRM.url(url);
}
......@@ -31,10 +32,25 @@
}
// Returns display value for a single column in a row
function formatDisplayValue(rowData, key, rowMeta) {
var column = _.findWhere(rowMeta, {key: key}),
displayValue = column.rewrite ? replaceTokens(column.rewrite, rowData, rowMeta) : formatRawValue(column, rowData[key]);
return displayValue;
function formatDisplayValue(rowData, key, columns) {
var column = _.findWhere(columns, {key: key}),
displayValue = column.rewrite ? replaceTokens(column.rewrite, rowData, columns) : formatRawValue(column, rowData[key]);
return angular.isArray(displayValue) ? displayValue.join(', ') : displayValue;
}
// Returns value and url for a column formatted as link(s)
function formatLinks(rowData, key, columns) {
var column = _.findWhere(columns, {key: key}),
value = formatRawValue(column, rowData[key]),
values = angular.isArray(value) ? value : [value],
links = [];
_.each(values, function(value, index) {
links.push({
value: value,
url: getUrl(column.link.path, rowData, index)
});
});
return links;
}
// Formats raw field value according to data type
......@@ -44,7 +60,7 @@
if (_.isArray(value)) {
return _.map(value, function(val) {
return formatRawValue(column, val);
}).join(', ');
});
}
if (value && (type === 'Date' || type === 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value)) {
result = CRM.utils.formatDate(value, null, type === 'Timestamp');
......@@ -88,6 +104,7 @@
return {
formatDisplayValue: formatDisplayValue,
formatLinks: formatLinks,
getApiParams: getApiParams,
getResults: getResults,
replaceTokens: replaceTokens,
......
......@@ -2,6 +2,10 @@
<span ng-if="::!col.link" ng-class="{'crm-editable-enabled': col.editable && !$ctrl.editing && row[col.editable.id]}" ng-click="col.editable && !$ctrl.editing && ($ctrl.editing = [rowIndex, col.key])">
{{:: $ctrl.formatFieldValue(row, col) }}
</span>
<a ng-if="::col.link" target="{{:: col.link.target }}" href="{{:: displayUtils.getUrl(col.link.path, row) }}">
{{:: $ctrl.formatFieldValue(row, col) }}
</a>
<span ng-if="::col.link">
<span ng-repeat="link in $ctrl.getLinks(row, col)">
<a target="{{:: col.link.target }}" href="{{:: link.url }}">
{{:: link.value }}</a><span ng-if="!$last">,
</span>
</span>
</span>
......@@ -61,6 +61,14 @@
return searchDisplayUtils.formatDisplayValue(rowData, col.key, ctrl.settings.columns);
};
this.getLinks = function(rowData, col) {
rowData._links = rowData._links || {};
if (!(col.key in rowData._links)) {
rowData._links[col.key] = searchDisplayUtils.formatLinks(rowData, col.key, ctrl.settings.columns);
}
return rowData._links[col.key];
};
}
});
......
......@@ -109,6 +109,14 @@
return searchDisplayUtils.formatDisplayValue(rowData, col.key, ctrl.settings.columns);
};
this.getLinks = function(rowData, col) {
rowData._links = rowData._links || {};
if (!(col.key in rowData._links)) {
rowData._links[col.key] = searchDisplayUtils.formatLinks(rowData, col.key, ctrl.settings.columns);
}
return rowData._links[col.key];
};
$scope.selectAllRows = function() {
// Deselect all
if (ctrl.allRowsSelected) {
......
......@@ -3,6 +3,7 @@ namespace api\v4\SearchDisplay;
use Civi\API\Exception\UnauthorizedException;
use Civi\Api4\Contact;
use Civi\Api4\ContactType;
use Civi\Api4\SavedSearch;
use Civi\Api4\SearchDisplay;
use Civi\Api4\UFMatch;
......@@ -27,11 +28,19 @@ class SearchRunTest extends \PHPUnit\Framework\TestCase implements HeadlessInter
* Test running a searchDisplay with various filters.
*/
public function testRunDisplay() {
foreach (['Tester', 'Bot'] as $type) {
ContactType::create(FALSE)
->addValue('parent_id.name', 'Individual')
->addValue('label', $type)
->addValue('name', $type)
->execute();
}
$lastName = uniqid(__FUNCTION__);
$sampleData = [
['first_name' => 'One', 'last_name' => $lastName],
['first_name' => 'Two', 'last_name' => $lastName],
['first_name' => 'Three', 'last_name' => $lastName],
['first_name' => 'One', 'last_name' => $lastName, 'contact_sub_type' => ['Tester', 'Bot']],
['first_name' => 'Two', 'last_name' => $lastName, 'contact_sub_type' => ['Tester']],
['first_name' => 'Three', 'last_name' => $lastName, 'contact_sub_type' => ['Bot']],
['first_name' => 'Four', 'last_name' => $lastName],
];
Contact::save(FALSE)->setRecords($sampleData)->execute();
......@@ -43,7 +52,7 @@ class SearchRunTest extends \PHPUnit\Framework\TestCase implements HeadlessInter
'api_entity' => 'Contact',
'api_params' => [
'version' => 4,
'select' => ['id', 'first_name', 'last_name'],
'select' => ['id', 'first_name', 'last_name', 'contact_sub_type:label'],
'where' => [],
],
],
......@@ -72,6 +81,12 @@ class SearchRunTest extends \PHPUnit\Framework\TestCase implements HeadlessInter
'dataType' => 'String',
'type' => 'field',
],
[
'key' => 'contact_sub_type:label',
'label' => 'Type',
'dataType' => 'String',
'type' => 'field',
],
],
'sort' => [
['id', 'ASC'],
......@@ -97,6 +112,14 @@ class SearchRunTest extends \PHPUnit\Framework\TestCase implements HeadlessInter
$this->assertCount(2, $result);
$this->assertEquals('Three', $result[0]['first_name']);
$this->assertEquals('Two', $result[1]['first_name']);
$params['filters'] = ['contact_sub_type:label' => ['Tester', 'Bot']];
$result = civicrm_api4('SearchDisplay', 'run', $params);
$this->assertCount(3, $result);
$params['filters'] = ['contact_sub_type' => ['Tester']];
$result = civicrm_api4('SearchDisplay', 'run', $params);
$this->assertCount(2, $result);
}
/**
......
......@@ -29,6 +29,7 @@ use Civi\Api4\CustomGroup;
class CustomContactRefTest extends BaseCustomValueTest {
public function testGetWithJoin() {
$firstName = uniqid('fav');
$customGroup = CustomGroup::create(FALSE)
->addValue('name', 'MyContactRef')
......@@ -43,31 +44,83 @@ class CustomContactRefTest extends BaseCustomValueTest {
->addValue('data_type', 'ContactReference')
->execute();
CustomField::create(FALSE)
->addValue('label', 'FavPeople')
->addValue('custom_group_id', $customGroup['id'])
->addValue('html_type', 'Autocomplete-Select')
->addValue('data_type', 'ContactReference')
->addValue('serialize', 1)
->execute();
$favPersonId = Contact::create(FALSE)
->addValue('first_name', 'Favorite')
->addValue('first_name', $firstName)
->addValue('last_name', 'Person')
->addValue('contact_type', 'Individual')
->execute()
->first()['id'];
$contactId = Contact::create(FALSE)
$favPeopleId1 = Contact::create(FALSE)
->addValue('first_name', 'FirstFav')
->addValue('last_name', 'People1')
->addValue('contact_type', 'Individual')
->execute()
->first()['id'];
$favPeopleId2 = Contact::create(FALSE)
->addValue('first_name', 'SecondFav')
->addValue('last_name', 'People2')
->addValue('contact_type', 'Individual')
->execute()
->first()['id'];
$contactId1 = Contact::create(FALSE)
->addValue('first_name', 'Mya')
->addValue('last_name', 'Tester')
->addValue('contact_type', 'Individual')
->addValue('MyContactRef.FavPerson', $favPersonId)
->addValue('MyContactRef.FavPeople', [$favPeopleId2, $favPeopleId1])
->execute()
->first()['id'];
$contact = Contact::get(FALSE)
$contactId2 = Contact::create(FALSE)
->addValue('first_name', 'Bea')
->addValue('last_name', 'Tester')
->addValue('contact_type', 'Individual')
->addValue('MyContactRef.FavPeople', [$favPeopleId2])
->execute()
->first()['id'];
$result = Contact::get(FALSE)
->addSelect('display_name')
->addSelect('MyContactRef.FavPerson.first_name')
->addSelect('MyContactRef.FavPerson.last_name')
->addWhere('id', '=', $contactId)
->addSelect('MyContactRef.FavPeople')
->addSelect('MyContactRef.FavPeople.last_name')
->addWhere('MyContactRef.FavPerson.first_name', '=', $firstName)
->execute()
->first();
->single();
$this->assertEquals($firstName, $result['MyContactRef.FavPerson.first_name']);
$this->assertEquals('Person', $result['MyContactRef.FavPerson.last_name']);
// Ensure serialized values are returned in order
$this->assertEquals([$favPeopleId2, $favPeopleId1], $result['MyContactRef.FavPeople']);
// Values returned from virtual join should be in the same order
$this->assertEquals(['People2', 'People1'], $result['MyContactRef.FavPeople.last_name']);
$result = Contact::get(FALSE)
->addSelect('id')
->addWhere('MyContactRef.FavPeople.first_name', 'CONTAINS', 'First')
->execute()
->single();
$this->assertEquals($contactId1, $result['id']);
$result = Contact::get(FALSE)
->addSelect('id')
->addWhere('MyContactRef.FavPeople.first_name', 'CONTAINS', 'Second')
->execute();
$this->assertEquals('Favorite', $contact['MyContactRef.FavPerson.first_name']);
$this->assertEquals('Person', $contact['MyContactRef.FavPerson.last_name']);
$this->assertCount(2, $result);
}
public function testCurrentUser() {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment