This is an automated email from the ASF dual-hosted git repository.
voidmatcha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new 00abf92f57 [MINOR] Interpreter completion candidates are filtered out
of the suggestion list
00abf92f57 is described below
commit 00abf92f578dac3447631c7bdc1d9f81c46bac07
Author: Changhoon Oh <[email protected]>
AuthorDate: Tue Sep 1 19:04:12 2026 +0900
[MINOR] Interpreter completion candidates are filtered out of the
suggestion list
### What is this PR for?
When an interpreter fills in the `meta` field of its completion candidates,
none of them reach the suggestion list. Pressing `Ctrl+.` shows nothing at all,
even though the backend answered with valid candidates.
`completionSupportWithBackend` decides what to keep by asking whether an
item carries a `meta` value:
```js
matches = matches.filter(function(item) {
if (!_.isEmpty(item.meta)) {
if (completionListLength !== 0) {
return false;
}
}
...
```
The intent of
[ZEPPELIN-3001](https://issues.apache.org/jira/browse/ZEPPELIN-3001)
(`fe07e5a49`) was to hide ace's own local/keyword suggestions once the
interpreter has answered, and `meta` was used as the marker for "this candidate
came from ace". That assumption does not hold — interpreters tag their own
candidates with `meta` as well. `SqlCompleter` sends `schema`, `table` and
`column`:
```java
addCompletions(candidates, schemaCandidates, CompletionType.schema.name());
addCompletions(candidates, columnCandidates, CompletionType.column.name());
addCompletions(candidates, tableCandidates, CompletionType.table.name());
```
So the filter drops the backend's own suggestions along with ace's, and the
list ends up empty.
The failure looks intermittent because the guard only lets anything through
while `completionListLength` happens to be `0`. That variable is declared per
paragraph controller but is updated through `$rootScope.$broadcast`, and it is
reset to `undefined` at the end of every filter pass — so whether anything
shows depends on what the other paragraphs in the note last answered.
Interpreters that leave `meta` empty are unaffected, which is why this went
unnoticed for so long.
This PR marks the candidates built by `remoteCompleter` with an explicit
`fromBackend` flag and filters on that instead of on `meta`. The original
intent is preserved — ace's own suggestions are still hidden once the
interpreter has answered — but the interpreter's candidates are always kept.
### What type of PR is it?
Bug Fix
### Todos
* [x] - Mark interpreter-provided candidates with an explicit `fromBackend`
flag
* [x] - Filter on that flag instead of on `meta`
### Related Jira issue (w/ regression) ?
* https://issues.apache.org/jira/browse/ZEPPELIN-3001
### How should this be tested?
No automated test is included: the change lives in the AngularJS notebook
UI (`zeppelin-web/src/app/notebook/paragraph/paragraph.controller.js`), which
has no test harness covering ace's completion pipeline. Suggestions on how to
cover it are welcome.
Manual steps, using any interpreter that tags `meta` — the JDBC family does:
1. Bind the `jdbc` interpreter to a database that has at least one schema
with tables.
2. In a paragraph, type `SELECT * FROM <schema>.` and press `Ctrl+.`.
* **Before:** nothing appears. Occasionally the full ace list appears
instead, depending on what the other paragraphs in the note last answered.
* **After:** the schema's tables are listed with the `table` meta label,
consistently on every press, and ace's `local`/`keyword` entries are hidden as
ZEPPELIN-3001 intended.
3. Press `Ctrl+.` five or six times in a row and confirm the list is
identical every time.
4. Repeat in a paragraph bound to an interpreter that leaves `meta` empty
(for example `python`) and confirm its behavior is unchanged.
### Before & After
| | |
|---|---|
| **Before** | _(empty suggestion list on a JDBC paragraph after `Ctrl+.`)_
|
| **After** | _(the schema's tables listed with the `table` label)_ |
### Questions:
* Does the license files need to update? — No.
* Is there breaking changes for older versions? — No. The filter keeps its
original behavior for
interpreters that leave `meta` empty; only candidates that were
previously dropped by mistake now
appear.
* Does this needs documentation? — No.
Closes #5415 from okayhooni/hotfix/auto-completion.
Signed-off-by: YONGJAE LEE <[email protected]>
---
.../org/apache/zeppelin/jdbc/SqlCompleter.java | 3 +-
.../app/notebook/paragraph/paragraph.controller.js | 15 +-
.../paragraph/paragraph.controller.test.js | 159 +++++++++++++++++++++
3 files changed, 172 insertions(+), 5 deletions(-)
diff --git a/jdbc/src/main/java/org/apache/zeppelin/jdbc/SqlCompleter.java
b/jdbc/src/main/java/org/apache/zeppelin/jdbc/SqlCompleter.java
index 20dc8d755d..8631e036f8 100644
--- a/jdbc/src/main/java/org/apache/zeppelin/jdbc/SqlCompleter.java
+++ b/jdbc/src/main/java/org/apache/zeppelin/jdbc/SqlCompleter.java
@@ -333,7 +333,8 @@ public class SqlCompleter {
}
LOGGER.info("Completer initialized with " + schemas.size() + "
schemas, " +
- columns.size() + " tables and " + keywords.size() + " keywords");
+ tables.size() + " tables, " + columns.size() + " columns and " +
+ keywords.size() + " keywords");
}
} catch (SQLException | IOException e) {
diff --git a/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.js
b/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.js
index 69ee634af0..51acc75f5f 100644
--- a/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.js
+++ b/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.js
@@ -878,6 +878,12 @@ function ParagraphCtrl($scope, $rootScope, $route,
$window, $routeParams, $locat
meta: v.meta,
caption: computeCaption(v.name, v.meta),
score: 300,
+ // Marks this candidate as coming from the interpreter, so
that
+ // completionSupportWithBackend can tell it apart from
ace's own local/keyword
+ // completions. `meta` cannot serve that purpose:
interpreters are free to fill it
+ // in (SqlCompleter sends schema/table/column), and using
it as the discriminator
+ // made those very suggestions disappear.
+ fromBackend: true,
});
}
}
@@ -1084,10 +1090,11 @@ function ParagraphCtrl($scope, $rootScope, $route,
$window, $routeParams, $locat
let prev = null;
matches = matches.filter(function(item) {
- if (!_.isEmpty(item.meta)) {
- if (completionListLength !== 0) {
- return false;
- }
+ // ZEPPELIN-3001 intent: once the interpreter has answered, hide ace's
own local/keyword
+ // suggestions so the list only shows what the backend knows. Candidates
coming from the
+ // interpreter are always kept - they are the reason it was asked in the
first place.
+ if (!item.fromBackend && completionListLength !== 0) {
+ return false;
}
let caption = item.snippet || item.caption || item.value;
if (caption === prev) {
diff --git
a/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.test.js
b/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.test.js
index 38d5480e02..2948cfdf4a 100644
--- a/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.test.js
+++ b/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.test.js
@@ -2,6 +2,7 @@ describe('Controller: ParagraphCtrl', function() {
beforeEach(angular.mock.module('zeppelinWebApp'));
let scope;
+ let rootScope;
let websocketMsgSrvMock = {};
let paragraphMock = {
config: {},
@@ -19,6 +20,7 @@ describe('Controller: ParagraphCtrl', function() {
beforeEach(inject(function($controller, $rootScope) {
scope = $rootScope.$new();
+ rootScope = $rootScope;
$rootScope.notebookScope = $rootScope.$new(true, $rootScope);
$controller('ParagraphCtrl', {
@@ -50,4 +52,161 @@ describe('Controller: ParagraphCtrl', function() {
it('should set default value of "paragraphFocused" as false', function() {
expect(scope.paragraphFocused).toEqual(false);
});
+
+ describe('completion candidate filtering', function() {
+ let FilteredList;
+ let originalSetFilter;
+
+ let completionParagraph = {
+ id: 'paragraph_completion',
+ config: {
+ editorSetting: {
+ completionSupport: true,
+ },
+ },
+ settings: {
+ forms: {},
+ },
+ };
+
+ let fromInterpreter = function(value, meta) {
+ return {value: value, caption: value, meta: meta, score: 300,
fromBackend: true};
+ };
+
+ let fromAce = function(value, meta) {
+ return {value: value, caption: value, meta: meta, score: 0};
+ };
+
+ // the filter lives on ace's FilteredList prototype, installed on focus
+ let applyFilter = function(candidates) {
+ let list = new FilteredList(candidates);
+ list.setFilter('');
+ return list.filtered;
+ };
+
+ beforeEach(function() {
+ FilteredList = ace.require('ace/autocomplete').FilteredList;
+ originalSetFilter = FilteredList.prototype.setFilter;
+ scope.init(completionParagraph);
+ rootScope.$broadcast('focusParagraph', completionParagraph.id, 0, 0,
true);
+ });
+
+ afterEach(function() {
+ FilteredList.prototype.setFilter = originalSetFilter;
+ });
+
+ it('should keep interpreter candidates that carry a meta label',
function() {
+ let table = fromInterpreter('my_table', 'table');
+ expect(applyFilter([table])).toContain(table);
+ });
+
+ it('should hide ace candidates while interpreter candidates are
available', function() {
+ let table = fromInterpreter('my_table', 'table');
+ let local = fromAce('my_local_var', 'local');
+ let keyword = fromAce('select', 'keyword');
+
+ let filtered = applyFilter([table, local, keyword]);
+
+ expect(filtered).toContain(table);
+ expect(filtered).not.toContain(local);
+ expect(filtered).not.toContain(keyword);
+ });
+
+ describe('when the interpreter answers with no candidates', function() {
+ let editorElement;
+
+ // completionListLength is only settable through a listener aceLoaded
registers
+ beforeEach(function() {
+ websocketMsgSrvMock.getEditorSetting = function() {};
+ websocketMsgSrvMock.completion = function() {};
+
+ editorElement = document.createElement('div');
+ editorElement.id = 'completion_test_editor';
+ document.body.appendChild(editorElement);
+
+ scope.aceLoaded(ace.edit(editorElement));
+ rootScope.$broadcast('completionListLength', 0);
+ });
+
+ afterEach(function() {
+ document.body.removeChild(editorElement);
+ });
+
+ it('should fall back to ace candidates', function() {
+ let local = fromAce('my_local_var', 'local');
+ let keyword = fromAce('select', 'keyword');
+
+ let filtered = applyFilter([local, keyword]);
+
+ expect(filtered).toContain(local);
+ expect(filtered).toContain(keyword);
+ });
+ });
+
+ describe('candidates built from an interpreter answer', function() {
+ let editorElement;
+ let editor;
+ let remoteCompleter;
+
+ beforeEach(function() {
+ websocketMsgSrvMock.getEditorSetting = function() {};
+ websocketMsgSrvMock.completion = function() {};
+
+ editorElement = document.createElement('div');
+ editorElement.id = 'completion_producer_editor';
+ document.body.appendChild(editorElement);
+
+ editor = ace.edit(editorElement);
+
+ scope.aceLoaded(editor);
+
+ // getCompletions returns without registering its listener unless the
editor is focused,
+ // and a headless run does not reliably grant it.
+ editor.isFocused = function() {
+ return true;
+ };
+
+ // Captured now, not at call time: setCompleters writes a
module-global list.
+ remoteCompleter = editor.completers[0];
+ });
+
+ afterEach(function() {
+ document.body.removeChild(editorElement);
+ });
+
+ let collectCandidates = function(completions) {
+ let received = null;
+ let called = false;
+
+ remoteCompleter.getCompletions(editor, editor.getSession(), {row: 0,
column: 0}, '', function(err, items) {
+ called = true;
+ received = items;
+ });
+ rootScope.$broadcast('completionList', {completions: completions});
+
+ // Otherwise a missing answer surfaces as a null-shape error somewhere
else.
+ expect(called).toBe(true);
+ return received;
+ };
+
+ it('should mark every candidate as coming from the interpreter',
function() {
+ let candidates = collectCandidates([
+ {name: 'my_table', value: 'my_table', meta: 'table'},
+ {name: 'my_schema', value: 'my_schema', meta: 'schema'},
+ ]);
+
+ expect(candidates).not.toBeNull();
+ expect(candidates.length).toEqual(2);
+ candidates.forEach(function(candidate) {
+ expect(candidate.fromBackend).toBe(true);
+ });
+ });
+
+ it('should produce candidates that survive the filter', function() {
+ let candidates = collectCandidates([{name: 'my_table', value:
'my_table', meta: 'table'}]);
+
+ expect(applyFilter(candidates)).toEqual(candidates);
+ });
+ });
+ });
});