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 fcd65c98010 Web console: use arrayIngestMode: array (#15588)
fcd65c98010 is described below

commit fcd65c98010c8660fd6ccfa3eaa3d9908fbe572f
Author: Vadim Ogievetsky <[email protected]>
AuthorDate: Wed Jan 31 20:19:29 2024 -0800

    Web console: use arrayIngestMode: array (#15588)
    
    * Adapt to new array mode
    
    * Feedback fixes
    
    * fixing type detection and highlighting
    
    * goodies
    
    * add docs
    
    * feedback fixes
    
    * finish array work
    
    * update snapshots
    
    * typo fix
    
    * color fixes
    
    * small fix
    
    * make MVDs default for now
    
    * better sqlStringifyArrays default
    
    * fix spec converter
    
    * fix tests
---
 licenses.yaml                                      |   2 +-
 web-console/assets/delta.png                       | Bin 6527 -> 26090 bytes
 web-console/package-lock.json                      |  14 +-
 web-console/package.json                           |   2 +-
 web-console/script/create-sql-docs.js              |  12 +-
 web-console/src/blueprint-overrides/_index.scss    |   1 +
 .../_special-switch-modes.scss}                    |  28 +-
 .../__snapshots__/array-mode-swtich.spec.tsx.snap  |  47 ++++
 .../array-mode-switch/array-mode-switch.tsx        |  62 ++++
 .../array-mode-switch/array-mode-swtich.spec.tsx}  |   8 +-
 web-console/src/components/index.ts                |   1 +
 .../__snapshots__/warning-checklist.spec.tsx.snap  |   6 +-
 .../warning-checklist/warning-checklist.tsx        |   2 +-
 .../druid-models/dimension-spec/dimension-spec.ts  | 128 ++++++++-
 .../external-config/external-config.ts             |  10 +-
 .../ingest-query-pattern.spec.ts                   | 139 ++++++++-
 .../ingest-query-pattern/ingest-query-pattern.ts   |  23 +-
 .../ingestion-spec/ingestion-spec.spec.ts          | 311 ++++++++++++++++++---
 .../druid-models/ingestion-spec/ingestion-spec.tsx | 171 +++++++++--
 .../src/druid-models/metric-spec/metric-spec.tsx   |   4 +-
 .../druid-models/query-context/query-context.tsx   |   1 +
 web-console/src/druid-models/stages/stages.ts      |   2 +-
 .../druid-models/timestamp-spec/timestamp-spec.tsx |   2 +-
 .../workbench-query/workbench-query.spec.ts        |   5 +
 .../workbench-query/workbench-query.ts             |  13 +-
 .../__snapshots__/spec-conversion.spec.ts.snap     |  23 ++
 web-console/src/helpers/spec-conversion.spec.ts    |  79 ++++++
 web-console/src/helpers/spec-conversion.ts         | 148 +++++-----
 web-console/src/utils/index.tsx                    |   1 +
 web-console/src/utils/null-mode-detection.ts       | 154 ++++++++++
 web-console/src/utils/object-change.ts             |   8 +-
 web-console/src/utils/sample-query.tsx             |  30 +-
 .../__snapshots__/home-view.spec.tsx.snap          |  36 ++-
 web-console/src/views/home-view/home-view.tsx      |   2 +-
 .../home-view/status-card/status-card.spec.tsx     |   4 +-
 .../views/home-view/status-card/status-card.tsx    |  48 +++-
 .../src/views/load-data-view/load-data-view.tsx    | 116 +++++---
 .../load-data-view/schema-table/schema-table.scss  |  28 +-
 .../load-data-view/schema-table/schema-table.tsx   |  14 +-
 .../column-editor/column-editor.tsx                | 270 ++++++++++++------
 .../schema-step/preview-table/preview-table.scss   |  16 ++
 .../schema-step/preview-table/preview-table.tsx    |  19 +-
 .../schema-step/schema-step.tsx                    |  26 +-
 .../sql-data-loader-view/sql-data-loader-view.tsx  |  19 +-
 .../connect-external-data-dialog.tsx               |   8 +-
 .../input-format-step/input-format-step.tsx        |  15 +-
 .../src/views/workbench-view/workbench-view.tsx    |   9 +-
 47 files changed, 1683 insertions(+), 384 deletions(-)

diff --git a/licenses.yaml b/licenses.yaml
index f1a4cba1159..f081b8adfff 100644
--- a/licenses.yaml
+++ b/licenses.yaml
@@ -5056,7 +5056,7 @@ license_category: binary
 module: web-console
 license_name: Apache License version 2.0
 copyright: Imply Data
-version: 0.21.4
+version: 0.21.9
 
 ---
 
diff --git a/web-console/assets/delta.png b/web-console/assets/delta.png
index db535506c7b..1dc3c072dad 100644
Binary files a/web-console/assets/delta.png and b/web-console/assets/delta.png 
differ
diff --git a/web-console/package-lock.json b/web-console/package-lock.json
index e4e92ef3a84..005e38f068f 100644
--- a/web-console/package-lock.json
+++ b/web-console/package-lock.json
@@ -14,7 +14,7 @@
         "@blueprintjs/datetime2": "^0.9.35",
         "@blueprintjs/icons": "^4.16.0",
         "@blueprintjs/popover2": "^1.14.9",
-        "@druid-toolkit/query": "^0.21.4",
+        "@druid-toolkit/query": "^0.21.9",
         "@druid-toolkit/visuals-core": "^0.3.3",
         "@druid-toolkit/visuals-react": "^0.3.3",
         "ace-builds": "~1.4.14",
@@ -1074,9 +1074,9 @@
       }
     },
     "node_modules/@druid-toolkit/query": {
-      "version": "0.21.4",
-      "resolved": 
"https://registry.npmjs.org/@druid-toolkit/query/-/query-0.21.4.tgz";,
-      "integrity": 
"sha512-rZYRrtahy68ZMp3XDWa2Z3Pa28yiQMgDVHbB7ZAqynNFbKOgqS1j08LS122CRmNrvpAUyzwCnMj3Og4BvWeq1Q==",
+      "version": "0.21.9",
+      "resolved": 
"https://registry.npmjs.org/@druid-toolkit/query/-/query-0.21.9.tgz";,
+      "integrity": 
"sha512-g8bs9cOqyrxPzf1qdvO4FAG0rv7aBR2le+OLbF/n/KC3YXq49CUifPUYIHVfVx/jwoXKrJd1w1jVLES8OusnTg==",
       "dependencies": {
         "tslib": "^2.5.2"
       }
@@ -20702,9 +20702,9 @@
       "dev": true
     },
     "@druid-toolkit/query": {
-      "version": "0.21.4",
-      "resolved": 
"https://registry.npmjs.org/@druid-toolkit/query/-/query-0.21.4.tgz";,
-      "integrity": 
"sha512-rZYRrtahy68ZMp3XDWa2Z3Pa28yiQMgDVHbB7ZAqynNFbKOgqS1j08LS122CRmNrvpAUyzwCnMj3Og4BvWeq1Q==",
+      "version": "0.21.9",
+      "resolved": 
"https://registry.npmjs.org/@druid-toolkit/query/-/query-0.21.9.tgz";,
+      "integrity": 
"sha512-g8bs9cOqyrxPzf1qdvO4FAG0rv7aBR2le+OLbF/n/KC3YXq49CUifPUYIHVfVx/jwoXKrJd1w1jVLES8OusnTg==",
       "requires": {
         "tslib": "^2.5.2"
       }
diff --git a/web-console/package.json b/web-console/package.json
index 4e1ac583059..7fae9ef1007 100644
--- a/web-console/package.json
+++ b/web-console/package.json
@@ -68,7 +68,7 @@
     "@blueprintjs/datetime2": "^0.9.35",
     "@blueprintjs/icons": "^4.16.0",
     "@blueprintjs/popover2": "^1.14.9",
-    "@druid-toolkit/query": "^0.21.4",
+    "@druid-toolkit/query": "^0.21.9",
     "@druid-toolkit/visuals-core": "^0.3.3",
     "@druid-toolkit/visuals-react": "^0.3.3",
     "ace-builds": "~1.4.14",
diff --git a/web-console/script/create-sql-docs.js 
b/web-console/script/create-sql-docs.js
index 794afb06e55..82328fd74b5 100755
--- a/web-console/script/create-sql-docs.js
+++ b/web-console/script/create-sql-docs.js
@@ -29,7 +29,15 @@ const MINIMUM_EXPECTED_NUMBER_OF_DATA_TYPES = 14;
 const initialFunctionDocs = {
   TABLE: [['external', convertMarkdownToHtml('Defines a logical table from an 
external.')]],
   EXTERN: [
-    ['inputSource, inputFormat, rowSignature?', convertMarkdownToHtml('Reads 
external data')],
+    ['inputSource, inputFormat, rowSignature?', convertMarkdownToHtml('Reads 
external data.')],
+  ],
+  TYPE: [
+    [
+      'nativeType',
+      convertMarkdownToHtml(
+        'A purely type system modification function what wraps a Druid native 
type to make it into a SQL type.',
+      ),
+    ],
   ],
 };
 
@@ -70,7 +78,7 @@ const readDoc = async () => {
     await fs.readFile('../docs/querying/sql-array-functions.md', 'utf-8'),
     await fs.readFile('../docs/querying/sql-multivalue-string-functions.md', 
'utf-8'),
     await fs.readFile('../docs/querying/sql-json-functions.md', 'utf-8'),
-    await fs.readFile('../docs/querying/sql-operators.md', 'utf-8')
+    await fs.readFile('../docs/querying/sql-operators.md', 'utf-8'),
   ].join('\n');
 
   const lines = data.split('\n');
diff --git a/web-console/src/blueprint-overrides/_index.scss 
b/web-console/src/blueprint-overrides/_index.scss
index faffe22bbb9..c345d815b80 100644
--- a/web-console/src/blueprint-overrides/_index.scss
+++ b/web-console/src/blueprint-overrides/_index.scss
@@ -24,3 +24,4 @@
 @import 'components/forms/common';
 @import 'components/navbar/navbar';
 @import 'components/card/card';
+@import 'special-switch-modes';
diff --git a/web-console/src/views/home-view/status-card/status-card.spec.tsx 
b/web-console/src/blueprint-overrides/_special-switch-modes.scss
similarity index 66%
copy from web-console/src/views/home-view/status-card/status-card.spec.tsx
copy to web-console/src/blueprint-overrides/_special-switch-modes.scss
index 5b7d419421d..267ac4a2726 100644
--- a/web-console/src/views/home-view/status-card/status-card.spec.tsx
+++ b/web-console/src/blueprint-overrides/_special-switch-modes.scss
@@ -16,16 +16,24 @@
  * limitations under the License.
  */
 
-import { render } from '@testing-library/react';
-import React from 'react';
+.bp4-dark .bp4-switch.bp4-control {
+  &.legacy-switch {
+    input:checked ~ .bp4-control-indicator {
+      background: $orange5;
+    }
 
-import { StatusCard } from './status-card';
+    &:hover input:checked ~ .bp4-control-indicator {
+      background: $orange2;
+    }
+  }
 
-describe('StatusCard', () => {
-  it('matches snapshot', () => {
-    const statusCard = <StatusCard />;
+  &.danger-switch {
+    input:checked ~ .bp4-control-indicator {
+      background: $red5;
+    }
 
-    const { container } = render(statusCard);
-    expect(container.firstChild).toMatchSnapshot();
-  });
-});
+    &:hover input:checked ~ .bp4-control-indicator {
+      background: $red2;
+    }
+  }
+}
diff --git 
a/web-console/src/components/array-mode-switch/__snapshots__/array-mode-swtich.spec.tsx.snap
 
b/web-console/src/components/array-mode-switch/__snapshots__/array-mode-swtich.spec.tsx.snap
new file mode 100644
index 00000000000..88d7564823a
--- /dev/null
+++ 
b/web-console/src/components/array-mode-switch/__snapshots__/array-mode-swtich.spec.tsx.snap
@@ -0,0 +1,47 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ArrayModeSwitch matches snapshot 1`] = `
+<div
+  class="bp4-form-group form-group-with-info"
+>
+  <div
+    class="bp4-form-content"
+  >
+    <label
+      class="bp4-control bp4-switch legacy-switch"
+    >
+      <input
+        checked=""
+        type="checkbox"
+      />
+      <span
+        class="bp4-control-indicator"
+      />
+      Store ARRAYs as MVDs
+    </label>
+    <span
+      aria-haspopup="true"
+      class="info-popover bp4-popover2-target"
+    >
+      <span
+        aria-hidden="true"
+        class="bp4-icon bp4-icon-info-sign"
+        icon="info-sign"
+      >
+        <svg
+          data-icon="info-sign"
+          height="14"
+          role="img"
+          viewBox="0 0 16 16"
+          width="14"
+        >
+          <path
+            d="M8 0C3.58 0 0 3.58 0 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8zM7 
3h2v2H7V3zm3 10H6v-1h1V7H6V6h3v6h1v1z"
+            fill-rule="evenodd"
+          />
+        </svg>
+      </span>
+    </span>
+  </div>
+</div>
+`;
diff --git a/web-console/src/components/array-mode-switch/array-mode-switch.tsx 
b/web-console/src/components/array-mode-switch/array-mode-switch.tsx
new file mode 100644
index 00000000000..4d9de1274b9
--- /dev/null
+++ b/web-console/src/components/array-mode-switch/array-mode-switch.tsx
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.
+ */
+
+import { Switch } from '@blueprintjs/core';
+import React from 'react';
+
+import type { ArrayMode } from '../../druid-models';
+import { getLink } from '../../links';
+import { ExternalLink } from '../external-link/external-link';
+import { FormGroupWithInfo } from 
'../form-group-with-info/form-group-with-info';
+import { PopoverText } from '../popover-text/popover-text';
+
+export interface ArrayModeSwitchProps {
+  arrayMode: ArrayMode;
+  changeArrayMode(newArrayMode: ArrayMode): void;
+}
+
+export const ArrayModeSwitch = React.memo(function ArrayModeSwitch(props: 
ArrayModeSwitchProps) {
+  const { arrayMode, changeArrayMode } = props;
+
+  return (
+    <FormGroupWithInfo
+      inlineInfo
+      info={
+        <PopoverText>
+          <p>
+            Store arrays as multi-value string columns instead of arrays. Note 
that all detected
+            array elements will be coerced to strings if you choose this 
option, and data will
+            behave more like a string than an array at query time. See{' '}
+            <ExternalLink href={`${getLink('DOCS')}/querying/arrays`}>array 
docs</ExternalLink> and{' '}
+            <ExternalLink 
href={`${getLink('DOCS')}/querying/multi-value-dimensions`}>
+              mvd docs
+            </ExternalLink>{' '}
+            for more details about the differences between arrays and 
multi-value strings.
+          </p>
+        </PopoverText>
+      }
+    >
+      <Switch
+        label="Store ARRAYs as MVDs"
+        className="legacy-switch"
+        checked={arrayMode === 'multi-values'}
+        onChange={() => changeArrayMode(arrayMode === 'arrays' ? 
'multi-values' : 'arrays')}
+      />
+    </FormGroupWithInfo>
+  );
+});
diff --git a/web-console/src/views/home-view/status-card/status-card.spec.tsx 
b/web-console/src/components/array-mode-switch/array-mode-swtich.spec.tsx
similarity index 80%
copy from web-console/src/views/home-view/status-card/status-card.spec.tsx
copy to web-console/src/components/array-mode-switch/array-mode-swtich.spec.tsx
index 5b7d419421d..4e738c38842 100644
--- a/web-console/src/views/home-view/status-card/status-card.spec.tsx
+++ b/web-console/src/components/array-mode-switch/array-mode-swtich.spec.tsx
@@ -19,13 +19,13 @@
 import { render } from '@testing-library/react';
 import React from 'react';
 
-import { StatusCard } from './status-card';
+import { ArrayModeSwitch } from './array-mode-switch';
 
-describe('StatusCard', () => {
+describe('ArrayModeSwitch', () => {
   it('matches snapshot', () => {
-    const statusCard = <StatusCard />;
+    const arrayInput = <ArrayModeSwitch arrayMode="multi-values" 
changeArrayMode={() => {}} />;
 
-    const { container } = render(statusCard);
+    const { container } = render(arrayInput);
     expect(container.firstChild).toMatchSnapshot();
   });
 });
diff --git a/web-console/src/components/index.ts 
b/web-console/src/components/index.ts
index a84dc063f8d..8e43f9ec94f 100644
--- a/web-console/src/components/index.ts
+++ b/web-console/src/components/index.ts
@@ -19,6 +19,7 @@
 export * from './action-cell/action-cell';
 export * from './action-icon/action-icon';
 export * from './array-input/array-input';
+export * from './array-mode-switch/array-mode-switch';
 export * from './auto-form/auto-form';
 export * from './braced-text/braced-text';
 export * from './center-message/center-message';
diff --git 
a/web-console/src/components/warning-checklist/__snapshots__/warning-checklist.spec.tsx.snap
 
b/web-console/src/components/warning-checklist/__snapshots__/warning-checklist.spec.tsx.snap
index 4112eb45841..f3228aac108 100644
--- 
a/web-console/src/components/warning-checklist/__snapshots__/warning-checklist.spec.tsx.snap
+++ 
b/web-console/src/components/warning-checklist/__snapshots__/warning-checklist.spec.tsx.snap
@@ -5,7 +5,7 @@ exports[`WarningChecklist matches snapshot 1`] = `
   class="warning-checklist"
 >
   <label
-    class="bp4-control bp4-switch"
+    class="bp4-control bp4-switch danger-switch"
   >
     <input
       type="checkbox"
@@ -16,7 +16,7 @@ exports[`WarningChecklist matches snapshot 1`] = `
     Check A
   </label>
   <label
-    class="bp4-control bp4-switch"
+    class="bp4-control bp4-switch danger-switch"
   >
     <input
       type="checkbox"
@@ -27,7 +27,7 @@ exports[`WarningChecklist matches snapshot 1`] = `
     Check B
   </label>
   <label
-    class="bp4-control bp4-switch"
+    class="bp4-control bp4-switch danger-switch"
   >
     <input
       type="checkbox"
diff --git a/web-console/src/components/warning-checklist/warning-checklist.tsx 
b/web-console/src/components/warning-checklist/warning-checklist.tsx
index 351203fe727..fa496b26a53 100644
--- a/web-console/src/components/warning-checklist/warning-checklist.tsx
+++ b/web-console/src/components/warning-checklist/warning-checklist.tsx
@@ -40,7 +40,7 @@ export const WarningChecklist = React.memo(function 
WarningChecklist(props: Warn
   return (
     <div className="warning-checklist">
       {checks.map((check, i) => (
-        <Switch key={i} onChange={() => doCheck(i)}>
+        <Switch key={i} className="danger-switch" onChange={() => doCheck(i)}>
           {check}
         </Switch>
       ))}
diff --git a/web-console/src/druid-models/dimension-spec/dimension-spec.ts 
b/web-console/src/druid-models/dimension-spec/dimension-spec.ts
index bb8d26a5578..f8d8f229dab 100644
--- a/web-console/src/druid-models/dimension-spec/dimension-spec.ts
+++ b/web-console/src/druid-models/dimension-spec/dimension-spec.ts
@@ -35,9 +35,20 @@ export interface DimensionSpec {
   readonly name: string;
   readonly createBitmapIndex?: boolean;
   readonly multiValueHandling?: string;
+  readonly castToType?: string;
 }
 
-const KNOWN_TYPES = ['string', 'long', 'float', 'double', 'json'];
+// This is a web console internal made up column type that represents a multi 
value dimension
+const MADE_UP_MV_COLUMN_TYPE = 'mv-string';
+function makeMadeUpMvDimensionSpec(name: string): DimensionSpec {
+  return {
+    type: 'string',
+    name,
+    multiValueHandling: 'SORTED_ARRAY',
+  };
+}
+
+const KNOWN_TYPES = ['auto', 'string', 'long', 'float', 'double', 'json'];
 export const DIMENSION_SPEC_FIELDS: Field<DimensionSpec>[] = [
   {
     name: 'name',
@@ -59,10 +70,25 @@ export const DIMENSION_SPEC_FIELDS: Field<DimensionSpec>[] 
= [
   },
   {
     name: 'multiValueHandling',
+    label: 'Multi-value handling',
     type: 'string',
     defined: typeIsKnown(KNOWN_TYPES, 'string'),
-    defaultValue: 'SORTED_ARRAY',
-    suggestions: ['SORTED_ARRAY', 'SORTED_SET', 'ARRAY'],
+    placeholder: 'unset (defaults to SORTED_ARRAY)',
+    suggestions: [undefined, 'SORTED_ARRAY', 'SORTED_SET', 'ARRAY'],
+  },
+  {
+    name: 'castToType',
+    type: 'string',
+    defined: typeIsKnown(KNOWN_TYPES, 'auto'),
+    suggestions: [
+      undefined,
+      'STRING',
+      'LONG',
+      'DOUBLE',
+      'ARRAY<STRING>',
+      'ARRAY<LONG>',
+      'ARRAY<DOUBLE>',
+    ],
   },
 ];
 
@@ -70,8 +96,59 @@ export function getDimensionSpecName(dimensionSpec: string | 
DimensionSpec): str
   return typeof dimensionSpec === 'string' ? dimensionSpec : 
dimensionSpec.name;
 }
 
-export function getDimensionSpecType(dimensionSpec: string | DimensionSpec): 
string {
-  return typeof dimensionSpec === 'string' ? 'string' : dimensionSpec.type;
+export function getDimensionSpecColumnType(dimensionSpec: string | 
DimensionSpec): string {
+  if (typeof dimensionSpec === 'string') return 'string';
+  switch (dimensionSpec.type) {
+    case 'string':
+      return typeof dimensionSpec.multiValueHandling === 'string'
+        ? MADE_UP_MV_COLUMN_TYPE
+        : 'string';
+
+    case 'auto':
+      return dimensionSpec.castToType ?? 'auto';
+
+    default:
+      return dimensionSpec.type;
+  }
+}
+
+export function getDimensionSpecUserType(
+  dimensionSpec: string | DimensionSpec,
+  identifyMv?: boolean,
+): string {
+  if (typeof dimensionSpec === 'string') return 'string';
+  switch (dimensionSpec.type) {
+    case 'string':
+      return identifyMv && typeof dimensionSpec.multiValueHandling === 'string'
+        ? 'string (multi-value)'
+        : 'string';
+
+    case 'auto':
+      return dimensionSpec.castToType ?? 'auto';
+
+    default:
+      return dimensionSpec.type;
+  }
+}
+
+export function getDimensionSpecClassType(
+  dimensionSpec: string | DimensionSpec,
+  identifyMv?: boolean,
+): string | undefined {
+  if (typeof dimensionSpec === 'string') return 'string';
+  switch (dimensionSpec.type) {
+    case 'string':
+      return identifyMv && typeof dimensionSpec.multiValueHandling === 'string'
+        ? MADE_UP_MV_COLUMN_TYPE
+        : 'string';
+
+    case 'auto':
+      if (String(dimensionSpec.castToType).startsWith('ARRAY')) return 'array';
+      return dimensionSpec.castToType?.toLowerCase();
+
+    default:
+      return dimensionSpec.type;
+  }
 }
 
 export function inflateDimensionSpec(dimensionSpec: string | DimensionSpec): 
DimensionSpec {
@@ -82,18 +159,47 @@ export function inflateDimensionSpec(dimensionSpec: string 
| DimensionSpec): Dim
 
 export function getDimensionSpecs(
   sampleResponse: SampleResponse,
-  typeHints: Record<string, string>,
+  columnTypeHints: Record<string, string>,
   guessNumericStringsAsNumbers: boolean,
+  forceMvdInsteadOfArray: boolean,
   hasRollup: boolean,
 ): (string | DimensionSpec)[] {
   return filterMap(getHeaderNamesFromSampleResponse(sampleResponse, 'ignore'), 
h => {
-    const dimensionType =
-      typeHints[h] ||
-      guessColumnTypeFromSampleResponse(sampleResponse, h, 
guessNumericStringsAsNumbers);
-    if (dimensionType === 'string') return h;
+    const columnTypeHint = columnTypeHints[h];
+    const guessedColumnType = guessColumnTypeFromSampleResponse(
+      sampleResponse,
+      h,
+      guessNumericStringsAsNumbers,
+    );
+    let columnType = columnTypeHint || guessedColumnType;
+
+    if (forceMvdInsteadOfArray) {
+      if (columnType.startsWith('ARRAY')) {
+        columnType = MADE_UP_MV_COLUMN_TYPE;
+      }
+
+      if (columnType === MADE_UP_MV_COLUMN_TYPE) {
+        return makeMadeUpMvDimensionSpec(h);
+      }
+    } else {
+      // Ignore the type hint if it is MVD and we don't want to force people 
into them
+      if (columnTypeHint === MADE_UP_MV_COLUMN_TYPE) {
+        columnType = guessedColumnType;
+      }
+    }
+
+    if (columnType === 'string') return h;
+    if (columnType.startsWith('ARRAY')) {
+      return {
+        type: 'auto',
+        name: h,
+        castToType: columnType.toUpperCase(),
+      };
+    }
+
     if (hasRollup) return;
     return {
-      type: dimensionType === 'COMPLEX<json>' ? 'json' : dimensionType,
+      type: columnType === 'COMPLEX<json>' ? 'json' : columnType,
       name: h,
     };
   });
diff --git a/web-console/src/druid-models/external-config/external-config.ts 
b/web-console/src/druid-models/external-config/external-config.ts
index eb7aefefe92..2cd841a1d68 100644
--- a/web-console/src/druid-models/external-config/external-config.ts
+++ b/web-console/src/druid-models/external-config/external-config.ts
@@ -32,6 +32,7 @@ import {
 import * as JSONBig from 'json-bigint-native';
 
 import { nonEmptyArray } from '../../utils';
+import type { ArrayMode } from '../ingestion-spec/ingestion-spec';
 import type { InputFormat } from '../input-format/input-format';
 import type { InputSource } from '../input-source/input-source';
 
@@ -127,15 +128,18 @@ export function externalConfigToTableExpression(config: 
ExternalConfig): SqlExpr
 
 export function externalConfigToInitDimensions(
   config: ExternalConfig,
-  isArrays: boolean[],
   timeExpression: SqlExpression | undefined,
+  arrayMode: ArrayMode,
 ): SqlExpression[] {
   return (timeExpression ? [timeExpression.as('__time')] : [])
     .concat(
-      filterMap(config.signature, (columnDeclaration, i) => {
+      filterMap(config.signature, columnDeclaration => {
         const columnName = columnDeclaration.getColumnName();
         if (timeExpression && timeExpression.containsColumnName(columnName)) 
return;
-        return C(columnName).applyIf(isArrays[i], ex => F('MV_TO_ARRAY', 
ex).as(columnName) as any);
+        return C(columnName).applyIf(
+          arrayMode === 'multi-values' && 
columnDeclaration.columnType.isArray(),
+          ex => F('ARRAY_TO_MV', ex).as(columnName),
+        );
       }),
     )
     .slice(0, MULTI_STAGE_QUERY_MAX_COLUMNS);
diff --git 
a/web-console/src/druid-models/ingest-query-pattern/ingest-query-pattern.spec.ts
 
b/web-console/src/druid-models/ingest-query-pattern/ingest-query-pattern.spec.ts
index 872ae5251ba..0ac9012e273 100644
--- 
a/web-console/src/druid-models/ingest-query-pattern/ingest-query-pattern.spec.ts
+++ 
b/web-console/src/druid-models/ingest-query-pattern/ingest-query-pattern.spec.ts
@@ -21,7 +21,7 @@ import { sane, SqlQuery } from '@druid-toolkit/query';
 import { fitIngestQueryPattern, ingestQueryPatternToQuery } from 
'./ingest-query-pattern';
 
 describe('ingest-query-pattern', () => {
-  it('works', () => {
+  it('works with no group by', () => {
     const query = SqlQuery.parse(sane`
       INSERT INTO "kttm-2019"
       WITH "ext" AS (
@@ -45,7 +45,142 @@ describe('ingest-query-pattern', () => {
     `);
 
     const insertQueryPattern = fitIngestQueryPattern(query);
-    expect(insertQueryPattern.partitionedBy).toEqual('hour');
+
+    expect(insertQueryPattern).toMatchInlineSnapshot(`
+      Object {
+        "clusteredBy": Array [
+          3,
+        ],
+        "destinationTableName": "kttm-2019",
+        "dimensions": Array [
+          "TIME_PARSE(\\"timestamp\\") AS __time",
+          "agent_category",
+          "agent_type",
+          "browser",
+          "browser_version",
+        ],
+        "filters": Array [],
+        "mainExternalConfig": Object {
+          "inputFormat": Object {
+            "type": "json",
+          },
+          "inputSource": Object {
+            "type": "http",
+            "uris": Array [
+              "https://example.com/data.json.gz";,
+            ],
+          },
+          "signature": Array [
+            "\\"timestamp\\" VARCHAR",
+            "\\"agent_category\\" VARCHAR",
+            "\\"agent_type\\" VARCHAR",
+            "\\"browser\\" VARCHAR",
+            "\\"browser_version\\" VARCHAR",
+            "\\"city\\" VARCHAR",
+            "\\"continent\\" VARCHAR",
+            "\\"country\\" VARCHAR",
+            "\\"version\\" VARCHAR",
+            "\\"event_type\\" VARCHAR",
+            "\\"event_subtype\\" VARCHAR",
+            "\\"loaded_image\\" VARCHAR",
+            "\\"adblock_list\\" VARCHAR",
+            "\\"forwarded_for\\" VARCHAR",
+            "\\"language\\" VARCHAR",
+            "\\"number\\" BIGINT",
+            "\\"os\\" VARCHAR",
+            "\\"path\\" VARCHAR",
+            "\\"platform\\" VARCHAR",
+            "\\"referrer\\" VARCHAR",
+            "\\"referrer_host\\" VARCHAR",
+            "\\"region\\" VARCHAR",
+            "\\"remote_address\\" VARCHAR",
+            "\\"screen\\" VARCHAR",
+            "\\"session\\" VARCHAR",
+            "\\"session_length\\" BIGINT",
+            "\\"timezone\\" VARCHAR",
+            "\\"timezone_offset\\" BIGINT",
+            "\\"window\\" VARCHAR",
+          ],
+        },
+        "mainExternalName": "ext",
+        "metrics": undefined,
+        "mode": "insert",
+        "overwriteWhere": undefined,
+        "partitionedBy": "hour",
+      }
+    `);
+
+    const query2 = ingestQueryPatternToQuery(insertQueryPattern);
+
+    expect(query2.toString()).toEqual(query.toString());
+  });
+
+  it('works with group by', () => {
+    const query = SqlQuery.parse(sane`
+      REPLACE INTO "inline_data" OVERWRITE ALL
+      WITH "ext" AS (
+        SELECT *
+        FROM TABLE(
+          EXTERN(
+            
'{"type":"inline","data":"{\\"name\\":\\"Moon\\",\\"num\\":11,\\"strings\\":[\\"A\\",
 
\\"B\\"],\\"ints\\":[1,2],\\"floats\\":[1.1,2.2],\\"msg\\":{\\"hello\\":\\"world\\",\\"letters\\":[{\\"letter\\":\\"A\\",\\"index\\":1},{\\"letter\\":\\"B\\",\\"index\\":2}]}}\\n{\\"name\\":\\"Beam\\",\\"num\\":12,\\"strings\\":[\\"A\\",
 
\\"C\\"],\\"ints\\":[3,4],\\"floats\\":[3.3,4,4],\\"msg\\":{\\"where\\":\\"go\\",\\"for\\":\\"food\\",\\"letters\\":[{\\"letter\\":\\"C\\",\\"index\\":3},{\
 [...]
+            '{"type":"json"}'
+          )
+        ) EXTEND ("name" VARCHAR, "num" BIGINT, "strings" VARCHAR ARRAY, 
"ints" BIGINT ARRAY, "floats" DOUBLE ARRAY, "msg" TYPE('COMPLEX<json>'))
+      )
+      SELECT
+        "name",
+        "num",
+        ARRAY_TO_MV("strings") AS "strings",
+        "ints",
+        "floats",
+        COUNT(*) AS "count"
+      FROM "ext"
+      GROUP BY 1, 2, "strings", 4, 5
+      PARTITIONED BY ALL
+    `);
+
+    const insertQueryPattern = fitIngestQueryPattern(query);
+
+    expect(insertQueryPattern).toMatchInlineSnapshot(`
+      Object {
+        "clusteredBy": Array [],
+        "destinationTableName": "inline_data",
+        "dimensions": Array [
+          "\\"name\\"",
+          "\\"num\\"",
+          "ARRAY_TO_MV(\\"strings\\") AS \\"strings\\"",
+          "\\"ints\\"",
+          "\\"floats\\"",
+        ],
+        "filters": Array [],
+        "mainExternalConfig": Object {
+          "inputFormat": Object {
+            "type": "json",
+          },
+          "inputSource": Object {
+            "data": 
"{\\"name\\":\\"Moon\\",\\"num\\":11,\\"strings\\":[\\"A\\", 
\\"B\\"],\\"ints\\":[1,2],\\"floats\\":[1.1,2.2],\\"msg\\":{\\"hello\\":\\"world\\",\\"letters\\":[{\\"letter\\":\\"A\\",\\"index\\":1},{\\"letter\\":\\"B\\",\\"index\\":2}]}}
+      {\\"name\\":\\"Beam\\",\\"num\\":12,\\"strings\\":[\\"A\\", 
\\"C\\"],\\"ints\\":[3,4],\\"floats\\":[3.3,4,4],\\"msg\\":{\\"where\\":\\"go\\",\\"for\\":\\"food\\",\\"letters\\":[{\\"letter\\":\\"C\\",\\"index\\":3},{\\"letter\\":\\"D\\",\\"index\\":4}]}}
+      ",
+            "type": "inline",
+          },
+          "signature": Array [
+            "\\"name\\" VARCHAR",
+            "\\"num\\" BIGINT",
+            "\\"strings\\" VARCHAR ARRAY",
+            "\\"ints\\" BIGINT ARRAY",
+            "\\"floats\\" DOUBLE ARRAY",
+            "\\"msg\\" TYPE('COMPLEX<json>')",
+          ],
+        },
+        "mainExternalName": "ext",
+        "metrics": Array [
+          "COUNT(*) AS \\"count\\"",
+        ],
+        "mode": "replace",
+        "overwriteWhere": undefined,
+        "partitionedBy": "all",
+      }
+    `);
 
     const query2 = ingestQueryPatternToQuery(insertQueryPattern);
 
diff --git 
a/web-console/src/druid-models/ingest-query-pattern/ingest-query-pattern.ts 
b/web-console/src/druid-models/ingest-query-pattern/ingest-query-pattern.ts
index b221411e63d..6730ecec659 100644
--- a/web-console/src/druid-models/ingest-query-pattern/ingest-query-pattern.ts
+++ b/web-console/src/druid-models/ingest-query-pattern/ingest-query-pattern.ts
@@ -34,16 +34,16 @@ import {
   externalConfigToTableExpression,
   fitExternalConfigPattern,
 } from '../external-config/external-config';
+import type { ArrayMode } from '../ingestion-spec/ingestion-spec';
 import { guessDataSourceNameFromInputSource } from 
'../ingestion-spec/ingestion-spec';
 
 export type IngestMode = 'insert' | 'replace';
 
-function removeMvToArray(dimension: SqlExpression): SqlExpression {
-  return dimension.walk(ex =>
-    ex instanceof SqlFunction && ex.getEffectiveFunctionName() === 
'MV_TO_ARRAY'
-      ? ex.getArg(0)
-      : ex,
-  ) as SqlExpression;
+function removeTopLevelArrayToMvOrUndefined(dimension: SqlExpression): 
SqlExpression | undefined {
+  const ex = dimension.getUnderlyingExpression();
+  return ex instanceof SqlFunction && ex.getEffectiveFunctionName() === 
'ARRAY_TO_MV'
+    ? ex.getArg(0)
+    : undefined;
 }
 
 export interface IngestQueryPattern {
@@ -61,9 +61,9 @@ export interface IngestQueryPattern {
 
 export function externalConfigToIngestQueryPattern(
   config: ExternalConfig,
-  isArrays: boolean[],
   timeExpression: SqlExpression | undefined,
   partitionedByHint: string | undefined,
+  arrayMode: ArrayMode,
 ): IngestQueryPattern {
   return {
     destinationTableName: 
guessDataSourceNameFromInputSource(config.inputSource) || 'data',
@@ -71,7 +71,7 @@ export function externalConfigToIngestQueryPattern(
     mainExternalName: 'ext',
     mainExternalConfig: config,
     filters: [],
-    dimensions: externalConfigToInitDimensions(config, isArrays, 
timeExpression),
+    dimensions: externalConfigToInitDimensions(config, timeExpression, 
arrayMode),
     partitionedBy: partitionedByHint || (timeExpression ? 'day' : 'all'),
     clusteredBy: [],
   };
@@ -268,7 +268,12 @@ export function ingestQueryPatternToQuery(
       ),
     ])
     .applyForEach(dimensions, (query, ex) =>
-      query.addSelect(preview ? removeMvToArray(ex) : ex, metrics ? { 
addToGroupBy: 'end' } : {}),
+      query.addSelect(
+        ex,
+        metrics
+          ? { addToGroupBy: 'end', groupByExpression: 
removeTopLevelArrayToMvOrUndefined(ex) }
+          : {},
+      ),
     )
     .applyForEach(metrics || [], (query, ex) => query.addSelect(ex))
     .applyIf(filters.length, query => 
query.changeWhereExpression(SqlExpression.and(...filters)))
diff --git a/web-console/src/druid-models/ingestion-spec/ingestion-spec.spec.ts 
b/web-console/src/druid-models/ingestion-spec/ingestion-spec.spec.ts
index 79a4d93e307..86787010f27 100644
--- a/web-console/src/druid-models/ingestion-spec/ingestion-spec.spec.ts
+++ b/web-console/src/druid-models/ingestion-spec/ingestion-spec.spec.ts
@@ -709,25 +709,93 @@ describe('spec utils', () => {
     });
 
     it('works for long', () => {
-      expect(guessColumnTypeFromInput([1, 2, 3], false)).toEqual('long');
-      expect(guessColumnTypeFromInput([1, 2, 3], true)).toEqual('long');
-      expect(guessColumnTypeFromInput(['1', '2', '3'], 
false)).toEqual('string');
-      expect(guessColumnTypeFromInput(['1', '2', '3'], true)).toEqual('long');
+      expect(guessColumnTypeFromInput([null, 1, 2, 3], false)).toEqual('long');
+      expect(guessColumnTypeFromInput([null, 1, 2, 3], true)).toEqual('long');
+      expect(guessColumnTypeFromInput([null, '1', '2', '3'], 
false)).toEqual('string');
+      expect(guessColumnTypeFromInput([null, '1', '2', '3'], 
true)).toEqual('long');
     });
 
     it('works for double', () => {
-      expect(guessColumnTypeFromInput([1, 2.1, 3], false)).toEqual('double');
-      expect(guessColumnTypeFromInput([1, 2.1, 3], true)).toEqual('double');
-      expect(guessColumnTypeFromInput(['1', '2.1', '3'], 
false)).toEqual('string');
-      expect(guessColumnTypeFromInput(['1', '2.1', '3'], 
true)).toEqual('double');
+      expect(guessColumnTypeFromInput([null, 1, 2.1, 3], 
false)).toEqual('double');
+      expect(guessColumnTypeFromInput([null, 1, 2.1, 3], 
true)).toEqual('double');
+      expect(guessColumnTypeFromInput([null, '1', '2.1', '3'], 
false)).toEqual('string');
+      expect(guessColumnTypeFromInput([null, '1', '2.1', '3'], 
true)).toEqual('double');
     });
 
-    it('works for multi-value', () => {
-      expect(guessColumnTypeFromInput(['a', ['b'], 'c'], 
false)).toEqual('string');
-      expect(guessColumnTypeFromInput([1, [2], 3], false)).toEqual('string');
-      expect(guessColumnTypeFromInput([true, [true, 7, false], false, 'x'], 
false)).toEqual(
-        'string',
-      );
+    it('works for ARRAY<string>', () => {
+      expect(
+        guessColumnTypeFromInput(
+          [
+            ['A', 'B'],
+            ['A', 'C'],
+          ],
+          false,
+        ),
+      ).toEqual('ARRAY<string>');
+    });
+
+    it('works for ARRAY<long>', () => {
+      expect(
+        guessColumnTypeFromInput(
+          [
+            [1, 2],
+            [3, 4],
+          ],
+          false,
+        ),
+      ).toEqual('ARRAY<long>');
+
+      expect(
+        guessColumnTypeFromInput(
+          [
+            ['1', '2'],
+            ['3', '4'],
+          ],
+          false,
+        ),
+      ).toEqual('ARRAY<string>');
+
+      expect(
+        guessColumnTypeFromInput(
+          [
+            ['1', '2'],
+            ['3', '4'],
+          ],
+          true,
+        ),
+      ).toEqual('ARRAY<long>');
+    });
+
+    it('works for ARRAY<double>', () => {
+      expect(
+        guessColumnTypeFromInput(
+          [
+            [1.1, 2.2],
+            [3.3, 4.4],
+          ],
+          false,
+        ),
+      ).toEqual('ARRAY<double>');
+
+      expect(
+        guessColumnTypeFromInput(
+          [
+            ['1.1', '2.2'],
+            ['3.3', '4.4'],
+          ],
+          false,
+        ),
+      ).toEqual('ARRAY<string>');
+
+      expect(
+        guessColumnTypeFromInput(
+          [
+            ['1.1', '2.2'],
+            ['3.3', '4.4'],
+          ],
+          true,
+        ),
+      ).toEqual('ARRAY<double>');
     });
 
     it('works for complex arrays', () => {
@@ -751,25 +819,115 @@ describe('spec utils', () => {
       expect(guessColumnTypeFromSampleResponse(CSV_SAMPLE, 'followers', 
false)).toEqual('string');
       expect(guessColumnTypeFromSampleResponse(CSV_SAMPLE, 'followers', 
true)).toEqual('long');
       expect(guessColumnTypeFromSampleResponse(CSV_SAMPLE, 'spend', 
true)).toEqual('double');
-      expect(guessColumnTypeFromSampleResponse(CSV_SAMPLE, 'nums', 
false)).toEqual('string');
-      expect(guessColumnTypeFromSampleResponse(CSV_SAMPLE, 'nums', 
true)).toEqual('string');
+      expect(guessColumnTypeFromSampleResponse(CSV_SAMPLE, 'nums', 
false)).toEqual('ARRAY<string>');
+      expect(guessColumnTypeFromSampleResponse(CSV_SAMPLE, 'nums', 
true)).toEqual('ARRAY<long>');
     });
   });
 
-  it('updateSchemaWithSample', () => {
-    const withRollup = updateSchemaWithSample(ingestionSpec, JSON_SAMPLE, 
'fixed', true);
+  describe('updateSchemaWithSample', () => {
+    it('works with rollup, arrays', () => {
+      const updateSpec = updateSchemaWithSample(
+        ingestionSpec,
+        JSON_SAMPLE,
+        'fixed',
+        'arrays',
+        true,
+      );
+      expect(updateSpec.spec).toMatchInlineSnapshot(`
+        Object {
+          "dataSchema": Object {
+            "dataSource": "wikipedia",
+            "dimensionsSpec": Object {
+              "dimensions": Array [
+                "user",
+                "id",
+                Object {
+                  "castToType": "ARRAY<STRING>",
+                  "name": "tags",
+                  "type": "auto",
+                },
+                Object {
+                  "castToType": "ARRAY<LONG>",
+                  "name": "nums",
+                  "type": "auto",
+                },
+              ],
+            },
+            "granularitySpec": Object {
+              "queryGranularity": "hour",
+              "rollup": true,
+              "segmentGranularity": "day",
+            },
+            "metricsSpec": Array [
+              Object {
+                "name": "count",
+                "type": "count",
+              },
+              Object {
+                "fieldName": "followers",
+                "name": "sum_followers",
+                "type": "longSum",
+              },
+              Object {
+                "fieldName": "spend",
+                "name": "sum_spend",
+                "type": "doubleSum",
+              },
+            ],
+            "timestampSpec": Object {
+              "column": "timestamp",
+              "format": "iso",
+            },
+          },
+          "ioConfig": Object {
+            "inputFormat": Object {
+              "type": "json",
+            },
+            "inputSource": Object {
+              "type": "http",
+              "uris": Array [
+                "https://website.com/wikipedia.json.gz";,
+              ],
+            },
+            "type": "index_parallel",
+          },
+          "tuningConfig": Object {
+            "forceGuaranteedRollup": true,
+            "partitionsSpec": Object {
+              "type": "hashed",
+            },
+            "type": "index_parallel",
+          },
+        }
+      `);
+    });
 
-    expect(withRollup).toMatchInlineSnapshot(`
-      Object {
-        "spec": Object {
+    it('works with rollup, MVDs', () => {
+      const updateSpec = updateSchemaWithSample(
+        ingestionSpec,
+        JSON_SAMPLE,
+        'fixed',
+        'multi-values',
+        true,
+      );
+      expect(updateSpec.spec).toMatchInlineSnapshot(`
+        Object {
           "dataSchema": Object {
             "dataSource": "wikipedia",
             "dimensionsSpec": Object {
               "dimensions": Array [
                 "user",
                 "id",
-                "tags",
-                "nums",
+                Object {
+                  "multiValueHandling": "SORTED_ARRAY",
+                  "name": "tags",
+                  "type": "string",
+                },
+                Object {
+                  "multiValueHandling": "SORTED_ARRAY",
+                  "name": "nums",
+                  "type": "string",
+                },
               ],
             },
             "granularitySpec": Object {
@@ -817,16 +975,88 @@ describe('spec utils', () => {
             },
             "type": "index_parallel",
           },
-        },
-        "type": "index_parallel",
-      }
-    `);
+        }
+      `);
+    });
 
-    const noRollup = updateSchemaWithSample(ingestionSpec, JSON_SAMPLE, 
'fixed', false);
+    it('works without rollup, arrays', () => {
+      const updatedSpec = updateSchemaWithSample(
+        ingestionSpec,
+        JSON_SAMPLE,
+        'fixed',
+        'arrays',
+        false,
+      );
+      expect(updatedSpec.spec).toMatchInlineSnapshot(`
+        Object {
+          "dataSchema": Object {
+            "dataSource": "wikipedia",
+            "dimensionsSpec": Object {
+              "dimensions": Array [
+                "user",
+                Object {
+                  "name": "followers",
+                  "type": "long",
+                },
+                Object {
+                  "name": "spend",
+                  "type": "double",
+                },
+                "id",
+                Object {
+                  "castToType": "ARRAY<STRING>",
+                  "name": "tags",
+                  "type": "auto",
+                },
+                Object {
+                  "castToType": "ARRAY<LONG>",
+                  "name": "nums",
+                  "type": "auto",
+                },
+              ],
+            },
+            "granularitySpec": Object {
+              "queryGranularity": "none",
+              "rollup": false,
+              "segmentGranularity": "day",
+            },
+            "timestampSpec": Object {
+              "column": "timestamp",
+              "format": "iso",
+            },
+          },
+          "ioConfig": Object {
+            "inputFormat": Object {
+              "type": "json",
+            },
+            "inputSource": Object {
+              "type": "http",
+              "uris": Array [
+                "https://website.com/wikipedia.json.gz";,
+              ],
+            },
+            "type": "index_parallel",
+          },
+          "tuningConfig": Object {
+            "partitionsSpec": Object {
+              "type": "dynamic",
+            },
+            "type": "index_parallel",
+          },
+        }
+      `);
+    });
 
-    expect(noRollup).toMatchInlineSnapshot(`
-      Object {
-        "spec": Object {
+    it('works without rollup, MVDs', () => {
+      const updatedSpec = updateSchemaWithSample(
+        ingestionSpec,
+        JSON_SAMPLE,
+        'fixed',
+        'multi-values',
+        false,
+      );
+      expect(updatedSpec.spec).toMatchInlineSnapshot(`
+        Object {
           "dataSchema": Object {
             "dataSource": "wikipedia",
             "dimensionsSpec": Object {
@@ -841,8 +1071,16 @@ describe('spec utils', () => {
                   "type": "double",
                 },
                 "id",
-                "tags",
-                "nums",
+                Object {
+                  "multiValueHandling": "SORTED_ARRAY",
+                  "name": "tags",
+                  "type": "string",
+                },
+                Object {
+                  "multiValueHandling": "SORTED_ARRAY",
+                  "name": "nums",
+                  "type": "string",
+                },
               ],
             },
             "granularitySpec": Object {
@@ -873,10 +1111,9 @@ describe('spec utils', () => {
             },
             "type": "index_parallel",
           },
-        },
-        "type": "index_parallel",
-      }
-    `);
+        }
+      `);
+    });
   });
 
   it('adjustId', () => {
diff --git a/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx 
b/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx
index 6bf10232e89..4f4b1e9a51b 100644
--- a/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx
+++ b/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx
@@ -44,11 +44,11 @@ import {
   typeIsKnown,
 } from '../../utils';
 import type { SampleResponse } from '../../utils/sampler';
-import type { DimensionsSpec } from '../dimension-spec/dimension-spec';
+import type { DimensionSpec, DimensionsSpec } from 
'../dimension-spec/dimension-spec';
 import {
+  getDimensionSpecColumnType,
   getDimensionSpecName,
   getDimensionSpecs,
-  getDimensionSpecType,
 } from '../dimension-spec/dimension-spec';
 import type { IndexSpec } from '../index-spec/index-spec';
 import { summarizeIndexSpec } from '../index-spec/index-spec';
@@ -73,6 +73,7 @@ export interface IngestionSpec {
   readonly type: IngestionType;
   readonly spec: IngestionSpecInner;
   readonly context?: { taskLockType?: 'APPEND' | 'REPLACE' };
+  readonly suspended?: boolean;
 }
 
 export interface IngestionSpecInner {
@@ -233,6 +234,9 @@ export function getRequiredModule(ingestionType: 
IngestionComboTypeWithExtra): s
     case 'index_parallel:azure':
       return 'druid-azure-extensions';
 
+    case 'index_parallel:delta':
+      return 'druid-deltalake-extensions';
+
     case 'index_parallel:google':
       return 'druid-google-extensions';
 
@@ -279,6 +283,8 @@ export interface DataSchema {
 
 export type SchemaMode = 'fixed' | 'string-only-discovery' | 
'type-aware-discovery';
 
+export type ArrayMode = 'arrays' | 'multi-values';
+
 export function getSchemaMode(spec: Partial<IngestionSpec>): SchemaMode {
   if (deepGet(spec, 'spec.dataSchema.dimensionsSpec.useSchemaDiscovery') === 
true) {
     return 'type-aware-discovery';
@@ -290,9 +296,66 @@ export function getSchemaMode(spec: 
Partial<IngestionSpec>): SchemaMode {
   return Array.isArray(dimensions) && dimensions.length === 0 ? 
'string-only-discovery' : 'fixed';
 }
 
-export function getRollup(spec: Partial<IngestionSpec>): boolean {
+export function getArrayMode(spec: Partial<IngestionSpec>): ArrayMode {
+  const schemaMode = getSchemaMode(spec);
+  switch (schemaMode) {
+    case 'type-aware-discovery':
+      return 'arrays';
+
+    case 'string-only-discovery':
+      return 'multi-values';
+
+    default: {
+      const dimensions: (DimensionSpec | string)[] = deepGet(
+        spec,
+        'spec.dataSchema.dimensionsSpec.dimensions',
+      );
+
+      if (
+        dimensions.some(
+          d =>
+            typeof d === 'object' && d.type === 'auto' && 
String(d.castToType).startsWith('ARRAY'),
+        )
+      ) {
+        return 'arrays';
+      }
+
+      if (
+        dimensions.some(
+          d =>
+            typeof d === 'object' &&
+            d.type === 'string' &&
+            typeof d.multiValueHandling === 'string',
+        )
+      ) {
+        return 'multi-values';
+      }
+
+      return 'arrays';
+    }
+  }
+}
+
+export function showArrayModeToggle(spec: Partial<IngestionSpec>): boolean {
+  const schemaMode = getSchemaMode(spec);
+  if (schemaMode !== 'fixed') return false;
+
+  const dimensions: (DimensionSpec | string)[] = deepGet(
+    spec,
+    'spec.dataSchema.dimensionsSpec.dimensions',
+  );
+
+  return dimensions.some(
+    d =>
+      typeof d === 'object' &&
+      ((d.type === 'auto' && String(d.castToType).startsWith('ARRAY')) ||
+        (d.type === 'string' && typeof d.multiValueHandling === 'string')),
+  );
+}
+
+export function getRollup(spec: Partial<IngestionSpec>, valueIfUnset = true): 
boolean {
   const specRollup = deepGet(spec, 'spec.dataSchema.granularitySpec.rollup');
-  return typeof specRollup === 'boolean' ? specRollup : true;
+  return typeof specRollup === 'boolean' ? specRollup : valueIfUnset;
 }
 
 export function getSpecType(spec: Partial<IngestionSpec>): IngestionType {
@@ -372,7 +435,7 @@ export function cleanSpec(
 ): Partial<IngestionSpec> {
   return allowKeys(
     spec,
-    ['type', 'spec', 'context'].concat(allowSuspended ? ['suspended'] : []),
+    ['type', 'spec', 'context'].concat(allowSuspended ? ['suspended'] : []) as 
any,
   ) as IngestionSpec;
 }
 
@@ -2400,11 +2463,26 @@ function inputFormatFromType(options: 
InputFormatFromTypeOptions): InputFormat {
 
 // ------------------------
 
-export function guessIsArrayFromSampleResponse(
-  sampleResponse: SampleResponse,
-  column: string,
-): boolean {
-  return sampleResponse.data.some(r => isSimpleArray(r.input?.[column]));
+function checkArray(array: any[], checkFn: (x: any) => boolean): boolean {
+  return array.every(as => checkFn(as) || (Array.isArray(as) && 
as.every(checkFn)));
+}
+
+function isIntegerOrNull(x: any): boolean {
+  return x == null || (typeof x === 'number' && Number.isInteger(x));
+}
+
+function isIntegerOrNullAcceptString(x: any): boolean {
+  return (
+    x == null || ((typeof x === 'number' || typeof x === 'string') && 
Number.isInteger(Number(x)))
+  );
+}
+
+function isNumberOrNull(x: any): boolean {
+  return x == null || (typeof x === 'number' && !isNaN(x));
+}
+
+function isNumberOrNullAcceptString(x: any): boolean {
+  return x == null || ((typeof x === 'number' || typeof x === 'string') && 
!isNaN(Number(x)));
 }
 
 export function guessColumnTypeFromInput(
@@ -2417,23 +2495,50 @@ export function guessColumnTypeFromInput(
   if (!definedValues.length) return 'string';
 
   // If we see any arrays in the input this is a multi-value dimension that 
must be a string
-  if (definedValues.some(v => isSimpleArray(v))) return 'string';
+  if (definedValues.some(v => isSimpleArray(v))) {
+    if (guessNumericStringsAsNumbers) {
+      if (checkArray(definedValues, isIntegerOrNullAcceptString)) {
+        return 'ARRAY<long>';
+      }
+
+      if (checkArray(definedValues, isNumberOrNullAcceptString)) {
+        return 'ARRAY<double>';
+      }
+    } else {
+      if (checkArray(definedValues, isIntegerOrNull)) {
+        return 'ARRAY<long>';
+      }
+
+      if (checkArray(definedValues, isNumberOrNull)) {
+        return 'ARRAY<double>';
+      }
+    }
+
+    return 'ARRAY<string>';
+  }
 
   // If we see any JSON objects in the input assume COMPLEX<json>
   if (definedValues.some(v => v && typeof v === 'object')) return 
'COMPLEX<json>';
 
-  if (
-    definedValues.every(v => {
-      return (
-        (typeof v === 'number' || (guessNumericStringsAsNumbers && typeof v 
=== 'string')) &&
-        !isNaN(Number(v))
-      );
-    })
-  ) {
-    return definedValues.every(v => v % 1 === 0) ? 'long' : 'double';
+  if (guessNumericStringsAsNumbers) {
+    if (definedValues.every(isIntegerOrNullAcceptString)) {
+      return 'long';
+    }
+
+    if (definedValues.every(isNumberOrNullAcceptString)) {
+      return 'double';
+    }
   } else {
-    return 'string';
+    if (definedValues.every(isIntegerOrNull)) {
+      return 'long';
+    }
+
+    if (definedValues.every(isNumberOrNull)) {
+      return 'double';
+    }
   }
+
+  return 'string';
 }
 
 export function guessColumnTypeFromSampleResponse(
@@ -2451,11 +2556,12 @@ export function 
inputFormatOutputsNumericStrings(inputFormat: InputFormat | unde
   return oneOf(inputFormat?.type, 'csv', 'tsv', 'regex');
 }
 
-function getTypeHintsFromSpec(spec: Partial<IngestionSpec>): Record<string, 
string> {
-  const typeHints: Record<string, string> = {};
+function getColumnTypeHintsFromSpec(spec: Partial<IngestionSpec>): 
Record<string, string> {
+  const columnTypeHints: Record<string, string> = {};
   const currentDimensions = deepGet(spec, 
'spec.dataSchema.dimensionsSpec.dimensions') || [];
   for (const currentDimension of currentDimensions) {
-    typeHints[getDimensionSpecName(currentDimension)] = 
getDimensionSpecType(currentDimension);
+    columnTypeHints[getDimensionSpecName(currentDimension)] =
+      getDimensionSpecColumnType(currentDimension);
   }
 
   const currentMetrics = deepGet(spec, 'spec.dataSchema.metricsSpec') || [];
@@ -2463,21 +2569,22 @@ function getTypeHintsFromSpec(spec: 
Partial<IngestionSpec>): Record<string, stri
     const singleFieldName = getMetricSpecSingleFieldName(currentMetric);
     const metricOutputType = getMetricSpecOutputType(currentMetric);
     if (singleFieldName && metricOutputType) {
-      typeHints[singleFieldName] = metricOutputType;
+      columnTypeHints[singleFieldName] = metricOutputType;
     }
   }
 
-  return typeHints;
+  return columnTypeHints;
 }
 
 export function updateSchemaWithSample(
   spec: Partial<IngestionSpec>,
   sampleResponse: SampleResponse,
   schemaMode: SchemaMode,
+  arrayMode: ArrayMode,
   rollup: boolean,
   forcePartitionInitialization = false,
 ): Partial<IngestionSpec> {
-  const typeHints = getTypeHintsFromSpec(spec);
+  const columnTypeHints = getColumnTypeHintsFromSpec(spec);
   const guessNumericStringsAsNumbers = inputFormatOutputsNumericStrings(
     deepGet(spec, 'spec.ioConfig.inputFormat'),
   );
@@ -2506,7 +2613,13 @@ export function updateSchemaWithSample(
       newSpec = deepSet(
         newSpec,
         'spec.dataSchema.dimensionsSpec.dimensions',
-        getDimensionSpecs(sampleResponse, typeHints, 
guessNumericStringsAsNumbers, rollup),
+        getDimensionSpecs(
+          sampleResponse,
+          columnTypeHints,
+          guessNumericStringsAsNumbers,
+          arrayMode === 'multi-values',
+          rollup,
+        ),
       );
       break;
   }
@@ -2514,7 +2627,7 @@ export function updateSchemaWithSample(
   if (rollup) {
     newSpec = deepSet(newSpec, 
'spec.dataSchema.granularitySpec.queryGranularity', 'hour');
 
-    const metrics = getMetricSpecs(sampleResponse, typeHints, 
guessNumericStringsAsNumbers);
+    const metrics = getMetricSpecs(sampleResponse, columnTypeHints, 
guessNumericStringsAsNumbers);
     if (metrics) {
       newSpec = deepSet(newSpec, 'spec.dataSchema.metricsSpec', metrics);
     }
diff --git a/web-console/src/druid-models/metric-spec/metric-spec.tsx 
b/web-console/src/druid-models/metric-spec/metric-spec.tsx
index 99cf11aaed4..367f922ca48 100644
--- a/web-console/src/druid-models/metric-spec/metric-spec.tsx
+++ b/web-console/src/druid-models/metric-spec/metric-spec.tsx
@@ -431,7 +431,7 @@ export function getMetricSpecOutputType(metricSpec: 
MetricSpec): string | undefi
 
 export function getMetricSpecs(
   sampleResponse: SampleResponse,
-  typeHints: Record<string, string>,
+  columnTypeHints: Record<string, string>,
   guessNumericStringsAsNumbers: boolean,
 ): MetricSpec[] {
   return [{ name: 'count', type: 'count' }].concat(
@@ -439,7 +439,7 @@ export function getMetricSpecs(
       const h = s.name;
       if (h === '__time') return;
       const type =
-        typeHints[h] ||
+        columnTypeHints[h] ||
         guessColumnTypeFromSampleResponse(sampleResponse, h, 
guessNumericStringsAsNumbers);
       switch (type) {
         case 'double':
diff --git a/web-console/src/druid-models/query-context/query-context.tsx 
b/web-console/src/druid-models/query-context/query-context.tsx
index 50036f63fbd..838ab1f3a50 100644
--- a/web-console/src/druid-models/query-context/query-context.tsx
+++ b/web-console/src/druid-models/query-context/query-context.tsx
@@ -32,6 +32,7 @@ export interface QueryContext {
   durableShuffleStorage?: boolean;
   maxParseExceptions?: number;
   groupByEnableMultiValueUnnesting?: boolean;
+  arrayIngestMode?: 'array' | 'mvd';
 
   [key: string]: any;
 }
diff --git a/web-console/src/druid-models/stages/stages.ts 
b/web-console/src/druid-models/stages/stages.ts
index 7cf41a8f464..2f97e7bc96f 100644
--- a/web-console/src/druid-models/stages/stages.ts
+++ b/web-console/src/druid-models/stages/stages.ts
@@ -383,7 +383,7 @@ export class Stages {
     if (!counters) return {};
     return sumByKey(
       filterMap(this.getCountersForStage(stage), c => {
-        const warningCounter = c.warnings;
+        const warningCounter = c.warnings as Record<string, number> | 
undefined;
         if (!warningCounter) return;
         return deleteKeys(warningCounter, ['type']);
       }),
diff --git a/web-console/src/druid-models/timestamp-spec/timestamp-spec.tsx 
b/web-console/src/druid-models/timestamp-spec/timestamp-spec.tsx
index 0107fa3be6d..a88ffc3d0ef 100644
--- a/web-console/src/druid-models/timestamp-spec/timestamp-spec.tsx
+++ b/web-console/src/druid-models/timestamp-spec/timestamp-spec.tsx
@@ -30,7 +30,7 @@ import {
 } from '../time/time';
 import type { Transform } from '../transform-spec/transform-spec';
 
-const NO_SUCH_COLUMN = '!!!_no_such_column_!!!';
+export const NO_SUCH_COLUMN = '!!!_no_such_column_!!!';
 
 export const TIME_COLUMN = '__time';
 
diff --git 
a/web-console/src/druid-models/workbench-query/workbench-query.spec.ts 
b/web-console/src/druid-models/workbench-query/workbench-query.spec.ts
index 36c108d7e5f..ec057b3efc7 100644
--- a/web-console/src/druid-models/workbench-query/workbench-query.spec.ts
+++ b/web-console/src/druid-models/workbench-query/workbench-query.spec.ts
@@ -289,6 +289,7 @@ describe('WorkbenchQuery', () => {
           context: {
             sqlOuterLimit: 1001,
             sqlQueryId: 'deadbeef-9fb0-499c-8475-ea461e96a4fd',
+            sqlStringifyArrays: false,
             useCache: false,
           },
           header: true,
@@ -316,6 +317,7 @@ describe('WorkbenchQuery', () => {
           context: {
             sqlOuterLimit: 1001,
             sqlQueryId: 'lol',
+            sqlStringifyArrays: false,
           },
           header: true,
           query: 'SELECT * FROM wikipedia',
@@ -354,6 +356,7 @@ describe('WorkbenchQuery', () => {
           context: {
             sqlOuterLimit: 1001,
             sqlQueryId: 'deadbeef-9fb0-499c-8475-ea461e96a4fd',
+            sqlStringifyArrays: false,
             useCache: false,
             x: 1,
           },
@@ -394,6 +397,7 @@ describe('WorkbenchQuery', () => {
           context: {
             sqlOuterLimit: 1001,
             sqlQueryId: 'lol',
+            sqlStringifyArrays: false,
             x: 1,
           },
           header: true,
@@ -422,6 +426,7 @@ describe('WorkbenchQuery', () => {
             executionMode: 'async',
             finalizeAggregations: false,
             groupByEnableMultiValueUnnesting: false,
+            sqlStringifyArrays: false,
             useCache: false,
             waitUntilSegmentsLoad: true,
           },
diff --git a/web-console/src/druid-models/workbench-query/workbench-query.ts 
b/web-console/src/druid-models/workbench-query/workbench-query.ts
index c97c478379e..f027efdc0a5 100644
--- a/web-console/src/druid-models/workbench-query/workbench-query.ts
+++ b/web-console/src/druid-models/workbench-query/workbench-query.ts
@@ -45,6 +45,7 @@ import {
   externalConfigToIngestQueryPattern,
   ingestQueryPatternToQuery,
 } from '../ingest-query-pattern/ingest-query-pattern';
+import type { ArrayMode } from '../ingestion-spec/ingestion-spec';
 import type { QueryContext } from '../query-context/query-context';
 
 const ISSUE_MARKER = '--:ISSUE:';
@@ -89,20 +90,22 @@ export class WorkbenchQuery {
 
   static fromInitExternalConfig(
     externalConfig: ExternalConfig,
-    isArrays: boolean[],
     timeExpression: SqlExpression | undefined,
     partitionedByHint: string | undefined,
+    arrayMode: ArrayMode,
   ): WorkbenchQuery {
     return new WorkbenchQuery({
       queryString: ingestQueryPatternToQuery(
         externalConfigToIngestQueryPattern(
           externalConfig,
-          isArrays,
           timeExpression,
           partitionedByHint,
+          arrayMode,
         ),
       ).toString(),
-      queryContext: {},
+      queryContext: {
+        arrayIngestMode: 'array',
+      },
     });
   }
 
@@ -558,6 +561,10 @@ export class WorkbenchQuery {
       }
     }
 
+    if (engine === 'sql-native' || engine === 'sql-msq-task') {
+      apiQuery.context.sqlStringifyArrays ??= false;
+    }
+
     if (Array.isArray(queryParameters) && queryParameters.length) {
       apiQuery.parameters = queryParameters;
     }
diff --git a/web-console/src/helpers/__snapshots__/spec-conversion.spec.ts.snap 
b/web-console/src/helpers/__snapshots__/spec-conversion.spec.ts.snap
index b6dceb034a6..11f2a388ad2 100644
--- a/web-console/src/helpers/__snapshots__/spec-conversion.spec.ts.snap
+++ b/web-console/src/helpers/__snapshots__/spec-conversion.spec.ts.snap
@@ -208,3 +208,26 @@ FROM "source"
 PARTITIONED BY HOUR
 CLUSTERED BY "isRobot"
 `;
+
+exports[`spec conversion works with ARRAY mode 1`] = `
+-- This SQL query was auto generated from an ingestion spec
+REPLACE INTO "lol" OVERWRITE ALL
+WITH "source" AS (SELECT * FROM TABLE(
+  EXTERN(
+    '{"type":"inline","data":"{\\"s\\":\\"X\\", \\"l\\":10, \\"f\\":10.1, 
\\"array_s\\":[\\"A\\", \\"B\\"], \\"array_l\\":[1,2], \\"array_f\\":[1.1,2.2], 
\\"mix1\\":[1, \\"lol\\"], \\"mix2\\":[1.1, 77]}\\n{\\"s\\":\\"Y\\", 
\\"l\\":11, \\"f\\":11.1, \\"array_s\\":[\\"C\\", \\"D\\"], 
\\"array_l\\":[3,4], \\"array_f\\":[3.3,4.4], \\"mix1\\":[2, \\"zoz\\"], 
\\"mix2\\":[1.2, 88]}"}',
+    '{"type":"json"}'
+  )
+) EXTEND ("s" VARCHAR, "l" BIGINT, "f" DOUBLE, "array_s" VARCHAR ARRAY, 
"array_l" BIGINT ARRAY, "array_f" DOUBLE ARRAY, "mix1" VARCHAR ARRAY, "mix2" 
DOUBLE ARRAY))
+SELECT
+  TIME_PARSE('2010-01-01T00:00:00Z') AS "__time",
+  "s",
+  "l",
+  "f",
+  "array_s",
+  "array_l",
+  "array_f",
+  "mix1",
+  "mix2"
+FROM "source"
+PARTITIONED BY DAY
+`;
diff --git a/web-console/src/helpers/spec-conversion.spec.ts 
b/web-console/src/helpers/spec-conversion.spec.ts
index 154bfacbb1b..bea3f730318 100644
--- a/web-console/src/helpers/spec-conversion.spec.ts
+++ b/web-console/src/helpers/spec-conversion.spec.ts
@@ -123,6 +123,7 @@ describe('spec conversion', () => {
     expect(converted.queryString).toMatchSnapshot();
 
     expect(converted.queryContext).toEqual({
+      arrayIngestMode: 'array',
       groupByEnableMultiValueUnnesting: false,
       maxParseExceptions: 3,
       finalizeAggregations: false,
@@ -232,6 +233,7 @@ describe('spec conversion', () => {
     expect(converted.queryString).toMatchSnapshot();
 
     expect(converted.queryContext).toEqual({
+      arrayIngestMode: 'array',
       groupByEnableMultiValueUnnesting: false,
       finalizeAggregations: false,
     });
@@ -356,6 +358,7 @@ describe('spec conversion', () => {
     expect(converted.queryString).toMatchSnapshot();
 
     expect(converted.queryContext).toEqual({
+      arrayIngestMode: 'array',
       groupByEnableMultiValueUnnesting: false,
       finalizeAggregations: false,
     });
@@ -585,4 +588,80 @@ describe('spec conversion', () => {
 
     expect(converted.queryString).toMatchSnapshot();
   });
+
+  it('works with ARRAY mode', () => {
+    const converted = convertSpecToSql({
+      type: 'index_parallel',
+      spec: {
+        ioConfig: {
+          type: 'index_parallel',
+          inputSource: {
+            type: 'inline',
+            data: '{"s":"X", "l":10, "f":10.1, "array_s":["A", "B"], 
"array_l":[1,2], "array_f":[1.1,2.2], "mix1":[1, "lol"], "mix2":[1.1, 
77]}\n{"s":"Y", "l":11, "f":11.1, "array_s":["C", "D"], "array_l":[3,4], 
"array_f":[3.3,4.4], "mix1":[2, "zoz"], "mix2":[1.2, 88]}',
+          },
+          inputFormat: {
+            type: 'json',
+          },
+        },
+        tuningConfig: {
+          type: 'index_parallel',
+          partitionsSpec: {
+            type: 'dynamic',
+          },
+        },
+        dataSchema: {
+          dataSource: 'lol',
+          timestampSpec: {
+            column: '!!!_no_such_column_!!!',
+            missingValue: '2010-01-01T00:00:00Z',
+          },
+          dimensionsSpec: {
+            dimensions: [
+              's',
+              {
+                type: 'long',
+                name: 'l',
+              },
+              {
+                type: 'double',
+                name: 'f',
+              },
+              {
+                type: 'auto',
+                name: 'array_s',
+                castToType: 'ARRAY<STRING>',
+              },
+              {
+                type: 'auto',
+                name: 'array_l',
+                castToType: 'ARRAY<LONG>',
+              },
+              {
+                type: 'auto',
+                name: 'array_f',
+                castToType: 'ARRAY<DOUBLE>',
+              },
+              {
+                type: 'auto',
+                name: 'mix1',
+                castToType: 'ARRAY<STRING>',
+              },
+              {
+                type: 'auto',
+                name: 'mix2',
+                castToType: 'ARRAY<DOUBLE>',
+              },
+            ],
+          },
+          granularitySpec: {
+            queryGranularity: 'none',
+            rollup: false,
+            segmentGranularity: 'day',
+          },
+        },
+      },
+    });
+
+    expect(converted.queryString).toMatchSnapshot();
+  });
 });
diff --git a/web-console/src/helpers/spec-conversion.ts 
b/web-console/src/helpers/spec-conversion.ts
index 9b67dac4cf6..f25406e5028 100644
--- a/web-console/src/helpers/spec-conversion.ts
+++ b/web-console/src/helpers/spec-conversion.ts
@@ -36,7 +36,7 @@ import type {
   TimestampSpec,
   Transform,
 } from '../druid-models';
-import { inflateDimensionSpec, TIME_COLUMN, upgradeSpec } from 
'../druid-models';
+import { inflateDimensionSpec, NO_SUCH_COLUMN, TIME_COLUMN, upgradeSpec } from 
'../druid-models';
 import { deepGet, filterMap, nonEmptyArray, oneOf } from '../utils';
 
 export function getSpecDatasourceName(spec: Partial<IngestionSpec>): string {
@@ -76,6 +76,7 @@ export function convertSpecToSql(spec: any): QueryWithContext 
{
   const context: Record<string, any> = {
     finalizeAggregations: false,
     groupByEnableMultiValueUnnesting: false,
+    arrayIngestMode: 'array',
   };
 
   const indexSpec = deepGet(spec, 'spec.tuningConfig.indexSpec');
@@ -94,17 +95,17 @@ export function convertSpecToSql(spec: any): 
QueryWithContext {
   const timestampSpec: TimestampSpec = deepGet(spec, 
'spec.dataSchema.timestampSpec');
   if (!timestampSpec) throw new Error(`spec.dataSchema.timestampSpec is not 
defined`);
 
-  let dimensions = deepGet(spec, 'spec.dataSchema.dimensionsSpec.dimensions');
-  if (!Array.isArray(dimensions)) {
+  const specDimensions: (string | DimensionSpec)[] = deepGet(
+    spec,
+    'spec.dataSchema.dimensionsSpec.dimensions',
+  );
+  if (!Array.isArray(specDimensions)) {
     throw new Error(`spec.dataSchema.dimensionsSpec.dimensions must be an 
array`);
   }
-  dimensions = dimensions.map(inflateDimensionSpec);
+  const dimensions = specDimensions.map(inflateDimensionSpec);
 
-  let columnDeclarations: SqlColumnDeclaration[] = dimensions.map((d: 
DimensionSpec) =>
-    SqlColumnDeclaration.create(
-      d.name,
-      SqlType.fromNativeType(dimensionSpecTypeToNativeDataType(d.type)),
-    ),
+  let columnDeclarations: SqlColumnDeclaration[] = dimensions.map(d =>
+    SqlColumnDeclaration.create(d.name, dimensionSpecToSqlType(d)),
   );
 
   const metricsSpec = deepGet(spec, 'spec.dataSchema.metricsSpec');
@@ -130,65 +131,71 @@ export function convertSpecToSql(spec: any): 
QueryWithContext {
 
   let timeExpression: string;
   const timestampColumnName = timestampSpec.column || 'timestamp';
-  const timestampColumn = C(timestampColumnName);
-  const format = timestampSpec.format || 'auto';
   const timeTransform = transforms.find(t => t.name === TIME_COLUMN);
-  if (timeTransform) {
-    timeExpression = `REWRITE_[${timeTransform.expression}]_TO_SQL`;
-  } else if (timestampColumnName === TIME_COLUMN) {
-    timeExpression = String(timestampColumn);
-    
columnDeclarations.unshift(SqlColumnDeclaration.create(timestampColumnName, 
SqlType.BIGINT));
+  if (timestampColumnName === NO_SUCH_COLUMN) {
+    timeExpression = timestampSpec.missingValue
+      ? `TIME_PARSE(${L(timestampSpec.missingValue)})`
+      : `TIMESTAMP '1970-01-01'`;
   } else {
-    let timestampColumnType: SqlType;
-    switch (format) {
-      case 'auto':
-        timestampColumnType = SqlType.VARCHAR;
-        timeExpression = `CASE WHEN CAST(${timestampColumn} AS BIGINT) > 0 
THEN MILLIS_TO_TIMESTAMP(CAST(${timestampColumn} AS BIGINT)) ELSE 
TIME_PARSE(TRIM(${timestampColumn})) END`;
-        break;
-
-      case 'iso':
-        timestampColumnType = SqlType.VARCHAR;
-        timeExpression = `TIME_PARSE(${timestampColumn})`;
-        break;
-
-      case 'posix':
-        timestampColumnType = SqlType.BIGINT;
-        timeExpression = `MILLIS_TO_TIMESTAMP(${timestampColumn} * 1000)`;
-        break;
-
-      case 'millis':
-        timestampColumnType = SqlType.BIGINT;
-        timeExpression = `MILLIS_TO_TIMESTAMP(${timestampColumn})`;
-        break;
-
-      case 'micro':
-        timestampColumnType = SqlType.BIGINT;
-        timeExpression = `MILLIS_TO_TIMESTAMP(${timestampColumn} / 1000)`;
-        break;
-
-      case 'nano':
-        timestampColumnType = SqlType.BIGINT;
-        timeExpression = `MILLIS_TO_TIMESTAMP(${timestampColumn} / 1000000)`;
-        break;
-
-      default:
-        timestampColumnType = SqlType.VARCHAR;
-        timeExpression = `TIME_PARSE(${timestampColumn}, ${L(format)})`;
-        break;
+    const timestampColumn = C(timestampColumnName);
+    const format = timestampSpec.format || 'auto';
+    if (timeTransform) {
+      timeExpression = `REWRITE_[${timeTransform.expression}]_TO_SQL`;
+    } else if (timestampColumnName === TIME_COLUMN) {
+      timeExpression = String(timestampColumn);
+      
columnDeclarations.unshift(SqlColumnDeclaration.create(timestampColumnName, 
SqlType.BIGINT));
+    } else {
+      let timestampColumnType: SqlType;
+      switch (format) {
+        case 'auto':
+          timestampColumnType = SqlType.VARCHAR;
+          timeExpression = `CASE WHEN CAST(${timestampColumn} AS BIGINT) > 0 
THEN MILLIS_TO_TIMESTAMP(CAST(${timestampColumn} AS BIGINT)) ELSE 
TIME_PARSE(TRIM(${timestampColumn})) END`;
+          break;
+
+        case 'iso':
+          timestampColumnType = SqlType.VARCHAR;
+          timeExpression = `TIME_PARSE(${timestampColumn})`;
+          break;
+
+        case 'posix':
+          timestampColumnType = SqlType.BIGINT;
+          timeExpression = `MILLIS_TO_TIMESTAMP(${timestampColumn} * 1000)`;
+          break;
+
+        case 'millis':
+          timestampColumnType = SqlType.BIGINT;
+          timeExpression = `MILLIS_TO_TIMESTAMP(${timestampColumn})`;
+          break;
+
+        case 'micro':
+          timestampColumnType = SqlType.BIGINT;
+          timeExpression = `MILLIS_TO_TIMESTAMP(${timestampColumn} / 1000)`;
+          break;
+
+        case 'nano':
+          timestampColumnType = SqlType.BIGINT;
+          timeExpression = `MILLIS_TO_TIMESTAMP(${timestampColumn} / 1000000)`;
+          break;
+
+        default:
+          timestampColumnType = SqlType.VARCHAR;
+          timeExpression = `TIME_PARSE(${timestampColumn}, ${L(format)})`;
+          break;
+      }
+      columnDeclarations.unshift(
+        SqlColumnDeclaration.create(timestampColumnName, timestampColumnType),
+      );
     }
-    columnDeclarations.unshift(
-      SqlColumnDeclaration.create(timestampColumnName, timestampColumnType),
-    );
-  }
 
-  if (timestampSpec.missingValue) {
-    timeExpression = `COALESCE(${timeExpression}, 
TIME_PARSE(${L(timestampSpec.missingValue)}))`;
-  }
+    if (timestampSpec.missingValue) {
+      timeExpression = `COALESCE(${timeExpression}, 
TIME_PARSE(${L(timestampSpec.missingValue)}))`;
+    }
 
-  timeExpression = convertQueryGranularity(
-    timeExpression,
-    deepGet(spec, 'spec.dataSchema.granularitySpec.queryGranularity'),
-  );
+    timeExpression = convertQueryGranularity(
+      timeExpression,
+      deepGet(spec, 'spec.dataSchema.granularitySpec.queryGranularity'),
+    );
+  }
 
   lines.push(`-- This SQL query was auto generated from an ingestion spec`);
 
@@ -385,13 +392,20 @@ const QUERY_GRANULARITY_MAP: Record<string, string> = {
   year: `TIME_FLOOR(?, 'P1Y')`,
 };
 
-function dimensionSpecTypeToNativeDataType(dimensionSpecType: string): string {
-  switch (dimensionSpecType) {
+function dimensionSpecToSqlType(dimensionSpec: DimensionSpec): SqlType {
+  switch (dimensionSpec.type) {
+    case 'auto':
+      if (dimensionSpec.castToType) {
+        return SqlType.fromNativeType(dimensionSpec.castToType);
+      } else {
+        return SqlType.VARCHAR;
+      }
+
     case 'json':
-      return 'COMPLEX<json>';
+      return SqlType.fromNativeType('COMPLEX<json>');
 
     default:
-      return dimensionSpecType;
+      return SqlType.fromNativeType(dimensionSpec.type);
   }
 }
 
diff --git a/web-console/src/utils/index.tsx b/web-console/src/utils/index.tsx
index e7dc33e7a65..244eec5372c 100644
--- a/web-console/src/utils/index.tsx
+++ b/web-console/src/utils/index.tsx
@@ -27,6 +27,7 @@ export * from './general';
 export * from './intermediate-query-state';
 export * from './local-storage-backed-visibility';
 export * from './local-storage-keys';
+export * from './null-mode-detection';
 export * from './object-change';
 export * from './query-action';
 export * from './query-manager';
diff --git a/web-console/src/utils/null-mode-detection.ts 
b/web-console/src/utils/null-mode-detection.ts
new file mode 100644
index 00000000000..1f72309391e
--- /dev/null
+++ b/web-console/src/utils/null-mode-detection.ts
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.
+ */
+
+export interface NullModeDetection {
+  useDefaultValueForNull?: boolean; // Modern expected value: false
+  useStrictBooleans?: boolean; // Modern expected value: true
+  useThreeValueLogicForNativeFilters?: boolean; // Modern expected value: true
+}
+
+const NULL_PROPERTY_DESCRIPTION: { prop: keyof NullModeDetection; newDefault: 
boolean }[] = [
+  {
+    prop: 'useDefaultValueForNull',
+    newDefault: false,
+  },
+  {
+    prop: 'useStrictBooleans',
+    newDefault: true,
+  },
+  {
+    prop: 'useThreeValueLogicForNativeFilters',
+    newDefault: true,
+  },
+];
+
+export const NULL_DETECTION_QUERY = {
+  queryType: 'timeseries',
+  dataSource: {
+    type: 'inline',
+    columnNames: ['n', 'v'],
+    columnTypes: ['STRING', 'LONG'],
+    rows: [
+      [null, 2],
+      ['A', 3],
+    ],
+  },
+  intervals: '1000/2000',
+  granularity: 'all',
+  aggregations: [
+    {
+      type: 'filtered',
+      name: 'sum_b', // There is no B so this will produce nothing but whether 
it gives null or 0 lets us determine the setting for useDefaultValueForNull
+      aggregator: {
+        type: 'longSum',
+        name: '_',
+        fieldName: 'v',
+      },
+      filter: {
+        type: 'selector',
+        dimension: 'n',
+        value: 'B',
+      },
+    },
+    {
+      type: 'filtered',
+      name: 'sum_not_b', // Whether `!= 'B'` matches the null row lets us 
determine the setting for useThreeValueLogicForNativeFilters
+      aggregator: {
+        type: 'longSum',
+        name: '_',
+        fieldName: 'v',
+      },
+      filter: {
+        type: 'not',
+        field: {
+          type: 'selector',
+          dimension: 'n',
+          value: 'B',
+        },
+      },
+    },
+  ],
+  postAggregations: [
+    {
+      type: 'expression',
+      name: 'two_or_three', // useStrictBooleans will force this expression to 
1 (true)
+      expression: '2 || 3',
+    },
+  ],
+};
+
+export interface NullDetectionQueryResult {
+  sum_b: number | null;
+  sum_not_b: number | null;
+  two_or_three: number | null;
+}
+
+export function nullDetectionQueryResultDecoder(
+  result: NullDetectionQueryResult,
+): NullModeDetection {
+  let useThreeValueLogicForNativeFilters;
+  if (result.sum_not_b === 3) useThreeValueLogicForNativeFilters = true;
+  if (result.sum_not_b === 5) useThreeValueLogicForNativeFilters = false;
+
+  let useStrictBooleans;
+  if (result.two_or_three === 1) useStrictBooleans = true;
+  if (result.two_or_three === 2) useStrictBooleans = false;
+
+  let useDefaultValueForNull;
+  if (result.sum_b === 0) useDefaultValueForNull = true;
+  if (result.sum_b === null) useDefaultValueForNull = false;
+
+  return {
+    useThreeValueLogicForNativeFilters,
+    useStrictBooleans,
+    useDefaultValueForNull,
+  };
+}
+
+export function summarizeNullModeDetection({
+  useThreeValueLogicForNativeFilters,
+  useStrictBooleans,
+  useDefaultValueForNull,
+}: NullModeDetection): string {
+  if (
+    typeof useThreeValueLogicForNativeFilters === 'undefined' ||
+    typeof useStrictBooleans === 'undefined' ||
+    typeof useDefaultValueForNull === 'undefined'
+  ) {
+    return 'Could not determine NULL mode';
+  }
+
+  if (useThreeValueLogicForNativeFilters && useStrictBooleans && 
!useDefaultValueForNull) {
+    return 'SQL compliant NULL mode';
+  }
+
+  if (!useThreeValueLogicForNativeFilters && !useStrictBooleans && 
useDefaultValueForNull) {
+    return 'Legacy NULL mode';
+  }
+
+  return 'Mixed NULL mode';
+}
+
+export function explainNullModeDetection(nullMode: NullModeDetection): 
string[] {
+  return NULL_PROPERTY_DESCRIPTION.map(({ prop, newDefault }) => {
+    const v = nullMode[prop];
+    return `${prop}: ${typeof v === 'undefined' ? 'Not detected' : v}${
+      v === newDefault ? ' (SQL compliant)' : ''
+    }${v === !newDefault ? ' (Legacy mode)' : ''}`;
+  });
+}
diff --git a/web-console/src/utils/object-change.ts 
b/web-console/src/utils/object-change.ts
index 1e06fc01fb5..9b29cda5dbf 100644
--- a/web-console/src/utils/object-change.ts
+++ b/web-console/src/utils/object-change.ts
@@ -159,8 +159,8 @@ export function deepExtend<T extends Record<string, 
any>>(target: T, diff: Recor
   return newValue;
 }
 
-export function allowKeys(obj: Record<string, any>, keys: string[]): 
Record<string, any> {
-  const newObj: Record<string, any> = {};
+export function allowKeys<T>(obj: T, keys: (keyof T)[]): T {
+  const newObj: T = {} as any;
   for (const key of keys) {
     if (Object.prototype.hasOwnProperty.call(obj, key)) {
       newObj[key] = obj[key];
@@ -169,8 +169,8 @@ export function allowKeys(obj: Record<string, any>, keys: 
string[]): Record<stri
   return newObj;
 }
 
-export function deleteKeys(obj: Record<string, any>, keys: string[]): 
Record<string, any> {
-  const newObj: Record<string, any> = { ...obj };
+export function deleteKeys<T>(obj: T, keys: (keyof T)[]): T {
+  const newObj: T = { ...obj };
   for (const key of keys) {
     delete newObj[key];
   }
diff --git a/web-console/src/utils/sample-query.tsx 
b/web-console/src/utils/sample-query.tsx
index 44747395cf7..2c32618fac1 100644
--- a/web-console/src/utils/sample-query.tsx
+++ b/web-console/src/utils/sample-query.tsx
@@ -31,7 +31,7 @@ import {
 
 import { oneOf } from './general';
 
-const SAMPLE_ARRAY_SEPARATOR = '-3432-d401-';
+const SAMPLE_ARRAY_SEPARATOR = '<#>'; // Note that this is a regexp so don't 
add anything that is a special regexp thing
 
 function nullForColumn(column: Column): LiteralValue {
   return oneOf(column.sqlType, 'BIGINT', 'DOUBLE', 'FLOAT') ? 0 : '';
@@ -39,7 +39,6 @@ function nullForColumn(column: Column): LiteralValue {
 
 export function sampleDataToQuery(sample: QueryResult): SqlQuery {
   const { header, rows } = sample;
-  const arrayIndexes: Record<number, boolean> = {};
   return SqlQuery.create(
     new SqlAlias({
       expression: SqlValues.create(
@@ -48,12 +47,14 @@ export function sampleDataToQuery(sample: QueryResult): 
SqlQuery {
             row.map((r, i) => {
               if (header[i].nativeType === 'COMPLEX<json>') {
                 return L(JSON.stringify(r));
-              } else if (Array.isArray(r)) {
-                arrayIndexes[i] = true;
+              } else if (String(header[i].sqlType).endsWith(' ARRAY')) {
                 return L(r.join(SAMPLE_ARRAY_SEPARATOR));
-              } else {
+              } else if (r == null || typeof r === 'object') {
                 // Avoid actually using NULL literals as they create havoc in 
the VALUES type system and throw errors.
-                return L(r == null ? nullForColumn(header[i]) : r);
+                // Also, cleanup array if it happens to get here, it shouldn't.
+                return L(nullForColumn(header[i]));
+              } else {
+                return L(r);
               }
             }),
           ),
@@ -63,16 +64,19 @@ export function sampleDataToQuery(sample: QueryResult): 
SqlQuery {
       columns: SqlColumnList.create(header.map((_, i) => 
RefName.create(`c${i}`, true))),
     }),
   ).changeSelectExpressions(
-    header.map((h, i) => {
+    header.map(({ name, nativeType, sqlType }, i) => {
       let ex: SqlExpression = C(`c${i}`);
-      if (h.nativeType === 'COMPLEX<json>') {
+      if (nativeType === 'COMPLEX<json>') {
         ex = F('PARSE_JSON', ex);
-      } else if (arrayIndexes[i]) {
-        ex = F('STRING_TO_MV', ex, SAMPLE_ARRAY_SEPARATOR);
-      } else if (h.sqlType) {
-        ex = ex.cast(h.sqlType);
+      } else if (sqlType && sqlType.endsWith(' ARRAY')) {
+        ex = F('STRING_TO_ARRAY', ex, SAMPLE_ARRAY_SEPARATOR);
+        if (sqlType !== 'VARCHAR ARRAY') {
+          ex = ex.cast(sqlType);
+        }
+      } else if (sqlType) {
+        ex = ex.cast(sqlType);
       }
-      return ex.as(h.name, true);
+      return ex.as(name, true);
     }),
   );
 }
diff --git 
a/web-console/src/views/home-view/__snapshots__/home-view.spec.tsx.snap 
b/web-console/src/views/home-view/__snapshots__/home-view.spec.tsx.snap
index 640fe8900be..c78b0a80f7e 100644
--- a/web-console/src/views/home-view/__snapshots__/home-view.spec.tsx.snap
+++ b/web-console/src/views/home-view/__snapshots__/home-view.spec.tsx.snap
@@ -4,7 +4,17 @@ exports[`HomeView matches snapshot (coordinator) 1`] = `
 <div
   className="home-view app-view"
 >
-  <Memo(StatusCard) />
+  <Memo(StatusCard)
+    capabilities={
+      Capabilities {
+        "clusterCapacity": undefined,
+        "coordinator": true,
+        "multiStageQuery": false,
+        "overlord": false,
+        "queryType": "none",
+      }
+    }
+  />
   <React.Fragment>
     <Memo(DatasourcesCard)
       capabilities={
@@ -58,7 +68,17 @@ exports[`HomeView matches snapshot (full) 1`] = `
 <div
   className="home-view app-view"
 >
-  <Memo(StatusCard) />
+  <Memo(StatusCard)
+    capabilities={
+      Capabilities {
+        "clusterCapacity": undefined,
+        "coordinator": true,
+        "multiStageQuery": true,
+        "overlord": true,
+        "queryType": "nativeAndSql",
+      }
+    }
+  />
   <React.Fragment>
     <Memo(DatasourcesCard)
       capabilities={
@@ -136,7 +156,17 @@ exports[`HomeView matches snapshot (overlord) 1`] = `
 <div
   className="home-view app-view"
 >
-  <Memo(StatusCard) />
+  <Memo(StatusCard)
+    capabilities={
+      Capabilities {
+        "clusterCapacity": undefined,
+        "coordinator": false,
+        "multiStageQuery": false,
+        "overlord": true,
+        "queryType": "none",
+      }
+    }
+  />
   <React.Fragment>
     <Memo(SupervisorsCard)
       capabilities={
diff --git a/web-console/src/views/home-view/home-view.tsx 
b/web-console/src/views/home-view/home-view.tsx
index 4a9158b9d4c..bfd110d4a8d 100644
--- a/web-console/src/views/home-view/home-view.tsx
+++ b/web-console/src/views/home-view/home-view.tsx
@@ -39,7 +39,7 @@ export const HomeView = React.memo(function HomeView(props: 
HomeViewProps) {
 
   return (
     <div className="home-view app-view">
-      <StatusCard />
+      <StatusCard capabilities={capabilities} />
       {capabilities.hasSqlOrCoordinatorAccess() && (
         <>
           <DatasourcesCard capabilities={capabilities} />
diff --git a/web-console/src/views/home-view/status-card/status-card.spec.tsx 
b/web-console/src/views/home-view/status-card/status-card.spec.tsx
index 5b7d419421d..bb3d4500ff3 100644
--- a/web-console/src/views/home-view/status-card/status-card.spec.tsx
+++ b/web-console/src/views/home-view/status-card/status-card.spec.tsx
@@ -19,11 +19,13 @@
 import { render } from '@testing-library/react';
 import React from 'react';
 
+import { Capabilities } from '../../../helpers';
+
 import { StatusCard } from './status-card';
 
 describe('StatusCard', () => {
   it('matches snapshot', () => {
-    const statusCard = <StatusCard />;
+    const statusCard = <StatusCard capabilities={Capabilities.FULL} />;
 
     const { container } = render(statusCard);
     expect(container.firstChild).toMatchSnapshot();
diff --git a/web-console/src/views/home-view/status-card/status-card.tsx 
b/web-console/src/views/home-view/status-card/status-card.tsx
index 09ea1f375d1..f1db97ac6c6 100644
--- a/web-console/src/views/home-view/status-card/status-card.tsx
+++ b/web-console/src/views/home-view/status-card/status-card.tsx
@@ -17,12 +17,23 @@
  */
 
 import { IconNames } from '@blueprintjs/icons';
+import { Tooltip2 } from '@blueprintjs/popover2';
 import React, { useState } from 'react';
 
 import { StatusDialog } from '../../../dialogs/status-dialog/status-dialog';
+import type { Capabilities } from '../../../helpers';
 import { useQueryManager } from '../../../hooks';
 import { Api } from '../../../singletons';
-import { pluralIfNeeded } from '../../../utils';
+import type { NullModeDetection } from '../../../utils';
+import {
+  deepGet,
+  explainNullModeDetection,
+  NULL_DETECTION_QUERY,
+  nullDetectionQueryResultDecoder,
+  pluralIfNeeded,
+  queryDruidRune,
+  summarizeNullModeDetection,
+} from '../../../utils';
 import { HomeViewCard } from '../home-view-card/home-view-card';
 
 interface StatusSummary {
@@ -30,11 +41,15 @@ interface StatusSummary {
   extensionCount: number;
 }
 
-export interface StatusCardProps {}
+export interface StatusCardProps {
+  capabilities: Capabilities;
+}
 
-export const StatusCard = React.memo(function StatusCard(_props: 
StatusCardProps) {
+export const StatusCard = React.memo(function StatusCard(props: 
StatusCardProps) {
+  const { capabilities } = props;
   const [showStatusDialog, setShowStatusDialog] = useState(false);
   const [statusSummaryState] = useQueryManager<null, StatusSummary>({
+    initQuery: null,
     processQuery: async () => {
       const statusResp = await Api.instance.get('/status');
       return {
@@ -42,10 +57,19 @@ export const StatusCard = React.memo(function 
StatusCard(_props: StatusCardProps
         extensionCount: statusResp.data.modules.length,
       };
     },
-    initQuery: null,
+  });
+
+  const [nullModeDetectionState] = useQueryManager<Capabilities, 
NullModeDetection>({
+    initQuery: capabilities,
+    processQuery: async capabilities => {
+      if (!capabilities.hasQuerying()) return {};
+      const nullDetectionResponse = await queryDruidRune(NULL_DETECTION_QUERY);
+      return nullDetectionQueryResultDecoder(deepGet(nullDetectionResponse, 
'0.result'));
+    },
   });
 
   const statusSummary = statusSummaryState.data;
+  const nullModeDetection = nullModeDetectionState.data;
   return (
     <>
       <HomeViewCard
@@ -64,6 +88,22 @@ export const StatusCard = React.memo(function 
StatusCard(_props: StatusCardProps
             <p>{`${pluralIfNeeded(statusSummary.extensionCount, 'extension')} 
loaded`}</p>
           </>
         )}
+        {nullModeDetection && (
+          <Tooltip2
+            content={
+              <div>
+                <p>
+                  <strong>Null related server properties</strong>
+                </p>
+                {explainNullModeDetection(nullModeDetection).map((line, i) => (
+                  <p key={i}>{line}</p>
+                ))}
+              </div>
+            }
+          >
+            <p>{summarizeNullModeDetection(nullModeDetection)}</p>
+          </Tooltip2>
+        )}
       </HomeViewCard>
       {showStatusDialog && (
         <StatusDialog
diff --git a/web-console/src/views/load-data-view/load-data-view.tsx 
b/web-console/src/views/load-data-view/load-data-view.tsx
index 18fdf045a5b..942e5d0aded 100644
--- a/web-console/src/views/load-data-view/load-data-view.tsx
+++ b/web-console/src/views/load-data-view/load-data-view.tsx
@@ -45,6 +45,7 @@ import type { JSX } from 'react';
 import React from 'react';
 
 import {
+  ArrayModeSwitch,
   AutoForm,
   CenterMessage,
   ClearableInput,
@@ -56,6 +57,7 @@ import {
 } from '../../components';
 import { AlertDialog, AsyncActionDialog } from '../../dialogs';
 import type {
+  ArrayMode,
   DimensionSpec,
   DruidFilter,
   FlattenField,
@@ -85,6 +87,7 @@ import {
   FILTER_FIELDS,
   FILTERS_FIELDS,
   FLATTEN_FIELD_FIELDS,
+  getArrayMode,
   getDimensionSpecName,
   getIngestionComboType,
   getIngestionImage,
@@ -118,6 +121,7 @@ import {
   possibleDruidFormatForValues,
   PRIMARY_PARTITION_RELATED_FORM_FIELDS,
   removeTimestampTransform,
+  showArrayModeToggle,
   splitFilter,
   STREAMING_INPUT_FORMAT_FIELDS,
   TIME_COLUMN,
@@ -201,8 +205,6 @@ import {
 
 import './load-data-view.scss';
 
-const DEFAULT_ROLLUP_SETTING = false;
-
 function showRawLine(line: SampleEntry): string {
   if (!line.parsed) return 'No parse';
   const raw = line.parsed.raw;
@@ -283,6 +285,14 @@ function getTimestampSpec(sampleResponse: SampleResponse | 
null): TimestampSpec
   return chooseByBestTimestamp(timestampSpecs) || CONSTANT_TIMESTAMP_SPEC;
 }
 
+function initializeSchemaWithSampleIfNeeded(
+  spec: Partial<IngestionSpec>,
+  sample: SampleResponse,
+): Partial<IngestionSpec> {
+  if (deepGet(spec, 'spec.dataSchema.dimensionsSpec')) return spec;
+  return updateSchemaWithSample(spec, sample, 'fixed', 'multi-values', 
getRollup(spec, false));
+}
+
 type Step =
   | 'welcome'
   | 'connect'
@@ -364,6 +374,7 @@ export interface LoadDataViewState {
   showResetConfirm: boolean;
   newRollup?: boolean;
   newSchemaMode?: SchemaMode;
+  newArrayMode?: ArrayMode;
 
   // welcome
   overlordModules?: string[];
@@ -1642,6 +1653,7 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
     if (selectedFlattenField) {
       return (
         <FormEditor
+          key={selectedFlattenField.index}
           fields={FLATTEN_FIELD_FIELDS}
           initValue={selectedFlattenField.value}
           onClose={this.resetSelected}
@@ -1999,19 +2011,7 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
           disabled: !transformQueryState.data,
           onNextStep: () => {
             if (!transformQueryState.data) return false;
-
-            let newSpec = spec;
-            if (!deepGet(newSpec, 'spec.dataSchema.dimensionsSpec')) {
-              const currentRollup = deepGet(newSpec, 
'spec.dataSchema.granularitySpec.rollup');
-              newSpec = updateSchemaWithSample(
-                newSpec,
-                transformQueryState.data,
-                'fixed',
-                typeof currentRollup === 'boolean' ? currentRollup : 
DEFAULT_ROLLUP_SETTING,
-              );
-            }
-
-            this.updateSpec(newSpec);
+            this.updateSpec(initializeSchemaWithSampleIfNeeded(spec, 
transformQueryState.data));
             return true;
           },
         })}
@@ -2034,6 +2034,7 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
     if (selectedTransform) {
       return (
         <FormEditor
+          key={selectedTransform.index}
           fields={TRANSFORM_FIELDS}
           initValue={selectedTransform.value}
           onClose={this.resetSelected}
@@ -2202,19 +2203,7 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
         {this.renderNextBar({
           onNextStep: () => {
             if (!filterQueryState.data) return false;
-
-            let newSpec = spec;
-            if (!deepGet(newSpec, 'spec.dataSchema.dimensionsSpec')) {
-              const currentRollup = deepGet(newSpec, 
'spec.dataSchema.granularitySpec.rollup');
-              newSpec = updateSchemaWithSample(
-                newSpec,
-                filterQueryState.data,
-                'fixed',
-                typeof currentRollup === 'boolean' ? currentRollup : 
DEFAULT_ROLLUP_SETTING,
-              );
-            }
-
-            this.updateSpec(newSpec);
+            this.updateSpec(initializeSchemaWithSampleIfNeeded(spec, 
filterQueryState.data));
             return true;
           },
         })}
@@ -2234,6 +2223,7 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
 
     return (
       <FormEditor
+        key={selectedFilter.index}
         fields={FILTER_FIELDS}
         initValue={selectedFilter.value}
         showCustom={f => !KNOWN_FILTER_TYPES.includes(f.type || '')}
@@ -2321,6 +2311,7 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
       selectedAutoDimension || selectedDimensionSpec || selectedMetricSpec,
     );
     const schemaMode = getSchemaMode(spec);
+    const arrayMode = getArrayMode(spec);
 
     let mainFill: JSX.Element | string;
     if (schemaQueryState.isInit()) {
@@ -2396,6 +2387,16 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
                   label="Explicitly specify schema"
                 />
               </FormGroupWithInfo>
+              {showArrayModeToggle(spec) && (
+                <ArrayModeSwitch
+                  arrayMode={arrayMode}
+                  changeArrayMode={newArrayMode => {
+                    this.setState({
+                      newArrayMode,
+                    });
+                  }}
+                />
+              )}
               {schemaMode !== 'fixed' && (
                 <AutoForm
                   fields={[
@@ -2523,7 +2524,8 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
           {this.renderDimensionSpecControls()}
           {this.renderMetricSpecControls()}
           {this.renderChangeRollupAction()}
-          {this.renderChangeDimensionModeAction()}
+          {this.renderChangeSchemaModeAction()}
+          {this.renderChangeArrayModeAction()}
         </div>
         {this.renderNextBar({
           disabled: !schemaQueryState.data,
@@ -2618,7 +2620,14 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
         action={async () => {
           const sampleResponse = await sampleForTransform(spec, cacheRows);
           this.updateSpec(
-            updateSchemaWithSample(spec, sampleResponse, getSchemaMode(spec), 
newRollup, true),
+            updateSchemaWithSample(
+              spec,
+              sampleResponse,
+              getSchemaMode(spec),
+              getArrayMode(spec),
+              newRollup,
+              true,
+            ),
           );
         }}
         confirmButtonText={`Yes - ${newRollup ? 'enable' : 'disable'} rollup`}
@@ -2633,7 +2642,7 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
     );
   }
 
-  renderChangeDimensionModeAction() {
+  renderChangeSchemaModeAction() {
     const { newSchemaMode, spec, cacheRows } = this.state;
     if (!newSchemaMode || !cacheRows) return;
     const autoDetect = newSchemaMode !== 'fixed';
@@ -2643,7 +2652,13 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
         action={async () => {
           const sampleResponse = await sampleForTransform(spec, cacheRows);
           this.updateSpec(
-            updateSchemaWithSample(spec, sampleResponse, newSchemaMode, 
getRollup(spec)),
+            updateSchemaWithSample(
+              spec,
+              sampleResponse,
+              newSchemaMode,
+              getArrayMode(spec),
+              getRollup(spec),
+            ),
           );
         }}
         confirmButtonText={`Yes - ${autoDetect ? 'auto detect' : 'explicitly 
define'} schema`}
@@ -2694,6 +2709,41 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
     );
   }
 
+  renderChangeArrayModeAction() {
+    const { newArrayMode, spec, cacheRows } = this.state;
+    if (!newArrayMode || !cacheRows) return;
+    const multiValues = newArrayMode === 'multi-values';
+
+    return (
+      <AsyncActionDialog
+        action={async () => {
+          const sampleResponse = await sampleForTransform(spec, cacheRows);
+          this.updateSpec(
+            updateSchemaWithSample(
+              spec,
+              sampleResponse,
+              getSchemaMode(spec),
+              newArrayMode,
+              getRollup(spec),
+            ),
+          );
+        }}
+        confirmButtonText={`Yes - ${multiValues ? 'use MVDs' : 'ARRAYs'}`}
+        successText={`Array mode changed to ${multiValues ? 'multi-values' : 
'arrays'}.`}
+        failText="Could not change array mode"
+        intent={Intent.WARNING}
+        onClose={() => this.setState({ newArrayMode: undefined })}
+      >
+        <p>
+          {multiValues
+            ? `Are you sure you want to use multi-value dimensions?`
+            : `Are you sure you want to use ARRAYs?`}
+        </p>
+        <p>Making this change will reset all schema configuration done so 
far.</p>
+      </AsyncActionDialog>
+    );
+  }
+
   renderAutoDimensionControls() {
     const { spec, selectedAutoDimension } = this.state;
     if (!selectedAutoDimension) return;
@@ -2799,6 +2849,7 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
 
     return (
       <FormEditor
+        key={selectedDimensionSpec.index}
         fields={DIMENSION_SPEC_FIELDS}
         initValue={selectedDimensionSpec.value}
         onClose={this.resetSelected}
@@ -2887,6 +2938,7 @@ export class LoadDataView extends 
React.PureComponent<LoadDataViewProps, LoadDat
 
     return (
       <FormEditor
+        key={selectedMetricSpec.index}
         fields={METRIC_SPEC_FIELDS}
         initValue={selectedMetricSpec.value}
         onClose={this.resetSelected}
diff --git 
a/web-console/src/views/load-data-view/schema-table/schema-table.scss 
b/web-console/src/views/load-data-view/schema-table/schema-table.scss
index 7906e58afa1..3eb6cbe0c94 100644
--- a/web-console/src/views/load-data-view/schema-table/schema-table.scss
+++ b/web-console/src/views/load-data-view/schema-table/schema-table.scss
@@ -24,14 +24,18 @@
     &.dimension {
       background: rgba($green3, 0.3);
 
-      &.long {
+      &.long,
+      &.float,
+      &.double {
         background: rgba($blue3, 0.3);
       }
-      &.float {
-        background: rgba($blue3, 0.3);
+
+      &.array {
+        background: rgba($orange2, 0.3);
       }
-      &.double {
-        background: rgba($blue3, 0.3);
+
+      &.mv-string {
+        background: rgba($orange4, 0.3);
       }
     }
 
@@ -44,14 +48,18 @@
     &.dimension {
       background: rgba($green3, 0.12);
 
-      &.long {
+      &.long,
+      &.float,
+      &.double {
         background: rgba($blue3, 0.1);
       }
-      &.float {
-        background: rgba($blue3, 0.1);
+
+      &.array {
+        background: rgba($orange2, 0.1);
       }
-      &.double {
-        background: rgba($blue3, 0.1);
+
+      &.mv-string {
+        background: rgba($orange4, 0.1);
       }
     }
 
diff --git a/web-console/src/views/load-data-view/schema-table/schema-table.tsx 
b/web-console/src/views/load-data-view/schema-table/schema-table.tsx
index b1245d6b39c..ff3a54785ba 100644
--- a/web-console/src/views/load-data-view/schema-table/schema-table.tsx
+++ b/web-console/src/views/load-data-view/schema-table/schema-table.tsx
@@ -24,8 +24,9 @@ import ReactTable from 'react-table';
 import { TableCell } from '../../../components';
 import type { DimensionSpec, MetricSpec } from '../../../druid-models';
 import {
+  getDimensionSpecClassType,
   getDimensionSpecName,
-  getDimensionSpecType,
+  getDimensionSpecUserType,
   getMetricSpecName,
   inflateDimensionSpec,
   TIME_COLUMN,
@@ -115,11 +116,16 @@ export const SchemaTable = React.memo(function 
SchemaTable(props: SchemaTablePro
             ? dimensions.findIndex(d => getDimensionSpecName(d) === columnName)
             : -1;
           const dimensionSpec = dimensions ? dimensions[dimensionSpecIndex] : 
undefined;
-          const dimensionSpecType = dimensionSpec ? 
getDimensionSpecType(dimensionSpec) : undefined;
+          const dimensionSpecUserType = dimensionSpec
+            ? getDimensionSpecUserType(dimensionSpec, definedDimensions)
+            : undefined;
+          const dimensionSpecClassType = dimensionSpec
+            ? getDimensionSpecClassType(dimensionSpec, definedDimensions)
+            : undefined;
 
           const columnClassName = classNames(
             isTimestamp ? 'timestamp' : 'dimension',
-            dimensionSpecType || 'string',
+            dimensionSpecClassType || 'string',
             {
               selected:
                 (dimensionSpec && dimensionSpecIndex === 
selectedDimensionSpecIndex) ||
@@ -142,7 +148,7 @@ export const SchemaTable = React.memo(function 
SchemaTable(props: SchemaTablePro
               >
                 <div className="column-name">{columnName}</div>
                 <div className="column-detail">
-                  {isTimestamp ? 'long (time column)' : dimensionSpecType || 
'(auto)'}&nbsp;
+                  {isTimestamp ? 'long (time column)' : dimensionSpecUserType 
|| '(auto)'}&nbsp;
                 </div>
               </div>
             ),
diff --git 
a/web-console/src/views/sql-data-loader-view/column-editor/column-editor.tsx 
b/web-console/src/views/sql-data-loader-view/column-editor/column-editor.tsx
index fcae24eb797..a981ce4140b 100644
--- a/web-console/src/views/sql-data-loader-view/column-editor/column-editor.tsx
+++ b/web-console/src/views/sql-data-loader-view/column-editor/column-editor.tsx
@@ -16,77 +16,109 @@
  * limitations under the License.
  */
 
-import { Button, FormGroup, InputGroup, Intent, Menu, MenuItem, Position } 
from '@blueprintjs/core';
+import {
+  Button,
+  FormGroup,
+  InputGroup,
+  Intent,
+  Menu,
+  MenuDivider,
+  MenuItem,
+  Position,
+} from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
 import { Popover2 } from '@blueprintjs/popover2';
 import type { QueryResult } from '@druid-toolkit/query';
-import { SqlExpression, SqlFunction } from '@druid-toolkit/query';
+import { F, SqlExpression, SqlFunction, SqlType } from '@druid-toolkit/query';
 import type { JSX } from 'react';
 import React, { useState } from 'react';
 
 import { AppToaster } from '../../../singletons';
-import { tickIcon } from '../../../utils';
+import { deepDelete, deleteKeys, tickIcon } from '../../../utils';
 import { FlexibleQueryInput } from 
'../../workbench-view/flexible-query-input/flexible-query-input';
 
 import './column-editor.scss';
 
-const NATIVE_TYPES: (undefined | string)[] = ['', 'string', 'long', 'double'];
+function getTargetTypes(isArray: boolean): SqlType[] {
+  return isArray
+    ? [SqlType.VARCHAR_ARRAY, SqlType.BIGINT_ARRAY, SqlType.DOUBLE_ARRAY]
+    : [SqlType.VARCHAR, SqlType.BIGINT, SqlType.DOUBLE];
+}
 
-interface Breakdown {
-  expression: SqlExpression;
-  nativeType?: string;
+interface CastBreakdown {
+  formula: string;
+  castType?: SqlType;
+  forceMultiValue: boolean;
   outputName: string;
 }
 
-function breakdownExpression(expression: SqlExpression): Breakdown {
+function expressionToCastBreakdown(expression: SqlExpression): CastBreakdown {
   const outputName = expression.getOutputName() || '';
   expression = expression.getUnderlyingExpression();
 
-  let nativeType: string | undefined;
   if (expression instanceof SqlFunction) {
     const asType = expression.getCastType();
+    const formula = String(expression.getArg(0));
     if (asType) {
-      switch (asType.value.toUpperCase()) {
-        case 'VARCHAR':
-          nativeType = 'string';
-          expression = expression.getArg(0)!;
-          break;
-
-        case 'BIGINT':
-          nativeType = 'long';
-          expression = expression.getArg(0)!;
-          break;
-
-        case 'DOUBLE':
-          nativeType = 'double';
-          expression = expression.getArg(0)!;
-          break;
-      }
+      return {
+        formula,
+        castType: asType,
+        forceMultiValue: false,
+        outputName,
+      };
+    } else if (expression.getEffectiveFunctionName() === 'ARRAY_TO_MV') {
+      return {
+        formula,
+        forceMultiValue: true,
+        outputName,
+      };
     }
   }
 
   return {
-    expression,
-    nativeType,
+    formula: String(expression),
+    forceMultiValue: false,
     outputName,
   };
 }
 
-function nativeTypeToSqlType(nativeType: string): string {
-  switch (nativeType) {
-    case 'string':
-      return 'VARCHAR';
-    case 'long':
-      return 'BIGINT';
-    case 'double':
-      return 'DOUBLE';
-    default:
-      return 'VARCHAR';
+function castBreakdownToExpression({
+  formula,
+  castType,
+  forceMultiValue,
+  outputName,
+}: CastBreakdown): SqlExpression {
+  let newExpression = SqlExpression.parse(formula);
+  const defaultOutputName = newExpression.getOutputName();
+
+  if (castType) {
+    newExpression = newExpression.cast(castType);
+  } else if (forceMultiValue) {
+    newExpression = F('ARRAY_TO_MV', newExpression);
+  }
+
+  if (!defaultOutputName && !outputName) {
+    throw new Error('Must explicitly define an output name');
+  }
+
+  if (newExpression.getOutputName() !== outputName) {
+    newExpression = newExpression.as(outputName);
   }
+
+  return newExpression;
+}
+
+function castBreakdownsEqual(a: CastBreakdown, b: CastBreakdown): boolean {
+  return (
+    a.formula === b.formula &&
+    String(a.castType) === String(b.castType) &&
+    a.forceMultiValue === b.forceMultiValue &&
+    a.outputName === b.outputName
+  );
 }
 
 interface ColumnEditorProps {
-  expression?: SqlExpression;
+  initExpression?: SqlExpression;
   onApply(expression: SqlExpression | undefined): void;
   onCancel(): void;
   dirty(): void;
@@ -96,52 +128,128 @@ interface ColumnEditorProps {
 }
 
 export const ColumnEditor = React.memo(function ColumnEditor(props: 
ColumnEditorProps) {
-  const { expression, onApply, onCancel, dirty, queryResult, headerIndex } = 
props;
+  const { initExpression, onApply, onCancel, dirty, queryResult, headerIndex } 
= props;
 
-  const breakdown = expression ? breakdownExpression(expression) : undefined;
-
-  const [outputName, setOutputName] = useState<string | undefined>();
-  const [nativeType, setNativeType] = useState<string | undefined>();
-  const [expressionString, setExpressionString] = useState<string | 
undefined>();
-
-  const effectiveOutputName = outputName ?? (breakdown?.outputName || '');
-  const effectiveNativeType = nativeType ?? breakdown?.nativeType;
-  const effectiveExpressionString = expressionString ?? 
(breakdown?.expression.toString() || '');
+  const [initBreakdown] = useState(
+    initExpression ? expressionToCastBreakdown(initExpression) : undefined,
+  );
+  const [currentBreakdown, setCurrentBreakdown] = useState(
+    initBreakdown || { formula: '', forceMultiValue: false, outputName: '' },
+  );
 
   let typeButton: JSX.Element | undefined;
-
   const sqlQuery = queryResult?.sqlQuery;
   if (queryResult && sqlQuery && headerIndex !== -1) {
     const column = queryResult.header[headerIndex];
+    const isArray = 
String(column.nativeType).toUpperCase().startsWith('ARRAY');
 
     const expression = 
queryResult.sqlQuery?.getSelectExpressionForIndex(headerIndex);
 
-    if (expression && column.sqlType !== 'TIMESTAMP') {
-      const implicitText =
-        'implicit' + (breakdown?.nativeType ? '' : ` 
(${String(column.nativeType).toLowerCase()})`);
+    if (expression && column.sqlType !== 'TIMESTAMP' && column.sqlType !== 
'OTHER') {
       const selectExpression = 
sqlQuery.getSelectExpressionForIndex(headerIndex);
+      const initCastType = initBreakdown?.castType;
+      const initForceMultiValue = initBreakdown?.forceMultiValue;
       if (selectExpression) {
+        const castToItems = (
+          <>
+            <MenuDivider title="Cast to..." />
+            {getTargetTypes(isArray).map((targetType, i) => (
+              <MenuItem
+                key={i}
+                icon={tickIcon(
+                  targetType.equals(currentBreakdown.castType) && 
!currentBreakdown.forceMultiValue,
+                )}
+                text={targetType.value}
+                label={targetType.getNativeType()}
+                onClick={() => {
+                  dirty();
+                  setCurrentBreakdown({ ...currentBreakdown, castType: 
targetType });
+                }}
+              />
+            ))}
+            {isArray && (
+              <>
+                <MenuDivider />
+                <MenuItem
+                  icon={tickIcon(currentBreakdown.forceMultiValue)}
+                  text="Make multi-value"
+                  onClick={() => {
+                    dirty();
+                    setCurrentBreakdown({
+                      ...deepDelete(currentBreakdown, 'castType'),
+                      forceMultiValue: true,
+                    });
+                  }}
+                />
+              </>
+            )}
+          </>
+        );
+
         typeButton = (
           <Popover2
             position={Position.BOTTOM_LEFT}
             minimal
             content={
-              <Menu>
-                {NATIVE_TYPES.map((type, i) => {
-                  return (
-                    <MenuItem
-                      key={i}
-                      icon={tickIcon(type === (effectiveNativeType || ''))}
-                      text={type || implicitText}
-                      onClick={() => setNativeType(type)}
-                    />
-                  );
-                })}
-              </Menu>
+              initCastType ? (
+                <Menu>
+                  <MenuItem
+                    icon={tickIcon(!currentBreakdown.castType && 
!currentBreakdown.forceMultiValue)}
+                    text="Remove explicit cast"
+                    onClick={() => {
+                      dirty();
+                      setCurrentBreakdown(
+                        deleteKeys(currentBreakdown, ['castType', 
'forceMultiValue']),
+                      );
+                    }}
+                  />
+                  {castToItems}
+                </Menu>
+              ) : initForceMultiValue ? (
+                <Menu>
+                  <MenuItem
+                    icon={tickIcon(!currentBreakdown.castType && 
currentBreakdown.forceMultiValue)}
+                    text="VARCHAR (multi-value)"
+                    onClick={() => {
+                      dirty();
+                      setCurrentBreakdown({ ...currentBreakdown, 
forceMultiValue: true });
+                    }}
+                  />
+                  <MenuItem
+                    icon={tickIcon(!currentBreakdown.castType && 
!currentBreakdown.forceMultiValue)}
+                    text="Remove multi-value coercion"
+                    onClick={() => {
+                      dirty();
+                      setCurrentBreakdown(
+                        deleteKeys(currentBreakdown, ['castType', 
'forceMultiValue']),
+                      );
+                    }}
+                  />
+                </Menu>
+              ) : (
+                <Menu>
+                  <MenuItem
+                    icon={tickIcon(!currentBreakdown.castType && 
!currentBreakdown.forceMultiValue)}
+                    text={`implicit (${column.sqlType})`}
+                    onClick={() => {
+                      dirty();
+                      setCurrentBreakdown(
+                        deleteKeys(currentBreakdown, ['castType', 
'forceMultiValue']),
+                      );
+                    }}
+                  />
+                  {castToItems}
+                </Menu>
+              )
             }
           >
             <Button
-              text={`Type: ${effectiveNativeType || implicitText}`}
+              text={`Type: ${
+                currentBreakdown.castType ||
+                (currentBreakdown.forceMultiValue
+                  ? 'VARCHAR (multi-value)'
+                  : `implicit (${column.sqlType})`)
+              }`}
               rightIcon={IconNames.CARET_DOWN}
             />
           </Popover2>
@@ -152,13 +260,13 @@ export const ColumnEditor = React.memo(function 
ColumnEditor(props: ColumnEditor
 
   return (
     <div className="column-editor">
-      <div className="title">{expression ? 'Edit column' : 'Add column'}</div>
+      <div className="title">{initExpression ? 'Edit column' : 'Add 
column'}</div>
       <FormGroup label="Name">
         <InputGroup
-          value={effectiveOutputName}
+          value={currentBreakdown.outputName}
           onChange={e => {
-            if (!outputName) dirty();
-            setOutputName(e.target.value);
+            dirty();
+            setCurrentBreakdown({ ...currentBreakdown, outputName: 
e.target.value });
           }}
         />
       </FormGroup>
@@ -166,17 +274,17 @@ export const ColumnEditor = React.memo(function 
ColumnEditor(props: ColumnEditor
         <FlexibleQueryInput
           showGutter={false}
           placeholder="expression"
-          queryString={effectiveExpressionString}
-          onQueryStringChange={f => {
-            if (!expressionString) dirty();
-            setExpressionString(f);
+          queryString={currentBreakdown.formula}
+          onQueryStringChange={formula => {
+            dirty();
+            setCurrentBreakdown({ ...currentBreakdown, formula });
           }}
           columnMetadata={undefined}
         />
       </FormGroup>
       {typeButton && <FormGroup>{typeButton}</FormGroup>}
       <div className="apply-cancel-buttons">
-        {expression && (
+        {initExpression && (
           <Button
             className="delete"
             icon={IconNames.TRASH}
@@ -191,27 +299,19 @@ export const ColumnEditor = React.memo(function 
ColumnEditor(props: ColumnEditor
         <Button
           text="Apply"
           intent={Intent.PRIMARY}
-          disabled={!outputName && !expressionString && typeof nativeType === 
'undefined'}
+          disabled={Boolean(initBreakdown && 
castBreakdownsEqual(initBreakdown, currentBreakdown))}
           onClick={() => {
             let newExpression: SqlExpression;
             try {
-              newExpression = SqlExpression.parse(effectiveExpressionString);
+              newExpression = castBreakdownToExpression(currentBreakdown);
             } catch (e) {
               AppToaster.show({
-                message: `Could not parse SQL expression: ${e.message}`,
+                message: e.message,
                 intent: Intent.DANGER,
               });
               return;
             }
 
-            if (nativeType) {
-              newExpression = 
newExpression.cast(nativeTypeToSqlType(nativeType));
-            }
-
-            if (newExpression.getOutputName() !== effectiveOutputName) {
-              newExpression = newExpression.as(effectiveOutputName);
-            }
-
             onApply(newExpression);
             onCancel();
           }}
diff --git 
a/web-console/src/views/sql-data-loader-view/schema-step/preview-table/preview-table.scss
 
b/web-console/src/views/sql-data-loader-view/schema-step/preview-table/preview-table.scss
index 2a185067427..462a852e8df 100644
--- 
a/web-console/src/views/sql-data-loader-view/schema-step/preview-table/preview-table.scss
+++ 
b/web-console/src/views/sql-data-loader-view/schema-step/preview-table/preview-table.scss
@@ -58,6 +58,14 @@
           &.double {
             background: rgba($blue3, 0.3);
           }
+
+          &.array {
+            background: rgba($orange2, 0.3);
+          }
+
+          &.multi-value {
+            background: rgba($orange4, 0.3);
+          }
         }
 
         &.metric {
@@ -119,6 +127,14 @@
         &.double {
           background: rgba($blue3, 0.1);
         }
+
+        &.array {
+          background: rgba($orange2, 0.1);
+        }
+
+        &.multi-value {
+          background: rgba($orange4, 0.1);
+        }
       }
 
       &.metric {
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 5feb7009d12..a674a3e6028 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,8 +19,8 @@
 import { Icon } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
 import { Popover2 } from '@blueprintjs/popover2';
-import type { Column, QueryResult, SqlQuery } from '@druid-toolkit/query';
-import { SqlAlias, SqlStar } from '@druid-toolkit/query';
+import type { Column, QueryResult, SqlExpression, SqlQuery } from 
'@druid-toolkit/query';
+import { SqlAlias, SqlFunction, SqlStar } from '@druid-toolkit/query';
 import classNames from 'classnames';
 import React, { useState } from 'react';
 import type { RowRenderProps } from 'react-table';
@@ -44,7 +44,13 @@ function isDate(v: any): v is Date {
   return Boolean(v && typeof v.toISOString === 'function');
 }
 
-function getExpressionIfAlias(query: SqlQuery, selectIndex: number): string {
+function isWrappedInArrayToMv(ex: SqlExpression | undefined) {
+  if (!ex) return false;
+  ex = ex.getUnderlyingExpression();
+  return ex instanceof SqlFunction && ex.getEffectiveFunctionName() === 
'ARRAY_TO_MV';
+}
+
+function formatFormulaAtIndex(query: SqlQuery, selectIndex: number): string {
   const ex = query.getSelectExpressionForIndex(selectIndex);
 
   if (query.isRealOutputColumnAtSelectIndex(selectIndex)) {
@@ -123,7 +129,10 @@ export const PreviewTable = React.memo(function 
PreviewTable(props: PreviewTable
                 column.isTimeColumn() ? 'timestamp' : 'dimension',
                 `column${i}`,
                 column.sqlType?.toLowerCase(),
-                { selected },
+                {
+                  selected,
+                  'multi-value': 
isWrappedInArrayToMv(parsedQuery.getSelectExpressionForIndex(i)),
+                },
               );
 
           return {
@@ -137,7 +146,7 @@ export const PreviewTable = React.memo(function 
PreviewTable(props: PreviewTable
                       <Icon className="filter-icon" icon={IconNames.FILTER} 
size={14} />
                     )}
                   </div>
-                  <div className="formula">{getExpressionIfAlias(parsedQuery, 
i)}</div>
+                  <div className="formula">{formatFormulaAtIndex(parsedQuery, 
i)}</div>
                 </div>
               );
             },
diff --git 
a/web-console/src/views/sql-data-loader-view/schema-step/schema-step.tsx 
b/web-console/src/views/sql-data-loader-view/schema-step/schema-step.tsx
index 8b5ee9f82f8..c52ee91abff 100644
--- a/web-console/src/views/sql-data-loader-view/schema-step/schema-step.tsx
+++ b/web-console/src/views/sql-data-loader-view/schema-step/schema-step.tsx
@@ -430,11 +430,19 @@ export const SchemaStep = function SchemaStep(props: 
SchemaStepProps) {
                 dimensions: filterMap(sampleExternalConfig.signature, s => {
                   const columnName = s.getColumnName();
                   if (columnName === TIME_COLUMN) return;
-                  const t = s.columnType.getNativeType();
-                  return {
-                    name: columnName,
-                    type: t === 'COMPLEX<json>' ? 'json' : t,
-                  };
+                  if (s.columnType.isArray()) {
+                    return {
+                      type: 'auto',
+                      castToType: s.columnType.getNativeType().toUpperCase(),
+                      name: columnName,
+                    };
+                  } else {
+                    const t = s.columnType.getNativeType();
+                    return {
+                      name: columnName,
+                      type: t === 'COMPLEX<json>' ? 'json' : t,
+                    };
+                  }
                 }),
               },
               granularitySpec: {
@@ -899,7 +907,8 @@ export const SchemaStep = function SchemaStep(props: 
SchemaStepProps) {
             {editorColumn && ingestQueryPattern && (
               <>
                 <ColumnEditor
-                  expression={editorColumn.expression}
+                  key={editorColumn.index}
+                  initExpression={editorColumn.expression}
                   onApply={newColumn => {
                     if (!editorColumn) return;
                     updatePattern(
@@ -912,7 +921,10 @@ export const SchemaStep = function SchemaStep(props: 
SchemaStepProps) {
                     );
                   }}
                   onCancel={() => setEditorColumn(undefined)}
-                  dirty={() => setEditorColumn({ ...editorColumn, dirty: true 
})}
+                  dirty={() => {
+                    if (!editorColumn.dirty) return;
+                    setEditorColumn({ ...editorColumn, dirty: true });
+                  }}
                   queryResult={previewResultState.data}
                   headerIndex={editorColumn.index}
                 />
diff --git 
a/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx 
b/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx
index 8e967487a32..4b99da236b3 100644
--- a/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx
+++ b/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx
@@ -45,6 +45,12 @@ import { TitleFrame } from './title-frame/title-frame';
 
 import './sql-data-loader-view.scss';
 
+const INITIAL_QUERY_CONTEXT: QueryContext = {
+  finalizeAggregations: false,
+  groupByEnableMultiValueUnnesting: false,
+  arrayIngestMode: 'array',
+};
+
 interface LoaderContent extends QueryWithContext {
   id?: string;
 }
@@ -183,31 +189,28 @@ export const SqlDataLoaderView = React.memo(function 
SqlDataLoaderView(
             inputSource={inputSource}
             initInputFormat={inputFormat}
             doneButton={false}
-            onSet={({ inputFormat, signature, isArrays, timeExpression }) => {
+            onSet={({ inputFormat, signature, timeExpression, arrayMode }) => {
               setContent({
                 queryString: ingestQueryPatternToQuery(
                   externalConfigToIngestQueryPattern(
                     { inputSource, inputFormat, signature },
-                    isArrays,
                     timeExpression,
                     undefined,
+                    arrayMode,
                   ),
                 ).toString(),
-                queryContext: {
-                  finalizeAggregations: false,
-                  groupByEnableMultiValueUnnesting: false,
-                },
+                queryContext: INITIAL_QUERY_CONTEXT,
               });
             }}
             altText="Skip the wizard and continue with custom SQL"
-            onAltSet={({ inputFormat, signature, isArrays, timeExpression }) 
=> {
+            onAltSet={({ inputFormat, signature, timeExpression, arrayMode }) 
=> {
               goToQuery({
                 queryString: ingestQueryPatternToQuery(
                   externalConfigToIngestQueryPattern(
                     { inputSource, inputFormat, signature },
-                    isArrays,
                     timeExpression,
                     undefined,
+                    arrayMode,
                   ),
                 ).toString(),
               });
diff --git 
a/web-console/src/views/workbench-view/connect-external-data-dialog/connect-external-data-dialog.tsx
 
b/web-console/src/views/workbench-view/connect-external-data-dialog/connect-external-data-dialog.tsx
index bd8fbff9fd0..9d4974332ef 100644
--- 
a/web-console/src/views/workbench-view/connect-external-data-dialog/connect-external-data-dialog.tsx
+++ 
b/web-console/src/views/workbench-view/connect-external-data-dialog/connect-external-data-dialog.tsx
@@ -20,7 +20,7 @@ import { Classes, Dialog } from '@blueprintjs/core';
 import type { SqlExpression } from '@druid-toolkit/query';
 import React, { useState } from 'react';
 
-import type { ExternalConfig, InputFormat, InputSource } from 
'../../../druid-models';
+import type { ArrayMode, ExternalConfig, InputFormat, InputSource } from 
'../../../druid-models';
 import { InputFormatStep } from '../input-format-step/input-format-step';
 import { InputSourceStep } from '../input-source-step/input-source-step';
 
@@ -30,9 +30,9 @@ export interface ConnectExternalDataDialogProps {
   initExternalConfig?: Partial<ExternalConfig>;
   onSetExternalConfig(
     config: ExternalConfig,
-    isArrays: boolean[],
     timeExpression: SqlExpression | undefined,
     partitionedByHint: string | undefined,
+    arrayMode: ArrayMode,
   ): void;
   onClose(): void;
 }
@@ -67,12 +67,12 @@ export const ConnectExternalDataDialog = 
React.memo(function ConnectExternalData
             inputSource={inputSource}
             initInputFormat={inputFormat}
             doneButton
-            onSet={({ inputFormat, signature, isArrays, timeExpression }) => {
+            onSet={({ inputFormat, signature, timeExpression, arrayMode }) => {
               onSetExternalConfig(
                 { inputSource, inputFormat, signature },
-                isArrays,
                 timeExpression,
                 partitionedByHint,
+                arrayMode,
               );
               onClose();
             }}
diff --git 
a/web-console/src/views/workbench-view/input-format-step/input-format-step.tsx 
b/web-console/src/views/workbench-view/input-format-step/input-format-step.tsx
index 7e5064867a3..e6ea2d9081b 100644
--- 
a/web-console/src/views/workbench-view/input-format-step/input-format-step.tsx
+++ 
b/web-console/src/views/workbench-view/input-format-step/input-format-step.tsx
@@ -22,14 +22,13 @@ import type { SqlExpression } from '@druid-toolkit/query';
 import { C, SqlColumnDeclaration, SqlType } from '@druid-toolkit/query';
 import React, { useState } from 'react';
 
-import { AutoForm, CenterMessage, LearnMore, Loader } from 
'../../../components';
-import type { InputFormat, InputSource } from '../../../druid-models';
+import { ArrayModeSwitch, AutoForm, CenterMessage, LearnMore, Loader } from 
'../../../components';
+import type { ArrayMode, InputFormat, InputSource } from 
'../../../druid-models';
 import {
   BATCH_INPUT_FORMAT_FIELDS,
   chooseByBestTimestamp,
   DETECTION_TIMESTAMP_SPEC,
   guessColumnTypeFromSampleResponse,
-  guessIsArrayFromSampleResponse,
   inputFormatOutputsNumericStrings,
   possibleDruidFormatForValues,
   TIME_COLUMN,
@@ -52,8 +51,8 @@ import './input-format-step.scss';
 export interface InputFormatAndMore {
   inputFormat: InputFormat;
   signature: SqlColumnDeclaration[];
-  isArrays: boolean[];
   timeExpression: SqlExpression | undefined;
+  arrayMode: ArrayMode;
 }
 
 interface PossibleTimeExpression {
@@ -80,6 +79,7 @@ export const InputFormatStep = React.memo(function 
InputFormatStep(props: InputF
     AutoForm.isValidModel(initInputFormat, BATCH_INPUT_FORMAT_FIELDS) ? 
initInputFormat : undefined,
   );
   const [selectTimestamp, setSelectTimestamp] = useState(true);
+  const [arrayMode, setArrayMode] = useState<ArrayMode>('multi-values');
 
   const [previewState] = useQueryManager<InputFormat, SampleResponse>({
     query: inputFormatToSample,
@@ -166,13 +166,13 @@ export const InputFormatStep = React.memo(function 
InputFormatStep(props: InputF
               ),
             ),
           ),
-          isArrays: headerNames.map(name =>
-            guessIsArrayFromSampleResponse(previewSampleResponse, name),
-          ),
           timeExpression: selectTimestamp ? 
possibleTimeExpression?.timeExpression : undefined,
+          arrayMode,
         }
       : undefined;
 
+  const hasArrays = inputFormatAndMore?.signature.some(d => 
d.columnType.isArray());
+
   return (
     <div className="input-format-step">
       <div className="preview">
@@ -224,6 +224,7 @@ export const InputFormatStep = React.memo(function 
InputFormatStep(props: InputF
           )}
         </div>
         <div className="bottom-controls">
+          {hasArrays && <ArrayModeSwitch arrayMode={arrayMode} 
changeArrayMode={setArrayMode} />}
           {possibleTimeExpression && (
             <FormGroup>
               <Callout>
diff --git a/web-console/src/views/workbench-view/workbench-view.tsx 
b/web-console/src/views/workbench-view/workbench-view.tsx
index 9c859f9b462..7d721da413f 100644
--- a/web-console/src/views/workbench-view/workbench-view.tsx
+++ b/web-console/src/views/workbench-view/workbench-view.tsx
@@ -320,13 +320,18 @@ export class WorkbenchView extends 
React.PureComponent<WorkbenchViewProps, Workb
 
     return (
       <ConnectExternalDataDialog
-        onSetExternalConfig={(externalConfig, isArrays, timeExpression, 
partitionedByHint) => {
+        onSetExternalConfig={(
+          externalConfig,
+          timeExpression,
+          partitionedByHint,
+          forceMultiValue,
+        ) => {
           this.handleNewTab(
             WorkbenchQuery.fromInitExternalConfig(
               externalConfig,
-              isArrays,
               timeExpression,
               partitionedByHint,
+              forceMultiValue,
             ),
             'Ext ' + 
guessDataSourceNameFromInputSource(externalConfig.inputSource),
           );


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

Reply via email to