Преглед изворни кода

PR342 [editor] Add simple SQL query builder functionality

* Added query builder to beeswax and notebook.

* Added license header to query builder.

* Removed extra console log calls

* Added legal header to queryBuilder.css
Josh Walters пре 9 година
родитељ
комит
87df2a6

+ 46 - 0
apps/beeswax/src/beeswax/templates/execute.mako

@@ -358,6 +358,7 @@ ${ layout.menubar(section='query') }
         <ul class="nav nav-tabs">
           <li class="active recentLi"><a href="#recentTab" data-toggle="tab">${_('Recent queries')}</a></li>
           <li><a href="#query" data-toggle="tab">${_('Query')}</a></li>
+          <li><a href="#queryBuilderTab" data-toggle="tab">${_('Query builder')}</a></li>
           <!-- ko if: !design.explain() -->
           <li><a href="#log" data-toggle="tab">${_('Log')}</a></li>
           <!-- /ko -->
@@ -395,6 +396,15 @@ ${ layout.menubar(section='query') }
             <pre data-bind="visible: viewModel.design.statement() != '', text: viewModel.design.statement()"></pre>
           </div>
 
+          <div class="tab-pane" id="queryBuilderTab">
+            <form onsubmit="generateQuery(); return false;">
+              <table id="queryBuilder"></table>
+              <div class="button-panel">
+                <button class="btn btn-primary disable-feedback">${_('Build query')}</button>
+              </div>
+            </form>
+          </div>
+
           <!-- ko if: design.explain() -->
           <div class="tab-pane" id="explanation">
             <pre data-bind="text: $root.design.results.explanation()"></pre>
@@ -754,6 +764,18 @@ ${ layout.menubar(section='query') }
   </div>
 </div>
 
+<div id="invalidQueryBuilder" class="modal hide fade">
+  <div class="modal-header">
+    <a href="#" class="close" data-dismiss="modal">&times;</a>
+    <h3>${_('Invalid Query')}</h3>
+  </div>
+  <div class="modal-body">
+    <p>${_('Query requires a select or an aggregate.')}</p>
+  </div>
+  <div class="modal-footer">
+    <a class="btn" data-dismiss="modal">${_('Ok')}</a>
+  </div>
+</div>
 
 ${ commonshare() | n,unicode }
 
@@ -778,6 +800,30 @@ ${ commonshare() | n,unicode }
 <script src="${ static('desktop/js/share.vm.js') }"></script>
 <script src="${ static('desktop/js/vkbeautify.js') }" type="text/javascript" charset="utf-8"></script>
 
+<!-- For query builder -->
+<link rel="stylesheet" href="${ static('desktop/ext/css/jquery.contextMenu.min.css') }">
+<link rel="stylesheet" href="${ static('desktop/css/queryBuilder.css') }">
+<script src="${ static('desktop/ext/js/jquery/plugins/jquery.contextMenu.min.js') }"></script>
+<script src="${ static('desktop/ext/js/jquery/plugins/jquery.ui.position.min.js') }"></script>
+<script src="${ static('desktop/js/queryBuilder.js') }"></script>
+<script>
+  // query-builder-menu is the class to use
+  // Callback will run after each rule add, just focus to the queryBuilder tab
+  QueryBuilder.bindMenu('.query-builder-menu', function() {
+    $("a[href='#queryBuilderTab']").click();
+  });
+  function generateQuery() {
+      var result = QueryBuilder.buildHiveQuery();
+      if (result.status == "fail") {
+          $("#invalidQueryBuilder").modal("show");
+      } else {
+          codeMirror.setValue(result.query);
+          codeMirror.focus();
+      }
+  }
+</script>
+<!-- End query builder imports -->
+
 <script src="${ static('desktop/ext/js/codemirror-3.11.js') }"></script>
 <link rel="stylesheet" href="${ static('desktop/ext/css/codemirror.css') }">
 <script src="${ static('desktop/js/codemirror-hql.js') }"></script>

+ 81 - 0
desktop/core/src/desktop/static/desktop/css/queryBuilder.css

@@ -0,0 +1,81 @@
+/*
+ Licensed to Cloudera, Inc. under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  Cloudera, Inc. licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+#column li {
+    color: blue;
+    text-decoration: underline;
+    list-style: none;
+}
+#column li:hover {
+    cursor: pointer;
+}
+#queryBuilder {
+    text-align: center;
+    margin-right: auto;
+    margin-left: auto;
+}
+#queryBuilder {} .databaseAndTable {
+    color: #999;
+    min-width: 10em;
+}
+#queryBuilder {} .queryBuilderRow {
+    border-collapse: separate;
+    border-spacing-bottom: 10px;
+    cursor: default;
+}
+#queryBuilder {} .column {
+    font-family: monospace;
+    padding-left: 0.5em;
+}
+#queryBuilder {} .rule {
+    padding-left: 1em;
+}
+#queryBuilder {} .input {
+    padding-left: 1em;
+}
+#queryBuilder {} .deleteRule {
+    color: #999;
+    padding-left: 1em;
+}
+.deleteRule:hover {
+    color: #333;
+    cursor: pointer;
+}
+#queryBuilder {} .container {
+    width: 80%;
+    margin: auto;
+    min-width: 1100px;
+    max-width: 1300px;
+    position: relative;
+}
+#queryBuilder {} .ruleInputPlain {
+    margin-left: 1em;
+}
+#queryBuilder {} .noselect {
+    -webkit-touch-callout: none;
+    -webkit-user-select: none;
+    -khtml-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+}
+#queryBuilder {} .button-panel {
+    margin: auto;
+    text-align: center;
+    margin-top: 1em;
+}

Разлика између датотеке није приказан због своје велике величине
+ 14 - 0
desktop/core/src/desktop/static/desktop/ext/css/jquery.contextMenu.min.css


Разлика између датотеке није приказан због своје велике величине
+ 16 - 0
desktop/core/src/desktop/static/desktop/ext/js/jquery/plugins/jquery.contextMenu.min.js


Разлика између датотеке није приказан због своје велике величине
+ 3 - 0
desktop/core/src/desktop/static/desktop/ext/js/jquery/plugins/jquery.ui.position.min.js


+ 551 - 0
desktop/core/src/desktop/static/desktop/js/queryBuilder.js

@@ -0,0 +1,551 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+(function (window) {
+    'use strict';
+
+    function defineQueryBuilder() {
+        var QueryBuilder = {};
+        const PROJECT = 'Project',
+              AGGREGATE = 'Aggregate',
+              ORDER = 'Order',
+              FROM = 'From',
+              FILTER = 'Filter',
+              SELECT = "Select",
+              SELECT_DISTINCT = "Select distinct",
+              COUNT = "Count",
+              COUNT_DISTINCT = "Count distinct",
+              SUM = "Sum",
+              MINIMUM = "Minimum",
+              MAXIMUM = "Maximum",
+              AVERAGE = "Average",
+              IS_NULL = "Is null",
+              IS_NOT_NULL = "Is not null",
+              EQUAL_TO = "Equal to",
+              NOT_EQUAL_TO = "Not equal to",
+              GREATER_THAN = "Greater than",
+              LESS_THAN = "Less than",
+              ASCENDING = "Ascending",
+              DESCENDING = "Descending";
+
+        /** Callback to be used on rule add. */
+        QueryBuilder.callbackOnRuleAdd = null;
+
+        /**
+         * Format string
+         * @param {string} Input template string
+         * @param {...Object} Objects to be used in template
+         */
+        QueryBuilder.formatString = function (template) {
+            var i, regEx;
+            for (i = 1; i < arguments.length; i += 1) {
+                regEx = new RegExp("\\{" + (i - 1) + "\\}", "gm");
+                template = template.replace(regEx, arguments[i]);
+            }
+            return template;
+        };
+
+        /**
+         * Get row by datbase, table, column, and rule
+         * @param {string} database
+         * @param {string} table
+         * @param {string} column
+         * @param {string} rule
+         * @return {Object} Query rule row
+         */
+        QueryBuilder.getRule = function (database, table, column, rule) {
+            return $(`.queryBuilderRow[database='${database}'][table='${table}'][column='${column}'][rule='${rule}']`);
+        };
+
+        /**
+         * Count number of rules by database, table, column and category combination.
+         * @param {string} database
+         * @param {string} table
+         * @param {string} column
+         * @param {string} category
+         * @return {number} Number of matching rows
+         */
+        QueryBuilder.countRulesByCategory = function(database, table, column, category) {
+            return $(`.queryBuilderRow[database='${database}'][table='${table}'][column='${column}'][category='${category}']`).length;
+        };
+
+        /**
+         * Get the first row matching the database, table and column.
+         * @param {string} database
+         * @param {string} table
+         * @param {string} column
+         * @return {Object} Query row
+         */
+        QueryBuilder.getFirstMatchingRow = function(database, table, column) {
+            return $(`.queryBuilderRow[database='${database}'][table='${table}'][column='${column}']`).first();
+        };
+
+        /**
+         * Count the number of rules matching database, table and column.
+         * @param {string} database
+         * @param {string} table
+         * @param {string} column
+         * @return {number} Number of matching rules
+         */
+        QueryBuilder.countMatchingRows = function(database, table, column) {
+            return $(`.queryBuilderRow[database='${database}'][table='${table}'][column='${column}']`).length;
+        };
+
+        /**
+         * Deletes a rule from the query builder.
+         * @param {Object} row Row to delete
+         */
+        QueryBuilder.deleteRule = function(row) {
+            var database = row.attr('database');
+            var table = row.attr('table');
+            var column = row.attr('column');
+            var isVisible = row.find('.databaseAndTable').css('visibility');
+
+            // Remove the row
+            row.remove();
+
+            // Check if row was the first row, and if there are other rows for this column
+            if (isVisible == 'visible' && QueryBuilder.countMatchingRows(database, table, column) > 0) {
+                // Change the visibility for the next row
+                var nextRow = QueryBuilder.getFirstMatchingRow(database, table, column);
+                nextRow.children('.databaseAndTable').css('visibility', 'visible');
+                nextRow.children('.column').css('visibility', 'visible');
+            }
+        };
+
+        /**
+         * Add rule to table.
+         * @param{string} database Database to use
+         * @param{string} table Table to use
+         * @param{string} column Column to use
+         * @param{string} rule Rule name
+         * @param{string} category Category for the rule
+         * @param{string} template Template string to use for building the query
+         * @param{boolean} hasInput Set to true if the rule uses input
+         * @param{boolean} allowDuplicate Set to true if the rule allows duplicates
+         */
+        QueryBuilder.addRule = function(database, table, column, rule, category, template, hasInput, allowDuplicate) {
+            // Check if we allow duplicates of this rule
+            if (!allowDuplicate) {
+                // Check if rule already exists
+                if (QueryBuilder.getRule(database, table, column, rule).length) {
+                    // Don't add rule
+                    return;
+                }
+            }
+
+            // Count matching rules, used later to hide
+            var numOfMatchingRows = QueryBuilder.countMatchingRows(database, table, column);
+
+            // Should we hide databaseAndTable and column?
+            var visibility = '';
+            if (numOfMatchingRows) {
+                visibility = `style='visibility:hidden'`;
+            }
+
+            // Should we have an input field?
+            var inputField = '';
+            if (hasInput) {
+                inputField = `<input type='text' placeholder='Add here' required>`;
+            }
+
+            // Used to store new rule
+            var row = `<tr class='queryBuilderRow' database='${database}' table='${table}' column='${column}' rule='${rule}' category='${category}' template='${template}'>
+                       <td class='databaseAndTable' ${visibility}>${database}.${table}</td>
+                       <td class='column' ${visibility}>${column}</td>
+                       <td class='rule'>${rule}</td>
+                       <td class='input'>${inputField}</td>
+                       <td class='deleteRule' onclick='QueryBuilder.deleteRule($(this).closest("tr"))'>✕</td>
+                       </tr>`;
+
+            // Check if the column is already in the rules
+            if (numOfMatchingRows) {
+                // Add after the last rule for this column
+                $(`.queryBuilderRow[database='${database}'][table='${table}'][column='${column}']`).last().after(row);
+            } else {
+                // Add at end of rules
+                $('#queryBuilder').append(row);
+            }
+
+            // Perform callback.
+            QueryBuilder.callbackOnRuleAdd();
+        };
+
+
+        /**
+         * Add database and table to query from list.
+         * @param {string} category
+         * @param {object} query JSON query object
+         * @param {object} row Row rule object
+         */
+        QueryBuilder.addRuleToQuery = function(category, query, row) {
+            query[category].push({
+                "rule": row.attr('rule'),
+                "column": row.attr('column'),
+                "table": row.attr('table'),
+                "input": row.find('input').val(),
+                "template": row.attr('template')
+            });
+
+            // Add database and table to data sources
+            if (!query[FROM].hasOwnProperty(row.attr('database'))) {
+                query[FROM][row.attr('database')] = [];
+            }
+            if (!query[FROM][row.attr('database')].hasOwnProperty(row.attr('table'))) {
+                query[FROM][row.attr('database')].push(row.attr('table'));
+            }
+        };
+
+        /**
+         * Convert the rules into a JSON object.
+         * @return {Object} JSON representation of the query
+         */
+        QueryBuilder.convertRulesToJson = function() {
+            // Stores the JSONified query
+            var query = {
+                [PROJECT]: [],
+                [AGGREGATE]: [],
+                [FILTER]: [],
+                [ORDER]: [],
+                [FROM]: {}
+            };
+
+            // Populate project
+            $(`.queryBuilderRow[category='${PROJECT}']`).each(function() {
+                QueryBuilder.addRuleToQuery(PROJECT, query, $(this));
+            });
+
+            // Populate aggregate
+            $(`.queryBuilderRow[category='${AGGREGATE}']`).each(function() {
+                QueryBuilder.addRuleToQuery(AGGREGATE, query, $(this));
+            });
+
+            // Populate filter
+            $(`.queryBuilderRow[category='${FILTER}']`).each(function() {
+                QueryBuilder.addRuleToQuery(FILTER, query, $(this));
+            });
+
+            // Populate order
+            $(`.queryBuilderRow[category='${ORDER}']`).each(function() {
+                QueryBuilder.addRuleToQuery(ORDER, query, $(this));
+            });
+
+            return query;
+        };
+
+        // Build a SQL query based on rules
+        /**
+         * Build a HiveQL query based on the provided rules.
+         * @param {string} X
+         * @return {{status: String, message: String, query: String}} JSON
+         *         object result. A 'status' of 'fail' will have error message in
+         *         'message'. A 'status' of 'pass' will have query in 'query'.
+         */
+        QueryBuilder.buildHiveQuery = function() {
+            // Get the rules in JSON format
+            var rules = QueryBuilder.convertRulesToJson();
+
+            // First, validate provided rules
+            if (rules[PROJECT].length < 1 && rules[AGGREGATE].length < 1) {
+                return {
+                    "status": "fail",
+                    "message": "Query requires a select or aggregate."
+                };
+            }
+
+            // Stores the final rules
+            var finalQuery;
+            // Indent for select
+            var selectIndent;
+            // Check if using SELECT DISTINCT
+            if ($(`.queryBuilderRow[rule='${SELECT_DISTINCT}']`).length > 0) {
+                finalQuery = 'SELECT DISTINCT ';
+                selectIndent = '                ';
+            } else {
+                finalQuery = 'SELECT ';
+                selectIndent = '       ';
+            }
+
+            // Build SELECT rules
+            var selectColumns = [];
+            $.each(rules[PROJECT], function(index, value) {
+                selectColumns.push(value.table + '.' + value.column);
+            });
+
+            // Build aggregate rules using templates
+            // {0} == database
+            // {1} == table
+            // {2} == input
+            var aggregateColumns = [];
+            $.each(rules[AGGREGATE], function(index, value) {
+                aggregateColumns.push(QueryBuilder.formatString(value.template, value.table, value.column, value.input));
+            });
+
+            // Add select and aggregate columns to final rules
+            finalQuery += $.merge($.merge([], selectColumns), aggregateColumns).join(',\n' + selectIndent);
+
+            // Build FROM rules
+            var databaseAndTables = [];
+            $.each(rules[FROM], function(database, tableArray) {
+                $.each(tableArray, function(i, table) {
+                    databaseAndTables.push(database + '.' + table);
+                });
+            });
+            $.unique(databaseAndTables);
+            finalQuery += '\n' + 'FROM ' + databaseAndTables.join(',\n     ');
+
+            // Build WHERE rules
+            var whereColumns = [];
+            $.each(rules[FILTER], function(index, value) {
+                // Check if the string is numeric
+                if (/^\d+$/.test(value.input)) {
+                    whereColumns.push(QueryBuilder.formatString(value.template, value.table, value.column, value.input));
+                } else {
+                    whereColumns.push(QueryBuilder.formatString(value.template, value.table, value.column, "'" + value.input + "'"));
+                }
+            });
+            if (whereColumns.length > 0) {
+                finalQuery += '\n' + 'WHERE ' + whereColumns.join('\n  AND ');
+            }
+
+            // Build GROUP rules
+            // If we have any aggregates, group on all projected columns
+            if (aggregateColumns.length > 0) {
+                if (selectColumns.length > 0) {
+                    finalQuery += '\n' + 'GROUP BY ' + selectColumns.join(',\n         ');
+                }
+            }
+
+            // Build ORDER rules
+            var orderColumns = [];
+            $.each(rules[ORDER], function(index, value) {
+                orderColumns.push(QueryBuilder.formatString(value.template, value.table, value.column));
+            });
+            if (orderColumns.length > 0) {
+                finalQuery += '\n' + 'ORDER BY ' + orderColumns.join(',\n         ');
+            }
+
+            // Add final semicolon
+            finalQuery += ';';
+
+            // Return the output query
+            return {
+                "status": "pass",
+                "query": finalQuery
+            };
+        }
+
+        /**
+         * Binds context menu to a provided selector class.
+         * Each object using the selector class is expected to have several attributes: 'database', 'table' and 'column'
+         * Each rule internally has a template that will be used. The format is '{0}' -> database, {1} -> table, {2} -> input
+         * @param {string} selectorClass The selector class to use.
+         */
+        QueryBuilder.bindMenu = function(selectorClass, callbackOnRuleAdd) {
+            QueryBuilder.callbackOnRuleAdd = callbackOnRuleAdd;
+            $.contextMenu({
+                selector: selectorClass,
+                trigger: 'left',
+                zIndex: 1000,
+                build: function($trigger, e) {
+                    // Get the database, table, and column
+                    var database = $trigger.attr('database');
+                    var table = $trigger.attr('table');
+                    var column = $trigger.attr('column');
+                    var result = {
+                        items: {
+                            "column-name": {
+                                name: column,
+                                disabled: true
+                            },
+                            "sep1": "---------",
+                            [PROJECT]: {
+                                "name": PROJECT,
+                                "items": {
+                                    [SELECT]: {
+                                        "name": SELECT,
+                                        callback: function(key, options) {
+                                            QueryBuilder.addRule(database, table, column, SELECT, PROJECT, "{0}", false, false);
+                                        },
+                                    },
+                                    [SELECT_DISTINCT]: {
+                                        "name": SELECT_DISTINCT,
+                                        callback: function(key, options) {
+                                            QueryBuilder.addRule(database, table, column, SELECT_DISTINCT, PROJECT, "{0}", false, false);
+                                        },
+                                    }
+                                }
+                            },
+                            [AGGREGATE]: {
+                                "name": AGGREGATE,
+                                "items": {
+                                    [COUNT]: {
+                                        "name": COUNT,
+                                        callback: function(key, options) {
+                                            QueryBuilder.addRule(database, table, column, COUNT, AGGREGATE, "COUNT({0}.{1}) as count_{1}", false, false);
+                                        },
+                                    },
+                                    [COUNT_DISTINCT]: {
+                                        "name": COUNT_DISTINCT,
+                                        callback: function(key, options) {
+                                            QueryBuilder.addRule(database, table, column, COUNT_DISTINCT, AGGREGATE, "COUNT(DISTINCT {0}.{1}) as distinct_count_{1}", false, false);
+                                        },
+                                    },
+                                    [SUM]: {
+                                        "name": SUM,
+                                        callback: function(key, options) {
+                                            QueryBuilder.addRule(database, table, column, SUM, AGGREGATE, "SUM({0}.{1}) as sum_{1}", false, false);
+                                        },
+                                    },
+                                    [MINIMUM]: {
+                                        "name": MINIMUM,
+                                        callback: function(key, options) {
+                                            QueryBuilder.addRule(database, table, column, MINIMUM, AGGREGATE, "MIN({0}.{1}) as min_{1}", false, false);
+                                        },
+                                    },
+                                    [MAXIMUM]: {
+                                        "name": MAXIMUM,
+                                        callback: function(key, options) {
+                                            QueryBuilder.addRule(database, table, column, MAXIMUM, AGGREGATE, "MAX({0}.{1}) as max_{1}", false, false);
+                                        },
+                                    },
+                                    [AVERAGE]: {
+                                        "name": AVERAGE,
+                                        callback: function(key, options) {
+                                            QueryBuilder.addRule(database, table, column, AVERAGE, AGGREGATE, "AVG({0}.{1}) as avg_{1}", false, false);
+                                        },
+                                    }
+                                }
+                            },
+                            [FILTER]: {
+                                "name": FILTER,
+                                "items": {
+                                    [IS_NULL]: {
+                                        "name": IS_NULL,
+                                        callback: function(key, options) {
+                                            // Check if there is a 'is not null' rule
+                                            var isNotNullRule = QueryBuilder.getRule(database, table, column, IS_NOT_NULL);
+                                            if (isNotNullRule.length) {
+                                                // Update it to be 'is null'
+                                                isNotNullRule.attr('rule', IS_NULL);
+                                                isNotNullRule.find('.rule').text(IS_NULL);
+                                            } else {
+                                                QueryBuilder.addRule(database, table, column, IS_NULL, FILTER, "{0}.{1} = null", false, false);
+                                            }
+                                        },
+                                    },
+                                    [IS_NOT_NULL]: {
+                                        "name": IS_NOT_NULL,
+                                        callback: function(key, options) {
+                                            // Check if there is a 'is null' rule
+                                            var isNullRule = QueryBuilder.getRule(database, table, column, IS_NULL);
+                                            if (isNullRule.length) {
+                                                // Update it to be 'is null'
+                                                isNullRule.attr('rule', IS_NOT_NULL);
+                                                isNullRule.find('.rule').text(IS_NOT_NULL);
+                                            } else {
+                                                QueryBuilder.addRule(database, table, column, IS_NOT_NULL, FILTER, "{0}.{1} != null", false, false);
+                                            }
+                                        },
+                                    },
+                                    [EQUAL_TO]: {
+                                        "name": EQUAL_TO,
+                                        callback: function(key, options) {
+                                            QueryBuilder.addRule(database, table, column, EQUAL_TO, FILTER, "{0}.{1} = {2}", true, true);
+                                        },
+                                    },
+                                    [NOT_EQUAL_TO]: {
+                                        "name": NOT_EQUAL_TO,
+                                        callback: function(key, options) {
+                                            QueryBuilder.addRule(database, table, column, NOT_EQUAL_TO, FILTER, "{0}.{1} != {2}", true, true);
+                                        },
+                                    },
+                                    [GREATER_THAN]: {
+                                        "name": GREATER_THAN,
+                                        callback: function(key, options) {
+                                            QueryBuilder.addRule(database, table, column, GREATER_THAN, FILTER, "{0}.{1} > {2}", true, false);
+                                        },
+                                    },
+                                    [LESS_THAN]: {
+                                        "name": LESS_THAN,
+                                        callback: function(key, options) {
+                                            QueryBuilder.addRule(database, table, column, LESS_THAN, FILTER, "{0}.{1} < {2}", true, false);
+                                        },
+                                    },
+                                },
+                            },
+                            [ORDER]: {
+                                "name": ORDER,
+                                "items": {
+                                    [ASCENDING]: {
+                                        "name": ASCENDING,
+                                        callback: function(key, options) {
+                                            // Check if there is a descending rule
+                                            var descendingRule = QueryBuilder.getRule(database, table, column, DESCENDING);
+                                            if (descendingRule.length) {
+                                                // Update it to be 'ascending'
+                                                descendingRule.attr('rule', ASCENDING);
+                                                descendingRule.find('.rule').text(ASCENDING);
+                                            } else {
+                                                QueryBuilder.addRule(database, table, column, ASCENDING, ORDER, "{0}.{1} ASC", false, false);
+                                            }
+                                        },
+                                    },
+                                    [DESCENDING]: {
+                                        "name": DESCENDING,
+                                        callback: function(key, options) {
+                                            // Check if there is a ascending rule
+                                            var ascendingRule = QueryBuilder.getRule(database, table, column, ASCENDING);
+                                            if (ascendingRule.length) {
+                                                // Update it to be 'descending'
+                                                ascendingRule.attr('rule', DESCENDING);
+                                                ascendingRule.find('.rule').text(DESCENDING);
+                                            } else {
+                                                QueryBuilder.addRule(database, table, column, DESCENDING, ORDER, "{0}.{1} DESC", false, false);
+                                            }
+                                        },
+                                    }
+                                }
+                            }
+                        }
+                    };
+
+                    // If select rule used, remove project and aggregates
+                    if (QueryBuilder.countRulesByCategory(database, table, column, PROJECT)) {
+                        delete result.items[PROJECT];
+                        delete result.items[AGGREGATE];
+                    }
+
+                    // If aggretes rule(s) used, remove project
+                    if (QueryBuilder.countRulesByCategory(database, table, column, AGGREGATE)) {
+                        delete result.items[PROJECT];
+                    }
+
+                    return result;
+                }
+            });
+        }
+
+        return QueryBuilder;
+    }
+
+    // Define QueryBuilder globally is it does not already exist
+    if (typeof(QueryBuilder) === 'undefined') {
+        window.QueryBuilder = defineQueryBuilder();
+    } else {
+        console.log("QueryBuilder already defined.");
+    }
+})(window);

+ 2 - 2
desktop/core/src/desktop/templates/assist.mako

@@ -435,7 +435,7 @@ from desktop.views import _ko
           <!-- ko if: definition.isView -->
             <i class="fa fa-fw fa-eye muted valign-middle"></i>
           <!-- /ko -->
-          <span draggable="true" data-bind="text: definition.displayName, draggableText: { text: editorText }"></span>
+          <span draggable="true" data-bind="css: {'query-builder-menu': definition.isColumn}, attr: {'column': columnName, 'table': tableName, 'database': databaseName}, text: definition.displayName, draggableText: { text: editorText }"></span>
         </a>
         <div class="center" data-bind="visible: loading" style="display:none;"><i class="fa fa-spinner fa-spin assist-spinner"></i></div>
         <!-- ko template: { if: open, name: 'assist-db-entries'  } --><!-- /ko -->
@@ -1273,4 +1273,4 @@ from desktop.views import _ko
       });
     }));
   </script>
-</%def>
+</%def>

+ 48 - 1
desktop/libs/notebook/src/notebook/templates/editor_components.mako

@@ -43,6 +43,29 @@ from desktop.views import _ko
 <script src="${ static('desktop/ext/js/jquery/plugins/jquery.hotkeys.js') }"></script>
 <script src="${ static('desktop/ext/js/jquery/plugins/jquery.mousewheel.min.js') }"></script>
 
+<!-- For query builder -->
+<link rel="stylesheet" href="${ static('desktop/ext/css/jquery.contextMenu.min.css') }">
+<link rel="stylesheet" href="${ static('desktop/css/queryBuilder.css') }">
+<script src="${ static('desktop/ext/js/jquery/plugins/jquery.contextMenu.min.js') }"></script>
+<script src="${ static('desktop/ext/js/jquery/plugins/jquery.ui.position.min.js') }"></script>
+<script src="${ static('desktop/js/queryBuilder.js') }"></script>
+<script>
+  // query-builder-menu is the class to use
+  // Callback will run after each rule add, just focus to the queryBuilder tab
+  QueryBuilder.bindMenu('.query-builder-menu', function() {
+    $("a[href='#queryBuilderTab']").click();
+  });
+  function generateQuery() {
+      var result = QueryBuilder.buildHiveQuery();
+      if (result.status == "fail") {
+          $("#invalidQueryBuilder").modal("show");
+      } else {
+          replaceAce(result.query);
+      }
+  }
+</script>
+<!-- End query builder imports -->
+
 <script src="${ static('desktop/ext/js/bootstrap-editable.min.js') }" type="text/javascript" charset="utf-8"></script>
 <script src="${ static('desktop/ext/chosen/chosen.jquery.min.js') }" type="text/javascript" charset="utf-8"></script>
 <script src="${ static('desktop/js/vkbeautify.js') }" type="text/javascript" charset="utf-8"></script>
@@ -312,6 +335,19 @@ ${ hueIcons.symbols() }
   </div>
 </div>
 
+<div id="invalidQueryBuilder" class="modal hide">
+  <div class="modal-header">
+    <a href="#" class="close" data-dismiss="modal">&times;</a>
+    <h3>${_('Invalid Query')}</h3>
+  </div>
+  <div class="modal-body">
+    <p>${_('Query requires a select or an aggregate.')}</p>
+  </div>
+  <div class="modal-footer">
+    <a class="btn" data-dismiss="modal">${_('Ok')}</a>
+  </div>
+</div>
+
 <a title="${_('Toggle Assist')}" class="pointer show-assist" data-bind="visible: !$root.isLeftPanelVisible() && $root.assistAvailable(), click: function() { wasAssistVisible = true; $root.isLeftPanelVisible(true); }">
   <i class="fa fa-chevron-right"></i>
 </a>
@@ -455,6 +491,7 @@ ${ hueIcons.symbols() }
           </a>
         </li>
         <li data-bind="click: function(){ currentQueryTab('savedQueries'); }"><a class="inactive-action" href="#savedQueries" data-toggle="tab">${_('Saved Queries')}</a></li>
+        <li data-bind="click: function(){ currentQueryTab('queryBuilderTab'); }"><a class="inactive-action" href="#queryBuilderTab" data-toggle="tab">${_('Query Builder')}</a></li>
       </ul>
       <div class="tab-content" style="border: none">
         <div class="tab-pane active" id="queryHistory">
@@ -512,6 +549,15 @@ ${ hueIcons.symbols() }
           </div>
           <!-- /ko -->
         </div>
+
+        <div class="tab-pane" id="queryBuilderTab">
+          <!--form onsubmit="generateQuery(); return false;"-->
+            <table id="queryBuilder"></table>
+            <div class="button-panel">
+              <button class="btn btn-primary disable-feedback" onclick="generateQuery()">${_('Build query')}</button>
+            </div>
+          <!--/form-->
+        </div>
       </div>
 
     </div>
@@ -2051,10 +2097,11 @@ ${ hueIcons.symbols() }
       var snip = viewModel.selectedNotebook().snippets()[0];
       if (snip) {
         snip.statement_raw(content);
-        snip.ace().setValue(content);
+        snip.ace().setValue(content, 1);
       }
       hideHoverMsg(viewModel);
     }
+    window.replaceAce = replaceAce;
 
     function addMarkdown (content) {
       var snip = viewModel.selectedNotebook().addSnippet({type: "markdown", result: {}}, true);

Неке датотеке нису приказане због велике количине промена