Merge pull request #18286 from sunilpawar/ui_30
[civicrm-core.git] / ang / api4Explorer / Explorer.js
index dc4ac31ca5b8872e93cb7dc65f8af9e9a339546d..7c64b6e6671c9d1e39d974db47e59bce121fed48 100644 (file)
@@ -10,6 +10,8 @@
   var actions = [];
   // Field options
   var fieldOptions = {};
+  // Api params
+  var params;
 
 
   angular.module('api4Explorer').config(function($routeProvider) {
@@ -21,7 +23,8 @@
   });
 
   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 = [];
@@ -31,7 +34,7 @@
     $scope.fieldsAndJoinsAndFunctionsWithSuffixes = [];
     $scope.fieldsAndJoinsAndFunctionsAndWildcards = [];
     $scope.availableParams = {};
-    $scope.params = {};
+    params = $scope.params = {};
     $scope.index = '';
     $scope.selectedTab = {result: 'result', code: 'php'};
     $scope.perm = {
@@ -53,7 +56,8 @@
     $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: ''},
@@ -68,7 +72,9 @@
         {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);
     }
   }