diff --git a/src/scenes/Schema/Row/index.tsx b/src/scenes/Schema/Row/index.tsx index e2f798620..af1c7fa82 100644 --- a/src/scenes/Schema/Row/index.tsx +++ b/src/scenes/Schema/Row/index.tsx @@ -47,10 +47,10 @@ import { Checkbox } from "../checkbox" import { PopperHover } from "../../../components/PopperHover" import { Tooltip } from "../../../components/Tooltip" import { mapColumnTypeToUI } from "../../../scenes/Import/ImportCSVFiles/utils" -import { MATVIEWS_GROUP_KEY, TABLES_GROUP_KEY } from "../localStorageUtils" +import { MATVIEWS_GROUP_KEY, TABLES_GROUP_KEY, VIEWS_GROUP_KEY } from "../localStorageUtils" import { TreeNavigationOptions } from "../VirtualTables" -export type TreeNodeKind = "column" | "table" | "matview" | "folder" | "detail" +export type TreeNodeKind = "column" | "table" | "matview" | "view" | "folder" | "detail" type Props = Readonly<{ id: string @@ -334,12 +334,12 @@ const Row = ({ const timeoutRef = useRef | null>(null) const wrapperRef = useRef(null) const isExpandable = - ["folder", "table", "matview"].includes(kind) || + ["folder", "table", "matview", "view"].includes(kind) || (kind === "column" && type === "SYMBOL") - const isTableKind = ["table", "matview"].includes(kind) - const isRootFolder = [MATVIEWS_GROUP_KEY, TABLES_GROUP_KEY].includes(id ?? "") + const isTableKind = ["table", "matview", "view"].includes(kind) + const isRootFolder = [MATVIEWS_GROUP_KEY, TABLES_GROUP_KEY, VIEWS_GROUP_KEY].includes(id ?? "") const matchesSearch = - ["column", "table", "matview"].includes(kind) && + ["column", "table", "matview", "view"].includes(kind) && query && name.toLowerCase().includes(query.toLowerCase()) @@ -500,10 +500,11 @@ const Row = ({ partitionBy={partitionBy} walEnabled={walEnabled} isMaterializedView={kind === "matview"} + isView={kind === "view"} /> )} {kind === "detail" && } - {["column", "table", "matview"].includes(kind) ? ( + {["column", "table", "matview", "view"].includes(kind) ? ( = ({ tables, walTables, materializedViews, + views, filterSuspendedOnly, state, loadingError, @@ -181,7 +185,7 @@ const VirtualTables: FC = ({ const wrapperRef = useRef(null) useRetainLastFocus({ virtuosoRef, focusedIndex, setFocusedIndex, wrapperRef }) - const [regularTables, matViewTables] = useMemo(() => { + const [regularTables, matViewTables, viewTables] = useMemo(() => { return tables .reduce( (acc, table: QuestDB.Table) => { @@ -200,7 +204,11 @@ const VirtualTables: FC = ({ const shownIfFilteredWithQuery = tableNameMatches || columnMatches if (shownIfFilteredSuspendedOnly && shownIfFilteredWithQuery) { - acc[table.matView ? 1 : 0].push({ + // Use table_type to categorize: 'T' = table, 'M' = matview, 'V' = view + // Default to 'T' (table) for backward compatibility with older servers + const tableType = table.table_type ?? "T" + const categoryIndex = tableType === "M" ? 1 : tableType === "V" ? 2 : 0 + acc[categoryIndex].push({ ...table, hasColumnMatches: columnMatches, }) @@ -208,7 +216,7 @@ const VirtualTables: FC = ({ } return acc }, - [[], []] as (QuestDB.Table & { hasColumnMatches: boolean })[][], + [[], [], []] as (QuestDB.Table & { hasColumnMatches: boolean })[][], ) .map((tables) => tables.sort((a, b) => @@ -224,23 +232,22 @@ const VirtualTables: FC = ({ }, [] as FlattenedTreeItem[]) }, [schemaTree]) - const handleCopyQuery = async (tableName: string, isMatView: boolean) => { + const handleCopyQuery = async (tableName: string, kind: "table" | "matview" | "view") => { try { - let response - if (isMatView) { - response = await quest.showMatViewDDL(tableName) - } else { - response = await quest.showTableDDL(tableName) - } + const response = + kind === "matview" + ? await quest.showMatViewDDL(tableName) + : kind === "view" + ? await quest.showViewDDL(tableName) + : await quest.showTableDDL(tableName) if (response?.type === QuestDB.Type.DQL && response.data?.[0]?.ddl) { await copyToClipboard(response.data[0].ddl) toast.success("Schema copied to clipboard") } } catch (error) { - toast.error( - `Cannot copy schema for ${isMatView ? "materialized view" : "table"} '${tableName}'`, - ) + const kindLabel = kind === "matview" ? "materialized view" : kind === "view" ? "view" : "table" + toast.error(`Cannot copy schema for ${kindLabel} '${tableName}'`) } } @@ -474,18 +481,24 @@ const VirtualTables: FC = ({ ) } - if (item.id === TABLES_GROUP_KEY || item.id === MATVIEWS_GROUP_KEY) { + if (item.id === TABLES_GROUP_KEY || item.id === MATVIEWS_GROUP_KEY || item.id === VIEWS_GROUP_KEY) { const isTable = item.id === TABLES_GROUP_KEY + const isMatView = item.id === MATVIEWS_GROUP_KEY + const isView = item.id === VIEWS_GROUP_KEY + const isEmpty = isTable + ? regularTables.length === 0 + : isMatView + ? matViewTables.length === 0 + : viewTables.length === 0 + const hookLabel = isTable ? "tables" : isMatView ? "materialized-views" : "views" return ( toggleNodeExpansion(item.id)} id={item.id} navigateInTree={navigateInTree} @@ -507,7 +520,8 @@ const VirtualTables: FC = ({ ) } - if (item.kind === "table" || item.kind === "matview") { + if (item.kind === "table" || item.kind === "matview" || item.kind === "view") { + const canSuspend = item.kind !== "view" // Views cannot be suspended return ( <> = ({ `Materialized view is invalid${item.matViewData?.invalidation_reason && `: ${item.matViewData?.invalidation_reason}`}`, ] : []), + ...(item.viewData?.view_status === "invalid" + ? [ + `View is invalid${item.viewData?.invalidation_reason && `: ${item.viewData?.invalidation_reason}`}`, + ] + : []), ...(item.walTableData?.suspended ? [`Suspended`] : []), ]} /> - {item.walTableData?.suspended && ( + {canSuspend && item.walTableData?.suspended && ( { setOpenedSuspensionDialog(isOpen ? item.id : null) @@ -554,23 +573,25 @@ const VirtualTables: FC = ({ - await handleCopyQuery(item.name, item.kind === "matview") + await handleCopyQuery(item.name, item.kind as "table" | "matview" | "view") } icon={} > Copy schema - - item.walTableData?.suspended && - setTimeout(() => setOpenedSuspensionDialog(item.id)) - } - icon={} - disabled={!item.walTableData?.suspended} - > - Resume WAL - + {canSuspend && ( + + item.walTableData?.suspended && + setTimeout(() => setOpenedSuspensionDialog(item.id)) + } + icon={} + disabled={!item.walTableData?.suspended} + > + Resume WAL + + )} @@ -600,6 +621,7 @@ const VirtualTables: FC = ({ flattenedItems, regularTables, matViewTables, + viewTables, toggleNodeExpansion, openedContextMenu, openedSuspensionDialog, @@ -624,7 +646,9 @@ const VirtualTables: FC = ({ table, TABLES_GROUP_KEY, false, + false, materializedViews, + views, walTables, allColumns[table.table_name] ?? [], ) @@ -654,7 +678,40 @@ const VirtualTables: FC = ({ table, MATVIEWS_GROUP_KEY, true, + false, + materializedViews, + views, + walTables, + allColumns[table.table_name] ?? [], + ) + if (table.hasColumnMatches) { + node.isExpanded = true + const columnsFolder = node.children.find((child) => + child.id.endsWith(":columns"), + ) + if (columnsFolder) { + columnsFolder.isExpanded = true + } + } + return node + }), + }, + [VIEWS_GROUP_KEY]: { + id: VIEWS_GROUP_KEY, + kind: "folder", + name: `Views (${viewTables.length})`, + isExpanded: + viewTables.length === 0 + ? false + : getSectionExpanded(VIEWS_GROUP_KEY), + children: viewTables.map((table) => { + const node = createTableNode( + table, + VIEWS_GROUP_KEY, + false, + true, materializedViews, + views, walTables, allColumns[table.table_name] ?? [], ) @@ -679,7 +736,9 @@ const VirtualTables: FC = ({ state.view, regularTables, matViewTables, + viewTables, materializedViews, + views, walTables, allColumns, ]) diff --git a/src/scenes/Schema/VirtualTables/utils.ts b/src/scenes/Schema/VirtualTables/utils.ts index 2144ac090..0c562ec64 100644 --- a/src/scenes/Schema/VirtualTables/utils.ts +++ b/src/scenes/Schema/VirtualTables/utils.ts @@ -131,7 +131,9 @@ export const createTableNode = ( table: QuestDB.Table, parentId: string, isMatView: boolean = false, + isView: boolean = false, materializedViews: QuestDB.MaterializedView[] | undefined, + views: QuestDB.View[] | undefined, walTables: QuestDB.WalTable[] | undefined, tableColumns: InformationSchemaColumn[], ): TreeNode => { @@ -139,18 +141,25 @@ export const createTableNode = ( const matViewData = isMatView ? materializedViews?.find((mv) => mv.view_name === table.table_name) : undefined + const viewData = isView + ? views?.find((v) => v.view_name === table.table_name) + : undefined const walTableData = walTables?.find((wt) => wt.name === table.table_name) const columnsId = `${tableId}:columns` const baseTablesId = `${tableId}:baseTables` const storageDetailsId = `${tableId}:storageDetails` + // Determine the kind + const kind = isMatView ? "matview" : isView ? "view" : "table" + const tableNode: TreeNode = { id: tableId, - kind: isMatView ? "matview" : "table", + kind, name: table.table_name, table, matViewData, + viewData, parent: parentId, isExpanded: getSectionExpanded(tableId), partitionBy: table.partitionBy, @@ -167,14 +176,19 @@ export const createTableNode = ( isExpanded: getSectionExpanded(columnsId), children: createColumnNodes(table, columnsId, tableColumns), }, - { - id: storageDetailsId, - kind: "folder", - name: "Storage details", - parent: tableId, - isExpanded: getSectionExpanded(storageDetailsId), - children: createStorageDetailsNodes(table, storageDetailsId), - }, + // Only show storage details for tables and materialized views (not for regular views) + ...(!isView + ? [ + { + id: storageDetailsId, + kind: "folder" as const, + name: "Storage details", + parent: tableId, + isExpanded: getSectionExpanded(storageDetailsId), + children: createStorageDetailsNodes(table, storageDetailsId), + }, + ] + : []), ], } diff --git a/src/scenes/Schema/index.tsx b/src/scenes/Schema/index.tsx index e3393ed80..3dc956b1b 100644 --- a/src/scenes/Schema/index.tsx +++ b/src/scenes/Schema/index.tsx @@ -142,6 +142,7 @@ const Schema = ({ const [walTables, setWalTables] = useState() const [materializedViews, setMaterializedViews] = useState() + const [views, setViews] = useState() const dispatch = useDispatch() const [filterSuspendedOnly, setFilterSuspendedOnly] = useState(false) const { autoRefreshTables, updateSettings } = useLocalStorage() @@ -171,6 +172,7 @@ const Schema = ({ ) } void fetchMaterializedViews() + void fetchViews() dispatchState({ view: View.ready }) } else { dispatchState({ view: View.error }) @@ -195,6 +197,17 @@ const Schema = ({ } } + const fetchViews = async () => { + try { + const viewsResponse = await quest.showViews() + if (viewsResponse && viewsResponse.type === QuestDB.Type.DQL) { + setViews(viewsResponse.data) + } + } catch (error) { + // Fail silently + } + } + const fetchColumns = async () => { const queries = [ "information_schema.questdb_columns()", @@ -224,13 +237,18 @@ const Schema = ({ const ddls = await Promise.all( selectedTables.map(async (table) => { try { - const tableDDLResponse = - table.type === "table" - ? await quest.showTableDDL(table.name) - : await quest.showMatViewDDL(table.name) - if (tableDDLResponse && tableDDLResponse.type === QuestDB.Type.DQL) { - return tableDDLResponse.data[0].ddl + // selectedTables only contains "table" | "matview" | "view" types from allSelectableTables + const response = + table.type === "matview" + ? await quest.showMatViewDDL(table.name) + : table.type === "view" + ? await quest.showViewDDL(table.name) + : await quest.showTableDDL(table.name) + + if (response?.type === QuestDB.Type.DQL && response.data?.[0]?.ddl) { + return response.data[0].ddl } + tablesWithError.push(table) } catch (error) { tablesWithError.push(table) } @@ -318,20 +336,21 @@ const Schema = ({ const allSelectableTables = useMemo(() => { if (!tables) return [] + // Default to 'T' (table) for backward compatibility with older servers const regularTables = tables - .filter( - (t) => !materializedViews?.find((v) => v.view_name === t.table_name), - ) + .filter((t) => (t.table_type ?? "T") === "T") .map((t) => ({ name: t.table_name, type: "table" as TreeNodeKind })) - const matViews = - materializedViews?.map((t) => ({ - name: t.view_name, - type: "matview" as TreeNodeKind, - })) ?? [] + const matViews = tables + .filter((t) => t.table_type === "M") + .map((t) => ({ name: t.table_name, type: "matview" as TreeNodeKind })) + + const viewsList = tables + .filter((t) => t.table_type === "V") + .map((t) => ({ name: t.table_name, type: "view" as TreeNodeKind })) - return [...regularTables, ...matViews] - }, [tables, materializedViews]) + return [...regularTables, ...matViews, ...viewsList] + }, [tables]) const suspendedTablesCount = useMemo( () => walTables?.filter((t) => t.suspended).length ?? 0, @@ -518,6 +537,7 @@ const Schema = ({ tables={tables ?? []} walTables={walTables} materializedViews={materializedViews} + views={views} filterSuspendedOnly={filterSuspendedOnly} state={state} loadingError={loadingError} diff --git a/src/scenes/Schema/localStorageUtils.ts b/src/scenes/Schema/localStorageUtils.ts index 36e2bc5dc..9f00f65f0 100644 --- a/src/scenes/Schema/localStorageUtils.ts +++ b/src/scenes/Schema/localStorageUtils.ts @@ -1,6 +1,7 @@ const STORAGE_KEY_PREFIX = "questdb:expanded:" export const TABLES_GROUP_KEY = `${STORAGE_KEY_PREFIX}tables` export const MATVIEWS_GROUP_KEY = `${STORAGE_KEY_PREFIX}matviews` +export const VIEWS_GROUP_KEY = `${STORAGE_KEY_PREFIX}views` export const getItemFromStorage = (key: string): boolean => { try { diff --git a/src/scenes/Schema/table-icon.tsx b/src/scenes/Schema/table-icon.tsx index f9956d296..5c07c4e3f 100644 --- a/src/scenes/Schema/table-icon.tsx +++ b/src/scenes/Schema/table-icon.tsx @@ -11,6 +11,7 @@ type TableIconProps = { partitionBy?: QuestDB.PartitionBy designatedTimestamp?: string isMaterializedView?: boolean + isView?: boolean } const WIDTH = "1.4rem" @@ -78,11 +79,24 @@ export const MaterializedViewIcon = ({ height = "14px", width = "14px" }) => ( ) +export const ViewIcon = ({ height = "14px", width = "14px" }) => ( + + + +) + export const TableIcon: FC = ({ walEnabled, partitionBy, designatedTimestamp, isMaterializedView, + isView, }) => { const isPartitioned = partitionBy && partitionBy !== "NONE" const partitionText = isPartitioned @@ -97,6 +111,24 @@ export const TableIcon: FC = ({ ? "WAL-based tables are the current and most up-to-date table format. This format supports advanced data recovery, replication and high-throughput ingestion. This is the recommended format if your table contains time-series data that has a designated timestamp." : "Legacy table format, without WAL (write-ahead-log). This table format should only be used when table does not have timestamp column and generally not a time series. These tables are not replicated and could be slower to ingress data into." + if (isView) { + return ( + + + + } + delay={1000} + placement="bottom" + > + + View - a stored query that can be referenced like a table. + + + ) + } + if (isMaterializedView) { return ( > { + return await this.query<{ ddl: string }>( + `SHOW CREATE VIEW '${viewName}';`, + ) + } + + async showViews(): Promise> { + return await this.query("views();") + } + async showTableDDL(table: string): Promise> { return await this.query<{ ddl: string }>(`SHOW CREATE TABLE '${table}';`) } diff --git a/src/utils/questdb/types.ts b/src/utils/questdb/types.ts index b3126eedc..d754d73e2 100644 --- a/src/utils/questdb/types.ts +++ b/src/utils/questdb/types.ts @@ -130,6 +130,8 @@ export type QueryResult> = export type PartitionBy = "HOUR" | "DAY" | "WEEK" | "MONTH" | "YEAR" | "NONE" +export type TableType = "T" | "M" | "V" // Table | MaterializedView | View + export type Table = { id: number table_name: string @@ -139,7 +141,16 @@ export type Table = { dedup: boolean ttlValue: number ttlUnit: string - matView: boolean + table_type?: TableType // Optional for backward compatibility with older servers +} + +export type View = { + view_name: string + view_sql: string + view_table_dir_name: string + invalidation_reason: string + view_status: "valid" | "invalid" + view_status_update_time: string } export type Partition = {