This is an automated email from the ASF dual-hosted git repository.
jbonofre pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris-tools.git
The following commit(s) were added to refs/heads/main by this push:
new abc2cd2 fix(console): Allow the console to show Iceberg Views. (#116)
abc2cd2 is described below
commit abc2cd23b49882e17f49fb0ac1a6f88e779b0768
Author: Adam Christian
<[email protected]>
AuthorDate: Sat Jan 10 01:48:11 2026 -0500
fix(console): Allow the console to show Iceberg Views. (#116)
* fix(console): Allow the console to show Iceberg Views.
* fix: Resolve TypeScript and React hooks linting errors
- Move useMutation hook before early return to comply with React hooks rules
- Replace enum with const object to fix erasableSyntaxOnly error
- Rename duplicate SchemaField interface to ViewSchemaField to avoid type
conflict
---------
Co-authored-by: Adam Christian <[email protected]>
---
console/src/api/catalog/views.ts | 110 +++++++
console/src/app.tsx | 2 +
console/src/components/catalog/CatalogExplorer.tsx | 48 ++-
console/src/components/catalog/CatalogTreeNode.tsx | 50 ++-
.../src/components/catalog/ViewDetailsDrawer.tsx | 229 ++++++++++++++
console/src/components/forms/CreateViewModal.tsx | 199 ++++++++++++
console/src/pages/NamespaceDetails.tsx | 111 ++++++-
console/src/pages/ViewDetails.tsx | 350 +++++++++++++++++++++
console/src/types/api.ts | 67 ++++
9 files changed, 1152 insertions(+), 14 deletions(-)
diff --git a/console/src/api/catalog/views.ts b/console/src/api/catalog/views.ts
new file mode 100644
index 0000000..30aa7d1
--- /dev/null
+++ b/console/src/api/catalog/views.ts
@@ -0,0 +1,110 @@
+/*
+ * 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 { apiClient } from "../client"
+import type { ListViewsResponse, CreateViewRequest, LoadViewResult } from
"@/types/api"
+
+/**
+ * Encodes a namespace array to URL format.
+ * Namespace parts are separated by the unit separator character (0x1F).
+ */
+function encodeNamespace(namespace: string[]): string {
+ return namespace.join("\x1F")
+}
+
+export const viewsApi = {
+ /**
+ * List views in a namespace.
+ * @param prefix - The catalog name (prefix)
+ * @param namespace - Namespace array (e.g., ["accounting", "tax"])
+ */
+ list: async (
+ prefix: string,
+ namespace: string[]
+ ): Promise<Array<{ namespace: string[]; name: string }>> => {
+ const namespaceStr = encodeNamespace(namespace)
+ const response = await apiClient
+ .getCatalogClient()
+ .get<ListViewsResponse>(
+
`/${encodeURIComponent(prefix)}/namespaces/${encodeURIComponent(namespaceStr)}/views`
+ )
+ return response.data.identifiers
+ },
+
+ /**
+ * Get view details.
+ * @param prefix - The catalog name
+ * @param namespace - Namespace array (e.g., ["accounting", "tax"])
+ * @param viewName - View name
+ */
+ get: async (
+ prefix: string,
+ namespace: string[],
+ viewName: string
+ ): Promise<LoadViewResult> => {
+ const namespaceStr = encodeNamespace(namespace)
+ const response = await apiClient
+ .getCatalogClient()
+ .get<LoadViewResult>(
+
`/${encodeURIComponent(prefix)}/namespaces/${encodeURIComponent(namespaceStr)}/views/${encodeURIComponent(viewName)}`
+ )
+ return response.data
+ },
+
+ /**
+ * Delete a view.
+ * @param prefix - The catalog name
+ * @param namespace - Namespace array
+ * @param viewName - View name
+ */
+ delete: async (
+ prefix: string,
+ namespace: string[],
+ viewName: string
+ ): Promise<void> => {
+ const namespaceStr = encodeNamespace(namespace)
+ await apiClient
+ .getCatalogClient()
+ .delete(
+
`/${encodeURIComponent(prefix)}/namespaces/${encodeURIComponent(namespaceStr)}/views/${encodeURIComponent(viewName)}`
+ )
+ },
+
+ /**
+ * Create a view in a namespace.
+ * @param prefix - The catalog name
+ * @param namespace - Namespace array
+ * @param request - Create view request body
+ */
+ create: async (
+ prefix: string,
+ namespace: string[],
+ request: CreateViewRequest
+ ): Promise<LoadViewResult> => {
+ const namespaceStr = encodeNamespace(namespace)
+ const response = await apiClient
+ .getCatalogClient()
+ .post<LoadViewResult>(
+
`/${encodeURIComponent(prefix)}/namespaces/${encodeURIComponent(namespaceStr)}/views`,
+ request
+ )
+ return response.data
+ },
+}
+
diff --git a/console/src/app.tsx b/console/src/app.tsx
index 3446a1f..78a45e2 100644
--- a/console/src/app.tsx
+++ b/console/src/app.tsx
@@ -33,6 +33,7 @@ import { CatalogDetails } from "@/pages/CatalogDetails"
import { NamespaceDetails } from "@/pages/NamespaceDetails"
import { AccessControl } from "@/pages/AccessControl"
import { TableDetails } from "@/pages/TableDetails"
+import { ViewDetails } from "@/pages/ViewDetails"
function ThemedToaster() {
const { effectiveTheme } = useTheme()
@@ -70,6 +71,7 @@ function App() {
<Route path="/catalogs/:catalogName"
element={<CatalogDetails />} />
<Route path="/catalogs/:catalogName/namespaces/:namespace"
element={<NamespaceDetails />} />
<Route
path="/catalogs/:catalogName/namespaces/:namespace/tables/:tableName"
element={<TableDetails />} />
+ <Route
path="/catalogs/:catalogName/namespaces/:namespace/views/:viewName"
element={<ViewDetails />} />
<Route path="/access-control" element={<AccessControl />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
diff --git a/console/src/components/catalog/CatalogExplorer.tsx
b/console/src/components/catalog/CatalogExplorer.tsx
index e1c3b58..11670c0 100644
--- a/console/src/components/catalog/CatalogExplorer.tsx
+++ b/console/src/components/catalog/CatalogExplorer.tsx
@@ -25,6 +25,7 @@ import { cn } from "@/lib/utils"
import { CatalogTreeNode, type TreeNode } from "./CatalogTreeNode"
import { catalogsApi } from "@/api/management/catalogs"
import { TableDetailsDrawer } from "./TableDetailsDrawer"
+import { ViewDetailsDrawer } from "./ViewDetailsDrawer"
import { useResizableWidth } from "@/hooks/useResizableWidth"
import { CATALOG_NODE_PREFIX } from "@/lib/constants"
@@ -34,10 +35,18 @@ interface CatalogExplorerProps {
className?: string
}
-interface SelectedTable {
+const CatalogEntityType = {
+ TABLE: "table",
+ VIEW: "view",
+} as const
+
+type CatalogEntityType = typeof CatalogEntityType[keyof typeof
CatalogEntityType]
+
+interface SelectedCatalogEntity {
catalogName: string
namespace: string[]
- tableName: string
+ name: string
+ type: CatalogEntityType
}
export function CatalogExplorer({
@@ -49,7 +58,7 @@ export function CatalogExplorer({
const [selectedNodeId, setSelectedNodeId] = useState<string>()
const [isCollapsed, setIsCollapsed] = useState(false)
const [drawerOpen, setDrawerOpen] = useState(false)
- const [selectedTable, setSelectedTable] = useState<SelectedTable |
null>(null)
+ const [selectedCatalogEntity, setSelectedCatalogEntity] =
useState<SelectedCatalogEntity | null>(null)
// Use custom hook for resizable width
const { width, isResizing, handleMouseDown } = useResizableWidth()
@@ -83,9 +92,18 @@ export function CatalogExplorer({
const handleTableClick = useCallback((
catalogName: string,
namespace: string[],
- tableName: string
+ name: string
+ ) => {
+ setSelectedCatalogEntity({ catalogName, namespace, name, type:
CatalogEntityType.TABLE })
+ setDrawerOpen(true)
+ }, [])
+
+ const handleViewClick = useCallback((
+ catalogName: string,
+ namespace: string[],
+ name: string
) => {
- setSelectedTable({ catalogName, namespace, tableName })
+ setSelectedCatalogEntity({ catalogName, namespace, name, type:
CatalogEntityType.VIEW })
setDrawerOpen(true)
}, [])
@@ -179,6 +197,7 @@ export function CatalogExplorer({
onToggleExpand={handleToggleExpand}
onSelectNode={handleSelectNode}
onTableClick={handleTableClick}
+ onViewClick={handleViewClick}
/>
))}
</div>
@@ -214,15 +233,26 @@ export function CatalogExplorer({
)}
{/* Table Details Drawer */}
- {selectedTable && (
+ {selectedCatalogEntity && selectedCatalogEntity.type ===
CatalogEntityType.TABLE && (
<TableDetailsDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
- catalogName={selectedTable.catalogName}
- namespace={selectedTable.namespace}
- tableName={selectedTable.tableName}
+ catalogName={selectedCatalogEntity.catalogName}
+ namespace={selectedCatalogEntity.namespace}
+ tableName={selectedCatalogEntity.name}
/>
)}
+
+ {/* View Details Drawer */}
+ {selectedCatalogEntity && selectedCatalogEntity.type ===
CatalogEntityType.VIEW && (
+ <ViewDetailsDrawer
+ open={drawerOpen}
+ onOpenChange={setDrawerOpen}
+ catalogName={selectedCatalogEntity.catalogName}
+ namespace={selectedCatalogEntity.namespace}
+ viewName={selectedCatalogEntity.name}
+ />
+ )}
</>
)
}
diff --git a/console/src/components/catalog/CatalogTreeNode.tsx
b/console/src/components/catalog/CatalogTreeNode.tsx
index 6682330..d669e25 100644
--- a/console/src/components/catalog/CatalogTreeNode.tsx
+++ b/console/src/components/catalog/CatalogTreeNode.tsx
@@ -31,9 +31,10 @@ import {
import { cn } from "@/lib/utils"
import { namespacesApi } from "@/api/catalog/namespaces"
import { tablesApi } from "@/api/catalog/tables"
+import { viewsApi } from "@/api/catalog/views"
import type { Catalog } from "@/types/api"
-export type TreeNodeType = "catalog" | "namespace" | "table"
+export type TreeNodeType = "catalog" | "namespace" | "table" | "view"
export interface TreeNode {
type: TreeNodeType
@@ -52,6 +53,7 @@ interface CatalogTreeNodeProps {
onToggleExpand: (nodeId: string) => void
onSelectNode?: (node: TreeNode) => void
onTableClick?: (catalogName: string, namespace: string[], tableName: string)
=> void
+ onViewClick?: (catalogName: string, namespace: string[], viewName: string)
=> void
}
export function CatalogTreeNode({
@@ -62,6 +64,7 @@ export function CatalogTreeNode({
onToggleExpand,
onSelectNode,
onTableClick,
+ onViewClick,
}: CatalogTreeNodeProps) {
const isExpanded = expandedNodes.has(node.id)
const isSelected = selectedNodeId === node.id
@@ -121,6 +124,22 @@ export function CatalogTreeNode({
currentNamespacePath.length > 0,
})
+ // Fetch views when namespace is expanded
+ const viewsQuery = useQuery({
+ queryKey: [
+ "views",
+ node.catalogName || "",
+ currentNamespacePath.join(".") || "",
+ ],
+ queryFn: () =>
+ viewsApi.list(node.catalogName || "", currentNamespacePath),
+ enabled:
+ node.type === "namespace" &&
+ isExpanded &&
+ !!node.catalogName &&
+ currentNamespacePath.length > 0,
+ })
+
// Fetch generic tables when namespace is expanded
const genericTablesQuery = useQuery({
queryKey: [
@@ -152,6 +171,12 @@ export function CatalogTreeNode({
onTableClick?.(node.catalogName, node.namespace, node.name)
}
onSelectNode?.(node)
+ } else if (node.type === "view") {
+ // Open view details when clicking a view
+ if (node.catalogName && node.namespace && node.namespace.length > 0) {
+ onViewClick?.(node.catalogName, node.namespace, node.name)
+ }
+ onSelectNode?.(node)
}
}
@@ -261,6 +286,21 @@ export function CatalogTreeNode({
parent: node,
})
})
+
+ // Add views under namespace
+ // Views API returns identifiers like [{namespace: ["accounting"], name:
"sales_view"}]
+ const views = viewsQuery.data || []
+ views.forEach((view) => {
+ const namespaceId = `${node.id}.view.${view.name}`
+ children.push({
+ type: "view",
+ id: namespaceId,
+ name: view.name,
+ namespace: currentNamespacePath, // Full namespace path where view
resides
+ catalogName: node.catalogName,
+ parent: node,
+ })
+ })
}
return children
@@ -269,6 +309,7 @@ export function CatalogTreeNode({
namespacesQuery.data,
childNamespacesQuery.data,
tablesQuery.data,
+ viewsQuery.data,
genericTablesQuery.data,
currentNamespacePath,
])
@@ -276,7 +317,7 @@ export function CatalogTreeNode({
const isLoading =
(node.type === "catalog" && namespacesQuery.isLoading) ||
(node.type === "namespace" &&
- (childNamespacesQuery.isLoading || tablesQuery.isLoading ||
genericTablesQuery.isLoading))
+ (childNamespacesQuery.isLoading || tablesQuery.isLoading ||
viewsQuery.isLoading || genericTablesQuery.isLoading))
const Icon = useMemo(() => {
if (node.type === "catalog") return Database
@@ -327,11 +368,12 @@ export function CatalogTreeNode({
onToggleExpand={onToggleExpand}
onSelectNode={onSelectNode}
onTableClick={onTableClick}
+ onViewClick={onViewClick}
/>
))}
- {!isLoading && childNodes.length === 0 && node.type !== "table" && (
+ {!isLoading && childNodes.length === 0 && node.type !== "table" &&
node.type !== "view" && (
<div className="px-2 py-1 text-xs text-muted-foreground italic">
- No {node.type === "catalog" ? "namespaces" : "tables"} found
+ No {node.type === "catalog" ? "namespaces" : "items"} found
</div>
)}
</div>
diff --git a/console/src/components/catalog/ViewDetailsDrawer.tsx
b/console/src/components/catalog/ViewDetailsDrawer.tsx
new file mode 100644
index 0000000..91db8da
--- /dev/null
+++ b/console/src/components/catalog/ViewDetailsDrawer.tsx
@@ -0,0 +1,229 @@
+/*
+ * 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 { useQuery } from "@tanstack/react-query"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { viewsApi } from "@/api/catalog/views"
+import { Loader2 } from "lucide-react"
+import { TableSchemaDisplay } from "./TableSchemaDisplay"
+
+interface ViewDetailsDrawerProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ catalogName: string
+ namespace: string[]
+ viewName: string
+}
+
+export function ViewDetailsDrawer({
+ open,
+ onOpenChange,
+ catalogName,
+ namespace,
+ viewName,
+}: ViewDetailsDrawerProps) {
+ const viewQuery = useQuery({
+ queryKey: ["view", catalogName, namespace.join("."), viewName],
+ queryFn: () => viewsApi.get(catalogName, namespace, viewName),
+ enabled: open && !!catalogName && namespace.length > 0 && !!viewName,
+ })
+
+ const viewData = viewQuery.data
+
+ const currentVersion = viewData?.metadata?.versions?.find(
+ (v) => v["version-id"] === viewData?.metadata?.["current-version-id"]
+ )
+
+ const currentSchema = viewData?.metadata?.schemas?.find(
+ (s) => s["schema-id"] === currentVersion?.["schema-id"]
+ ) || viewData?.metadata?.schemas?.[0]
+
+ const currentSql = currentVersion?.representations?.find(
+ (r) => r.type === "sql"
+ )
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent side="right" className="w-full sm:max-w-2xl
overflow-y-auto">
+ <SheetHeader>
+ <SheetTitle className="text-xl font-bold">
+ {viewName}
+ </SheetTitle>
+ <SheetDescription>
+ {namespace.length > 0 ? (
+ <span className="text-sm text-muted-foreground">
+ {catalogName}.{namespace.join(".")}.{viewName}
+ </span>
+ ) : (
+ <span className="text-sm text-muted-foreground">
+ {catalogName}.{viewName}
+ </span>
+ )}
+ </SheetDescription>
+ </SheetHeader>
+
+ {viewQuery.isLoading && (
+ <div className="flex items-center justify-center py-12">
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+ </div>
+ )}
+
+ {viewQuery.isError && (
+ <div className="py-12">
+ <div className="text-sm text-destructive">
+ Failed to load view details
+ </div>
+ </div>
+ )}
+
+ {viewData && (
+ <div className="mt-6 space-y-6">
+ {/* View Info */}
+ <div>
+ <h3 className="text-sm font-semibold mb-2">View Information</h3>
+ <div className="space-y-1 text-sm">
+ <div className="flex justify-between">
+ <span className="text-muted-foreground">UUID:</span>
+ <span className="font-mono text-xs">
+ {viewData.metadata["view-uuid"]}
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="text-muted-foreground">Format
Version:</span>
+ <span>{viewData.metadata["format-version"]}</span>
+ </div>
+ <div className="flex justify-between">
+ <span className="text-muted-foreground">Current
Version:</span>
+ <span>{viewData.metadata["current-version-id"]}</span>
+ </div>
+ {currentSql && (
+ <div className="flex justify-between">
+ <span className="text-muted-foreground">SQL Dialect:</span>
+ <span>{currentSql.dialect}</span>
+ </div>
+ )}
+ {viewData.metadata.location && (
+ <div className="flex justify-between">
+ <span className="text-muted-foreground">Location:</span>
+ <span className="font-mono text-xs break-all">
+ {viewData.metadata.location}
+ </span>
+ </div>
+ )}
+ {viewData["metadata-location"] && (
+ <div className="flex justify-between">
+ <span className="text-muted-foreground">
+ Metadata Location:
+ </span>
+ <span className="font-mono text-xs break-all">
+ {viewData["metadata-location"]}
+ </span>
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* SQL Query */}
+ {currentSql && (
+ <div>
+ <h3 className="text-sm font-semibold mb-2">SQL Query</h3>
+ <pre className="bg-muted p-3 rounded-md overflow-x-auto
text-xs font-mono whitespace-pre-wrap">
+ {currentSql.sql}
+ </pre>
+ </div>
+ )}
+
+ {/* Schema */}
+ {currentSchema && (
+ <TableSchemaDisplay schema={currentSchema} />
+ )}
+
+ {/* Properties */}
+ {viewData.metadata.properties &&
+ Object.keys(viewData.metadata.properties).length > 0 && (
+ <div>
+ <h3 className="text-sm font-semibold mb-2">Properties</h3>
+ <div className="border rounded-md">
+ <div className="divide-y">
+ {Object.entries(viewData.metadata.properties).map(
+ ([key, value]) => (
+ <div
+ key={key}
+ className="px-3 py-2 flex justify-between text-sm"
+ >
+ <span
className="text-muted-foreground">{key}:</span>
+ <span className="font-mono text-xs break-all">
+ {String(value)}
+ </span>
+ </div>
+ )
+ )}
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Version History */}
+ {viewData.metadata.versions && viewData.metadata.versions.length >
0 && (
+ <div>
+ <h3 className="text-sm font-semibold mb-2">Version History</h3>
+ <div className="space-y-2">
+ {viewData.metadata.versions.map((version) => {
+ const sqlRep = version.representations?.find((r) => r.type
=== "sql")
+ const isCurrent = version["version-id"] ===
viewData.metadata["current-version-id"]
+ return (
+ <div
+ key={version["version-id"]}
+ className={`border rounded-md p-3 text-sm ${isCurrent
? "border-primary bg-primary/5" : ""}`}
+ >
+ <div className="flex items-center justify-between
mb-1">
+ <span className="font-medium">
+ Version {version["version-id"]}
+ {isCurrent && (
+ <span className="ml-2 text-xs
text-primary">(current)</span>
+ )}
+ </span>
+ <span className="text-xs text-muted-foreground">
+ {new
Date(version["timestamp-ms"]).toLocaleString()}
+ </span>
+ </div>
+ {sqlRep && (
+ <div className="text-xs">
+ <span
className="text-muted-foreground">Dialect:</span> {sqlRep.dialect}
+ </div>
+ )}
+ </div>
+ )
+ })}
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+ </SheetContent>
+ </Sheet>
+ )
+}
+
diff --git a/console/src/components/forms/CreateViewModal.tsx
b/console/src/components/forms/CreateViewModal.tsx
new file mode 100644
index 0000000..c73a845
--- /dev/null
+++ b/console/src/components/forms/CreateViewModal.tsx
@@ -0,0 +1,199 @@
+/*
+ * 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 { useForm } from "react-hook-form"
+import { z } from "zod"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useMutation } from "@tanstack/react-query"
+import { toast } from "sonner"
+import { viewsApi } from "@/api/catalog/views"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import type { CreateViewRequest } from "@/types/api"
+
+const schema = z.object({
+ name: z
+ .string()
+ .min(1, "View name is required")
+ .regex(
+ /^[a-zA-Z_][a-zA-Z0-9_]*$/,
+ "View name must start with a letter or underscore and contain only
alphanumeric characters and underscores"
+ ),
+ sql: z.string().min(1, "SQL query is required"),
+ dialect: z.string().min(1, "SQL dialect is required"),
+})
+
+type FormValues = z.infer<typeof schema>
+
+interface CreateViewModalProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ catalogName: string
+ namespace: string[]
+ onCreated?: () => void
+}
+
+export function CreateViewModal({
+ open,
+ onOpenChange,
+ catalogName,
+ namespace,
+ onCreated,
+}: CreateViewModalProps) {
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors },
+ } = useForm<FormValues>({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ name: "",
+ sql: "",
+ dialect: "spark",
+ },
+ })
+
+ const createMutation = useMutation({
+ mutationFn: (values: FormValues) => {
+ const request: CreateViewRequest = {
+ name: values.name,
+ schema: {
+ type: "struct",
+ fields: [],
+ },
+ "view-version": {
+ "version-id": 1,
+ "timestamp-ms": Date.now(),
+ "schema-id": 0,
+ summary: {
+ "engine-name": values.dialect,
+ "engine-version": "1.0.0",
+ },
+ representations: [
+ {
+ type: "sql",
+ sql: values.sql,
+ dialect: values.dialect,
+ },
+ ],
+ "default-namespace": namespace,
+ },
+ properties: {},
+ }
+ return viewsApi.create(catalogName, namespace, request)
+ },
+ onSuccess: () => {
+ toast.success("Iceberg view created successfully")
+ onOpenChange(false)
+ reset()
+ onCreated?.()
+ },
+ onError: (error: Error) => {
+ toast.error("Failed to create Iceberg view", {
+ description: error.message || "An error occurred",
+ })
+ },
+ })
+
+ const onSubmit = (values: FormValues) => {
+ createMutation.mutate(values)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle>Create Iceberg View</DialogTitle>
+ <DialogDescription>
+ Create a new Iceberg view in the namespace "{namespace.join(".")}".
+ </DialogDescription>
+ </DialogHeader>
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+ <div className="space-y-2">
+ <Label htmlFor="name">View Name</Label>
+ <Input
+ id="name"
+ placeholder="my_view"
+ {...register("name")}
+ />
+ {errors.name && (
+ <p className="text-sm text-red-600">{errors.name.message}</p>
+ )}
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="dialect">SQL Dialect</Label>
+ <Input
+ id="dialect"
+ placeholder="spark"
+ {...register("dialect")}
+ />
+ {errors.dialect && (
+ <p className="text-sm text-red-600">{errors.dialect.message}</p>
+ )}
+ <p className="text-xs text-muted-foreground">
+ The SQL dialect used for the view (e.g., spark, trino, presto)
+ </p>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="sql">SQL Query</Label>
+ <Textarea
+ id="sql"
+ placeholder="SELECT * FROM my_table WHERE ..."
+ className="min-h-[150px] font-mono text-sm"
+ {...register("sql")}
+ />
+ {errors.sql && (
+ <p className="text-sm text-red-600">{errors.sql.message}</p>
+ )}
+ <p className="text-xs text-muted-foreground">
+ The SQL query that defines this view
+ </p>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={createMutation.isPending}>
+ {createMutation.isPending ? "Creating..." : "Create View"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/console/src/pages/NamespaceDetails.tsx
b/console/src/pages/NamespaceDetails.tsx
index 8aeda8d..01c8d10 100644
--- a/console/src/pages/NamespaceDetails.tsx
+++ b/console/src/pages/NamespaceDetails.tsx
@@ -23,6 +23,7 @@ import { toast } from "sonner"
import { ArrowLeft, Folder, Table as TableIcon, RefreshCw, Trash2, Plus } from
"lucide-react"
import { namespacesApi } from "@/api/catalog/namespaces"
import { tablesApi } from "@/api/catalog/tables"
+import { viewsApi } from "@/api/catalog/views"
import { catalogsApi } from "@/api/management/catalogs"
import type { GenericTableIdentifier } from "@/types/api"
import { Button } from "@/components/ui/button"
@@ -46,6 +47,7 @@ import {
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useState } from "react"
+import { CreateViewModal } from "@/components/forms/CreateViewModal"
export function NamespaceDetails() {
const { catalogName, namespace: namespaceParam } = useParams<{
@@ -56,6 +58,7 @@ export function NamespaceDetails() {
const queryClient = useQueryClient()
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [createGenericTableOpen, setCreateGenericTableOpen] = useState(false)
+ const [createViewOpen, setCreateViewOpen] = useState(false)
const [genericTableForm, setGenericTableForm] = useState({
name: "",
format: "",
@@ -96,6 +99,12 @@ export function NamespaceDetails() {
enabled: !!catalogName && namespaceArray.length > 0,
})
+ const viewsQuery = useQuery({
+ queryKey: ["views", catalogName, namespaceArray],
+ queryFn: () => viewsApi.list(catalogName!, namespaceArray),
+ enabled: !!catalogName && namespaceArray.length > 0,
+ })
+
const deleteMutation = useMutation({
mutationFn: () => namespacesApi.delete(catalogName!, namespaceArray),
onSuccess: () => {
@@ -149,6 +158,7 @@ export function NamespaceDetails() {
const namespace = namespaceQuery.data
const tables = tablesQuery.data ?? []
const childrenNamespaces = childrenNamespacesQuery.data ?? []
+ const views = viewsQuery.data ?? []
const handleTableClick = (tableName: string) => {
if (!catalogName || !namespaceParam) return
@@ -165,6 +175,13 @@ export function NamespaceDetails() {
)
}
+ const handleViewClick = (viewName: string) => {
+ if (!catalogName || !namespaceParam) return
+ navigate(
+
`/catalogs/${encodeURIComponent(catalogName)}/namespaces/${encodeURIComponent(namespaceParam)}/views/${encodeURIComponent(viewName)}`
+ )
+ }
+
const handleDelete = () => {
deleteMutation.mutate()
}
@@ -203,8 +220,9 @@ export function NamespaceDetails() {
catalogQuery.refetch()
childrenNamespacesQuery.refetch()
polarisGenericTablesQuery.refetch()
+ viewsQuery.refetch()
}}
- disabled={namespaceQuery.isFetching || tablesQuery.isFetching ||
childrenNamespacesQuery.isFetching || polarisGenericTablesQuery.isFetching}
+ disabled={namespaceQuery.isFetching || tablesQuery.isFetching ||
childrenNamespacesQuery.isFetching || polarisGenericTablesQuery.isFetching ||
viewsQuery.isFetching}
>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
@@ -408,6 +426,86 @@ export function NamespaceDetails() {
</>
)}
+ {/* Iceberg Views Section */}
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle>Iceberg Views</CardTitle>
+ <CardDescription>
+ Iceberg views in this namespace
+ </CardDescription>
+ </div>
+ <Button
+ onClick={() => setCreateViewOpen(true)}
+ disabled={!catalogName || !namespaceParam}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ Iceberg View
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent>
+ {viewsQuery.isLoading ? (
+ <div>Loading Iceberg views...</div>
+ ) : viewsQuery.error ? (
+ <div className="text-red-600">
+ Error loading Iceberg views: {viewsQuery.error.message}
+ </div>
+ ) : views.length === 0 ? (
+ <div className="text-center text-muted-foreground py-8">
+ No Iceberg views found in this namespace.
+ </div>
+ ) : (
+ <div className="rounded-md border">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>Name</TableHead>
+ <TableHead>Namespace</TableHead>
+ <TableHead>Actions</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {views.map((view, idx) => {
+ const viewNamespace = view.namespace.join(".")
+ return (
+ <TableRow
+ key={idx}
+ className="cursor-pointer hover:bg-muted/50"
+ onClick={() => handleViewClick(view.name)}
+ >
+ <TableCell>
+ <div className="flex items-center gap-2">
+ <TableIcon className="h-4 w-4
text-muted-foreground" />
+ <span className="font-medium">{view.name}</span>
+ </div>
+ </TableCell>
+ <TableCell>
+ <span className="text-muted-foreground
text-sm">{viewNamespace}</span>
+ </TableCell>
+ <TableCell>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleViewClick(view.name)
+ }}
+ >
+ View Details
+ </Button>
+ </TableCell>
+ </TableRow>
+ )
+ })}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
{/* Polaris Generic Tables Section */}
<Card>
<CardHeader>
@@ -599,6 +697,17 @@ export function NamespaceDetails() {
</DialogFooter>
</DialogContent>
</Dialog>
+
+ {/* Create Iceberg View Modal */}
+ <CreateViewModal
+ open={createViewOpen}
+ onOpenChange={setCreateViewOpen}
+ catalogName={catalogName!}
+ namespace={namespaceArray}
+ onCreated={() => {
+ viewsQuery.refetch()
+ }}
+ />
</div>
)
}
diff --git a/console/src/pages/ViewDetails.tsx
b/console/src/pages/ViewDetails.tsx
new file mode 100644
index 0000000..f50f35a
--- /dev/null
+++ b/console/src/pages/ViewDetails.tsx
@@ -0,0 +1,350 @@
+/*
+ * 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 { useState } from "react"
+import { useParams, useNavigate, Link } from "react-router-dom"
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
+import { ArrowLeft, RefreshCw, Eye, Trash2 } from "lucide-react"
+import { viewsApi } from "@/api/catalog/views"
+import { catalogsApi } from "@/api/management/catalogs"
+import { namespacesApi } from "@/api/catalog/namespaces"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from
"@/components/ui/card"
+import { SchemaViewer } from "@/components/table/SchemaViewer"
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader,
DialogTitle } from "@/components/ui/dialog"
+import { toast } from "sonner"
+
+export function ViewDetails() {
+ const { catalogName, namespace: namespaceParam, viewName } = useParams<{
+ catalogName: string
+ namespace: string
+ viewName: string
+ }>()
+
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+
+ const namespaceArray = namespaceParam?.split(".") || []
+
+ const catalogQuery = useQuery({
+ queryKey: ["catalog", catalogName],
+ queryFn: () => catalogsApi.get(catalogName!),
+ enabled: !!catalogName,
+ })
+
+ const namespaceQuery = useQuery({
+ queryKey: ["namespace", catalogName, namespaceArray],
+ queryFn: () => namespacesApi.get(catalogName!, namespaceArray),
+ enabled: !!catalogName && namespaceArray.length > 0,
+ })
+
+ const viewQuery = useQuery({
+ queryKey: ["view", catalogName, namespaceArray.join("."), viewName],
+ queryFn: () => viewsApi.get(catalogName!, namespaceArray, viewName!),
+ enabled: !!catalogName && namespaceArray.length > 0 && !!viewName,
+ })
+
+ // Modals
+ const [deleteOpen, setDeleteOpen] = useState(false)
+
+ // Delete mutation
+ const deleteMutation = useMutation({
+ mutationFn: () => viewsApi.delete(catalogName!, namespaceArray, viewName!),
+ onSuccess: () => {
+ toast.success("View deleted successfully")
+ queryClient.invalidateQueries({ queryKey: ["views", catalogName,
namespaceArray] })
+ queryClient.invalidateQueries({ queryKey: ["namespace", catalogName,
namespaceArray] })
+
navigate(`/catalogs/${encodeURIComponent(catalogName!)}/namespaces/${encodeURIComponent(namespaceParam!)}`)
+ },
+ onError: (error: Error) => {
+ toast.error("Failed to delete view", {
+ description: error.message || "An error occurred",
+ })
+ },
+ })
+
+ if (!catalogName || !namespaceParam || !viewName) {
+ return <div>Catalog, namespace, and view name are required</div>
+ }
+
+ const nsPath = namespaceArray.join(".")
+ const refreshDisabled = viewQuery.isFetching || namespaceQuery.isFetching ||
catalogQuery.isFetching
+
+ const viewData = viewQuery.data
+
+ const handleDelete = () => {
+ deleteMutation.mutate()
+ }
+
+ const currentSchema = viewData?.metadata?.schemas?.find(
+ (s) => s["schema-id"] === viewData.metadata["current-version-id"]
+ ) || viewData?.metadata?.schemas?.[0]
+
+ const currentVersion = viewData?.metadata?.versions?.find(
+ (v) => v["version-id"] === viewData.metadata["current-version-id"]
+ )
+
+ const currentSql = currentVersion?.representations?.find(
+ (r) => r.type === "sql"
+ )
+
+ return (
+ <div className="p-6 md:p-8 space-y-6 overflow-y-auto">
+ {/* Header */}
+ <div className="flex items-start justify-between gap-4">
+ <div className="flex items-center gap-4">
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() =>
+ navigate(
+
`/catalogs/${encodeURIComponent(catalogName)}/namespaces/${encodeURIComponent(namespaceParam)}`
+ )
+ }
+ >
+ <ArrowLeft className="h-4 w-4" />
+ </Button>
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <Eye className="h-6 w-6" />
+ <h1 className="text-2xl font-bold">{viewName}</h1>
+ </div>
+ <p className="text-muted-foreground">
+ <Link
+ to={`/catalogs/${encodeURIComponent(catalogName)}`}
+ className="underline-offset-2 hover:underline"
+ >
+ {catalogQuery.data?.name || catalogName}
+ </Link>
+ <span className="mx-1">/</span>
+ <Link
+
to={`/catalogs/${encodeURIComponent(catalogName)}/namespaces/${encodeURIComponent(namespaceParam)}`}
+ className="underline-offset-2 hover:underline"
+ >
+ {nsPath}
+ </Link>
+ <span className="mx-1">/</span>
+ <span className="font-medium">{viewName}</span>
+ </p>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="secondary"
+ onClick={() => {
+ viewQuery.refetch()
+ namespaceQuery.refetch()
+ catalogQuery.refetch()
+ }}
+ disabled={refreshDisabled}
+ >
+ <RefreshCw className="mr-2 h-4 w-4" />
+ Refresh
+ </Button>
+ <Button variant="destructive" onClick={() => setDeleteOpen(true)}
disabled={!viewData}>
+ <Trash2 className="mr-2 h-4 w-4" /> Delete
+ </Button>
+ </div>
+ </div>
+
+ {viewQuery.isLoading ? (
+ <div>Loading view details...</div>
+ ) : viewQuery.error ? (
+ <div className="text-red-600">Error loading view:
{viewQuery.error.message}</div>
+ ) : !viewData ? (
+ <div>View not found</div>
+ ) : (
+ <>
+ {/* View Information */}
+ <Card>
+ <CardHeader>
+ <CardTitle>View Information</CardTitle>
+ <CardDescription>Core metadata for this Iceberg
view</CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
+ <div>
+ <label className="text-sm font-medium
text-muted-foreground">UUID</label>
+ <p className="mt-1 text-sm
font-mono">{viewData.metadata["view-uuid"]}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium
text-muted-foreground">Format Version</label>
+ <p className="mt-1
text-sm">{viewData.metadata["format-version"]}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium
text-muted-foreground">Current Version ID</label>
+ <p className="mt-1
text-sm">{viewData.metadata["current-version-id"]}</p>
+ </div>
+ {currentSql && (
+ <div>
+ <label className="text-sm font-medium
text-muted-foreground">SQL Dialect</label>
+ <p className="mt-1 text-sm">{currentSql.dialect}</p>
+ </div>
+ )}
+ <div className="md:col-span-2">
+ <label className="text-sm font-medium
text-muted-foreground">Location</label>
+ <p className="mt-1 text-sm font-mono
break-all">{viewData.metadata.location}</p>
+ </div>
+ {viewData["metadata-location"] && (
+ <div className="md:col-span-2">
+ <label className="text-sm font-medium
text-muted-foreground">Metadata Location</label>
+ <p className="mt-1 text-sm font-mono
break-all">{viewData["metadata-location"]}</p>
+ </div>
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* SQL Query */}
+ {currentSql && (
+ <Card>
+ <CardHeader>
+ <CardTitle>SQL Query</CardTitle>
+ <CardDescription>The SQL definition of this
view</CardDescription>
+ </CardHeader>
+ <CardContent>
+ <pre className="bg-muted p-4 rounded-md overflow-x-auto
text-sm font-mono whitespace-pre-wrap">
+ {currentSql.sql}
+ </pre>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* Schema */}
+ {currentSchema && (
+ <Card>
+ <CardHeader>
+ <CardTitle>Schema</CardTitle>
+ <CardDescription>Output schema of this view</CardDescription>
+ </CardHeader>
+ <CardContent>
+ <SchemaViewer schema={currentSchema} />
+ </CardContent>
+ </Card>
+ )}
+
+ {/* Properties */}
+ {viewData.metadata.properties &&
Object.keys(viewData.metadata.properties).length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle>Properties</CardTitle>
+ <CardDescription>View properties</CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="border rounded-md overflow-hidden">
+ <table className="w-full text-sm">
+ <thead className="bg-muted/50">
+ <tr>
+ <th className="px-3 py-2 text-left
font-medium">Key</th>
+ <th className="px-3 py-2 text-left
font-medium">Value</th>
+ </tr>
+ </thead>
+ <tbody className="divide-y">
+ {Object.entries(viewData.metadata.properties).map(([key,
value]) => (
+ <tr key={key} className="hover:bg-muted/50">
+ <td className="px-3 py-2 font-mono
text-xs">{key}</td>
+ <td className="px-3 py-2 text-xs
break-all">{String(value)}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* Version History */}
+ {viewData.metadata.versions && viewData.metadata.versions.length > 0
&& (
+ <Card>
+ <CardHeader>
+ <CardTitle>Version History</CardTitle>
+ <CardDescription>All versions of this view</CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-3">
+ {viewData.metadata.versions.map((version) => {
+ const sqlRep = version.representations?.find((r) => r.type
=== "sql")
+ const isCurrent = version["version-id"] ===
viewData.metadata["current-version-id"]
+ return (
+ <div
+ key={version["version-id"]}
+ className={`border rounded-md p-3 ${isCurrent ?
"border-primary bg-primary/5" : ""}`}
+ >
+ <div className="flex items-center justify-between
mb-2">
+ <span className="font-medium">
+ Version {version["version-id"]}
+ {isCurrent && (
+ <span className="ml-2 text-xs
text-primary">(current)</span>
+ )}
+ </span>
+ <span className="text-xs text-muted-foreground">
+ {new
Date(version["timestamp-ms"]).toLocaleString()}
+ </span>
+ </div>
+ {sqlRep && (
+ <div className="text-xs">
+ <span
className="text-muted-foreground">Dialect:</span> {sqlRep.dialect}
+ </div>
+ )}
+ </div>
+ )
+ })}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </>
+ )}
+
+ {/* Delete Confirmation */}
+ {viewData && (
+ <Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete view</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete "{viewName}"? This action
cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setDeleteOpen(false)}
+ disabled={deleteMutation.isPending}
+ >
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDelete}
+ disabled={deleteMutation.isPending}
+ >
+ {deleteMutation.isPending ? "Deleting..." : "Delete"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )}
+ </div>
+ )
+}
+
+export default ViewDetails
diff --git a/console/src/types/api.ts b/console/src/types/api.ts
index 6d35596..ded04a9 100644
--- a/console/src/types/api.ts
+++ b/console/src/types/api.ts
@@ -253,6 +253,73 @@ export interface ListTablesResponse {
nextPageToken?: string
}
+// Views
+export interface ListViewsResponse {
+ identifiers: Array<{
+ namespace: string[]
+ name: string
+ }>
+ nextPageToken?: string
+}
+
+export interface ViewSchemaField {
+ id: number
+ name: string
+ type: string
+ required: boolean
+ doc?: string
+}
+
+export interface IcebergSchema {
+ type: "struct"
+ fields: ViewSchemaField[]
+ "schema-id"?: number
+ "identifier-field-ids"?: number[]
+}
+
+export interface SQLViewRepresentation {
+ type: "sql"
+ sql: string
+ dialect: string
+}
+
+export interface ViewVersion {
+ "version-id": number
+ "timestamp-ms": number
+ "schema-id": number
+ summary: Record<string, string>
+ representations: SQLViewRepresentation[]
+ "default-namespace": string[]
+}
+
+export interface CreateViewRequest {
+ name: string
+ location?: string
+ schema: IcebergSchema
+ "view-version": ViewVersion
+ properties: Record<string, string>
+}
+
+export interface ViewMetadata {
+ "view-uuid": string
+ "format-version": number
+ location: string
+ "current-version-id": number
+ versions: ViewVersion[]
+ "version-log": Array<{
+ "version-id": number
+ "timestamp-ms": number
+ }>
+ schemas: IcebergSchema[]
+ properties?: Record<string, string>
+}
+
+export interface LoadViewResult {
+ "metadata-location": string
+ metadata: ViewMetadata
+ config?: Record<string, string>
+}
+
// LoadTableResult - response from GET
/v1/{prefix}/namespaces/{namespace}/tables/{table}
export interface LoadTableResult {
"metadata-location"?: string | null