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.
This commit is contained in:
Ivan Vasilov authored and GitHub committed 2024-01-02 12:52:06 +01:00
1 parent bf4421aca8
commit c867205b20
9 files changed
+85 -127

No files matched your search

@@ -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,
@@ -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'
@@ -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)
+3 -1
View File
@@ -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,
}
+41 -24
View File
@@ -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<ReturnType<typeof getViews>>
export type ViewsError = unknown
export type ViewsError = ResponseError
export const useViewsQuery = <TData = ViewsData>(
{ projectRef, connectionString }: ViewsVariables,
{ projectRef, connectionString, schema }: ViewsVariables,
{ enabled = true, ...options }: UseQueryOptions<ViewsData, ViewsError, TData> = {}
) =>
useQuery<ViewsData, ViewsError, TData>(
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])
}
+1 -7
View File
@@ -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<PostgresColumn>
views: IViewStore
projectRef?: string
connectionString?: string
query: (value: string) => Promise<any | { error: ResponseError }>
validateQuery: (value: string) => Promise<any | { error: ResponseError }>
@@ -117,7 +116,6 @@ export default class MetaStore implements IMetaStore {
rootStore: IRootStore
tables: TableStore
columns: PostgresMetaInterface<PostgresColumn>
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)
}
}
-69
View File
@@ -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<SchemaView> {
loadById: (id: number | string) => Promise<Partial<SchemaView> | { error: ResponseError }>
}
export default class ViewStore extends PostgresMetaInterface<SchemaView> {
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<string, PostgresColumn[]>)
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<SchemaView>
// @ts-ignore
this.data[id] = data
return data
} catch (error: any) {
return { error }
}
}
}
+8 -5
View File
@@ -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(