This is an automated email from the ASF dual-hosted git repository.

vogievetsky pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 3e9c628f084 feat: Web console: highlight NULLS FIRST / NULLS LAST and 
upgrade druid-query-toolkit to 1.7.0 (#20621)
3e9c628f084 is described below

commit 3e9c628f0844657cfd3a64eccb591b5b9ae34daf
Author: Vadim Ogievetsky <[email protected]>
AuthorDate: Mon Oct 19 15:17:43 2026 -0700

    feat: Web console: highlight NULLS FIRST / NULLS LAST and upgrade 
druid-query-toolkit to 1.7.0 (#20621)
    
    * Web console: upgrade druid-query-toolkit to 1.7.0
    
    - Bump druid-query-toolkit to ^1.7.0 and widen query types to SqlQueryBase
      where the toolkit now uses it (parseSqlQuery, result table, preview table,
      cell filter menu)
    - Add "node" to tsconfig types: the old toolkit typings leaked Node globals
    - Override js-yaml for @istanbuljs/load-nyc-config to drop vulnerable 
sprintf-js
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * Web console: highlight and complete NULLS FIRST / NULLS LAST
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * Web console: fix line numbers duplicating when a sub query run button 
goes away
    
    The gutter removes a single node per marker, but SubQueryMarker.toDOM 
returned
    a fragment of two nodes, so the line number text was left behind every time
    the marker was removed.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * Web console: do not suggest ROW / ROWS after NULLS FIRST
    
    FIRST is followed by ROW / ROWS in FETCH FIRST but ends the clause in
    NULLS FIRST. Follow-up keyword suggestions can now be keyed by the two
    keywords before the cursor, which take precedence over the one keyword.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * Web console: suggest keywords based on the two keywords before the cursor
    
    Uses the keyword pairs to tell apart GROUP BY / PARTITIONED BY / ORDER BY,
    SELECT ALL / UNION ALL / PARTITIONED BY ALL, SELECT DISTINCT / IS DISTINCT,
    CURRENT ROW / FETCH FIRST ROW and EXPLAIN PLAN FOR / SUBSTRING(... FOR ...).
    Also suggests JOIN after OUTER and DISTINCT after IS NOT.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 web-console/package-lock.json                      | 56 ++--------------------
 web-console/package.json                           |  7 ++-
 .../cell-filter-menu/cell-filter-menu.tsx          |  4 +-
 web-console/src/components/code-editor/README.md   |  3 ++
 .../src/druid-models/execution/execution.spec.ts   |  2 +-
 .../src/druid-models/execution/execution.ts        | 12 ++---
 .../src/editor-completions/sql-completions.spec.ts | 55 +++++++++++++++++++++
 .../src/editor-completions/sql-completions.ts      | 50 +++++++++++++++++--
 web-console/src/editor-languages/dsql-keywords.ts  |  2 +
 .../src/editor-languages/editor-languages.spec.ts  | 15 ++++++
 .../grouping-table-module.tsx                      |  4 +-
 .../schema-step/preview-table/preview-table.tsx    |  4 +-
 .../flexible-query-input/sub-query-markers.spec.ts | 22 +++++++++
 .../flexible-query-input/sub-query-markers.ts      |  8 ++--
 .../result-table-pane/result-table-pane.tsx        |  8 +++-
 web-console/tsconfig.json                          |  2 +-
 16 files changed, 177 insertions(+), 77 deletions(-)

diff --git a/web-console/package-lock.json b/web-console/package-lock.json
index 59914166fac..0fc3df4b3ee 100644
--- a/web-console/package-lock.json
+++ b/web-console/package-lock.json
@@ -38,7 +38,7 @@
         "d3-shape": "^3.2.0",
         "d3-time-format": "^4.1.0",
         "dayjs": "^1.11.15",
-        "druid-query-toolkit": "^1.2.2",
+        "druid-query-toolkit": "^1.7.0",
         "echarts": "^6.1.0",
         "file-saver": "^2.0.5",
         "hjson": "^3.2.2",
@@ -5270,16 +5270,6 @@
         "node": ">=8"
       }
     },
-    "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
-      "version": "1.0.10",
-      "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz";,
-      "integrity": 
"sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "sprintf-js": "~1.0.2"
-      }
-    },
     "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz";,
@@ -5294,20 +5284,6 @@
         "node": ">=8"
       }
     },
-    "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
-      "version": "3.15.2",
-      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz";,
-      "integrity": 
"sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "argparse": "^1.0.7",
-        "esprima": "^4.0.0"
-      },
-      "bin": {
-        "js-yaml": "bin/js-yaml.js"
-      }
-    },
     "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
       "version": "5.0.0",
       "resolved": 
"https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz";,
@@ -11632,13 +11608,10 @@
       }
     },
     "node_modules/druid-query-toolkit": {
-      "version": "1.2.2",
-      "resolved": 
"https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-1.2.2.tgz";,
-      "integrity": 
"sha512-725nxfbbq/AXGvzzRi+8j5v5/jTkAMVmipGxt57ATApMs0lmTN01+BWI4TuOiyD7OrmHyoi7huEy0xfQ9QpPxg==",
+      "version": "1.7.0",
+      "resolved": 
"https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-1.7.0.tgz";,
+      "integrity": 
"sha512-V63vhsQQ/UqrCWmH/ipk7I0EDrqJ53bnYKwe7Nw2e1LyE/fJha+/pVVj41l+TczGjtbaryEp8o+gbZy9JUDCBg==",
       "license": "Apache-2.0",
-      "dependencies": {
-        "tslib": "^2.5.2"
-      },
       "engines": {
         "node": ">= 14.17.0"
       }
@@ -12486,20 +12459,6 @@
         "url": "https://opencollective.com/eslint";
       }
     },
-    "node_modules/esprima": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz";,
-      "integrity": 
"sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
-      "dev": true,
-      "license": "BSD-2-Clause",
-      "bin": {
-        "esparse": "bin/esparse.js",
-        "esvalidate": "bin/esvalidate.js"
-      },
-      "engines": {
-        "node": ">=4"
-      }
-    },
     "node_modules/esquery": {
       "version": "1.7.0",
       "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz";,
@@ -20461,13 +20420,6 @@
         "spdx-license-ids": "^3.0.0"
       }
     },
-    "node_modules/sprintf-js": {
-      "version": "1.0.3",
-      "resolved": 
"https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz";,
-      "integrity": 
"sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
-      "dev": true,
-      "license": "BSD-3-Clause"
-    },
     "node_modules/ssri": {
       "version": "13.0.1",
       "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz";,
diff --git a/web-console/package.json b/web-console/package.json
index 2fd415f6913..7863d4905dc 100644
--- a/web-console/package.json
+++ b/web-console/package.json
@@ -76,7 +76,7 @@
     "d3-shape": "^3.2.0",
     "d3-time-format": "^4.1.0",
     "dayjs": "^1.11.15",
-    "druid-query-toolkit": "^1.2.2",
+    "druid-query-toolkit": "^1.7.0",
     "echarts": "^6.1.0",
     "file-saver": "^2.0.5",
     "hjson": "^3.2.2",
@@ -151,5 +151,10 @@
     "playwright-chromium": true,
     "@parcel/watcher": false,
     "unrs-resolver": false
+  },
+  "overrides": {
+    "@istanbuljs/load-nyc-config": {
+      "js-yaml": "^4.1.1"
+    }
   }
 }
diff --git a/web-console/src/components/cell-filter-menu/cell-filter-menu.tsx 
b/web-console/src/components/cell-filter-menu/cell-filter-menu.tsx
index 5d2098c7db4..95766dac248 100644
--- a/web-console/src/components/cell-filter-menu/cell-filter-menu.tsx
+++ b/web-console/src/components/cell-filter-menu/cell-filter-menu.tsx
@@ -18,7 +18,7 @@
 
 import { Menu, MenuItem } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
-import type { Column, SqlExpression, SqlQuery } from 'druid-query-toolkit';
+import type { Column, SqlExpression, SqlQueryBase } from 'druid-query-toolkit';
 import { C, L, SqlComparison, SqlLiteral, SqlRecord, trimString } from 
'druid-query-toolkit';
 
 import type { QueryAction } from '../../utils';
@@ -80,7 +80,7 @@ export interface CellFilterMenuProps {
   value: unknown;
   headerIndex: number;
   runeMode?: boolean;
-  query: SqlQuery | undefined;
+  query: SqlQueryBase | undefined;
   onQueryAction?(action: QueryAction): void;
   onShowFullValue?(valueString: string): void;
 }
diff --git a/web-console/src/components/code-editor/README.md 
b/web-console/src/components/code-editor/README.md
index 29a7a81317e..c9e88d90cd8 100644
--- a/web-console/src/components/code-editor/README.md
+++ b/web-console/src/components/code-editor/README.md
@@ -223,6 +223,9 @@ made of:
 - `lineNumbers({ domEventHandlers })` for the click and hover, which hands 
over the line that was clicked or hovered
 - a `StateEffect` to set the hovered query
 
+A gutter marker's `toDOM` must return a single node (not a 
`DocumentFragment`): the gutter removes just one node when
+the marker goes away, so any extra nodes would be left behind.
+
 React only provides `onRun`. Because `extensions` is only read once, 
`FlexibleQueryInput` puts the markers in its own
 `Compartment` and reconfigures it when they are turned on or off.
 
diff --git a/web-console/src/druid-models/execution/execution.spec.ts 
b/web-console/src/druid-models/execution/execution.spec.ts
index 1c3f25e3595..50b47bf101a 100644
--- a/web-console/src/druid-models/execution/execution.spec.ts
+++ b/web-console/src/druid-models/execution/execution.spec.ts
@@ -641,7 +641,7 @@ describe('Execution', () => {
           "id": "query-45f1dafd-8a52-4eb7-9a6c-77840cddd349",
           "nativeQuery": undefined,
           "queryContext": undefined,
-          "result": _QueryResult {
+          "result": QueryResult {
             "header": [
               Column {
                 "name": "channel",
diff --git a/web-console/src/druid-models/execution/execution.ts 
b/web-console/src/druid-models/execution/execution.ts
index f26b739b445..6a0835cfd21 100644
--- a/web-console/src/druid-models/execution/execution.ts
+++ b/web-console/src/druid-models/execution/execution.ts
@@ -16,7 +16,7 @@
  * limitations under the License.
  */
 
-import { Column, QueryResult, SqlExpression, SqlQuery, SqlWithQuery } from 
'druid-query-toolkit';
+import { Column, QueryResult, SqlExpression, SqlQueryBase } from 
'druid-query-toolkit';
 
 import { maybeGetClusterCapacity } from '../../helpers';
 import {
@@ -52,13 +52,11 @@ const IGNORE_CONTEXT_KEYS = [
   'sqlReplaceTimeChunks',
 ];
 
-// Hack around the concept that we might get back a SqlWithQuery and will need 
to unpack it
-function parseSqlQuery(queryString: string): SqlQuery | undefined {
+// Parse any query form, flattening a WITH query so that its select 
expressions line up with the result columns
+function parseSqlQuery(queryString: string): SqlQueryBase | undefined {
   const q = SqlExpression.maybeParse(queryString);
-  if (!q) return;
-  if (q instanceof SqlWithQuery) return q.flattenWith();
-  if (q instanceof SqlQuery) return q;
-  return;
+  if (!(q instanceof SqlQueryBase)) return;
+  return q.flattenWith();
 }
 
 export interface ExecutionError {
diff --git a/web-console/src/editor-completions/sql-completions.spec.ts 
b/web-console/src/editor-completions/sql-completions.spec.ts
index ac0bc8d1f36..3f2b6b6c419 100644
--- a/web-console/src/editor-completions/sql-completions.spec.ts
+++ b/web-console/src/editor-completions/sql-completions.spec.ts
@@ -38,6 +38,13 @@ describe('sql-completions', () => {
       return getSqlCompletions(context, word, options);
     }
 
+    // The keywords suggested with the cursor at the |
+    function keywordsAt(textWithCursor: string): string[] {
+      return completionsAt(textWithCursor)
+        .filter(c => c.detail === 'keyword')
+        .map(c => c.label);
+    }
+
     const columnMetadata: ColumnMetadata[] = [
       {
         TABLE_SCHEMA: 'druid',
@@ -130,6 +137,54 @@ describe('sql-completions', () => {
       expect(keywordValues).not.toContain('SELECT');
     });
 
+    it('suggests NULLS FIRST / NULLS LAST after an ORDER BY direction', () => {
+      expect(keywordsAt('SELECT * FROM t ORDER BY 1 DESC 
|')).toEqual(['NULLS']);
+      expect(keywordsAt('SELECT * FROM t ORDER BY 1 ASC 
|')).toEqual(['NULLS']);
+      expect(keywordsAt('SELECT * FROM t ORDER BY 1 DESC NULLS 
|')).toEqual(['FIRST', 'LAST']);
+      expect(keywordsAt('SELECT * FROM t ORDER BY 1 DESC NULLS LAST 
|')).toEqual([]);
+      expect(keywordsAt('SELECT * FROM t ORDER BY 1 DESC NULLS FIRST 
|')).toEqual([]);
+      expect(keywordsAt('SELECT * FROM t ORDER BY 1 nulls\n  first 
|')).toEqual([]);
+
+      // FIRST is still followed by ROW / ROWS in a FETCH
+      expect(keywordsAt('SELECT * FROM t ORDER BY 1 FETCH FIRST 
|')).toEqual(['ROW', 'ROWS']);
+    });
+
+    it('suggests keywords based on the two keywords before the cursor', () => {
+      // BY
+      expect(keywordsAt('SELECT * FROM t GROUP BY |')).toEqual(['GROUPING', 
'ROLLUP', 'CUBE']);
+      expect(keywordsAt('REPLACE INTO t SELECT * FROM s PARTITIONED BY 
|')).toEqual([
+        'ALL',
+        'HOUR',
+        'DAY',
+        'MONTH',
+        'YEAR',
+      ]);
+      expect(keywordsAt('SELECT * FROM t ORDER BY |')).toEqual([]);
+      expect(keywordsAt('SELECT ROW_NUMBER() OVER (PARTITION BY 
|')).toEqual([]);
+      expect(
+        keywordsAt('REPLACE INTO t SELECT * FROM s PARTITIONED BY DAY 
CLUSTERED BY |'),
+      ).toEqual([]);
+
+      // ALL
+      expect(keywordsAt('REPLACE INTO t SELECT * FROM s PARTITIONED BY ALL 
|')).toEqual(['TIME']);
+      expect(keywordsAt('SELECT ALL |')).toEqual([]);
+      expect(keywordsAt('SELECT 1 UNION ALL |')).toEqual(['SELECT']);
+
+      // DISTINCT
+      expect(keywordsAt('SELECT DISTINCT |')).toEqual([]);
+      expect(keywordsAt('SELECT COUNT(DISTINCT |')).toEqual([]);
+      expect(keywordsAt('SELECT * FROM t WHERE x IS |')).toEqual(['NOT', 
'DISTINCT']);
+      expect(keywordsAt('SELECT * FROM t WHERE x IS NOT 
|')).toEqual(['DISTINCT']);
+      expect(keywordsAt('SELECT * FROM t WHERE x IS DISTINCT 
|')).toEqual(['FROM']);
+      expect(keywordsAt('SELECT * FROM t WHERE x IS NOT DISTINCT 
|')).toEqual(['FROM']);
+
+      // Others
+      expect(keywordsAt('SELECT * FROM t LEFT OUTER |')).toEqual(['JOIN']);
+      expect(keywordsAt('SELECT SUM(x) OVER (ORDER BY y ROWS CURRENT ROW 
|')).toEqual([]);
+      expect(keywordsAt('SELECT * FROM t FETCH FIRST ROW 
|')).toEqual(['ONLY']);
+      expect(keywordsAt('EXPLAIN PLAN FOR |')).toEqual(['SELECT', 'INSERT', 
'REPLACE']);
+    });
+
     it('looks at the keyword on an earlier line and past comments', () => {
       for (const sql of ['SELECT * FROM t GROUP\n  |', 'SELECT * FROM t GROUP 
/* by what */ |']) {
         expect(
diff --git a/web-console/src/editor-completions/sql-completions.ts 
b/web-console/src/editor-completions/sql-completions.ts
index 8ce3859fb83..5402352dce0 100644
--- a/web-console/src/editor-completions/sql-completions.ts
+++ b/web-console/src/editor-completions/sql-completions.ts
@@ -42,7 +42,9 @@ const SQL_KEYWORDS_THAT_CAN_NOT_BE_FOLLOWED_BY_FUNCTION = [
   'SET',
   'VALUES',
   'FETCH',
+  'NULLS',
   'FIRST',
+  'LAST',
   'NEXT',
   'ONLY',
   'PRECEDING',
@@ -55,7 +57,9 @@ const SQL_KEYWORDS_THAT_CAN_NOT_BE_FOLLOWED_BY_REF = [
   'DESC',
   'LIMIT',
   'OFFSET',
+  'NULLS',
   'FIRST',
+  'LAST',
   'NEXT',
   'ONLY',
   'PRECEDING',
@@ -93,6 +97,7 @@ const SQL_KEYWORD_FOLLOW_SUGGESTIONS: Record<string, 
string[]> = {
   RIGHT: ['JOIN', 'OUTER'],
   INNER: ['JOIN'],
   FULL: ['JOIN', 'OUTER'],
+  OUTER: ['JOIN'],
   CROSS: ['JOIN'],
   NATURAL: ['JOIN', 'LEFT', 'RIGHT', 'INNER', 'FULL'],
   EXPLAIN: ['PLAN'],
@@ -100,18 +105,20 @@ const SQL_KEYWORD_FOLLOW_SUGGESTIONS: Record<string, 
string[]> = {
   GROUPING: ['SETS'],
   FETCH: ['FIRST', 'NEXT'],
   FIRST: ['ROW', 'ROWS'],
+  ASC: ['NULLS'],
+  DESC: ['NULLS'],
+  NULLS: ['FIRST', 'LAST'],
   NEXT: ['ROW', 'ROWS'],
   ROWS: ['ONLY'],
   ROW: ['ONLY'],
   IS: ['NOT', 'DISTINCT'],
-  DISTINCT: ['FROM'],
   BY: ['GROUPING', 'ROLLUP', 'CUBE', 'ALL', 'HOUR', 'DAY', 'MONTH', 'YEAR'],
   ALL: ['TIME'],
 
   // Keywords that cannot be followed by any other keyword
   AS: [],
-  ASC: [],
-  DESC: [],
+  DISTINCT: [], // Except in IS [NOT] DISTINCT FROM, see 
SQL_KEYWORD_PAIR_FOLLOW_SUGGESTIONS
+  LAST: [],
   LIMIT: [],
   OFFSET: [],
   RETURNING: [],
@@ -157,6 +164,29 @@ const SQL_KEYWORD_FOLLOW_SUGGESTIONS: Record<string, 
string[]> = {
   MILLENNIUM: [],
 };
 
+// Like SQL_KEYWORD_FOLLOW_SUGGESTIONS but keyed by the two keywords before 
the cursor, takes precedence over it. For
+// keywords that are followed by different keywords depending on what comes 
before them
+const SQL_KEYWORD_PAIR_FOLLOW_SUGGESTIONS: Record<string, string[]> = {
+  // BY on its own suggests the union of these
+  'GROUP BY': ['GROUPING', 'ROLLUP', 'CUBE'],
+  'PARTITIONED BY': ['ALL', 'HOUR', 'DAY', 'MONTH', 'YEAR'],
+  'ORDER BY': [],
+  'PARTITION BY': [],
+  'CLUSTERED BY': [],
+
+  // ALL on its own is PARTITIONED BY ALL TIME
+  'SELECT ALL': [],
+  'UNION ALL': ['SELECT'],
+
+  'IS NOT': ['DISTINCT'],
+  'IS DISTINCT': ['FROM'],
+  'NOT DISTINCT': ['FROM'],
+
+  'NULLS FIRST': [], // Unlike FETCH FIRST
+  'CURRENT ROW': [], // Unlike FETCH FIRST ROW
+  'PLAN FOR': ['SELECT', 'INSERT', 'REPLACE'], // Unlike SUBSTRING(... FOR ...)
+};
+
 const COMMENT_NODES = new Set(['LineComment', 'BlockComment', 'Issue']);
 
 // The tokens that are words, the specializer in dsql-tokens.ts tells them 
apart
@@ -233,6 +263,14 @@ export function getSqlCompletions(
     tokenBeforePrefix && WORD_NODES.has(tokenBeforePrefix.name)
       ? state.sliceDoc(tokenBeforePrefix.from, 
tokenBeforePrefix.to).toUpperCase()
       : undefined;
+  const tokenBeforeKeyword =
+    tokenBeforePrefix && keywordBeforePrefix
+      ? tokenEndingBefore(state, tokenBeforePrefix.from)
+      : undefined;
+  const keywordsBeforePrefix =
+    tokenBeforeKeyword && WORD_NODES.has(tokenBeforeKeyword.name)
+      ? `${state.sliceDoc(tokenBeforeKeyword.from, 
tokenBeforeKeyword.to).toUpperCase()} ${keywordBeforePrefix}`
+      : undefined;
 
   // Other than literals, do not autocomplete numbers
   if (/^\d+$/.test(prefix)) {
@@ -249,7 +287,11 @@ export function getSqlCompletions(
 
   if (!quote) {
     completions = completions.concat(
-      (SQL_KEYWORD_FOLLOW_SUGGESTIONS[keywordBeforePrefix || ''] || 
SQL_KEYWORDS).map(v => ({
+      (
+        SQL_KEYWORD_PAIR_FOLLOW_SUGGESTIONS[keywordsBeforePrefix || ''] ||
+        SQL_KEYWORD_FOLLOW_SUGGESTIONS[keywordBeforePrefix || ''] ||
+        SQL_KEYWORDS
+      ).map(v => ({
         label: v,
         boost: 10,
         detail: 'keyword',
diff --git a/web-console/src/editor-languages/dsql-keywords.ts 
b/web-console/src/editor-languages/dsql-keywords.ts
index c7f94817a59..5cbea914c31 100644
--- a/web-console/src/editor-languages/dsql-keywords.ts
+++ b/web-console/src/editor-languages/dsql-keywords.ts
@@ -66,6 +66,7 @@ export const SQL_KEYWORDS = [
   'ISOYEAR',
   'JOIN',
   'KEY',
+  'LAST',
   'LEADING',
   'LEFT',
   'LIKE',
@@ -81,6 +82,7 @@ export const SQL_KEYWORDS = [
   'NATURAL',
   'NEXT',
   'NOT',
+  'NULLS',
   'OFFSET',
   'ON',
   'ONLY',
diff --git a/web-console/src/editor-languages/editor-languages.spec.ts 
b/web-console/src/editor-languages/editor-languages.spec.ts
index b80064e3e8e..726b324324b 100644
--- a/web-console/src/editor-languages/editor-languages.spec.ts
+++ b/web-console/src/editor-languages/editor-languages.spec.ts
@@ -66,6 +66,21 @@ line */ SELECT`;
     expect(tokenOf(dsqlLanguage, sql, '(x AS VARCHAR)')).toEqual('Parens');
   });
 
+  it('highlights NULLS FIRST / NULLS LAST as keywords', () => {
+    expect(tokenize(dsqlLanguage, 'ORDER BY 2 DESC NULLS LAST, 1 NULLS 
FIRST')).toEqual([
+      ['Keyword', 'ORDER'],
+      ['Keyword', 'BY'],
+      ['Number', '2'],
+      ['Keyword', 'DESC'],
+      ['Keyword', 'NULLS'],
+      ['Keyword', 'LAST'],
+      ['Punctuation', ','],
+      ['Number', '1'],
+      ['Keyword', 'NULLS'],
+      ['Keyword', 'FIRST'],
+    ]);
+  });
+
   it('highlights unterminated DruidSQL tokens', () => {
     expect(tokenOf(dsqlLanguage, 'SELECT /* not closed', '/* not 
closed')).toEqual('BlockComment');
     // An unterminated literal or quoted identifier lasts until the end of the 
line
diff --git 
a/web-console/src/views/explore-view/modules/grouping-table-module/grouping-table-module.tsx
 
b/web-console/src/views/explore-view/modules/grouping-table-module/grouping-table-module.tsx
index 937128454c7..0d9ddabb803 100644
--- 
a/web-console/src/views/explore-view/modules/grouping-table-module/grouping-table-module.tsx
+++ 
b/web-console/src/views/explore-view/modules/grouping-table-module/grouping-table-module.tsx
@@ -20,7 +20,7 @@ import { Button } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
 import type { Timezone } from 'chronoshift';
 import type { SqlExpression, SqlOrderByDirection } from 'druid-query-toolkit';
-import { C, F } from 'druid-query-toolkit';
+import { C, F, SqlQuery } from 'druid-query-toolkit';
 import { useMemo } from 'react';
 
 import { Loader } from '../../../../components';
@@ -279,7 +279,7 @@ 
ModuleRepository.registerModule<GroupingTableParameterValues>({
         const { query, columnHints } = queryAndHints;
 
         let result = await runSqlQuery({ query, timezone }, signal);
-        if (result.sqlQuery) {
+        if (result.sqlQuery instanceof SqlQuery) {
           result = result.attachQuery(
             { query: '' },
             result.sqlQuery.changeWhereExpression(globalWhere),
diff --git 
a/web-console/src/views/sql-data-loader-view/schema-step/preview-table/preview-table.tsx
 
b/web-console/src/views/sql-data-loader-view/schema-step/preview-table/preview-table.tsx
index 9fafa1bf916..1761c9274d4 100644
--- 
a/web-console/src/views/sql-data-loader-view/schema-step/preview-table/preview-table.tsx
+++ 
b/web-console/src/views/sql-data-loader-view/schema-step/preview-table/preview-table.tsx
@@ -19,7 +19,7 @@
 import { Icon, PopoverNext } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
 import classNames from 'classnames';
-import type { Column, QueryResult, SqlExpression, SqlQuery } from 
'druid-query-toolkit';
+import type { Column, QueryResult, SqlExpression, SqlQueryBase } from 
'druid-query-toolkit';
 import { SqlAlias, SqlFunction, SqlStar } from 'druid-query-toolkit';
 import React, { useState } from 'react';
 
@@ -48,7 +48,7 @@ function isWrappedInArrayToMv(ex: SqlExpression | undefined) {
   return ex instanceof SqlFunction && ex.getEffectiveFunctionName() === 
'ARRAY_TO_MV';
 }
 
-function formatFormulaAtIndex(query: SqlQuery, selectIndex: number): string {
+function formatFormulaAtIndex(query: SqlQueryBase, selectIndex: number): 
string {
   const ex = query.getSelectExpressionForIndex(selectIndex);
 
   if (query.isRealOutputColumnAtSelectIndex(selectIndex)) {
diff --git 
a/web-console/src/views/workbench-view/flexible-query-input/sub-query-markers.spec.ts
 
b/web-console/src/views/workbench-view/flexible-query-input/sub-query-markers.spec.ts
index db70d04070b..9dba7d51cb9 100644
--- 
a/web-console/src/views/workbench-view/flexible-query-input/sub-query-markers.spec.ts
+++ 
b/web-console/src/views/workbench-view/flexible-query-input/sub-query-markers.spec.ts
@@ -70,6 +70,28 @@ describe('sub-query-markers', () => {
     view.destroy();
   });
 
+  it('leaves just the line number when a run button goes away', () => {
+    const query = 'SELECT 1\nFROM t\nORDER BY 1 DESC ';
+    const view = new EditorView({
+      state: State.create({ doc: query, extensions: subQueryMarkers(() => {}) 
}),
+    });
+    const firstLineNumber = () =>
+      view.dom.querySelectorAll('.cm-lineNumbers 
.cm-gutterElement')[1]?.textContent;
+
+    // Typing a stray word makes the query only parsable as a sub query (so it 
gets a run button), deleting it puts
+    // the text back to a whole query (so the button goes away)
+    for (let i = 0; i < 3; i++) {
+      view.dispatch({ changes: { from: query.length, insert: 'N' } });
+      
expect(view.dom.querySelectorAll('.sub-query-run-button')).toHaveLength(1);
+      expect(firstLineNumber()).toEqual('1');
+
+      view.dispatch({ changes: { from: query.length, to: query.length + 1 } });
+      
expect(view.dom.querySelectorAll('.sub-query-run-button')).toHaveLength(0);
+      expect(firstLineNumber()).toEqual('1');
+    }
+    view.destroy();
+  });
+
   it('has no queries without the extension', () => {
     expect(getSubQueries(State.create({ doc: TEXT }))).toEqual([]);
   });
diff --git 
a/web-console/src/views/workbench-view/flexible-query-input/sub-query-markers.ts
 
b/web-console/src/views/workbench-view/flexible-query-input/sub-query-markers.ts
index 04a4cba8b2d..d2622262b48 100644
--- 
a/web-console/src/views/workbench-view/flexible-query-input/sub-query-markers.ts
+++ 
b/web-console/src/views/workbench-view/flexible-query-input/sub-query-markers.ts
@@ -75,9 +75,11 @@ class SubQueryMarker extends GutterMarker {
     button.setAttribute('data-tooltip', 'Run this query');
     button.append(icon);
 
-    const fragment = document.createDocumentFragment();
-    fragment.append(button, String(this.lineNumber));
-    return fragment;
+    // The gutter expects a single node per marker (it removes just the one 
node when the marker goes away) so the button
+    // and the line number must be wrapped together
+    const wrapper = document.createElement('span');
+    wrapper.append(button, String(this.lineNumber));
+    return wrapper;
   }
 }
 
diff --git 
a/web-console/src/views/workbench-view/result-table-pane/result-table-pane.tsx 
b/web-console/src/views/workbench-view/result-table-pane/result-table-pane.tsx
index 01e389ed6b1..83b5340e2fe 100644
--- 
a/web-console/src/views/workbench-view/result-table-pane/result-table-pane.tsx
+++ 
b/web-console/src/views/workbench-view/result-table-pane/result-table-pane.tsx
@@ -19,7 +19,7 @@
 import { Button, Icon, Intent, Menu, MenuItem, PopoverNext } from 
'@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
 import classNames from 'classnames';
-import type { Column, QueryResult, SqlExpression, SqlQuery } from 
'druid-query-toolkit';
+import type { Column, QueryResult, SqlExpression, SqlQueryBase } from 
'druid-query-toolkit';
 import { C, F, SqlAlias, SqlFunction, SqlLiteral, SqlStar, SqlType } from 
'druid-query-toolkit';
 import * as JSONBig from 'json-bigint-native';
 import type { JSX } from 'react';
@@ -91,7 +91,11 @@ function isStringJsonString(value: string): boolean {
   }
 }
 
-function getExpressionIfAlias(query: SqlQuery, columnIndex: number, 
numColumns: number): string {
+function getExpressionIfAlias(
+  query: SqlQueryBase,
+  columnIndex: number,
+  numColumns: number,
+): string {
   const selectExpressionsArray = query.getSelectExpressionsArray();
   // If there is a * before
   if (
diff --git a/web-console/tsconfig.json b/web-console/tsconfig.json
index ca1c7539b62..1bf71a21281 100644
--- a/web-console/tsconfig.json
+++ b/web-console/tsconfig.json
@@ -19,7 +19,7 @@
     "strict": true,
     "useUnknownInCatchVariables": false,
     "target": "es2022",
-    "types": ["jest"]
+    "types": ["jest", "node"]
   },
   "include": ["src", "e2e-tests", "typings"]
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to