Copilot commented on code in PR #9923:
URL: https://github.com/apache/gravitino/pull/9923#discussion_r2782048889
##########
web-v2/web/src/lib/store/metalakes/index.js:
##########
@@ -247,92 +248,128 @@ export const setIntoTreeNodeWithFetch = createAsyncThunk(
const loaded = loadedNodes.filter(key => !reloadedKeys.includes(key))
dispatch(setLoadedNodes(loaded))
}
- } else if (pathArr.length === 4 && type === 'relational') {
- const [err, res] = await to(getTablesApi({ metalake, catalog, schema }))
+ } else if (pathArr.length === 4) {
+ const [funcErr, funcRes] = await to(getFunctionsApi({ metalake, catalog,
schema, details: false }))
- if (err || !res) {
- throw new Error(err)
+ if (funcErr || !funcRes) {
+ throw new Error(funcErr)
}
- const { identifiers = [] } = res || {}
-
- result.data = identifiers.map(tableItem => {
+ const { identifiers: functionIdentifiers = [] } = funcRes || {}
+ const functions = functionIdentifiers.map(functionItem => {
+ const functionName = functionItem?.name ||
functionItem?.identifier?.name || functionItem
return {
- ...tableItem,
- node: 'table',
- id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${tableItem.name}}}`,
- key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${tableItem.name}}}`,
- path: `?${new URLSearchParams({ metalake, catalog, catalogType:
type, schema, table: tableItem.name }).toString()}`,
- name: tableItem.name,
- title: tableItem.name,
+ ...functionItem,
+ node: 'function',
+ id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${functionName}}}`,
+ key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${functionName}}}`,
+ path: `?${new URLSearchParams({ metalake, catalog, catalogType:
type, schema, function: functionName }).toString()}`,
+ name: functionName,
+ title: functionName,
isLeaf: true,
- columns: [],
children: []
}
})
- } else if (pathArr.length === 4 && type === 'fileset') {
- const [err, res] = await to(getFilesetsApi({ metalake, catalog, schema
}))
- if (err || !res) {
- throw new Error(err)
- }
+ switch (type) {
+ case 'relational': {
+ const [tableErr, tableRes] = await to(getTablesApi({ metalake,
catalog, schema }))
+ if (tableErr || !tableRes) {
+ throw new Error(tableErr)
+ }
- const { identifiers = [] } = res || {}
+ const { identifiers: tableIdentifiers = [] } = tableRes || {}
+ const tables = tableIdentifiers.map(tableItem => {
+ return {
+ ...tableItem,
+ node: 'table',
+ id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${tableItem.name}}}`,
+ key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${tableItem.name}}}`,
+ path: `?${new URLSearchParams({ metalake, catalog, catalogType:
type, schema, table: tableItem.name }).toString()}`,
+ name: tableItem.name,
+ title: tableItem.name,
+ isLeaf: true,
+ columns: [],
+ children: []
+ }
+ })
- result.data = identifiers.map(filesetItem => {
- return {
- ...filesetItem,
- node: 'fileset',
- id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${filesetItem.name}}}`,
- key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${filesetItem.name}}}`,
- path: `?${new URLSearchParams({ metalake, catalog, catalogType:
type, schema, fileset: filesetItem.name }).toString()}`,
- name: filesetItem.name,
- title: filesetItem.name,
- isLeaf: true
+ result.data = [...tables, ...functions]
+ break
}
- })
- } else if (pathArr.length === 4 && type === 'messaging') {
- const [err, res] = await to(getTopicsApi({ metalake, catalog, schema }))
-
- if (err || !res) {
- throw new Error(err)
- }
+ case 'fileset': {
+ const [filesetErr, filesetRes] = await to(getFilesetsApi({ metalake,
catalog, schema }))
+ if (filesetErr || !filesetRes) {
+ throw new Error(filesetErr)
+ }
- const { identifiers = [] } = res || {}
+ const { identifiers: filesetIdentifiers = [] } = filesetRes || {}
+ const filesets = filesetIdentifiers.map(filesetItem => {
+ return {
+ ...filesetItem,
+ node: 'fileset',
+ id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${filesetItem.name}}}`,
+ key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${filesetItem.name}}}`,
+ path: `?${new URLSearchParams({ metalake, catalog, catalogType:
type, schema, fileset: filesetItem.name }).toString()}`,
+ name: filesetItem.name,
+ title: filesetItem.name,
+ isLeaf: true
+ }
+ })
- result.data = identifiers.map(topicItem => {
- return {
- ...topicItem,
- node: 'topic',
- id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${topicItem.name}}}`,
- key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${topicItem.name}}}`,
- path: `?${new URLSearchParams({ metalake, catalog, catalogType:
type, schema, topic: topicItem.name }).toString()}`,
- name: topicItem.name,
- title: topicItem.name,
- isLeaf: true
+ result.data = [...filesets, ...functions]
+ break
}
- })
- } else if (pathArr.length === 4 && type === 'model') {
- const [err, res] = await to(getModelsApi({ metalake, catalog, schema }))
+ case 'messaging': {
+ const [topicErr, topicRes] = await to(getTopicsApi({ metalake,
catalog, schema }))
+ if (topicErr || !topicRes) {
+ throw new Error(topicErr)
+ }
- if (err || !res) {
- throw new Error(err)
- }
+ const { identifiers: topicIdentifiers = [] } = topicRes || {}
+ const topics = topicIdentifiers.map(topicItem => {
+ return {
+ ...topicItem,
+ node: 'topic',
+ id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${topicItem.name}}}`,
+ key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${topicItem.name}}}`,
+ path: `?${new URLSearchParams({ metalake, catalog, catalogType:
type, schema, topic: topicItem.name }).toString()}`,
+ name: topicItem.name,
+ title: topicItem.name,
+ isLeaf: true
+ }
+ })
- const { identifiers = [] } = res || {}
+ result.data = [...topics, ...functions]
+ break
+ }
+ case 'model': {
+ const [modelErr, modelRes] = await to(getModelsApi({ metalake,
catalog, schema }))
+ if (modelErr || !modelRes) {
+ throw new Error(modelErr)
+ }
- result.data = identifiers.map(modelItem => {
- return {
- ...modelItem,
- node: 'model',
- id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${modelItem.name}}}`,
- key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${modelItem.name}}}`,
- path: `?${new URLSearchParams({ metalake, catalog, catalogType:
type, schema, model: modelItem.name }).toString()}`,
- name: modelItem.name,
- title: modelItem.name,
- isLeaf: true
+ const { identifiers: modelIdentifiers = [] } = modelRes || {}
+ const models = modelIdentifiers.map(modelItem => {
+ return {
+ ...modelItem,
+ node: 'model',
+ id:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${modelItem.name}}}`,
+ key:
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${modelItem.name}}}`,
+ path: `?${new URLSearchParams({ metalake, catalog, catalogType:
type, schema, model: modelItem.name }).toString()}`,
+ name: modelItem.name,
+ title: modelItem.name,
+ isLeaf: true
+ }
+ })
+
+ result.data = [...models, ...functions]
+ break
}
- })
+ default:
+ result.data = functions
+ break
+ }
Review Comment:
`setIntoTreeNodeWithFetch` appends `functions` under *all* catalog types
(`fileset`, `messaging`, `model`, and `default`). However the UI
routing/details handling appears to only support function details when
`catalogType === 'relational'` (see `RightContent`). This can produce function
nodes in the tree that navigate to unsupported pages/empty details. Either
limit function nodes to `relational` schemas, or extend the routing/details
pages to support `function` for the other catalog types.
##########
web-v2/web/src/app/catalogs/rightContent/entitiesContent/Functions.js:
##########
@@ -0,0 +1,101 @@
+/*
+ * 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.
+ */
+
+'use client'
+
+import { useEffect, useMemo } from 'react'
+import { Spin, Table } from 'antd'
+import { useAntdColumnResize } from 'react-antd-column-resize'
+import { useAppDispatch, useAppSelector } from '@/lib/hooks/useStore'
+import { fetchFunctions } from '@/lib/store/metalakes'
+import { formatToDateTime, isValidDate } from '@/lib/utils/date'
+import Link from 'next/link'
+
+export default function Functions({ metalake, catalog, schema }) {
+ const dispatch = useAppDispatch()
+ const store = useAppSelector(state => state.metalakes)
+
+ useEffect(() => {
+ if (!metalake || !catalog || !schema) return
+ dispatch(fetchFunctions({ metalake, catalog, schema, init: true }))
+ }, [dispatch, metalake, catalog, schema])
+
+ const columns = useMemo(
+ () => [
+ {
+ title: 'Name',
+ dataIndex: 'name',
+ key: 'name',
+ width: 240,
+ ellipsis: true,
+ sorter: (a, b) =>
a?.name.toLowerCase().localeCompare(b?.name.toLowerCase()),
+ render: name => (
+ <Link
+ data-refer={`function-link-${name}`}
+
href={`/catalogs?metalake=${encodeURIComponent(metalake)}&catalogType=relational&catalog=${encodeURIComponent(catalog)}&schema=${encodeURIComponent(schema)}&function=${encodeURIComponent(name)}`}
+ >
+ {name}
+ </Link>
+ )
+ },
Review Comment:
The `columns` memo is created with an empty dependency array, but the column
`render` builds a Link using `metalake`, `catalog`, and `schema` props. If
those props change without an unmount, the Links will keep pointing at the old
values. Include these props in the `useMemo` dependency list (or avoid
memoizing the columns).
##########
web-v2/web/src/lib/store/metalakes/index.js:
##########
@@ -247,92 +248,128 @@ export const setIntoTreeNodeWithFetch = createAsyncThunk(
const loaded = loadedNodes.filter(key => !reloadedKeys.includes(key))
dispatch(setLoadedNodes(loaded))
}
- } else if (pathArr.length === 4 && type === 'relational') {
- const [err, res] = await to(getTablesApi({ metalake, catalog, schema }))
+ } else if (pathArr.length === 4) {
+ const [funcErr, funcRes] = await to(getFunctionsApi({ metalake, catalog,
schema, details: false }))
- if (err || !res) {
- throw new Error(err)
+ if (funcErr || !funcRes) {
+ throw new Error(funcErr)
}
Review Comment:
In `setIntoTreeNodeWithFetch` (schema children load), `getFunctionsApi(...)`
is called unconditionally and the thunk throws if it fails. This means a
failure/404 from the functions endpoint prevents tables/filesets/topics/models
from loading in the tree at all. Consider scoping the functions request to
supported catalog types (likely `relational`), or treating function-list errors
as non-fatal (e.g., fall back to the non-function entities and optionally
toast/log).
##########
web-v2/web/src/app/catalogs/rightContent/entitiesContent/Functions.js:
##########
@@ -0,0 +1,101 @@
+/*
+ * 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.
+ */
+
+'use client'
+
+import { useEffect, useMemo } from 'react'
+import { Spin, Table } from 'antd'
+import { useAntdColumnResize } from 'react-antd-column-resize'
+import { useAppDispatch, useAppSelector } from '@/lib/hooks/useStore'
+import { fetchFunctions } from '@/lib/store/metalakes'
+import { formatToDateTime, isValidDate } from '@/lib/utils/date'
+import Link from 'next/link'
+
+export default function Functions({ metalake, catalog, schema }) {
+ const dispatch = useAppDispatch()
+ const store = useAppSelector(state => state.metalakes)
+
+ useEffect(() => {
+ if (!metalake || !catalog || !schema) return
+ dispatch(fetchFunctions({ metalake, catalog, schema, init: true }))
+ }, [dispatch, metalake, catalog, schema])
+
+ const columns = useMemo(
+ () => [
+ {
+ title: 'Name',
+ dataIndex: 'name',
+ key: 'name',
+ width: 240,
+ ellipsis: true,
+ sorter: (a, b) =>
a?.name.toLowerCase().localeCompare(b?.name.toLowerCase()),
Review Comment:
The Name sorter calls `a?.name.toLowerCase()` / `b?.name.toLowerCase()`
without guarding for `name` being undefined/non-string, which can throw at
runtime. Use a safe fallback (e.g., `(a?.name ?? '').toString().toLowerCase()`).
```suggestion
sorter: (a, b) => {
const aName = (a?.name ?? '').toString().toLowerCase()
const bName = (b?.name ?? '').toString().toLowerCase()
return aName.localeCompare(bName)
},
```
##########
web-v2/web/src/app/catalogs/TreeComponent.js:
##########
@@ -292,6 +292,12 @@ export const TreeComponent = forwardRef(function
TreeComponent(props, ref) {
<Icons.iconify icon='mdi:globe-model' className='my-icon-small' />
</span>
)
+ case 'function':
+ return (
+ <span role='img' aria-label='frown' className='anticon
anticon-frown'>
+ <Icons.iconify icon='material-symbols:function'
className='my-icon-small' />
+ </span>
Review Comment:
The function tree icon uses `aria-label='frown'`, which is not descriptive
of the icon’s meaning and is confusing for screen readers. Use a meaningful
label (e.g., "function") for this new node type (and ideally align the other
node icons similarly over time).
##########
web-v2/web/src/lib/store/metalakes/index.js:
##########
@@ -1613,6 +1650,32 @@ export const deleteVersion = createAsyncThunk(
}
)
+export const fetchFunctions = createAsyncThunk(
+ 'appMetalakes/fetchFunctions',
+ async ({ init, metalake, catalog, schema }, { dispatch }) => {
+ if (init) {
+ await dispatch(setTableLoading(true))
+ }
+
+ const [err, res] = await to(getFunctionsApi({ metalake, catalog, schema,
details: true }))
+ await dispatch(setTableLoading(false))
Review Comment:
`fetchFunctions` always dispatches `setTableLoading(false)` even when `init`
is false (i.e., when it didn't set loading to true). This can inadvertently
clear loading state that was set by other thunks. Gate the "set false" call the
same way as the "set true" call, or move functions to their own loading flag.
```suggestion
if (init) {
await dispatch(setTableLoading(false))
}
```
##########
web-v2/web/src/lib/store/metalakes/index.js:
##########
@@ -2103,6 +2168,14 @@ export const appMetalakesSlice = createSlice({
toast.error(action.error.message)
}
})
+ builder.addCase(fetchFunctions.fulfilled, (state, action) => {
+ state.functions = action.payload.functions
Review Comment:
`fetchFunctions.fulfilled` overwrites `state.functions` regardless of which
schema is currently active, and there is no clear-on-pending behavior. If users
switch schemas quickly, an out-of-order response can populate functions for the
previous schema. Consider tracking the active `{metalake,catalog,schema}` for
the functions list and ignoring stale responses (or using `abort`/requestId
checks in the thunk).
```suggestion
const requestArgs = action.meta && action.meta.arg ? action.meta.arg :
{}
const { metalake, catalog, schema } = requestArgs
const { metalakeInUse, catalogInUse, schemaInUse } = state
// If we don't have an active selection recorded, preserve existing
behavior.
if (
metalakeInUse === undefined ||
catalogInUse === undefined ||
schemaInUse === undefined
) {
state.functions = action.payload.functions
return
}
// Only update functions if the response still matches the active
selection.
if (
metalake === metalakeInUse &&
catalog === catalogInUse &&
schema === schemaInUse
) {
state.functions = action.payload.functions
}
```
##########
web-v2/web/src/lib/store/metalakes/index.js:
##########
@@ -1613,6 +1650,32 @@ export const deleteVersion = createAsyncThunk(
}
)
+export const fetchFunctions = createAsyncThunk(
+ 'appMetalakes/fetchFunctions',
+ async ({ init, metalake, catalog, schema }, { dispatch }) => {
+ if (init) {
+ await dispatch(setTableLoading(true))
+ }
+
+ const [err, res] = await to(getFunctionsApi({ metalake, catalog, schema,
details: true }))
+ await dispatch(setTableLoading(false))
Review Comment:
`fetchFunctions` toggles the shared `tableLoading` flag. On schema load
(`catalogs/page.js`) functions are fetched in parallel with
tables/filesets/topics/models, so whichever request finishes first will set
`tableLoading(false)` and potentially stop the spinner while other entity
requests are still in flight. Use a dedicated loading flag for functions (e.g.,
`functionsLoading`) or a ref-counted loading mechanism instead of sharing
`tableLoading` across independent requests.
```suggestion
const [err, res] = await to(getFunctionsApi({ metalake, catalog, schema,
details: true }))
```
##########
web-v2/web/src/app/catalogs/rightContent/RightContent.js:
##########
@@ -220,7 +228,16 @@ const RightContent = () => {
...(entity
? [
{
- title: (
+ title: functionName ? (
+ <Link
+
href={`/catalogs?metalake=${currentMetalake}&catalogType=${catalogType}&catalog=${decodeURIComponent(catalog)}&schema=${decodeURIComponent(schema)}`}
+ title={entity}
+ className='inline-block min-w-10 truncate'
+ style={{ maxWidth: `calc((${width}px - 160px)/4)` }}
+ >
+ {decodeURIComponent(entity)}
+ </Link>
Review Comment:
In the new breadcrumb case for functions, the `href` is built using
`decodeURIComponent(catalog)` / `decodeURIComponent(schema)`. These values are
being inserted into a URL query string and should be encoded, not decoded,
otherwise names containing reserved characters can break navigation. Use
`encodeURIComponent(...)` (or `URLSearchParams`) when constructing the link URL.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]