// Add ACLs first to avoid redundant subclauses
$this->query->where($this->getAclClause(self::MAIN_TABLE_ALIAS, $baoName));
+
+ // Add explicit joins. Other joins implied by dot notation may be added later
+ $this->addExplicitJoins($apiGet->getJoin());
}
/**
if ($dir !== 'ASC' && $dir !== 'DESC') {
throw new \API_Exception("Invalid sort direction. Cannot order by $item $dir");
}
- $expr = SqlExpression::convert($item);
- foreach ($expr->getFields() as $fieldName) {
- $this->getField($fieldName, TRUE);
- }
- $this->query->orderBy($expr->render($this->apiFieldSpec) . " $dir");
+ $this->query->orderBy($this->renderExpression($item) . " $dir");
}
}
*/
protected function buildGroupBy() {
foreach ($this->groupBy as $item) {
- $expr = SqlExpression::convert($item);
- foreach ($expr->getFields() as $fieldName) {
- $this->getField($fieldName, TRUE);
- }
- $this->query->groupBy($expr->render($this->apiFieldSpec));
+ $this->query->groupBy($this->renderExpression($item));
}
}
*
* @param array $clause
* @param string $type
- * WHERE|HAVING
+ * WHERE|HAVING|ON
* @return string SQL where clause
*
* @throws \API_Exception
* Validate and transform a leaf clause array to SQL.
* @param array $clause [$fieldName, $operator, $criteria]
* @param string $type
- * WHERE|HAVING
+ * WHERE|HAVING|ON
* @return string SQL
* @throws \API_Exception
* @throws \Exception
protected function composeClause(array $clause, string $type) {
// Pad array for unary operators
list($expr, $operator, $value) = array_pad($clause, 3, NULL);
+ if (!in_array($operator, \CRM_Core_DAO::acceptedSQLOperators(), TRUE)) {
+ throw new \API_Exception('Illegal operator');
+ }
// For WHERE clause, expr must be the name of a field.
if ($type === 'WHERE') {
$fieldAlias = $field['sql_name'];
}
// For HAVING, expr must be an item in the SELECT clause
- else {
+ elseif ($type === 'HAVING') {
// Expr references a fieldName or alias
if (isset($this->selectAliases[$expr])) {
$fieldAlias = $expr;
}
$fieldAlias = '`' . $fieldAlias . '`';
}
+ elseif ($type === 'ON') {
+ $expr = $this->getExpression($expr);
+ $fieldName = count($expr->getFields()) === 1 ? $expr->getFields()[0] : NULL;
+ $fieldAlias = $expr->render($this->apiFieldSpec);
+ if (is_string($value)) {
+ $valExpr = $this->getExpression($value);
+ if ($fieldName && $valExpr->getType() === 'SqlString') {
+ FormattingUtil::formatInputValue($valExpr->expr, $fieldName, $this->apiFieldSpec[$fieldName]);
+ }
+ return sprintf('%s %s %s', $fieldAlias, $operator, $valExpr->render($this->apiFieldSpec));
+ }
+ elseif ($fieldName) {
+ FormattingUtil::formatInputValue($value, $fieldName, $this->apiFieldSpec[$fieldName]);
+ }
+ }
$sql_clause = \CRM_Core_DAO::createSQLFilter($fieldAlias, [$operator => $value]);
if ($sql_clause === NULL) {
return $sql_clause;
}
+ /**
+ * @param string $expr
+ * @return SqlExpression
+ * @throws \API_Exception
+ */
+ protected function getExpression(string $expr) {
+ $sqlExpr = SqlExpression::convert($expr);
+ foreach ($sqlExpr->getFields() as $fieldName) {
+ $this->getField($fieldName, TRUE);
+ }
+ return $sqlExpr;
+ }
+
+ /**
+ * @param string $expr
+ * @return string
+ * @throws \API_Exception
+ */
+ protected function renderExpression(string $expr) {
+ $sqlExpr = $this->getExpression($expr);
+ return $sqlExpr->render($this->apiFieldSpec);
+ }
+
/**
* @inheritDoc
*/
return $field;
}
+ /**
+ * Join onto other entities as specified by the api call.
+ *
+ * @param $joins
+ * @throws \API_Exception
+ * @throws \Civi\API\Exception\NotImplementedException
+ */
+ private function addExplicitJoins($joins) {
+ foreach ($joins as $join) {
+ // First item in the array is the entity name
+ $entity = array_shift($join);
+ // Which might contain an alias. Split on the keyword "AS"
+ list($entity, $alias) = array_pad(explode(' AS ', $entity), 2, NULL);
+ // Ensure alias is a safe string, and supply default if not given
+ $alias = $alias ? \CRM_Utils_String::munge($alias) : 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';
+ $joinEntityGet = \Civi\API\Request::create($entity, 'get', ['version' => 4, 'checkPermissions' => $this->checkPermissions]);
+ foreach ($joinEntityGet->entityFields() as $field) {
+ $field['sql_name'] = '`' . $alias . '`.`' . $field['column_name'] . '`';
+ $field['is_join'] = TRUE;
+ $this->addSpecField($alias . '.' . $field['name'], $field);
+ }
+ $conditions = [];
+ foreach (array_merge($join, $this->getJoinConditions($entity, $alias)) as $clause) {
+ $conditions[] = $this->treeWalkClauses($clause, 'ON');
+ }
+ $tableName = AllCoreTables::getTableForEntityName($entity);
+ $this->join($side, $tableName, $alias, $conditions);
+ }
+ }
+
+ /**
+ * Supply conditions for an explicit join.
+ *
+ * @param $entity
+ * @param $alias
+ * @return array
+ */
+ private function getJoinConditions($entity, $alias) {
+ $conditions = [];
+ // getAclClause() expects a stack of 1-to-1 join fields to help it dedupe, but this is more flexible,
+ // so unless this is a direct 1-to-1 join with the main entity, we'll just hack it
+ // with a padded empty stack to bypass its deduping.
+ $stack = [NULL, NULL];
+ foreach ($this->apiFieldSpec as $name => $field) {
+ if ($field['entity'] !== $entity && $field['fk_entity'] === $entity) {
+ $conditions[] = [$name, '=', "$alias.id"];
+ $stack = [$name];
+ }
+ elseif (strpos($name, "$alias.") === 0 && substr_count($name, '.') === 1 && $field['fk_entity'] === $this->entity) {
+ $conditions[] = [$name, '=', 'id'];
+ }
+ }
+ // Hmm, if we came up with > 1 condition, then it's ambiguous how it should be joined so we won't return anything but the generic ACLs
+ if (count($conditions) > 1) {
+ return $this->getAclClause($alias, AllCoreTables::getFullName($entity), [NULL, NULL]);
+ }
+ $acls = $this->getAclClause($alias, AllCoreTables::getFullName($entity), $stack);
+ return array_merge($acls, $conditions);
+ }
+
/**
* Joins a path and adds all fields in the joined entity to apiFieldSpec
*
use api\v4\UnitTestCase;
use Civi\Api4\Activity;
use Civi\Api4\Contact;
+use Civi\Api4\Email;
+use Civi\Api4\Phone;
/**
* @group headless
$this->assertCount(1, $results);
}
+ public function testOptionalJoin() {
+ // DefaultDataSet includes 2 phones for contact 1, 0 for contact 2.
+ // We'll add one for contact 2 as a red herring to make sure we only get back the correct ones.
+ Phone::create()->setCheckPermissions(FALSE)
+ ->setValues(['contact_id' => $this->getReference('test_contact_2')['id'], 'phone' => '123456'])
+ ->execute();
+ $contacts = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addJoin('Phone', FALSE)
+ ->addSelect('id', 'phone.phone')
+ ->addWhere('id', 'IN', [$this->getReference('test_contact_1')['id']])
+ ->addOrderBy('phone.id')
+ ->execute();
+ $this->assertCount(2, $contacts);
+ $this->assertEquals($this->getReference('test_contact_1')['id'], $contacts[0]['id']);
+ $this->assertEquals($this->getReference('test_contact_1')['id'], $contacts[1]['id']);
+ }
+
+ public function testRequiredJoin() {
+ // Joining with no condition
+ $contacts = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('id', 'phone.phone')
+ ->addJoin('Phone', TRUE)
+ ->addWhere('id', 'IN', [$this->getReference('test_contact_1')['id'], $this->getReference('test_contact_2')['id']])
+ ->addOrderBy('phone.id')
+ ->execute();
+ $this->assertCount(2, $contacts);
+ $this->assertEquals($this->getReference('test_contact_1')['id'], $contacts[0]['id']);
+ $this->assertEquals($this->getReference('test_contact_1')['id'], $contacts[1]['id']);
+
+ // Add is_primary condition, should result in only one record
+ $contacts = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('id', 'phone.phone', 'phone.location_type_id')
+ ->addJoin('Phone', TRUE, ['phone.is_primary', '=', TRUE])
+ ->addWhere('id', 'IN', [$this->getReference('test_contact_1')['id'], $this->getReference('test_contact_2')['id']])
+ ->addOrderBy('phone.id')
+ ->execute();
+ $this->assertCount(1, $contacts);
+ $this->assertEquals($this->getReference('test_contact_1')['id'], $contacts[0]['id']);
+ $this->assertEquals('+35355439483', $contacts[0]['phone.phone']);
+ $this->assertEquals('1', $contacts[0]['phone.location_type_id']);
+ }
+
+ public function testJoinToTheSameTableTwice() {
+ $cid1 = Contact::create()->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Aaa')
+ ->addChain('email1', Email::create()->setValues(['email' => 'yoohoo@yahoo.test', 'contact_id' => '$id', 'location_type_id:name' => 'Home']))
+ ->addChain('email2', Email::create()->setValues(['email' => 'yahoo@yoohoo.test', 'contact_id' => '$id', 'location_type_id:name' => 'Work']))
+ ->execute()
+ ->first()['id'];
+
+ $cid2 = Contact::create()->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Bbb')
+ ->addChain('email1', Email::create()->setValues(['email' => '1@test.test', 'contact_id' => '$id', 'location_type_id:name' => 'Home']))
+ ->addChain('email2', Email::create()->setValues(['email' => '2@test.test', 'contact_id' => '$id', 'location_type_id:name' => 'Work']))
+ ->addChain('email3', Email::create()->setValues(['email' => '3@test.test', 'contact_id' => '$id', 'location_type_id:name' => 'Other']))
+ ->execute()
+ ->first()['id'];
+
+ $cid3 = Contact::create()->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Ccc')
+ ->execute()
+ ->first()['id'];
+
+ $contacts = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('id', 'first_name', 'any_email.email', 'any_email.location_type_id:name', 'any_email.is_primary', 'primary_email.email')
+ ->addJoin('Email AS any_email', TRUE)
+ ->addJoin('Email AS primary_email', FALSE, ['primary_email.is_primary', '=', TRUE])
+ ->addWhere('id', 'IN', [$cid1, $cid2, $cid3])
+ ->addOrderBy('any_email.id')
+ ->setDebug(TRUE)
+ ->execute();
+ $this->assertCount(5, $contacts);
+ $this->assertEquals('Home', $contacts[0]['any_email.location_type_id:name']);
+ $this->assertEquals('yoohoo@yahoo.test', $contacts[1]['primary_email.email']);
+ $this->assertEquals('1@test.test', $contacts[2]['primary_email.email']);
+ $this->assertEquals('1@test.test', $contacts[3]['primary_email.email']);
+ $this->assertEquals('1@test.test', $contacts[4]['primary_email.email']);
+ }
+
}