From 4d27ae0aceda516fcf0ae51ce6a7a21b7ed203cb Mon Sep 17 00:00:00 2001 From: Vignesh Nayak Manel Date: Fri, 20 Mar 2026 23:01:37 +0530 Subject: [PATCH 1/2] (feat:Console) - Add update iceberg table functionality --- console/src/api/catalog/tables.ts | 36 ++ .../src/components/forms/CreateTableModal.tsx | 605 +----------------- .../components/forms/SchemaFieldEditor.tsx | 405 ++++++++++++ .../components/forms/UpdateSchemaModal.tsx | 298 +++++++++ .../src/components/forms/schemaFieldUtils.ts | 242 +++++++ console/src/pages/TableDetails.tsx | 18 + console/src/types/api.ts | 1 + 7 files changed, 1011 insertions(+), 594 deletions(-) create mode 100644 console/src/components/forms/SchemaFieldEditor.tsx create mode 100644 console/src/components/forms/UpdateSchemaModal.tsx create mode 100644 console/src/components/forms/schemaFieldUtils.ts diff --git a/console/src/api/catalog/tables.ts b/console/src/api/catalog/tables.ts index 2394e0ec..7c4e7398 100644 --- a/console/src/api/catalog/tables.ts +++ b/console/src/api/catalog/tables.ts @@ -26,6 +26,7 @@ import type { LoadTableResult, LoadGenericTableResponse, GenericTableIdentifier, + SchemaField, } from "@/types/api" /** @@ -130,6 +131,41 @@ export const tablesApi = { await apiClient.getCatalogClient().post(`/${encodeURIComponent(prefix)}/tables/rename`, payload) }, + /** + * Update table schema using the commit API. + * Sends add-schema + set-current-schema updates with an assert-current-schema-id requirement. + */ + updateSchema: async ( + prefix: string, + namespace: string[], + tableName: string, + newSchemaFields: SchemaField[], + currentSchemaId: number, + identifierFieldIds?: number[] + ): Promise => { + const namespaceStr = encodeNamespace(namespace) + const schema: Record = { + type: "struct", + fields: newSchemaFields, + } + if (identifierFieldIds && identifierFieldIds.length > 0) { + schema["identifier-field-ids"] = identifierFieldIds + } + const body = { + requirements: [{ type: "assert-current-schema-id", "current-schema-id": currentSchemaId }], + updates: [ + { action: "add-schema", schema }, + { action: "set-current-schema", "schema-id": -1 }, + ], + } + await apiClient + .getCatalogClient() + .post( + `/${encodeURIComponent(prefix)}/namespaces/${encodeURIComponent(namespaceStr)}/tables/${encodeURIComponent(tableName)}`, + body + ) + }, + /** * Update table properties using commit API. * Sends set-properties and/or remove-properties updates. diff --git a/console/src/components/forms/CreateTableModal.tsx b/console/src/components/forms/CreateTableModal.tsx index f6190a0b..0593dcb0 100644 --- a/console/src/components/forms/CreateTableModal.tsx +++ b/console/src/components/forms/CreateTableModal.tsx @@ -47,39 +47,15 @@ import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Plus, X } from "lucide-react" import type { CreateTableRequest, SchemaField } from "@/types/api" - -const COMPLEX_TYPES = ["struct", "list", "map"] as const - -type ComplexType = (typeof COMPLEX_TYPES)[number] -type FieldTypeCategory = "primitive" | "complex" - -interface SchemaFieldState extends Omit { - type: string | FieldTypeObject - typeCategory: FieldTypeCategory - // For parameterized types - decimalPrecision?: number - decimalScale?: number - fixedLength?: number - // For complex types - nestedFields?: SchemaFieldState[] // for struct - elementType?: string | FieldTypeObject // for list - elementRequired?: boolean - elementId?: number - keyType?: string | FieldTypeObject // for map - valueType?: string | FieldTypeObject - keyRequired?: boolean - valueRequired?: boolean - keyId?: number - valueId?: number - // UI state - expanded?: boolean -} - -interface FieldTypeObject { - type: string - fields?: Array - [key: string]: unknown -} +import { + COMPLEX_TYPES, + type ComplexType, + type SchemaFieldState, + convertFieldToApi, + fieldFromJson, + getNextFieldId, +} from "./schemaFieldUtils" +import { SchemaFieldEditor } from "./SchemaFieldEditor" const schema = z.object({ name: z @@ -158,24 +134,6 @@ export function CreateTableModal({ const schemaJson = watch("schemaJson") - // Helper function to get next available ID recursively - const getNextId = (currentId: number, fieldsToCheck: SchemaFieldState[]): number => { - let maxId = currentId - - for (const field of fieldsToCheck) { - if (field.id > maxId) maxId = field.id - if (field.elementId && field.elementId > maxId) maxId = field.elementId - if (field.keyId && field.keyId > maxId) maxId = field.keyId - if (field.valueId && field.valueId > maxId) maxId = field.valueId - - if (field.nestedFields && field.nestedFields.length > 0) { - maxId = getNextId(maxId, field.nestedFields) - } - } - - return maxId + 1 - } - // Add a new field to the schema const addField = ( parentFields?: SchemaFieldState[], @@ -274,69 +232,6 @@ export function CreateTableModal({ setter(updated) } - const convertFieldToApi = (field: SchemaFieldState): SchemaField => { - let fieldType: string | FieldTypeObject - - if (field.typeCategory === "complex") { - if (field.type === "struct" && field.nestedFields) { - fieldType = { - type: "struct", - fields: field.nestedFields.map(convertFieldToApi), - } - } else if (field.type === "list" && field.elementType) { - let elementType: string | FieldTypeObject - - if (typeof field.elementType === "object") { - if (field.elementType.type === "struct" && field.elementType.fields) { - elementType = { - type: "struct", - fields: (field.elementType.fields as SchemaFieldState[]).map(convertFieldToApi), - } - } else { - elementType = field.elementType - } - } else { - elementType = field.elementType - } - - fieldType = { - type: "list", - "element-id": field.elementId!, - element: elementType, - "element-required": field.elementRequired ?? true, - } - } else if (field.type === "map" && field.keyType && field.valueType) { - fieldType = { - type: "map", - "key-id": field.keyId!, - key: field.keyType, - "value-id": field.valueId!, - value: field.valueType, - "value-required": field.valueRequired ?? true, - } - } else { - fieldType = String(field.type) - } - } else { - // Handle parameterized primitives - if (field.type === "decimal" && field.decimalPrecision && field.decimalScale !== undefined) { - fieldType = `decimal(${field.decimalPrecision},${field.decimalScale})` - } else if (field.type === "fixed" && field.fixedLength) { - fieldType = `fixed[${field.fixedLength}]` - } else { - fieldType = String(field.type) - } - } - - return { - id: field.id, - name: field.name, - type: fieldType, - required: field.required, - ...(field.comment && { comment: field.comment }), - } - } - const parseJsonSchema = () => { if (!schemaJson) { toast.error("Please provide a JSON schema") @@ -356,77 +251,11 @@ export function CreateTableModal({ return } - // Convert parsed JSON to SchemaFieldState - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const convertFromJson = (jsonField: any): SchemaFieldState => { - let fieldType: string - let typeCategory: FieldTypeCategory = "primitive" - let decimalPrecision: number | undefined - let decimalScale: number | undefined - let fixedLength: number | undefined - - // Parse the type - if (typeof jsonField.type === "string") { - fieldType = jsonField.type - - // Check for parameterized types - // decimal(10,2) - const decimalMatch = fieldType.match(/^decimal\((\d+),(\d+)\)$/) - if (decimalMatch) { - fieldType = "decimal" - decimalPrecision = parseInt(decimalMatch[1]) - decimalScale = parseInt(decimalMatch[2]) - } - - // fixed[16] - const fixedMatch = fieldType.match(/^fixed\[(\d+)\]$/) - if (fixedMatch) { - fieldType = "fixed" - fixedLength = parseInt(fixedMatch[1]) - } - } else { - // Complex type - fieldType = jsonField.type.type - typeCategory = "complex" - } - - const baseField: SchemaFieldState = { - id: jsonField.id, - name: jsonField.name, - type: fieldType, - typeCategory, - required: jsonField.required, - comment: jsonField.doc || jsonField.comment, - ...(decimalPrecision !== undefined && { decimalPrecision }), - ...(decimalScale !== undefined && { decimalScale }), - ...(fixedLength !== undefined && { fixedLength }), - } - - if (typeof jsonField.type === "object") { - if (jsonField.type.type === "struct") { - baseField.nestedFields = jsonField.type.fields.map(convertFromJson) - } else if (jsonField.type.type === "list") { - baseField.elementType = jsonField.type.element - baseField.elementRequired = jsonField.type["element-required"] - baseField.elementId = jsonField.type["element-id"] - } else if (jsonField.type.type === "map") { - baseField.keyType = jsonField.type.key - baseField.valueType = jsonField.type.value - baseField.valueRequired = jsonField.type["value-required"] - baseField.keyId = jsonField.type["key-id"] - baseField.valueId = jsonField.type["value-id"] - } - } - - return baseField - } - - const parsedFields = parsed.fields.map(convertFromJson) + const parsedFields = parsed.fields.map(fieldFromJson) setFields(parsedFields) // Update next field ID - const maxId = getNextId(0, parsedFields) - setNextFieldId(maxId) + setNextFieldId(getNextFieldId(parsedFields)) toast.success("Schema imported successfully") setSchemaMode("manual") @@ -797,415 +626,3 @@ export function CreateTableModal({ ) } - -// Schema Field Editor Component -interface SchemaFieldEditorProps { - fields: SchemaFieldState[] - setFields: (fields: SchemaFieldState[]) => void - updateField: ( - index: number, - updates: Partial, - parentFields?: SchemaFieldState[], - parentSetter?: (fields: SchemaFieldState[]) => void - ) => void - removeField: ( - index: number, - parentFields?: SchemaFieldState[], - parentSetter?: (fields: SchemaFieldState[]) => void - ) => void - changeFieldType: ( - index: number, - newType: string, - parentFields?: SchemaFieldState[], - parentSetter?: (fields: SchemaFieldState[]) => void - ) => void - addField: ( - parentFields?: SchemaFieldState[], - parentSetter?: (fields: SchemaFieldState[]) => void - ) => void - nextFieldId: number - setNextFieldId: (id: number) => void - level?: number -} - -function SchemaFieldEditor({ - fields, - setFields, - updateField, - removeField, - changeFieldType, - addField, - nextFieldId, - setNextFieldId, - level = 0, -}: SchemaFieldEditorProps) { - return ( -
- {fields.map((field, index) => ( -
-
- {/* Field Name */} -
- updateField(index, { name: e.target.value }, fields, setFields)} - /> -
- - {/* Field Type */} -
- -
- - {/* Required Checkbox */} -
- - updateField(index, { required: e.target.checked }, fields, setFields) - } - className="h-4 w-4" - /> - -
- - {/* Doc/Comment */} -
- updateField(index, { comment: e.target.value }, fields, setFields)} - /> -
- - {/* Remove Button */} -
- -
-
- - {/* Parameterized Type Configuration */} - {field.type === "decimal" && ( -
- - updateField( - index, - { decimalPrecision: parseInt(e.target.value) || undefined }, - fields, - setFields - ) - } - /> - - updateField( - index, - { decimalScale: parseInt(e.target.value) || undefined }, - fields, - setFields - ) - } - /> -
- )} - - {field.type === "fixed" && ( -
- - updateField( - index, - { fixedLength: parseInt(e.target.value) || undefined }, - fields, - setFields - ) - } - /> -
- )} - - {/* Complex Type Configuration */} - {field.type === "struct" && ( -
-
- - -
- {field.nestedFields && field.nestedFields.length > 0 && ( - - updateField(index, { nestedFields: newNestedFields }, fields, setFields) - } - updateField={updateField} - removeField={removeField} - changeFieldType={changeFieldType} - addField={addField} - nextFieldId={nextFieldId} - setNextFieldId={setNextFieldId} - level={level + 1} - /> - )} -
- )} - - {field.type === "list" && ( -
-
-
- - -
-
-
- - updateField(index, { elementRequired: e.target.checked }, fields, setFields) - } - className="h-4 w-4" - /> - -
-
-
- - {/* Nested fields for struct element type */} - {typeof field.elementType === "object" && field.elementType.type === "struct" && ( -
-
- - -
- {typeof field.elementType === "object" && - field.elementType.fields && - field.elementType.fields.length > 0 && ( - - updateField( - index, - { - elementType: { - type: "struct", - fields: newFields, - }, - }, - fields, - setFields - ) - } - updateField={updateField} - removeField={removeField} - changeFieldType={changeFieldType} - addField={addField} - nextFieldId={nextFieldId} - setNextFieldId={setNextFieldId} - level={level + 1} - /> - )} -
- )} -
- )} - - {field.type === "map" && ( -
-
-
- - -
-
- - -
-
-
- - updateField(index, { valueRequired: e.target.checked }, fields, setFields) - } - className="h-4 w-4" - /> - -
-
- )} -
- ))} -
- ) -} diff --git a/console/src/components/forms/SchemaFieldEditor.tsx b/console/src/components/forms/SchemaFieldEditor.tsx new file mode 100644 index 00000000..0887f955 --- /dev/null +++ b/console/src/components/forms/SchemaFieldEditor.tsx @@ -0,0 +1,405 @@ +/* + * 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 { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Plus, X } from "lucide-react" +import { ALL_TYPE_OPTIONS, NESTED_TYPE_OPTIONS, type SchemaFieldState } from "./schemaFieldUtils" + +// Predefined Tailwind classes for nesting levels (avoids dynamic class generation) +const LEVEL_INDENT: Record = { + 0: "", + 1: "ml-5", + 2: "ml-10", + 3: "ml-[60px]", + 4: "ml-[80px]", +} + +export interface SchemaFieldEditorProps { + fields: SchemaFieldState[] + setFields: (fields: SchemaFieldState[]) => void + updateField: ( + index: number, + updates: Partial, + parentFields?: SchemaFieldState[], + parentSetter?: (fields: SchemaFieldState[]) => void + ) => void + removeField: ( + index: number, + parentFields?: SchemaFieldState[], + parentSetter?: (fields: SchemaFieldState[]) => void + ) => void + changeFieldType: ( + index: number, + newType: string, + parentFields?: SchemaFieldState[], + parentSetter?: (fields: SchemaFieldState[]) => void + ) => void + addField: ( + parentFields?: SchemaFieldState[], + parentSetter?: (fields: SchemaFieldState[]) => void + ) => void + nextFieldId: number + setNextFieldId: (id: number) => void + level?: number +} + +export function SchemaFieldEditor({ + fields, + setFields, + updateField, + removeField, + changeFieldType, + addField, + nextFieldId, + setNextFieldId, + level = 0, +}: SchemaFieldEditorProps) { + const mlClass = LEVEL_INDENT[Math.min(level, 4)] + + return ( +
+ {fields.map((field, index) => ( +
+
+ {/* Field Name */} +
+ updateField(index, { name: e.target.value }, fields, setFields)} + /> +
+ + {/* Field Type */} +
+ +
+ + {/* Required Checkbox */} +
+ + updateField(index, { required: !!checked }, fields, setFields) + } + /> + +
+ + {/* Doc/Comment */} +
+ updateField(index, { comment: e.target.value }, fields, setFields)} + /> +
+ + {/* Remove Button */} +
+ +
+
+ + {/* Parameterized Type Configuration */} + {field.type === "decimal" && ( +
+ { + const n = parseInt(e.target.value, 10) + updateField(index, { decimalPrecision: isNaN(n) ? undefined : n }, fields, setFields) + }} + /> + { + const n = parseInt(e.target.value, 10) + updateField(index, { decimalScale: isNaN(n) ? undefined : n }, fields, setFields) + }} + /> +
+ )} + + {field.type === "fixed" && ( +
+ { + const n = parseInt(e.target.value, 10) + updateField(index, { fixedLength: isNaN(n) ? undefined : n }, fields, setFields) + }} + /> +
+ )} + + {/* Complex Type Configuration */} + {field.type === "struct" && ( +
+
+ + +
+ {field.nestedFields && field.nestedFields.length > 0 && ( + + updateField(index, { nestedFields: newNestedFields }, fields, setFields) + } + updateField={updateField} + removeField={removeField} + changeFieldType={changeFieldType} + addField={addField} + nextFieldId={nextFieldId} + setNextFieldId={setNextFieldId} + level={level + 1} + /> + )} +
+ )} + + {field.type === "list" && ( +
+
+
+ + +
+
+
+ + updateField(index, { elementRequired: !!checked }, fields, setFields) + } + /> + +
+
+
+ + {/* Nested fields for struct element type */} + {typeof field.elementType === "object" && field.elementType.type === "struct" && ( +
+
+ + +
+ {typeof field.elementType === "object" && + field.elementType.fields && + field.elementType.fields.length > 0 && ( + + updateField( + index, + { elementType: { type: "struct", fields: newFields } }, + fields, + setFields + ) + } + updateField={updateField} + removeField={removeField} + changeFieldType={changeFieldType} + addField={addField} + nextFieldId={nextFieldId} + setNextFieldId={setNextFieldId} + level={level + 1} + /> + )} +
+ )} +
+ )} + + {field.type === "map" && ( +
+
+
+ + +
+
+ + +
+
+
+ + updateField(index, { valueRequired: !!checked }, fields, setFields) + } + /> + +
+
+ )} +
+ ))} +
+ ) +} diff --git a/console/src/components/forms/UpdateSchemaModal.tsx b/console/src/components/forms/UpdateSchemaModal.tsx new file mode 100644 index 00000000..67d27fab --- /dev/null +++ b/console/src/components/forms/UpdateSchemaModal.tsx @@ -0,0 +1,298 @@ +/* + * 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 { useEffect, useMemo, useState } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" +import { tablesApi } from "@/api/catalog/tables" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Plus, TriangleAlert } from "lucide-react" +import type { TableMetadata } from "@/types/api" +import { + COMPLEX_TYPES, + type ComplexType, + type SchemaFieldState, + convertFieldToApi, + fieldFromJson, + getNextFieldId, +} from "./schemaFieldUtils" +import { SchemaFieldEditor } from "./SchemaFieldEditor" + +interface UpdateSchemaModalProps { + open: boolean + onOpenChange: (open: boolean) => void + catalogName: string + namespace: string[] + tableName: string + tableMetadata: TableMetadata +} + +function hasEmptyName(fields: SchemaFieldState[]): boolean { + for (const field of fields) { + if (!field.name.trim()) return true + if (field.nestedFields && hasEmptyName(field.nestedFields)) return true + } + return false +} + +export function UpdateSchemaModal({ + open, + onOpenChange, + catalogName, + namespace, + tableName, + tableMetadata, +}: UpdateSchemaModalProps) { + const queryClient = useQueryClient() + const [fields, setFields] = useState([]) + const [nextFieldId, setNextFieldId] = useState(1) + const [initialFieldIds, setInitialFieldIds] = useState>(new Set()) + + useEffect(() => { + if (open) { + const currentSchema = tableMetadata.schemas.find( + (s) => s["schema-id"] === tableMetadata["current-schema-id"] + ) + if (currentSchema) { + const parsed = currentSchema.fields.map(fieldFromJson) + setFields(parsed) + setNextFieldId(getNextFieldId(parsed)) + setInitialFieldIds(new Set(parsed.map((f) => f.id))) + } else { + setFields([]) + setNextFieldId(1) + setInitialFieldIds(new Set()) + } + } + }, [open, tableMetadata]) + + const removedCount = useMemo(() => { + return [...initialFieldIds].filter((id) => !fields.some((f) => f.id === id)).length + }, [fields, initialFieldIds]) + + const addField = ( + parentFields?: SchemaFieldState[], + parentSetter?: (fields: SchemaFieldState[]) => void + ) => { + const targetFields = parentFields !== undefined ? parentFields : fields + const setter = parentSetter !== undefined ? parentSetter : setFields + + const newField: SchemaFieldState = { + id: nextFieldId, + name: "", + type: "string", + typeCategory: "primitive", + required: false, + } + + setter([...targetFields, newField]) + setNextFieldId(nextFieldId + 1) + } + + const removeField = ( + index: number, + parentFields?: SchemaFieldState[], + parentSetter?: (fields: SchemaFieldState[]) => void + ) => { + const targetFields = parentFields !== undefined ? parentFields : fields + const setter = parentSetter !== undefined ? parentSetter : setFields + setter(targetFields.filter((_, i) => i !== index)) + } + + const updateField = ( + index: number, + updates: Partial, + parentFields?: SchemaFieldState[], + parentSetter?: (fields: SchemaFieldState[]) => void + ) => { + const targetFields = parentFields !== undefined ? parentFields : fields + const setter = parentSetter !== undefined ? parentSetter : setFields + const updated = [...targetFields] + updated[index] = { ...updated[index], ...updates } + setter(updated) + } + + const changeFieldType = ( + index: number, + newType: string, + parentFields?: SchemaFieldState[], + parentSetter?: (fields: SchemaFieldState[]) => void + ) => { + const targetFields = parentFields !== undefined ? parentFields : fields + const setter = parentSetter !== undefined ? parentSetter : setFields + + const updated = [...targetFields] + const field = updated[index] + + if (COMPLEX_TYPES.includes(newType as ComplexType)) { + field.typeCategory = "complex" + field.type = newType + + if (newType === "struct") { + field.nestedFields = [] + } else if (newType === "list") { + field.elementType = "string" + field.elementRequired = true + field.elementId = nextFieldId + setNextFieldId(nextFieldId + 1) + } else if (newType === "map") { + field.keyType = "string" + field.valueType = "string" + field.valueRequired = true + field.keyId = nextFieldId + field.valueId = nextFieldId + 1 + setNextFieldId(nextFieldId + 2) + } + } else { + field.typeCategory = "primitive" + field.type = newType + delete field.nestedFields + delete field.elementType + delete field.elementRequired + delete field.elementId + delete field.keyType + delete field.valueType + delete field.keyRequired + delete field.valueRequired + delete field.keyId + delete field.valueId + } + + setter(updated) + } + + const currentSchemaId = tableMetadata["current-schema-id"] + const currentSchema = tableMetadata.schemas.find((s) => s["schema-id"] === currentSchemaId) + + const updateMutation = useMutation({ + mutationFn: async () => { + const schemaFields = fields.map(convertFieldToApi) + const identifierFieldIds = currentSchema?.["identifier-field-ids"] + return tablesApi.updateSchema( + catalogName, + namespace, + tableName, + schemaFields, + currentSchemaId, + identifierFieldIds + ) + }, + onSuccess: () => { + toast.success("Schema updated successfully") + queryClient.invalidateQueries({ + queryKey: ["table", catalogName, namespace.join("."), tableName], + }) + onOpenChange(false) + }, + onError: (error: unknown) => { + const msg = error instanceof Error ? error.message : "Failed to update schema" + toast.error("Failed to update schema", { description: msg }) + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (fields.length === 0) { + toast.error("Schema must have at least one field") + return + } + if (hasEmptyName(fields)) { + toast.error("All fields must have a name") + return + } + updateMutation.mutate() + } + + return ( + + + + Edit Schema + + Modify the schema for {tableName}. Existing field IDs are preserved. + Removing a field will drop that column from the schema. + + + +
+
+ {removedCount > 0 && ( +
+ + + {removedCount} field{removedCount !== 1 ? "s" : ""} will be permanently removed + from the schema. Existing data in those columns will become inaccessible. + +
+ )} + +
+ + {fields.length} field{fields.length !== 1 ? "s" : ""} + + +
+ + {fields.length === 0 ? ( +
+ No fields defined. Add a field to get started. +
+ ) : ( + + )} +
+ + + + + +
+
+
+ ) +} diff --git a/console/src/components/forms/schemaFieldUtils.ts b/console/src/components/forms/schemaFieldUtils.ts new file mode 100644 index 00000000..0a0998fd --- /dev/null +++ b/console/src/components/forms/schemaFieldUtils.ts @@ -0,0 +1,242 @@ +/* + * 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 type { SchemaField } from "@/types/api" + +export const COMPLEX_TYPES = ["struct", "list", "map"] as const + +export type ComplexType = (typeof COMPLEX_TYPES)[number] +export type FieldTypeCategory = "primitive" | "complex" + +export const PRIMITIVE_TYPE_OPTIONS = [ + { value: "string", label: "String" }, + { value: "int", label: "Int" }, + { value: "long", label: "Long" }, + { value: "float", label: "Float" }, + { value: "double", label: "Double" }, + { value: "boolean", label: "Boolean" }, + { value: "date", label: "Date" }, + { value: "time", label: "Time" }, + { value: "timestamp", label: "Timestamp" }, + { value: "timestamptz", label: "Timestamp TZ" }, + { value: "uuid", label: "UUID" }, + { value: "binary", label: "Binary" }, + { value: "decimal", label: "Decimal" }, + { value: "fixed", label: "Fixed" }, +] as const + +export const ALL_TYPE_OPTIONS = [ + ...PRIMITIVE_TYPE_OPTIONS, + { value: "struct", label: "Struct" }, + { value: "list", label: "List" }, + { value: "map", label: "Map" }, +] as const + +// Types valid for list element / map value (excludes parameterized decimal/fixed for nested contexts +// and excludes list/map to avoid unbounded nesting complexity in the UI — struct is supported) +export const NESTED_TYPE_OPTIONS = [ + { value: "string", label: "String" }, + { value: "int", label: "Int" }, + { value: "long", label: "Long" }, + { value: "float", label: "Float" }, + { value: "double", label: "Double" }, + { value: "boolean", label: "Boolean" }, + { value: "date", label: "Date" }, + { value: "time", label: "Time" }, + { value: "timestamp", label: "Timestamp" }, + { value: "timestamptz", label: "Timestamp TZ" }, + { value: "uuid", label: "UUID" }, + { value: "binary", label: "Binary" }, + { value: "struct", label: "Struct" }, +] as const + +export interface FieldTypeObject { + type: string + fields?: Array + [key: string]: unknown +} + +export interface SchemaFieldState extends Omit { + type: string | FieldTypeObject + typeCategory: FieldTypeCategory + // For parameterized types + decimalPrecision?: number + decimalScale?: number + fixedLength?: number + // For complex types + nestedFields?: SchemaFieldState[] // for struct + elementType?: string | FieldTypeObject // for list + elementRequired?: boolean + elementId?: number + keyType?: string | FieldTypeObject // for map + valueType?: string | FieldTypeObject + keyRequired?: boolean + valueRequired?: boolean + keyId?: number + valueId?: number + // UI state + expanded?: boolean +} + +/** + * Returns the next available field ID (max across all field IDs + 1). + */ +export function getNextFieldId(fields: SchemaFieldState[]): number { + let maxId = 0 + + const scan = (fs: SchemaFieldState[]) => { + for (const field of fs) { + if (field.id > maxId) maxId = field.id + if (field.elementId && field.elementId > maxId) maxId = field.elementId + if (field.keyId && field.keyId > maxId) maxId = field.keyId + if (field.valueId && field.valueId > maxId) maxId = field.valueId + if (field.nestedFields && field.nestedFields.length > 0) scan(field.nestedFields) + } + } + + scan(fields) + return maxId + 1 +} + +/** + * Converts a SchemaFieldState (UI state) to a SchemaField (API format). + */ +export function convertFieldToApi(field: SchemaFieldState): SchemaField { + let fieldType: string | FieldTypeObject + + if (field.typeCategory === "complex") { + if (field.type === "struct" && field.nestedFields) { + fieldType = { + type: "struct", + fields: field.nestedFields.map(convertFieldToApi), + } + } else if (field.type === "list" && field.elementType) { + let elementType: string | FieldTypeObject + + if (typeof field.elementType === "object") { + if (field.elementType.type === "struct" && field.elementType.fields) { + elementType = { + type: "struct", + fields: (field.elementType.fields as SchemaFieldState[]).map(convertFieldToApi), + } + } else { + elementType = field.elementType + } + } else { + elementType = field.elementType + } + + fieldType = { + type: "list", + "element-id": field.elementId!, + element: elementType, + "element-required": field.elementRequired ?? true, + } + } else if (field.type === "map" && field.keyType && field.valueType) { + fieldType = { + type: "map", + "key-id": field.keyId!, + key: field.keyType, + "value-id": field.valueId!, + value: field.valueType, + "value-required": field.valueRequired ?? true, + } + } else { + fieldType = String(field.type) + } + } else { + if (field.type === "decimal" && field.decimalPrecision && field.decimalScale !== undefined) { + fieldType = `decimal(${field.decimalPrecision},${field.decimalScale})` + } else if (field.type === "fixed" && field.fixedLength) { + fieldType = `fixed[${field.fixedLength}]` + } else { + fieldType = String(field.type) + } + } + + return { + id: field.id, + name: field.name, + type: fieldType, + required: field.required, + ...(field.comment && { doc: field.comment }), + } +} + +/** + * Converts a raw JSON schema field (from API or paste) to SchemaFieldState. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function fieldFromJson(jsonField: any): SchemaFieldState { + let fieldType: string + let typeCategory: FieldTypeCategory = "primitive" + let decimalPrecision: number | undefined + let decimalScale: number | undefined + let fixedLength: number | undefined + + if (typeof jsonField.type === "string") { + fieldType = jsonField.type + + const decimalMatch = fieldType.match(/^decimal\((\d+),(\d+)\)$/) + if (decimalMatch) { + fieldType = "decimal" + decimalPrecision = parseInt(decimalMatch[1]) + decimalScale = parseInt(decimalMatch[2]) + } + + const fixedMatch = fieldType.match(/^fixed\[(\d+)\]$/) + if (fixedMatch) { + fieldType = "fixed" + fixedLength = parseInt(fixedMatch[1]) + } + } else { + fieldType = jsonField.type.type + typeCategory = "complex" + } + + const baseField: SchemaFieldState = { + id: jsonField.id, + name: jsonField.name, + type: fieldType, + typeCategory, + required: jsonField.required, + comment: jsonField.doc || jsonField.comment, + ...(decimalPrecision !== undefined && { decimalPrecision }), + ...(decimalScale !== undefined && { decimalScale }), + ...(fixedLength !== undefined && { fixedLength }), + } + + if (typeof jsonField.type === "object") { + if (jsonField.type.type === "struct") { + baseField.nestedFields = jsonField.type.fields.map(fieldFromJson) + } else if (jsonField.type.type === "list") { + baseField.elementType = jsonField.type.element + baseField.elementRequired = jsonField.type["element-required"] + baseField.elementId = jsonField.type["element-id"] + } else if (jsonField.type.type === "map") { + baseField.keyType = jsonField.type.key + baseField.valueType = jsonField.type.value + baseField.valueRequired = jsonField.type["value-required"] + baseField.keyId = jsonField.type["key-id"] + baseField.valueId = jsonField.type["value-id"] + } + } + + return baseField +} diff --git a/console/src/pages/TableDetails.tsx b/console/src/pages/TableDetails.tsx index 2eaf0276..2270ee94 100644 --- a/console/src/pages/TableDetails.tsx +++ b/console/src/pages/TableDetails.tsx @@ -31,6 +31,7 @@ import { SchemaViewer } from "@/components/table/SchemaViewer" import { MetadataViewer } from "@/components/table/MetadataViewer" import { RenameTableModal } from "@/components/forms/RenameTableModal" import { EditTablePropertiesModal } from "@/components/forms/EditTablePropertiesModal" +import { UpdateSchemaModal } from "@/components/forms/UpdateSchemaModal" import { Dialog, DialogContent, @@ -97,6 +98,7 @@ export function TableDetails() { // Modals const [renameOpen, setRenameOpen] = useState(false) const [editPropsOpen, setEditPropsOpen] = useState(false) + const [editSchemaOpen, setEditSchemaOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) const nsPath = namespaceArray.join(".") @@ -361,6 +363,13 @@ export function TableDetails() { +