From c867205b203fd58472948cd67c517eeccb4382dd Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Tue, 2 Jan 2024 12:52:06 +0100 Subject: [PATCH] chore: Migrate ViewsStore (#19975) * Move useEncryptedColumns hook and use it in the TableGridEditor. * Convert the viewsQuery to use data/fetchers. It also handles invalidation of schemas. * Invalidate the view when deleting a column or a table. * Use the new query function in VaultStore. * Remove the viewStore. * Fix two more edge cases. Add comments for it. --- .../DeleteConfirmationDialogs.tsx | 28 ++++++-- .../ForeignRowSelector/ForeignRowSelector.tsx | 2 +- ...ctor.utils.ts => SidePanelEditor.utils.ts} | 0 .../TableGridEditor/TableGridEditor.tsx | 23 ++----- apps/studio/data/views/keys.ts | 4 +- apps/studio/data/views/views-query.ts | 65 ++++++++++------- apps/studio/stores/pgmeta/MetaStore.ts | 8 +-- apps/studio/stores/pgmeta/ViewStore.ts | 69 ------------------- apps/studio/stores/project/VaultStore.ts | 13 ++-- 9 files changed, 85 insertions(+), 127 deletions(-) rename apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/{RowEditor/ForeignRowSelector/ForeignRowSelector.utils.ts => SidePanelEditor.utils.ts} (100%) delete mode 100644 apps/studio/stores/pgmeta/ViewStore.ts diff --git a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx index 72d7d59b151..e4deab3cc86 100644 --- a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx @@ -14,6 +14,7 @@ import { useTableRowDeleteMutation } from 'data/table-rows/table-row-delete-muta import { useTableRowTruncateMutation } from 'data/table-rows/table-row-truncate-mutation' import { tableKeys } from 'data/tables/keys' import { useGetTables } from 'data/tables/tables-query' +import { viewKeys } from 'data/views/keys' import { useStore, useUrlState } from 'hooks' import { TableLike } from 'hooks/misc/useTable' import { noop } from 'lib/void' @@ -151,9 +152,19 @@ const DeleteConfirmationDialogs = ({ selectedTable!.name, ]) ), + // refetch all entities in the sidebar because deleting a column may regenerate a view (and change its id) + queryClient.invalidateQueries(entityTypeKeys.list(projectRef)), + // invalidate all views from this schema, not sure if this is needed since you can't actually delete a column + // which has a view dependent on it + snap.selectedSchemaName + ? queryClient.invalidateQueries( + viewKeys.listBySchema(projectRef, snap.selectedSchemaName) + ) + : null, + // invalidate the view if there's a view with this id, not sure if this is needed because you can't delete a + // column from a view + queryClient.invalidateQueries(viewKeys.view(projectRef, selectedTable?.id)), ]) - - if (snap.selectedSchemaName) await meta.views.loadBySchema(snap.selectedSchemaName) } catch (error: any) { ui.setNotification({ category: 'error', @@ -176,7 +187,17 @@ const DeleteConfirmationDialogs = ({ const tables = await getTables(snap.selectedSchemaName) - await queryClient.invalidateQueries(entityTypeKeys.list(projectRef)) + await Promise.all([ + queryClient.invalidateQueries(entityTypeKeys.list(projectRef)), + // invalidate all views from this schema + snap.selectedSchemaName + ? queryClient.invalidateQueries( + viewKeys.listBySchema(projectRef, snap.selectedSchemaName) + ) + : null, + // invalidate the view if there's a view with this id + queryClient.invalidateQueries(viewKeys.view(projectRef, selectedTableToDelete?.id)), + ]) onAfterDeleteTable(tables) @@ -184,7 +205,6 @@ const DeleteConfirmationDialogs = ({ category: 'success', message: `Successfully deleted table "${selectedTableToDelete.name}"`, }) - if (snap.selectedSchemaName) await meta.views.loadBySchema(snap.selectedSchemaName) } catch (error: any) { ui.setNotification({ error, diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx index 5a54d5bba68..1def91748b0 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx @@ -15,7 +15,7 @@ import { useTableRowsQuery } from 'data/table-rows/table-rows-query' import { useTableQuery } from 'data/tables/table-query' import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state' import ActionBar from '../../ActionBar' -import { useEncryptedColumns } from './ForeignRowSelector.utils' +import { useEncryptedColumns } from '../../SidePanelEditor.utils' import Pagination from './Pagination' import SelectorGrid from './SelectorGrid' diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.utils.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.ts similarity index 100% rename from apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.utils.ts rename to apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.ts diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index ebb574ed8f0..319cccbad07 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -5,10 +5,8 @@ import { useParams } from 'common' import { find, isUndefined } from 'lodash' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' import { parseSupaTable, SupabaseGrid, SupaTable } from 'components/grid' -import { Dictionary } from 'types' import { ERROR_PRIMARY_KEY_NOTFOUND } from 'components/grid/constants' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import Connecting from 'components/ui/Loading/Loading' @@ -28,11 +26,12 @@ import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' import { EMPTY_ARR } from 'lib/void' import { useGetImpersonatedRole } from 'state/role-impersonation-state' import { useTableEditorStateSnapshot } from 'state/table-editor' -import { SchemaView } from 'types' +import { Dictionary, SchemaView } from 'types' import { RoleImpersonationPopover } from '../RoleImpersonationSelector' import GridHeaderActions from './GridHeaderActions' import NotFoundState from './NotFoundState' import SidePanelEditor from './SidePanelEditor' +import { useEncryptedColumns } from './SidePanelEditor/SidePanelEditor.utils' import TableDefinition from './TableDefinition' export interface TableGridEditorProps { @@ -49,7 +48,7 @@ const TableGridEditor = ({ selectedTable, }: TableGridEditorProps) => { const router = useRouter() - const { meta, ui, vault } = useStore() + const { meta, ui } = useStore() const { ref: projectRef, id } = useParams() const { project } = useProjectContext() @@ -57,8 +56,6 @@ const TableGridEditor = ({ const getImpersonatedRole = useGetImpersonatedRole() - const [encryptedColumns, setEncryptedColumns] = useState([]) - const [{ view: selectedView = 'data' }, setUrlState] = useUrlState() const setSelectedView = (view: string) => { if (view === 'data') { @@ -72,10 +69,10 @@ const TableGridEditor = ({ const canEditColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') const isReadOnly = !canEditTables && !canEditColumns - const getEncryptedColumns = async (table: any) => { - const columns = await vault.listEncryptedColumns(table.schema, table.name) - setEncryptedColumns(columns) - } + const encryptedColumns = useEncryptedColumns({ + schemaName: selectedTable?.schema, + tableName: selectedTable?.name, + }) const queryClient = useQueryClient() const { mutate: mutateUpdateTableRow } = useTableRowUpdateMutation({ @@ -143,12 +140,6 @@ const TableGridEditor = ({ }) const foreignKeyMeta = data || [] - useEffect(() => { - if (selectedTable !== undefined && selectedTable.id !== undefined) { - getEncryptedColumns(selectedTable) - } - }, [selectedTable?.id]) - const entityType = useEntityType(selectedTable?.id) const columnsRef = useLatest(selectedTable?.columns ?? EMPTY_ARR) diff --git a/apps/studio/data/views/keys.ts b/apps/studio/data/views/keys.ts index 943f32500f1..24c879ae256 100644 --- a/apps/studio/data/views/keys.ts +++ b/apps/studio/data/views/keys.ts @@ -1,5 +1,7 @@ export const viewKeys = { list: (projectRef: string | undefined) => ['projects', projectRef, 'views'] as const, + listBySchema: (projectRef: string | undefined, schema: string) => + [...viewKeys.list(projectRef), schema] as const, view: (projectRef: string | undefined, id: number | undefined) => - ['projects', projectRef, 'views', id] as const, + [...viewKeys.list(projectRef), id] as const, } diff --git a/apps/studio/data/views/views-query.ts b/apps/studio/data/views/views-query.ts index 08300ba9dfd..d0901f091ae 100644 --- a/apps/studio/data/views/views-query.ts +++ b/apps/studio/data/views/views-query.ts @@ -1,49 +1,66 @@ -import { PostgresView } from '@supabase/postgres-meta' -import { useQuery, UseQueryOptions } from '@tanstack/react-query' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' +import { UseQueryOptions, useQuery, useQueryClient } from '@tanstack/react-query' +import { useCallback } from 'react' + +import { get } from 'data/fetchers' +import { ResponseError } from 'types' import { viewKeys } from './keys' -import { View } from './view-query' export type ViewsVariables = { projectRef?: string connectionString?: string + schema?: string } -export type ViewsResponse = View[] | { error?: any } - export async function getViews( - { projectRef, connectionString }: ViewsVariables, + { projectRef, connectionString, schema }: ViewsVariables, signal?: AbortSignal ) { - if (!projectRef) { - throw new Error('projectRef is required') - } + if (!projectRef) throw new Error('projectRef is required') let headers = new Headers() if (connectionString) headers.set('x-connection-encrypted', connectionString) - const response = (await get(`${API_URL}/pg-meta/${projectRef}/views`, { - headers: Object.fromEntries(headers), + const { data, error } = await get('/platform/pg-meta/{ref}/views', { + params: { + header: { 'x-connection-encrypted': connectionString! }, + path: { ref: projectRef }, + query: { + included_schemas: schema || '', + } as any, + }, + headers, signal, - })) as ViewsResponse + }) - if (!Array.isArray(response) && response.error) { - throw response.error - } - - return response as PostgresView[] + if (error) throw error + return data } export type ViewsData = Awaited> -export type ViewsError = unknown +export type ViewsError = ResponseError export const useViewsQuery = ( - { projectRef, connectionString }: ViewsVariables, + { projectRef, connectionString, schema }: ViewsVariables, { enabled = true, ...options }: UseQueryOptions = {} ) => useQuery( - viewKeys.list(projectRef), - ({ signal }) => getViews({ projectRef, connectionString }, signal), - { enabled: enabled && typeof projectRef !== 'undefined', ...options } + schema ? viewKeys.listBySchema(projectRef, schema) : viewKeys.list(projectRef), + ({ signal }) => getViews({ projectRef, connectionString, schema }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + } ) + +export const useViewsPrefetch = ({ projectRef, connectionString, schema }: ViewsVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef) { + client.prefetchQuery( + schema ? viewKeys.listBySchema(projectRef, schema) : viewKeys.list(projectRef), + ({ signal }) => getViews({ projectRef, connectionString, schema }, signal) + ) + } + }, [projectRef, schema]) +} diff --git a/apps/studio/stores/pgmeta/MetaStore.ts b/apps/studio/stores/pgmeta/MetaStore.ts index 9be48411471..e8a6eb8e922 100644 --- a/apps/studio/stores/pgmeta/MetaStore.ts +++ b/apps/studio/stores/pgmeta/MetaStore.ts @@ -37,7 +37,6 @@ import { tableKeys } from 'data/tables/keys' import { getTable } from 'data/tables/table-query' import { getTables } from 'data/tables/tables-query' import PostgresMetaInterface from '../common/PostgresMetaInterface' -import ViewStore, { IViewStore } from './ViewStore' const BATCH_SIZE = 1000 const CHUNK_SIZE = 1024 * 1024 * 0.1 // 0.1MB @@ -45,9 +44,9 @@ const CHUNK_SIZE = 1024 * 1024 * 0.1 // 0.1MB export interface IMetaStore { tables: ITableStore columns: IPostgresMetaInterface - views: IViewStore projectRef?: string + connectionString?: string query: (value: string) => Promise validateQuery: (value: string) => Promise @@ -117,7 +116,6 @@ export default class MetaStore implements IMetaStore { rootStore: IRootStore tables: TableStore columns: PostgresMetaInterface - views: ViewStore projectRef?: string connectionString?: string @@ -142,7 +140,6 @@ export default class MetaStore implements IMetaStore { `${this.baseUrl}/columns`, this.headers ) - this.views = new ViewStore(this.rootStore, `${this.baseUrl}/views`, this.headers) makeObservable(this, {}) } @@ -832,8 +829,5 @@ export default class MetaStore implements IMetaStore { this.columns.setUrl(`${this.baseUrl}/columns`) this.columns.setHeaders(this.headers) - - this.views.setUrl(`${this.baseUrl}/views`) - this.views.setHeaders(this.headers) } } diff --git a/apps/studio/stores/pgmeta/ViewStore.ts b/apps/studio/stores/pgmeta/ViewStore.ts deleted file mode 100644 index 66568182004..00000000000 --- a/apps/studio/stores/pgmeta/ViewStore.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { ResponseError, SchemaView } from 'types' -import PostgresMetaInterface, { IPostgresMetaInterface } from '../common/PostgresMetaInterface' -import { IRootStore } from '../RootStore' -import { get } from 'lib/common/fetch' -import { PostgresColumn, PostgresView } from '@supabase/postgres-meta' - -export interface IViewStore extends IPostgresMetaInterface { - loadById: (id: number | string) => Promise | { error: ResponseError }> -} - -export default class ViewStore extends PostgresMetaInterface { - constructor( - rootStore: IRootStore, - dataUrl: string, - headers?: { - [prop: string]: any - }, - options?: { identifier: string } - ) { - super(rootStore, dataUrl, headers, options) - } - - // Customize ViewStore fetchData method to improve request performance - async fetchData() { - const headers = { 'Content-Type': 'application/json', ...this.headers } - // load all views w/o columns info - const _url = new URL(this.url) - _url.searchParams.set('include_columns', 'false') - const url = `${_url}` - // load all columns - const urlColumns = this.url.replace('/views', '/columns') - const [viewsResponse, columnsResponse] = await Promise.all([ - get(url, { headers }), - get(urlColumns, { headers }), - ]) - if (viewsResponse.error) throw viewsResponse.error - if (columnsResponse.error) throw columnsResponse.error - - // merge 2 response to create the final array - const columnsByTableId = (columnsResponse as PostgresColumn[]).reduce((acc, curr) => { - acc[curr.table_id] ??= [] - acc[curr.table_id].push(curr) - return acc - }, {} as Record) - const views: PostgresView[] = [] - viewsResponse.forEach((view: PostgresView) => { - const columns = columnsByTableId[view.id] - views.push({ ...view, columns }) - }) - - this.setDataArray(views as any) - return views as any - } - - async loadById(id: number | string) { - try { - const url = `${this.url}?id=${id}` - const response = await get(url, { headers: this.headers }) - if (response.error) throw response.error - - const data = response as Partial - // @ts-ignore - this.data[id] = data - return data - } catch (error: any) { - return { error } - } - } -} diff --git a/apps/studio/stores/project/VaultStore.ts b/apps/studio/stores/project/VaultStore.ts index eedeb1f17f4..b3aab1026e2 100644 --- a/apps/studio/stores/project/VaultStore.ts +++ b/apps/studio/stores/project/VaultStore.ts @@ -1,7 +1,8 @@ import type { PostgresColumn } from '@supabase/postgres-meta' import { Query } from 'components/grid/query/Query' +import { getViews } from 'data/views/views-query' import { makeAutoObservable } from 'mobx' -import { SchemaView, VaultSecret } from 'types' +import { VaultSecret } from 'types' import { IRootStore } from '../RootStore' export interface IVaultStore { @@ -211,10 +212,12 @@ export default class VaultStore implements IVaultStore { async listEncryptedColumns(schema: string, table: string) { if (!table) return [] - await this.rootStore.meta.views.loadBySchema(schema) - const decryptedView = this.rootStore.meta.views.find( - (view: SchemaView) => view.name === `decrypted_${table}` - ) + const views = await getViews({ + projectRef: this.rootStore.meta.projectRef, + connectionString: this.rootStore.meta.connectionString, + schema, + }) + const decryptedView = views.find((view) => view.name === `decrypted_${table}`) if (!decryptedView) return [] const encryptedColumns = await this.rootStore.meta.query(