var actions = [];
// Field options
var fieldOptions = {};
+ // Api params
+ var params;
angular.module('api4Explorer').config(function($routeProvider) {
});
angular.module('api4Explorer').controller('Api4Explorer', function($scope, $routeParams, $location, $timeout, $http, crmUiHelp, crmApi4, dialogService) {
- var ts = $scope.ts = CRM.ts();
+ var ts = $scope.ts = CRM.ts(),
+ ctrl = $scope.$ctrl = this;
$scope.entities = entities;
$scope.actions = actions;
$scope.fields = [];
$scope.fieldsAndJoinsAndFunctionsWithSuffixes = [];
$scope.fieldsAndJoinsAndFunctionsAndWildcards = [];
$scope.availableParams = {};
- $scope.params = {};
+ params = $scope.params = {};
$scope.index = '';
$scope.selectedTab = {result: 'result', code: 'php'};
$scope.perm = {
$scope.loading = false;
$scope.controls = {};
$scope.langs = ['php', 'js', 'ang', 'cli'];
- $scope.joinTypes = [{k: false, v: ts('Optional')}, {k: true, v: ts('Required')}];
+ $scope.joinTypes = [{k: false, v: 'FALSE (LEFT JOIN)'}, {k: true, v: 'TRUE (INNER JOIN)'}];
+ $scope.bridgeEntities = _.filter(schema, {type: 'BridgeEntity'});
$scope.code = {
php: [
{name: 'oop', label: ts('OOP Style'), code: ''},
{name: 'ang2', label: ts('Batch Calls'), code: ''}
],
cli: [
- {name: 'cv', label: ts('CV'), code: ''}
+ {name: 'short', label: ts('CV (short)'), code: ''},
+ {name: 'long', label: ts('CV (long)'), code: ''},
+ {name: 'pipe', label: ts('CV (pipe)'), code: ''}
]
};
}
function pluralize(str) {
- switch (str[str.length-1]) {
- case 's':
- return str + 'es';
- case 'y':
- return str.slice(0, -1) + 'ies';
- default:
- return str + 's';
+ var lastLetter = str[str.length - 1],
+ lastTwo = str[str.length - 2] + lastLetter;
+ if (lastLetter === 's' || lastLetter === 'x' || lastTwo === 'ch') {
+ return str + 'es';
+ }
+ if (lastLetter === 'y' && lastTwo !== 'ey') {
+ return str.slice(0, -1) + 'ies';
}
+ return str + 's';
}
// Reformat an existing array of objects for compatibility with select2
return container;
}
- // Returns field list formatted for select2
- function getFieldList(action, addPseudoconstant) {
- var fields = [],
- fieldInfo = _.findWhere(getEntity().actions, {name: action}).fields;
+ // Replaces contents of fieldList array with current fields formatted for select2
+ function getFieldList(fieldList, action, addPseudoconstant) {
+ var fieldInfo = _.cloneDeep(_.findWhere(getEntity().actions, {name: action}).fields);
+ fieldList.length = 0;
if (addPseudoconstant) {
- fieldInfo = _.cloneDeep(fieldInfo);
addPseudoconstants(fieldInfo, addPseudoconstant);
}
- formatForSelect2(fieldInfo, fields, 'name', ['description', 'required', 'default_value']);
- return fields;
+ formatForSelect2(fieldInfo, fieldList, 'name', ['description', 'required', 'default_value']);
}
// Note: this function expects fieldList to be select2-formatted already
function addJoins(fieldList, addWildcard, addPseudoconstant) {
- var fields = _.cloneDeep(fieldList);
+ // Add entities specified by the join param
+ _.each(getExplicitJoins(), function(joinEntity, joinAlias) {
+ var wildCard = addWildcard ? [{id: joinAlias + '.*', text: joinAlias + '.*', 'description': 'All core ' + joinEntity + ' fields'}] : [],
+ joinFields = _.cloneDeep(entityFields(joinEntity));
+ if (joinFields) {
+ if (addPseudoconstant) {
+ addPseudoconstants(joinFields, addPseudoconstant);
+ }
+ fieldList.push({
+ text: joinEntity + ' AS ' + joinAlias,
+ description: 'Explicit join to ' + joinEntity,
+ children: wildCard.concat(formatForSelect2(joinFields, [], 'name', ['description'], joinAlias + '.'))
+ });
+ }
+ });
+ // Add implicit joins based on schema links
_.each(links[$scope.entity], function(link) {
var linkFields = _.cloneDeep(entityFields(link.entity)),
wildCard = addWildcard ? [{id: link.alias + '.*', text: link.alias + '.*', 'description': 'All core ' + link.entity + ' fields'}] : [];
if (addPseudoconstant) {
addPseudoconstants(linkFields, addPseudoconstant);
}
- fields.push({
+ fieldList.push({
text: link.alias,
description: 'Implicit join to ' + link.entity,
children: wildCard.concat(formatForSelect2(linkFields, [], 'name', ['description'], link.alias + '.'))
});
}
});
- return fields;
}
// Note: this function transforms a raw list a-la getFields; not a select2-formatted list
// Returns field list for write params (values, defaults)
$scope.fieldList = function(param) {
return function() {
- var fields = _.cloneDeep(getFieldList($scope.action === 'getFields' ? ($scope.params.action || 'get') : $scope.action, ['name']));
+ var fields = [];
+ getFieldList(fields, $scope.action === 'getFields' ? ($scope.params.action || 'get') : $scope.action, ['name']);
// Disable fields that are already in use
_.each($scope.params[param] || [], function(val) {
var usedField = val[0].replace(':name', '');
};
$scope.selectRowCount = function() {
- if ($scope.isSelectRowCount()) {
- $scope.params.select = [];
+ var index = params.select.indexOf('row_count');
+ if (index < 0) {
+ $scope.params.select.push('row_count');
} else {
- $scope.params.select = ['row_count'];
- $scope.index = '';
- if ($scope.params.limit == 25) {
- $scope.params.limit = 0;
- }
+ $scope.params.select.splice(index, 1);
}
};
};
function isSelectRowCount(params) {
- return params && params.select && params.select.length === 1 && params.select[0] === 'row_count';
+ return params && params.select && params.select.indexOf('row_count') >= 0;
}
function getEntity(entityName) {
return _.findWhere(schema, {name: entityName || $scope.entity});
}
+ // Get name of entity given join alias
+ function entityNameFromAlias(alias) {
+ var joins = getExplicitJoins(),
+ entity = $scope.entity,
+ path = alias.split('.');
+ // First check explicit joins
+ if (joins[alias]) {
+ return joins[alias];
+ }
+ // Then lookup implicit links
+ _.each(path, function(node) {
+ var link = _.find(links[entity], {alias: node});
+ if (!link) {
+ return false;
+ }
+ entity = link.entity;
+ });
+ return entity;
+ }
+
// Get all params that have been set
function getParams() {
var params = {};
}
function parseYaml(input) {
- if (typeof input === 'undefined') {
- return undefined;
+ if (typeof input === 'undefined' || input === '') {
+ return input;
}
- if (input === '') {
- return '';
+ // Return literal quoted string without removing quotes - for the sake of JOIN ON clauses
+ if (_.isString(input) && input[0] === input[input.length - 1] && _.includes(["'", '"'], input[0])) {
+ return input;
}
if (_.isObject(input) || _.isArray(input)) {
_.each(input, function(item, index) {
}
}
+ this.buildFieldList = function() {
+ var actionInfo = _.findWhere(actions, {id: $scope.action});
+ getFieldList($scope.fields, $scope.action);
+ getFieldList($scope.fieldsAndJoins, $scope.action, ['name']);
+ getFieldList($scope.fieldsAndJoinsAndFunctions, $scope.action);
+ getFieldList($scope.fieldsAndJoinsAndFunctionsWithSuffixes, $scope.action, ['name', 'label']);
+ getFieldList($scope.fieldsAndJoinsAndFunctionsAndWildcards, $scope.action, ['name', 'label']);
+ if (_.contains(['get', 'update', 'delete', 'replace'], $scope.action)) {
+ addJoins($scope.fieldsAndJoins);
+ // SQL functions are supported if HAVING is
+ if (actionInfo.params.having) {
+ var functions = {
+ text: ts('FUNCTION'),
+ description: ts('Calculate result of a SQL function'),
+ children: _.transform(CRM.vars.api4.functions, function(result, fn) {
+ result.push({
+ id: fn.name + '() AS ' + fn.name.toLowerCase(),
+ text: fn.name + '()',
+ description: fn.name + '(' + describeSqlFn(fn.params) + ')'
+ });
+ })
+ };
+ $scope.fieldsAndJoinsAndFunctions.push(functions);
+ $scope.fieldsAndJoinsAndFunctionsWithSuffixes.push(functions);
+ $scope.fieldsAndJoinsAndFunctionsAndWildcards.push(functions);
+ }
+ addJoins($scope.fieldsAndJoinsAndFunctions, true);
+ addJoins($scope.fieldsAndJoinsAndFunctionsWithSuffixes, false, ['name', 'label']);
+ addJoins($scope.fieldsAndJoinsAndFunctionsAndWildcards, true, ['name', 'label']);
+ }
+ // Custom fields are supported if HAVING is
+ if (actionInfo.params.having) {
+ $scope.fieldsAndJoinsAndFunctionsAndWildcards.unshift({id: 'custom.*', text: 'custom.*', 'description': 'All custom fields'});
+ }
+ $scope.fieldsAndJoinsAndFunctionsAndWildcards.unshift({id: '*', text: '*', 'description': 'All core ' + $scope.entity + ' fields'});
+ };
+
function selectAction() {
$scope.action = $routeParams.api4action;
- $scope.fieldsAndJoins.length = 0;
- $scope.fieldsAndJoinsAndFunctions.length = 0;
- $scope.fieldsAndJoinsAndFunctionsWithSuffixes.length = 0;
- $scope.fieldsAndJoinsAndFunctionsAndWildcards.length = 0;
if (!actions.length) {
formatForSelect2(getEntity().actions, actions, 'name', ['description', 'params']);
}
if ($scope.action) {
var actionInfo = _.findWhere(actions, {id: $scope.action});
- $scope.fields = getFieldList($scope.action);
- if (_.contains(['get', 'update', 'delete', 'replace'], $scope.action)) {
- $scope.fieldsAndJoins = addJoins(getFieldList($scope.action, ['name']));
- var functions = [];
- // SQL functions are supported if HAVING is
- if (actionInfo.params.having) {
- functions.push({
- text: ts('FUNCTION'),
- description: ts('Calculate result of a SQL function'),
- children: _.transform(CRM.vars.api4.functions, function(result, fn) {
- result.push({
- id: fn.name + '() AS ' + fn.name.toLowerCase(),
- text: fn.name + '()',
- description: fn.name + '(' + describeSqlFn(fn.params) + ')'
- });
- })
- });
- }
- $scope.fieldsAndJoinsAndFunctions = addJoins($scope.fields.concat(functions), true);
- $scope.fieldsAndJoinsAndFunctionsWithSuffixes = addJoins(getFieldList($scope.action, ['name', 'label']).concat(functions), false, ['name', 'label']);
- $scope.fieldsAndJoinsAndFunctionsAndWildcards = addJoins(getFieldList($scope.action, ['name', 'label']).concat(functions), true, ['name', 'label']);
- } else {
- $scope.fieldsAndJoins = getFieldList($scope.action, ['name']);
- $scope.fieldsAndJoinsAndFunctions = $scope.fields;
- $scope.fieldsAndJoinsAndFunctionsWithSuffixes = getFieldList($scope.action, ['name', 'label']);
- $scope.fieldsAndJoinsAndFunctionsAndWildcards = getFieldList($scope.action, ['name', 'label']);
- }
- $scope.fieldsAndJoinsAndFunctionsAndWildcards.unshift({id: '*', text: '*', 'description': 'All core ' + $scope.entity + ' fields'});
_.each(actionInfo.params, function (param, name) {
var format,
defaultVal = _.cloneDeep(param.default);
}, true);
}
if (name === 'select' && actionInfo.params.having) {
- $scope.$watchCollection('params.select', function(values) {
+ $scope.$watchCollection('params.select', function(newSelect) {
+ // Ignore row_count, it can't be used in HAVING clause
+ var select = _.without(newSelect, 'row_count');
$scope.havingOptions.length = 0;
- _.each(values, function(item) {
- var pieces = item.split(' AS '),
+ // An empty select is an implicit *
+ if (!select.length) {
+ select.push('*');
+ }
+ _.each(select, function(item) {
+ var joinEntity,
+ pieces = item.split(' AS '),
alias = _.trim(pieces[pieces.length - 1]).replace(':label', ':name');
- $scope.havingOptions.push({id: alias, text: alias});
+ // Expand wildcards
+ if (alias[alias.length - 1] === '*') {
+ if (alias.length > 1) {
+ joinEntity = entityNameFromAlias(alias.slice(0, -2));
+ }
+ var fieldList = _.filter(getEntity(joinEntity).fields, {custom_field_id: null});
+ formatForSelect2(fieldList, $scope.havingOptions, 'name', ['description', 'required', 'default_value'], alias.slice(0, -1));
+ }
+ else {
+ $scope.havingOptions.push({id: alias, text: alias});
+ }
});
});
}
$timeout(function() {
if (field) {
if (name === 'join') {
- $scope.params[name].push([field + ' AS ' + _.snakeCase(field), false, '[]']);
+ $scope.params[name].push([field + ' AS ' + _.snakeCase(field), false]);
+ ctrl.buildFieldList();
}
else if (typeof objectParams[name] === 'undefined') {
$scope.params[name].push(field);
});
}
});
+ ctrl.buildFieldList();
$scope.availableParams = actionInfo.params;
}
writeCode();
paramCount = _.size(params),
i = 0;
- if (isSelectRowCount(params)) {
- results = result + 'Count';
- }
-
switch ($scope.selectedTab.code) {
case 'js':
case 'ang':
// Write oop code
code.oop = '$' + results + " = " + formatOOP(entity, action, params, 2) + "\n ->execute()";
- if (isSelectRowCount(params)) {
- code.oop += "\n ->count()";
- } else if (_.isNumber(index)) {
+ if (_.isNumber(index)) {
code.oop += !index ? '\n ->first()' : (index === -1 ? '\n ->last()' : '\n ->itemAt(' + index + ')');
} else if (index) {
if (_.isString(index) || (_.isPlainObject(index) && !index[0] && !index['0'])) {
}
}
code.oop += ";\n";
- if (!_.isNumber(index) && !isSelectRowCount(params)) {
+ if (!_.isNumber(index)) {
code.oop += "foreach ($" + results + ' as $' + ((_.isString(index) && index) ? index + ' => $' : '') + result + ') {\n // do something\n}';
}
break;
case 'cli':
- // Write cli code
- code.cv = 'cv api4 ' + entity + '.' + action + " '" + stringify(params) + "'";
+ // Cli code using json input
+ code.long = 'cv api4 ' + entity + '.' + action + ' ' + cliFormat(JSON.stringify(params));
+ code.pipe = 'echo ' + cliFormat(JSON.stringify(params)) + ' | cv api4 ' + entity + '.' + action + ' --in=json';
+
+ // Cli code using short syntax
+ code.short = 'cv api4 ' + entity + '.' + action;
+ var limitSet = false;
+ _.each(params, function(param, key) {
+ switch (true) {
+ case (key === 'select' && !_.includes(param.join(), ' ')):
+ code.short += ' +s ' + cliFormat(param.join(','));
+ break;
+ case (key === 'where' && !_.intersection(_.map(param, 0), ['AND', 'OR', 'NOT']).length):
+ _.each(param, function(clause) {
+ code.short += ' +w ' + cliFormat(clause[0] + ' ' + clause[1] + (clause.length > 2 ? (' ' + JSON.stringify(clause[2])) : ''));
+ });
+ break;
+ case (key === 'orderBy'):
+ _.each(param, function(dir, field) {
+ code.short += ' +o ' + cliFormat(field + ' ' + dir);
+ });
+ break;
+ case (key === 'values'):
+ _.each(param, function(val, field) {
+ code.short += ' +v ' + cliFormat(field + '=' + val);
+ });
+ break;
+ case (key === 'limit' || key === 'offset'):
+ // These 2 get combined
+ if (!limitSet) {
+ limitSet = true;
+ code.short += ' +l ' + (params.limit || '0') + (params.offset ? ('@' + params.offset) : '');
+ }
+ break;
+ default:
+ code.short += ' ' + key + '=' + (typeof param === 'string' ? cliFormat(param) : cliFormat(JSON.stringify(param)));
+ }
+ });
}
}
_.each($scope.code, function(vals) {
// Format oop params
function formatOOP(entity, action, params, indent) {
var code = '',
- newLine = "\n" + _.repeat(' ', indent);
+ newLine = "\n" + _.repeat(' ', indent),
+ perm = params.checkPermissions === false ? 'FALSE' : '';
if (entity.substr(0, 7) !== 'Custom_') {
- code = "\\Civi\\Api4\\" + entity + '::' + action + '()';
+ code = "\\Civi\\Api4\\" + entity + '::' + action + '(' + perm + ')';
} else {
- code = "\\Civi\\Api4\\CustomValue::" + action + "('" + entity.substr(7) + "')";
+ code = "\\Civi\\Api4\\CustomValue::" + action + "('" + entity.substr(7) + "'" + (perm ? ', ' : '') + perm + ")";
}
_.each(params, function(param, key) {
var val = '';
}
});
} else if (key === 'select') {
- code += newLine;
- // addSelect() is a variadic function & can take multiple arguments; selectRowCount() is a shortcut for addSelect('row_count')
- code += isSelectRowCount(params) ? '->selectRowCount()' : '->addSelect(' + phpFormat(param).slice(1, -1) + ')';
+ // selectRowCount() is a shortcut for addSelect('row_count')
+ if (isSelectRowCount(params)) {
+ code += newLine + '->selectRowCount()';
+ param = _.without(param, 'row_count');
+ }
+ // addSelect() is a variadic function & can take multiple arguments
+ if (param.length) {
+ code += newLine + '->addSelect(' + phpFormat(param).slice(1, -1) + ')';
+ }
} else if (key === 'chain') {
_.each(param, function(chain, name) {
code += newLine + "->addChain('" + name + "', " + formatOOP(chain[0], chain[1], chain[2], 2 + indent);
code += (chain.length > 3 ? ',' : '') + (!_.isEmpty(chain[2]) ? newLine : ' ') + (chain.length > 3 ? phpFormat(chain[3]) : '') + ')';
});
}
- else {
+ else if (key !== 'checkPermissions') {
code += newLine + "->set" + ucfirst(key) + '(' + phpFormat(param, 2 + indent) + ')';
}
});
$scope.loading = false;
$scope.status = resp.data && resp.data.debug && resp.data.debug.log ? 'warning' : 'success';
$scope.debug = debugFormat(resp.data);
- $scope.result = [formatMeta(resp.data), prettyPrintOne(_.escape(JSON.stringify(resp.data.values, null, 2)), 'js', 1)];
+ $scope.result = [
+ formatMeta(resp.data),
+ prettyPrintOne((_.isArray(resp.data.values) ? '(' + resp.data.values.length + ') ' : '') + _.escape(JSON.stringify(resp.data.values, null, 2)), 'js', 1)
+ ];
}, function(resp) {
$scope.loading = false;
$scope.status = 'danger';
$scope.debug = debugFormat(resp.data);
- $scope.result = [formatMeta(resp), prettyPrintOne(_.escape(JSON.stringify(resp.data, null, 2)))];
+ $scope.result = [
+ formatMeta(resp),
+ prettyPrintOne(_.escape(JSON.stringify(resp.data, null, 2)))
+ ];
});
};
return JSON.stringify(val).replace(/\$/g, '\\$');
}
+ // Format string to be cli-input-safe
+ function cliFormat(str) {
+ if (!_.includes(str, ' ') && !_.includes(str, '"') && !_.includes(str, "'")) {
+ return str;
+ }
+ if (!_.includes(str, "'")) {
+ return "'" + str + "'";
+ }
+ if (!_.includes(str, '"')) {
+ return '"' + str + '"';
+ }
+ return "'" + str.replace(/'/g, "\\'") + "'";
+ }
+
function fetchMeta() {
crmApi4(getMetaParams)
.then(function(data) {
};
});
- angular.module('api4Explorer').directive('crmApi4Clause', function($timeout) {
+ angular.module('api4Explorer').directive('crmApi4Clause', function() {
return {
scope: {
- data: '=crmApi4Clause'
+ data: '<crmApi4Clause'
},
templateUrl: '~/api4Explorer/Clause.html',
- link: function (scope, element, attrs) {
- var ts = scope.ts = CRM.ts();
- scope.newClause = '';
- scope.conjunctions = ['AND', 'OR', 'NOT'];
- scope.operators = CRM.vars.api4.operators;
+ controller: function ($scope, $element, $timeout) {
+ var ts = $scope.ts = CRM.ts(),
+ ctrl = $scope.$ctrl = this;
+ this.conjunctions = {AND: ts('And'), OR: ts('Or'), NOT: ts('Not')};
+ this.operators = CRM.vars.api4.operators;
+ this.sortOptions = {
+ axis: 'y',
+ connectWith: '.api4-clause-group-sortable',
+ containment: $element.closest('.api4-clause-fieldset'),
+ over: onSortOver,
+ start: onSort,
+ stop: onSort
+ };
- scope.addGroup = function(op) {
- scope.data.clauses.push([op, []]);
+ this.addGroup = function(op) {
+ $scope.data.clauses.push([op, []]);
};
- scope.removeGroup = function() {
- scope.data.groupParent.splice(scope.data.groupIndex, 1);
+ this.removeGroup = function() {
+ $scope.data.groupParent.splice($scope.data.groupIndex, 1);
};
- scope.onSort = function(event, ui) {
- $(element).closest('.api4-clause-fieldset').toggleClass('api4-sorting', event.type === 'sortstart');
+ function onSort(event, ui) {
+ $($element).closest('.api4-clause-fieldset').toggleClass('api4-sorting', event.type === 'sortstart');
$('.api4-input.form-inline').css('margin-left', '');
- };
+ }
// Indent clause while dragging between nested groups
- scope.onSortOver = function(event, ui) {
+ function onSortOver(event, ui) {
var offset = 0;
if (ui.sender) {
offset = $(ui.placeholder).offset().left - $(ui.sender).offset().left;
}
$('.api4-input.form-inline.ui-sortable-helper').css('margin-left', '' + offset + 'px');
- };
+ }
- scope.$watch('newClause', function(value) {
- var field = value;
+ this.addClause = function() {
$timeout(function() {
- if (field) {
- scope.data.clauses.push([field, '=', '']);
- scope.newClause = null;
+ if (ctrl.newClause) {
+ $scope.data.clauses.push([ctrl.newClause, '=', '']);
+ ctrl.newClause = null;
}
});
- });
- scope.$watch('data.clauses', function(values) {
- // Remove empty values
- _.each(values, function(clause, index) {
- if (typeof clause !== 'undefined' && !clause[0]) {
- values.splice(index, 1);
- }
- if (typeof clause[1] === 'string' && _.contains(clause[1], 'NULL')) {
- clause.length = 2;
- } else if (typeof clause[1] === 'string' && clause.length == 2) {
- clause.push('');
+ };
+ $scope.$watch('data.clauses', function(values) {
+ // Iterate in reverse order so index doesn't get out-of-sync during splice
+ _.forEachRight(values, function(clause, index) {
+ // Remove empty values
+ if (index >= ($scope.data.skip || 0)) {
+ if (typeof clause !== 'undefined' && !clause[0]) {
+ values.splice(index, 1);
+ }
+ // Add/remove value if operator allows for one
+ else if (typeof clause[1] === 'string' && _.contains(clause[1], 'NULL')) {
+ clause.length = 2;
+ } else if (typeof clause[1] === 'string' && clause.length === 2) {
+ clause.push('');
+ }
}
});
}, true);
scope.$watchCollection('data', function(data) {
destroyWidget();
var field = getField(data.field, entity, action);
- if (field) {
+ if (field && data.format !== 'plain') {
makeWidget(field, data.op);
}
});
return _.result(entity, 'fields');
}
+ function getExplicitJoins() {
+ return _.transform(params.join, function(joins, join) {
+ var j = join[0].split(' AS '),
+ joinEntity = _.trim(j[0]),
+ joinAlias = _.trim(j[1]) || joinEntity.toLowerCase();
+ joins[joinAlias] = joinEntity;
+ }, {});
+ }
+
function getField(fieldName, entity, action) {
var suffix = fieldName.split(':')[1];
fieldName = fieldName.split(':')[0];
return comboName;
}
var linkName = fieldNames.shift(),
- newEntity = _.findWhere(links[entity], {alias: linkName}).entity;
+ newEntity = getExplicitJoins()[linkName] || _.findWhere(links[entity], {alias: linkName}).entity;
return get(newEntity, fieldNames);
}
}