diff --git a/web-v2/web/src/app/catalogs/rightContent/CreateTableDialog.js b/web-v2/web/src/app/catalogs/rightContent/CreateTableDialog.js
index 30bc3d72f33..bf051ad6df4 100644
--- a/web-v2/web/src/app/catalogs/rightContent/CreateTableDialog.js
+++ b/web-v2/web/src/app/catalogs/rightContent/CreateTableDialog.js
@@ -47,6 +47,7 @@ import { TreeRefContext } from '../page'
import Icons from '@/components/Icons'
import { useResetFormOnCloseModal } from '@/lib/hooks/use-reset'
import ColumnTypeComponent from '@/components/ColumnTypeComponent'
+import PartitionPanel from '@/components/PartitionPanel'
import RenderPropertiesFormItem from '@/components/EntityPropertiesFormItem'
import { validateMessages, mismatchName } from '@/config'
import {
@@ -435,10 +436,29 @@ export default function CreateTableDialog({ ...props }) {
let idxPartiton = 0
if (table.partitioning?.length) {
table.partitioning.forEach(item => {
- const fields = item.fieldName || item.fieldNames.map(f => f[0])
+ const fieldName = item.fieldName?.[0] ?? item.fieldNames?.[0]?.[0] ?? ''
form.setFieldValue(['partitions', idxPartiton, 'strategy'], item.strategy)
- form.setFieldValue(['partitions', idxPartiton, 'fieldName'], fields)
+ form.setFieldValue(['partitions', idxPartiton, 'fieldName'], fieldName)
form.setFieldValue(['partitions', idxPartiton, 'number'], item.numBuckets || item.width || item.number)
+ if (item.strategy === 'range' && item.assignments?.length) {
+ form.setFieldValue(
+ ['partitions', idxPartiton, 'assignments'],
+ item.assignments.map(a => ({
+ name: a.name,
+ upper: a.upper?.value ?? '',
+ lower: a.lower?.value ?? ''
+ }))
+ )
+ }
+ if (item.strategy === 'list' && item.assignments?.length) {
+ form.setFieldValue(
+ ['partitions', idxPartiton, 'listAssignments'],
+ item.assignments.map(a => ({
+ name: a.name,
+ listGroups: a.lists?.map(list => list.map(l => l.value ?? '').join(', ')) || ['']
+ }))
+ )
+ }
idxPartiton++
})
}
@@ -668,14 +688,80 @@ export default function CreateTableDialog({ ...props }) {
if (partitioningInfo) {
submitData['partitioning'] = values.partitions?.map(p => {
const field = {}
+
+ // Shared column type -> literal dataType mapping for range and list partitions
+ const columnType =
+ values.columns?.find(c => c?.name === p.fieldName)?.typeObj?.type?.split('(')[0] || 'string'
+
+ const dataType =
+ {
+ integer: 'integer',
+ long: 'long',
+ short: 'short',
+ float: 'float',
+ double: 'double',
+ decimal: 'decimal',
+ date: 'date',
+ time: 'time',
+ timestamp: 'timestamp',
+ timestamp_tz: 'timestamp_tz',
+ string: 'varchar',
+ varchar: 'varchar',
+ char: 'char',
+ boolean: 'boolean'
+ }[columnType] || 'varchar'
+
if (p.strategy === 'list') {
+ // fieldNames is String[][] (each inner array is one column name); fieldName is String[]
field['fieldNames'] = [[p.fieldName]]
+ if (p.listAssignments?.length) {
+ // Number of partition columns (fieldNames is String[][], each inner array is one column name)
+ const numPartitionCols = field['fieldNames']?.length || 1
+ field['assignments'] = p.listAssignments.map(a => {
+ // listGroups: array of comma-separated value strings
+ // Each group becomes one or more rows in LiteralDTO[][] (one IN tuple per row)
+ // For single-column partition: 'v1, v2, v3' -> [[{v1}], [{v2}], [{v3}]]
+ // For multi-column partition: 'v1, v2' -> [[{v1}, {v2}]]
+ const lists = (a.listGroups || [])
+ .filter(g => g && g.trim() !== '')
+ .flatMap(group => {
+ const vals = group
+ .split(',')
+ .map(v => v.trim())
+ .filter(v => v !== '')
+ if (numPartitionCols === 1) {
+ return vals.map(v => [{ type: 'literal', dataType, value: v }])
+ } else {
+ return [vals.map(v => ({ type: 'literal', dataType, value: v }))]
+ }
+ })
+
+ return { type: 'list', name: a.name, lists, properties: null }
+ })
+ }
} else if (p.strategy === 'bucket') {
field['numBuckets'] = p.number
field['fieldNames'] = [[p.fieldName]]
} else if (p.strategy === 'truncate') {
field['width'] = p.number
field['fieldName'] = [p.fieldName]
+ } else if (p.strategy === 'range') {
+ // fieldName is String[] for range (not fieldNames)
+ field['fieldName'] = [p.fieldName]
+ if (p.assignments?.length) {
+ field['assignments'] = p.assignments.map(a => {
+ const assignment = { type: 'range', name: a.name }
+ assignment.upper = a.upper
+ ? { type: 'literal', dataType, value: a.upper }
+ : { type: 'literal', dataType: 'null', value: 'NULL' }
+ assignment.lower = a.lower
+ ? { type: 'literal', dataType, value: a.lower }
+ : { type: 'literal', dataType: 'null', value: 'NULL' }
+ assignment.properties = null
+
+ return assignment
+ })
+ }
} else {
field['fieldName'] = [p.fieldName]
}
@@ -1040,95 +1126,6 @@ export default function CreateTableDialog({ ...props }) {
)
}
- const renderTablePartitions = (fields, subOpt) => {
- return (
-
-
-
Field
-
Strategy
-
Action
-
- {fields.map(subField => (
-
-
-
-
-
-
-
-
-
-
-
- {['truncate', 'bucket'].includes(form.getFieldValue(['partitions', subField.name, 'strategy'])) && (
-
-
-
- )}
-
-
- {
- if (!!editTable) return
- subOpt.remove(subField.name)
- }}
- />
-
-
-
- ))}
-
- }
- disabled={!!editTable}
- onClick={() => {
- subOpt.add()
- }}
- >
- Add Partition
-
-
-
- )
- }
-
const renderTableSortOrders = (fields, subOpt) => {
return (
@@ -1439,9 +1436,9 @@ export default function CreateTableDialog({ ...props }) {
{(fields, subOpt) => renderTableColumns(fields, subOpt)}
{partitioningInfo && (
-
- {(fields, subOpt) => renderTablePartitions(fields, subOpt)}
-
+
)}
{sortOredsInfo && (
diff --git a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/TableDetailsPage.js b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/TableDetailsPage.js
index 90ad4a0d2e7..850b937af9f 100644
--- a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/TableDetailsPage.js
+++ b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/TableDetailsPage.js
@@ -142,7 +142,7 @@ export default function TableDetailsPage({ ...props }) {
const partitioning = store.activatedDetails?.partitioning?.map((i, index) => {
let fields = i.fieldName || []
let sub = ''
- let last = i.fieldName
+ let last = i.fieldName || ''
switch (i.strategy) {
case 'bucket':
@@ -157,6 +157,14 @@ export default function TableDetailsPage({ ...props }) {
fields = i.fieldNames
last = i.fieldNames.map(v => v[0]).join(',')
break
+ case 'range':
+ if (i.assignments?.length) {
+ sub = `[${i.assignments.length} partitions]`
+ }
+
+ // range uses fieldName (String[]), not fieldNames (String[][])
+ last = i.fieldName || (i.fieldNames ? i.fieldNames.map(v => v[0]).join(',') : '')
+ break
case 'function':
sub = `[${i.funcName}]`
fields = i.funcArgs.map(v => v.fieldName)
@@ -173,6 +181,7 @@ export default function TableDetailsPage({ ...props }) {
width: i.width,
funcName: i.funcName,
fields,
+ assignments: i.assignments,
text: `${i.strategy}${sub}(${last})`
}
})
diff --git a/web-v2/web/src/components/PartitionPanel.js b/web-v2/web/src/components/PartitionPanel.js
new file mode 100644
index 00000000000..770993fdc58
--- /dev/null
+++ b/web-v2/web/src/components/PartitionPanel.js
@@ -0,0 +1,486 @@
+/*
+ * 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 React, { useCallback } from 'react'
+
+import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
+import { Button, DatePicker, Flex, Form, Input, InputNumber, Select, TimePicker } from 'antd'
+import dayjs from 'dayjs'
+import Icons from '@/components/Icons'
+import { partitionInfoMap, transformsLimitMap } from '@/config'
+import { capitalizeFirstLetter } from '@/lib/utils'
+import { cn } from '@/lib/utils/tailwind'
+
+const dateTypes = ['date', 'timestamp', 'timestamp_tz']
+const timeTypes = ['time']
+
+/** Helper: get the base column type for a partition row */
+function getColumnBaseType(form, partitionIndex) {
+ const fieldName = form.getFieldValue(['partitions', partitionIndex, 'fieldName'])
+ const cols = form.getFieldValue('columns') || []
+ const column = cols.find(c => c?.name === fieldName)
+
+ return column?.typeObj?.type?.split('(')[0]
+}
+
+/** Helper: ensure Doris list-partition columns are set to NOT NULL */
+function ensureDorisListNotNull(form, provider, partitionIndex) {
+ if (provider !== 'jdbc-doris') return
+ const strategy = form.getFieldValue(['partitions', partitionIndex, 'strategy'])
+ if (strategy !== 'list') return
+ const fieldName = form.getFieldValue(['partitions', partitionIndex, 'fieldName'])
+ if (!fieldName) return
+ const columns = form.getFieldValue('columns') || []
+ const colIdx = columns.findIndex(c => c?.name === fieldName)
+ if (colIdx >= 0 && !columns[colIdx]?.required) {
+ form.setFieldValue(['columns', colIdx, 'required'], true)
+ }
+}
+
+/** Helper: create a stable key for assignment items to avoid index-based keys */
+let assignmentIdCounter = 0
+function newAssignmentId() {
+ return `assign-${++assignmentIdCounter}`
+}
+
+function BoundInput({ columnType, value, onChange, ...props }) {
+ if (timeTypes.includes(columnType)) {
+ return (
+ {
+ const val = Array.isArray(timeString) ? timeString[0] : timeString
+ onChange?.(val || '')
+ }}
+ {...props}
+ />
+ )
+ }
+
+ if (dateTypes.includes(columnType)) {
+ return (
+ {
+ // dateString can be string or string[] when multiple formats are configured
+ const val = Array.isArray(dateString) ? dateString[0] : dateString
+ onChange?.(val || '')
+ }}
+ {...props}
+ />
+ )
+ }
+
+ return onChange?.(e.target.value)} {...props} />
+}
+
+export default function PartitionPanel({ form, editTable, provider }) {
+ const partitioningInfo = partitionInfoMap[provider]
+
+ return (
+
+ {(fields, subOpt) => (
+
+
+
Field
+
Strategy
+
Action
+
+ {fields.map(subField => (
+
+ ))}
+
+ }
+ disabled={!!editTable}
+ onClick={() => {
+ subOpt.add()
+ }}
+ >
+ Add Partition
+
+
+
+ )}
+
+ )
+}
+
+function PartitionRow({ form, editTable, subField, subOpt, partitioningInfo, provider }) {
+ // Only watch strategy to control visibility of number input and range/list assignments.
+ // Do NOT watch 'columns' or 'fieldName' here — reading them via form.getFieldValue
+ // inside callbacks and render avoids unnecessary re-renders that reset assignment values.
+ const currentStrategy = Form.useWatch(['partitions', subField.name, 'strategy'], form)
+ const idx = subField.name
+
+ const handleFieldChange = useCallback(() => {
+ // Clear bound values when field changes, since the column type may differ
+ const assignments = form.getFieldValue(['partitions', idx, 'assignments'])
+ if (assignments?.length > 0) {
+ form.setFieldValue(
+ ['partitions', idx, 'assignments'],
+ assignments.map(a => ({ ...a, upper: undefined, lower: undefined }))
+ )
+ }
+
+ // Also clear list assignments since column type may differ
+ const listAssignments = form.getFieldValue(['partitions', idx, 'listAssignments'])
+ if (listAssignments?.length > 0) {
+ form.setFieldValue(['partitions', idx, 'listAssignments'], [])
+ }
+ ensureDorisListNotNull(form, provider, idx)
+ }, [form, provider, idx])
+
+ const handleStrategyChange = useCallback(() => {
+ const newStrategy = form.getFieldValue(['partitions', idx, 'strategy'])
+
+ // Clear assignments when strategy changes away from range/list
+ if (newStrategy !== 'range') {
+ form.setFieldValue(['partitions', idx, 'assignments'], [])
+ }
+ if (newStrategy !== 'list') {
+ form.setFieldValue(['partitions', idx, 'listAssignments'], [])
+ }
+ ensureDorisListNotNull(form, provider, idx)
+ }, [form, provider, idx])
+
+ const columnOptions = (() => {
+ const cols = form.getFieldValue('columns') || []
+
+ return cols
+ .filter(col => col?.name)
+ .map(col => (
+
+
+ {col?.name}
+ {col?.typeObj?.type}
+
+
+ ))
+ })()
+
+ const strategyOptions = (() => {
+ const type = getColumnBaseType(form, idx)
+
+ return partitioningInfo
+ ?.filter(s => transformsLimitMap[s]?.includes(type) || !transformsLimitMap[s])
+ .map(s => (
+
+ {capitalizeFirstLetter(s)}
+
+ ))
+ })()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {['truncate', 'bucket'].includes(currentStrategy) && (
+
+
+
+ )}
+
+
+ {
+ if (!!editTable) return
+ subOpt.remove(subField.name)
+ }}
+ />
+
+
+ {/* Always render assignment panels but hide with CSS when not active.
+ This prevents Form.List unmount/remount which loses field values. */}
+
+
+
+
+
+
+
+ )
+}
+
+function RangeAssignments({ editTable, partitionIndex, form }) {
+ // Read assignments directly from form store to avoid nested Form.List path resolution issues.
+ // Nested Form.List inside another Form.List causes field path conflicts where updating
+ // one field inadvertently resets siblings (name/upper/lower overwrite each other).
+ const assignments = Form.useWatch(['partitions', partitionIndex, 'assignments'], form) || []
+
+ const onFieldChange = (assignIdx, field, value) => {
+ const current = form.getFieldValue(['partitions', partitionIndex, 'assignments']) || []
+
+ const updated = current.map((item, idx) => (idx === assignIdx ? { ...item, [field]: value } : item))
+ form.setFieldValue(['partitions', partitionIndex, 'assignments'], updated)
+ }
+
+ const addAssignment = () => {
+ const current = form.getFieldValue(['partitions', partitionIndex, 'assignments']) || []
+ form.setFieldValue(
+ ['partitions', partitionIndex, 'assignments'],
+ [...current, { _key: newAssignmentId(), name: '', upper: '', lower: '' }]
+ )
+ }
+
+ const removeAssignment = assignIdx => {
+ const current = form.getFieldValue(['partitions', partitionIndex, 'assignments']) || []
+ form.setFieldValue(
+ ['partitions', partitionIndex, 'assignments'],
+ current.filter((_, idx) => idx !== assignIdx)
+ )
+ }
+
+ const columnType = getColumnBaseType(form, partitionIndex)
+
+ return (
+
+
Range Partitions
+
+
+
Name
+
Upper Bound
+
Lower Bound
+
+
+ {assignments.map((assignItem, assignIdx) => (
+
+
+ onFieldChange(assignIdx, 'name', e.target.value)}
+ />
+
+
+ onFieldChange(assignIdx, 'upper', value ?? '')}
+ />
+
+
+ onFieldChange(assignIdx, 'lower', value ?? '')}
+ />
+
+
+ {
+ if (!!editTable) return
+ removeAssignment(assignIdx)
+ }}
+ />
+
+
+ ))}
+
} disabled={!!editTable} onClick={addAssignment}>
+ Add Range Partition
+
+
+
+ )
+}
+
+function ListAssignments({ editTable, partitionIndex, form }) {
+ const assignments = Form.useWatch(['partitions', partitionIndex, 'listAssignments'], form) || []
+
+ const onFieldChange = (assignIdx, field, value) => {
+ const current = form.getFieldValue(['partitions', partitionIndex, 'listAssignments']) || []
+
+ const updated = current.map((item, idx) => (idx === assignIdx ? { ...item, [field]: value } : item))
+ form.setFieldValue(['partitions', partitionIndex, 'listAssignments'], updated)
+ }
+
+ const onListGroupChange = (assignIdx, groupIdx, value) => {
+ const current = form.getFieldValue(['partitions', partitionIndex, 'listAssignments']) || []
+ const item = current[assignIdx]
+ if (!item) return
+ const newGroups = [...(item.listGroups || [])]
+ newGroups[groupIdx] = value
+
+ const updated = current.map((it, idx) => (idx === assignIdx ? { ...it, listGroups: newGroups } : it))
+ form.setFieldValue(['partitions', partitionIndex, 'listAssignments'], updated)
+ }
+
+ const addListGroup = assignIdx => {
+ const current = form.getFieldValue(['partitions', partitionIndex, 'listAssignments']) || []
+ const item = current[assignIdx]
+ if (!item) return
+ const newGroups = [...(item.listGroups || []), '']
+
+ const updated = current.map((it, idx) => (idx === assignIdx ? { ...it, listGroups: newGroups } : it))
+ form.setFieldValue(['partitions', partitionIndex, 'listAssignments'], updated)
+ }
+
+ const removeListGroup = (assignIdx, groupIdx) => {
+ const current = form.getFieldValue(['partitions', partitionIndex, 'listAssignments']) || []
+ const item = current[assignIdx]
+ if (!item) return
+ const newGroups = (item.listGroups || []).filter((_, i) => i !== groupIdx)
+
+ const updated = current.map((it, idx) => (idx === assignIdx ? { ...it, listGroups: newGroups } : it))
+ form.setFieldValue(['partitions', partitionIndex, 'listAssignments'], updated)
+ }
+
+ const addAssignment = () => {
+ const current = form.getFieldValue(['partitions', partitionIndex, 'listAssignments']) || []
+ form.setFieldValue(
+ ['partitions', partitionIndex, 'listAssignments'],
+ [...current, { _key: newAssignmentId(), name: '', listGroups: [''] }]
+ )
+ }
+
+ const removeAssignment = assignIdx => {
+ const current = form.getFieldValue(['partitions', partitionIndex, 'listAssignments']) || []
+ form.setFieldValue(
+ ['partitions', partitionIndex, 'listAssignments'],
+ current.filter((_, idx) => idx !== assignIdx)
+ )
+ }
+
+ return (
+
+
List Partitions
+
+ {assignments.map((assignItem, assignIdx) => (
+
+
+
+
Name
+
onFieldChange(assignIdx, 'name', e.target.value)}
+ />
+
+
+
Value Groups (each group = one IN tuple)
+ {(assignItem?.listGroups || []).map((group, groupIdx) => (
+
+ onListGroupChange(assignIdx, groupIdx, e.target.value)}
+ />
+ {
+ if (!!editTable) return
+ removeListGroup(assignIdx, groupIdx)
+ }}
+ />
+
+ ))}
+
}
+ disabled={!!editTable}
+ onClick={() => addListGroup(assignIdx)}
+ >
+ Add Value Group
+
+
+
+ {
+ if (!!editTable) return
+ removeAssignment(assignIdx)
+ }}
+ />
+
+
+
+ ))}
+
} disabled={!!editTable} onClick={addAssignment}>
+ Add List Partition
+
+
+
+ )
+}
diff --git a/web-v2/web/src/config/index.js b/web-v2/web/src/config/index.js
index 15c23285180..8366398942e 100644
--- a/web-v2/web/src/config/index.js
+++ b/web-v2/web/src/config/index.js
@@ -312,7 +312,8 @@ export const transformsLimitMap = {
year: ['date', 'timestamp', 'timestamp_tz'],
month: ['date', 'timestamp', 'timestamp_tz'],
day: ['date', 'timestamp', 'timestamp_tz'],
- hour: ['timestamp', 'timestamp_tz']
+ hour: ['timestamp', 'timestamp_tz'],
+ range: ['integer', 'long', 'decimal', 'date', 'time', 'timestamp', 'timestamp_tz', 'string']
}
export const sortOrdersInfoMap = {