From 5b46e216584380acc94f8aab828c3f40addb2c07 Mon Sep 17 00:00:00 2001
From: Coleman Watts
Date: Sun, 10 Jan 2016 00:31:27 -0500
Subject: [PATCH] CRM-17795 - Support joins in api explorer
---
CRM/Admin/Page/APIExplorer.php | 3 +-
Civi/API/SelectQuery.php | 4 +-
templates/CRM/Admin/Page/APIExplorer.hlp | 19 +++
templates/CRM/Admin/Page/APIExplorer.js | 188 +++++++++++++++++++----
templates/CRM/Admin/Page/APIExplorer.tpl | 43 +++++-
5 files changed, 220 insertions(+), 37 deletions(-)
diff --git a/CRM/Admin/Page/APIExplorer.php b/CRM/Admin/Page/APIExplorer.php
index 0df6d0c9e2..de63dbc997 100644
--- a/CRM/Admin/Page/APIExplorer.php
+++ b/CRM/Admin/Page/APIExplorer.php
@@ -45,7 +45,8 @@ class CRM_Admin_Page_APIExplorer extends CRM_Core_Page {
CRM_Core_Resources::singleton()
->addScriptFile('civicrm', 'templates/CRM/Admin/Page/APIExplorer.js')
->addScriptFile('civicrm', 'bower_components/google-code-prettify/bin/prettify.min.js', 99)
- ->addStyleFile('civicrm', 'bower_components/google-code-prettify/bin/prettify.min.css', 99);
+ ->addStyleFile('civicrm', 'bower_components/google-code-prettify/bin/prettify.min.css', 99)
+ ->addVars('explorer', array('max_joins' => \Civi\API\SelectQuery::MAX_JOINS));
$this->assign('operators', CRM_Core_DAO::acceptedSQLOperators());
diff --git a/Civi/API/SelectQuery.php b/Civi/API/SelectQuery.php
index 3652c93865..3c0a7228f5 100644
--- a/Civi/API/SelectQuery.php
+++ b/Civi/API/SelectQuery.php
@@ -41,6 +41,8 @@ namespace Civi\API;
*/
class SelectQuery {
+ const MAX_JOINS = 4;
+
/**
* @var \CRM_Core_DAO
*/
@@ -361,7 +363,7 @@ class SelectQuery {
continue;
}
// More than 4 joins deep seems excessive - DOS attack?
- if ($depth > 4) {
+ if ($depth > self::MAX_JOINS) {
throw new \API_Exception("Maximum number of joins exceeded in api.{$this->entity}.get");
}
if (!isset($fkField['FKApiName']) && !isset($fkField['FKClassName'])) {
diff --git a/templates/CRM/Admin/Page/APIExplorer.hlp b/templates/CRM/Admin/Page/APIExplorer.hlp
index c351e8dafe..cb9b555fe8 100644
--- a/templates/CRM/Admin/Page/APIExplorer.hlp
+++ b/templates/CRM/Admin/Page/APIExplorer.hlp
@@ -59,3 +59,22 @@
{/htxt}
+
+{htxt id="api-chain-title"}
+ {ts}Api Chaining{/ts}
+{/htxt}
+{htxt id="api-chain"}
+ {ts}Chains allow you to execute api calls on the results of this one.{/ts}
+ {ts}Example: When creating a contact, you can chain the Email.create api to add email addresses for them.{/ts}
+ {ts}Disambiguation: api chains are different from joins in that chaining executes a separate api call for every result returned from the main call.{/ts}
+{/htxt}
+
+{htxt id="api-join-title"}
+ {ts}Api Joins{/ts}
+{/htxt}
+{htxt id="api-join"}
+ {ts}Join this api call to return values from or filter on a related entity.{/ts}
+ {ts}The new fields will be selectable from the "Params" and "Fields to return" lists.{/ts}
+ {ts}Example: If the api entity is Email, you can also fetch the display name of the contact this email belongs to.{/ts}
+ {ts}Disambiguation: Joins are different from api chaining in that joins execute as a single SELECT query, and only work for api "get" operations.{/ts}
+{/htxt}
diff --git a/templates/CRM/Admin/Page/APIExplorer.js b/templates/CRM/Admin/Page/APIExplorer.js
index d1d81e7327..953bd66fce 100644
--- a/templates/CRM/Admin/Page/APIExplorer.js
+++ b/templates/CRM/Admin/Page/APIExplorer.js
@@ -4,6 +4,7 @@
var
entity,
action,
+ joins = [],
actions = {values: ['get']},
fields = [],
getFieldData = {},
@@ -50,15 +51,74 @@
}
}
+ /**
+ * Data provider for select2 "field" selectors
+ * @returns {{results: Array.}}
+ */
+ function returnFields() {
+ return {results: fields.concat({id: '-', text: ts('Other') + '...', description: ts('Choose a field not in this list')})};
+ }
+
+ /**
+ * Recursively populates data for select2 "field" selectors
+ * @param fields
+ * @param entity
+ * @param action
+ * @param prefix
+ */
+ function populateFields(fields, entity, action, prefix) {
+ _.each(getFieldsCache[entity+action].values, function(field) {
+ var name = prefix + field.name,
+ pos = fields.length;
+ fields.push({
+ id: name,
+ text: field.title || field.name,
+ multi: !!field['api.multiple'],
+ description: field.description || '',
+ required: !(!field['api.required'] || field['api.required'] === '0')
+ });
+ if (joins[name]) {
+ fields[pos].children = [];
+ populateFields(fields[pos].children, joins[name], 'get', name + '.');
+ }
+ if (!prefix && field['api.required'] && field['api.required'] !== '0') {
+ required.push(field.name);
+ }
+ });
+ }
+
+ /**
+ * Fetch metadata for a field by name - searches across joins
+ * @param name string
+ * @returns {*}
+ */
+ function getField(name) {
+ if (getFieldData[name]) {
+ return getFieldData[name];
+ }
+ var ent = entity,
+ act = action,
+ prefix = '';
+ _.each(name.split('.'), function(piece) {
+ if (joins[prefix]) {
+ ent = joins[prefix];
+ act = 'get';
+ }
+ name = piece;
+ prefix += (prefix.length ? '.' : '') + piece;
+ });
+ return getFieldsCache[ent+act].values[name] || {};
+ }
+
/**
* Add a "fields" row
- * @param name
+ * @param name string
*/
function addField(name) {
$('#api-params').append($(fieldTpl({name: name || '', noOps: _.includes(NO_OPERATORS, action)})));
var $row = $('tr:last-child', '#api-params');
$('input.api-param-name', $row).crmSelect2({
- data: fields.concat({id: '-', text: ts('Other') + '...', description: ts('Choose a field not in this list')}),
+ data: returnFields,
formatSelection: function(field) {
return field.text +
(field.required ? ' * ' : '');
@@ -116,7 +176,7 @@
$row = $selector.closest('tr');
if (entity) {
$selector.prop('disabled', true);
- CRM.api3(entity, 'getactions')
+ getActions(entity)
.done(function(actions) {
$selector.prop('disabled', false);
CRM.utils.setOptions($('.api-chain-action', $row), _.transform(actions.values, function(ret, item) {ret.push({value: item, key: item});}));
@@ -144,14 +204,23 @@
}
CRM.api3(apiCalls)
.done(function(data) {
+ data.getfields.values = _.indexBy(data.getfields.values, 'name');
getFieldsCache[entity+action] = data.getfields;
getActionsCache[entity] = getActionsCache[entity] || data.getactions;
- response.resolve(data.getfields);
+ response.resolve(getFieldsCache[entity+action]);
});
}
return response;
}
+ function getActions(entity) {
+ if (getActionsCache[entity]) {
+ return $.Deferred().resolve(getActionsCache[entity]);
+ } else {
+ return CRM.api3(entity, 'getactions');
+ }
+ }
+
/**
* Respond to changing the main entity+action
*/
@@ -166,6 +235,7 @@
text: ts('Action')
});
getFieldData.api_action = {
+ name: 'api_action',
options: _.reduce(actions.values, function(ret, item) {
ret[item] = item;
return ret;
@@ -181,23 +251,11 @@
if (data.deprecated) CRM.alert(data.deprecated, entity + ' Deprecated');
}
onChangeAction(action);
- _.each(data.values, function(field) {
- if (field.name) {
- getFieldData[field.name] = field;
- fields.push({
- id: field.name,
- text: field.title || field.name,
- multi: !!field['api.multiple'],
- description: field.description || '',
- required: !(!field['api.required'] || field['api.required'] === '0')
- });
- if (field['api.required'] && field['api.required'] !== '0') {
- required.push(field.name);
- }
- }
- });
+ getFieldData = data.values;
+ populateFields(fields, entity, action, '');
showFields(required);
- if (action === 'get' || action === 'getsingle' || action == 'getvalue' || action === 'getstat') {
+ renderJoinSelector();
+ if (_.includes(['get', 'getsingle', 'getvalue', 'getstat'], action)) {
showReturn();
}
});
@@ -211,9 +269,12 @@
function showReturn() {
var title = ts('Fields to return'),
params = {
- data: fields,
+ data: returnFields,
multiple: true,
- placeholder: ts('Leave blank for default')
+ placeholder: ts('Leave blank for default'),
+ formatResult: function(field) {
+ return field.text + '' + field.description + '
';
+ }
};
if (action == 'getstat') {
title = ts('Group by');
@@ -286,7 +347,7 @@
}
function isYesNo(fieldName) {
- return getFieldData[fieldName] && getFieldData[fieldName].type === 16;
+ return getField(fieldName).type === 16;
}
/**
@@ -297,7 +358,7 @@
* @returns boolean
*/
function isSelect(fieldName, operator) {
- var fieldSpec = getFieldData[fieldName] || {};
+ var fieldSpec = getField(fieldName);
return (isYesNo(fieldName) || fieldSpec.options || fieldSpec.FKApiName) && !_.includes(TEXT, operator);
}
@@ -340,6 +401,7 @@
$valField = $('input.api-param-value', $row),
multiSelect = isMultiSelect(name, operator),
currentVal = $valField.val(),
+ fieldSpec = getField(name),
wasSelect = $valField.data('select2');
if (wasSelect) {
$valField.crmEntityRef('destroy');
@@ -369,17 +431,17 @@
});
}
// Select options
- else if (getFieldData[name].options) {
+ else if (fieldSpec.options) {
$valField.select2({
multiple: multiSelect,
- data: _.map(getFieldData[name].options, function (value, key) {
+ data: _.map(fieldSpec.options, function (value, key) {
return {id: key, text: value};
})
});
}
// EntityRef
else {
- var entity = getFieldData[name].FKApiName;
+ var entity = fieldSpec.FKApiName;
$valField.attr('placeholder', entity == 'Contact' ? '[' + ts('Auto-Select Current User') + ']' : ts('- select -'));
$valField.crmEntityRef({
entity: entity,
@@ -746,6 +808,61 @@
}
}
+ /**
+ * Renders nested checkboxes for adding joins to an api.get call
+ */
+ function renderJoinSelector() {
+ $('#api-join').hide();
+ if (!_.includes(['Contact', 'Contribution', 'Pledge'], entity) && _.includes(['get', 'getsingle'], action)) {
+ var joinable = {};
+ (function recurse(fields, joinable, prefix, depth) {
+ _.each(fields, function(field) {
+ if (field.FKApiName && field.FKClassName) {
+ var name = prefix + field.name;
+ joinable[name] = {
+ title: field.title,
+ entity: field.FKApiName,
+ checked: !!joins[name]
+ };
+ if (joins[name] && depth < CRM.vars.explorer.max_joins) {
+ joinable[name].children = {};
+ recurse(getFieldsCache[field.FKApiName+'get'].values, joinable[name].children, name + '.', depth+1);
+ }
+ }
+ });
+ })(getFieldData, joinable, '', 1);
+ if (!_.isEmpty(joinable)) {
+ // Send joinTpl as a param so it can recursively call itself to render children
+ $('#api-join').show().children('div').html(joinTpl({joins: joinable, tpl: joinTpl}));
+ }
+ }
+ }
+
+ /**
+ * When adding or removing a join from an api.get call
+ */
+ function onSelectJoin() {
+ var name = $(this).val(),
+ ent = $(this).data('entity');
+ fields = [];
+ $('input', '#api-join').prop('disabled', true);
+ if ($(this).is(':checked')) {
+ joins[name] = ent;
+ $('input.api-param-name, #api-return-value').addClass('loading');
+ getMetadata(ent, 'get').done(function() {
+ renderJoinSelector();
+ populateFields(fields, entity, action, '');
+ $('input.api-param-name, #api-return-value').removeClass('loading');
+ });
+ } else {
+ joins = _.omit(joins, function(entity, n) {
+ return n.indexOf(name) === 0;
+ });
+ renderJoinSelector();
+ populateFields(fields, entity, action, '');
+ }
+ }
+
$(document).ready(function() {
// Set up tabs - bind active tab to document hash because... it's cool?
document.location.hash = document.location.hash || 'explorer';
@@ -773,6 +890,7 @@
.on('change', '#api-entity, #api-action', function() {
entity = $('#api-entity').val();
action = $('#api-action').val();
+ joins = {};
if ($(this).is('#api-entity')) {
$('#api-action').addClass('loading');
}
@@ -784,14 +902,17 @@
})
.on('change keyup', 'input.api-input, #api-params select', buildParams)
.on('submit', submit);
+
$('#api-params')
.on('change', 'input.api-param-name, select.api-param-op', renderValueField)
- .on('change', 'input.api-param-name, .api-option-name', function() {
- if ($(this).val() === '-' && $(this).data('select2')) {
- $(this)
- .crmSelect2('destroy')
- .val('')
- .focus();
+ .on('select2-selecting', 'input.api-param-name, .api-option-name, #api-return-value', function(e) {
+ if (e.val === '-') {
+ $(this).one('change', function() {
+ $(this)
+ .crmSelect2('destroy')
+ .val('')
+ .focus();
+ });
}
})
.on('click', '.api-param-remove', function(e) {
@@ -800,6 +921,7 @@
buildParams();
})
.on('change', 'select.api-chain-entity', getChainedAction);
+ $('#api-join').on('change', 'input', onSelectJoin);
$('#example-entity').on('change', getExamples);
$('#example-action').on('change', getExample);
$('#doc-entity').on('change', getDocEntity);
diff --git a/templates/CRM/Admin/Page/APIExplorer.tpl b/templates/CRM/Admin/Page/APIExplorer.tpl
index 399fb586b9..7ab8599f49 100644
--- a/templates/CRM/Admin/Page/APIExplorer.tpl
+++ b/templates/CRM/Admin/Page/APIExplorer.tpl
@@ -85,6 +85,22 @@
display: inline;
font-weight: bold;
}
+ #mainTabContainer label.api-checkbox-label {
+ font-weight: normal;
+ }
+ #mainTabContainer h4 {
+ font-weight: bold;
+ font-size: 1.2em;
+ margin: .2em .2em 0.5em;
+ }
+ #api-join {
+ margin-top: 1em;
+ font-size: .8em;
+ }
+ #api-join ul {
+ margin: 0;
+ padding: 0 0 0.25em 2.5em;
+ }
#api-generated-wraper,
#api-result {
overflow: auto;
@@ -174,15 +190,20 @@
-
+
debug
|
-
+
sequential
+
+