console (feature): component to introspect and track functions in the new data tab UI

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/8570
GitOrigin-RevId: addf15967efcbcdc86aedd42096d59b95be14a27
This commit is contained in:
Vijay Prasanna 2023-04-19 18:21:40 +05:30 committed by hasura-bot
parent fad183d854
commit 2f7e89a9c2
23 changed files with 531 additions and 25 deletions

View File

@ -0,0 +1,28 @@
import { TbMathFunction } from 'react-icons/tb';
import { QualifiedFunction } from '../../../hasura-metadata-types';
import { adaptFunctionName } from '../utils';
export const FunctionDisplayName = ({
dataSourceName,
qualifiedFunction,
}: {
dataSourceName?: string;
qualifiedFunction: QualifiedFunction;
}) => {
const functionName = adaptFunctionName(qualifiedFunction);
if (!dataSourceName)
return (
<div className="flex gap-1 items-center">
<TbMathFunction className="text-2xl text-gray-600" />
{functionName.join(' / ')}
</div>
);
return (
<div className="flex gap-1 items-center">
<TbMathFunction className="text-2xl text-gray-600" />
{dataSourceName} / {functionName.join(' / ')}
</div>
);
};

View File

@ -0,0 +1,13 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query';
import { UntrackedFunctions } from './UntrackedFunctions';
export default {
component: UntrackedFunctions,
decorators: [ReactQueryDecorator()],
} as ComponentMeta<typeof UntrackedFunctions>;
export const Primary: ComponentStory<typeof UntrackedFunctions> = () => (
<UntrackedFunctions dataSourceName="chinook" />
);

View File

@ -0,0 +1,234 @@
import Skeleton from 'react-loading-skeleton';
import { useQuery } from 'react-query';
import { Button } from '../../../../new-components/Button';
import { CardedTable } from '../../../../new-components/CardedTable';
import { DropdownMenu } from '../../../../new-components/DropdownMenu';
import { DataSource, Feature, IntrospectedFunction } from '../../../DataSource';
import {
areTablesEqual,
MetadataSelectors,
useInvalidateMetadata,
useMetadata,
} from '../../../hasura-metadata-api';
import { useHttpClient } from '../../../Network';
import { useTrackFunction } from '../hooks/useTrackFunction';
import { adaptFunctionName, search } from '../utils';
import { FunctionDisplayName } from './FunctionDisplayName';
import { SlOptionsVertical } from 'react-icons/sl';
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
import { LearnMoreLink } from '../../../../new-components/LearnMoreLink';
import { SearchBar } from '../../TrackResources/components/SearchBar';
import { Badge } from '../../../../new-components/Badge';
import { useState } from 'react';
import {
DEFAULT_PAGE_NUMBER,
DEFAULT_PAGE_SIZE,
DEFAULT_PAGE_SIZES,
} from '../../TrackResources/constants';
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
import { paginate } from '../../TrackResources/utils';
import { getDefaultQueryOptions } from '../../reactQueryUtils';
type UntrackedFunctionsProps = {
dataSourceName: string;
};
const useGetUntrackedFunctions = (
dataSourceName: string,
autoFireOnMount = true
) => {
const httpClient = useHttpClient();
const { data: trackedFunctions = [], isFetching } = useMetadata(m =>
(MetadataSelectors.findSource(dataSourceName)(m)?.functions ?? []).map(fn =>
adaptFunctionName(fn.function)
)
);
return useQuery({
queryKey: [dataSourceName, 'functions'],
queryFn: async () => {
const result = await DataSource(httpClient).getTrackableFunctions(
dataSourceName
);
if (result === Feature.NotImplemented) return result;
return (result ?? []).filter(fn => {
const isAlreadyTracked = trackedFunctions.find(trackedFn =>
areTablesEqual(fn.qualifiedFunction, trackedFn)
);
return !isAlreadyTracked;
});
},
...getDefaultQueryOptions<Feature | IntrospectedFunction[]>(),
enabled: autoFireOnMount && !isFetching,
});
};
export const UntrackedFunctions = (props: UntrackedFunctionsProps) => {
const { dataSourceName } = props;
const [pageNumber, setPageNumber] = useState(DEFAULT_PAGE_NUMBER);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [searchText, setSearchText] = useState('');
const { data: untrackedFunctions = [], isLoading } =
useGetUntrackedFunctions(dataSourceName);
const invalidateMetadata = useInvalidateMetadata();
const { trackFunction } = useTrackFunction({
dataSourceName,
});
if (isLoading) return <Skeleton count={5} height={20} className="mb-1" />;
if (untrackedFunctions === Feature.NotImplemented) return null;
if (!untrackedFunctions.length)
return (
<IndicatorCard status="info" headline="No untracked functions found">
We couldn't find any compatible functions in your database that can be
tracked in Hasura.{' '}
<LearnMoreLink href="https://hasura.io/docs/latest/schema/postgres/postgres-guides/functions/" />
</IndicatorCard>
);
const filteredResult = search(untrackedFunctions, searchText);
return (
<div>
<div className="flex justify-between space-x-4 mb-sm">
<div className="flex gap-5">
<div className="flex gap-2">
<SearchBar
onSearch={data => {
setSearchText(data);
setPageNumber(DEFAULT_PAGE_NUMBER);
}}
/>
{searchText.length ? (
<Badge>{filteredResult.length} results found</Badge>
) : null}
</div>
</div>
<div className="flex gap-1">
<Button
icon={<FaAngleLeft />}
onClick={() => setPageNumber(pageNumber - 1)}
disabled={pageNumber === 1}
/>
<select
value={pageSize}
onChange={e => {
setPageSize(Number(e.target.value));
}}
className="block w-full max-w-xl h-8 min-h-full shadow-sm rounded pl-3 pr-6 py-0.5 border border-gray-300 hover:border-gray-400 focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-yellow-200 focus-visible:border-yellow-400"
>
{DEFAULT_PAGE_SIZES.map(_pageSize => (
<option key={_pageSize} value={_pageSize}>
Show {_pageSize} tables
</option>
))}
</select>
<Button
icon={<FaAngleRight />}
onClick={() => setPageNumber(pageNumber + 1)}
disabled={pageNumber >= filteredResult.length / pageSize}
/>
</div>
</div>
<CardedTable.Table>
<CardedTable.TableHead>
<CardedTable.TableHeadRow>
<CardedTable.TableHeadCell>Function</CardedTable.TableHeadCell>
<CardedTable.TableHeadCell>
<div className="float-right">
<DropdownMenu
items={[
[
<span
className="py-2"
onClick={() => invalidateMetadata()}
>
Refresh
</span>,
],
]}
options={{
content: {
alignOffset: -50,
avoidCollisions: false,
},
}}
>
<SlOptionsVertical />
</DropdownMenu>
</div>
</CardedTable.TableHeadCell>
</CardedTable.TableHeadRow>
</CardedTable.TableHead>
<CardedTable.TableBody>
{paginate(filteredResult, pageSize, pageNumber).map(
untrackedFunction => (
<CardedTable.TableBodyRow>
<CardedTable.TableBodyCell>
<FunctionDisplayName
qualifiedFunction={untrackedFunction.qualifiedFunction}
/>
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
<div className="flex gap-2 justify-end">
{untrackedFunction.isVolatile ? (
<>
<Button
onClick={() =>
trackFunction({
function: untrackedFunction.qualifiedFunction,
configuration: {
exposed_as: 'mutation',
},
source: dataSourceName,
})
}
>
Track as Mutation
</Button>
<Button
onClick={() =>
trackFunction({
function: untrackedFunction.qualifiedFunction,
configuration: {
exposed_as: 'query',
},
source: dataSourceName,
})
}
>
Track as Query
</Button>
</>
) : (
<Button
onClick={() => {
trackFunction({
function: untrackedFunction.qualifiedFunction,
source: dataSourceName,
});
}}
>
Track as Root Field
</Button>
)}
</div>
</CardedTable.TableBodyCell>
</CardedTable.TableBodyRow>
)
)}
</CardedTable.TableBody>
</CardedTable.Table>
</div>
);
};

View File

@ -0,0 +1,78 @@
import { useCallback, useMemo } from 'react';
import { APIError } from '../../../../hooks/error';
import { hasuraToast } from '../../../../new-components/Toasts';
import {
MetadataSelectors,
useInvalidateMetadata,
useMetadata,
} from '../../../hasura-metadata-api';
import {
MetadataFunction,
QualifiedFunction,
} from '../../../hasura-metadata-types';
import { useMetadataMigration } from '../../../MetadataAPI';
export type MetadataFunctionPayload = {
function: QualifiedFunction;
configuration?: MetadataFunction['configuration'];
source: string;
comment?: string;
};
export const useTrackFunction = ({
dataSourceName,
onSuccess,
onError,
}: {
dataSourceName: string;
onSuccess?: () => void;
onError?: (err: unknown) => void;
}) => {
const { mutate, ...rest } = useMetadataMigration();
const invalidateMetadata = useInvalidateMetadata();
const { data: driver } = useMetadata(
m => MetadataSelectors.findSource(dataSourceName)(m)?.kind
);
const mutationOptions = useMemo(
() => ({
onSuccess: () => {
hasuraToast({
type: 'success',
title: 'Tracked Successfully!',
});
onSuccess?.();
invalidateMetadata();
},
onError: (err: APIError) => {
console.log(err.message);
hasuraToast({
type: 'error',
title: 'Failed to track!',
message: err.message,
});
onError?.(err);
},
}),
[invalidateMetadata, onError, onSuccess]
);
const trackFunction = useCallback(
(values: MetadataFunctionPayload) => {
mutate(
{
query: {
type: `${driver}_track_function`,
args: values,
},
},
{
...mutationOptions,
}
);
},
[mutate, mutationOptions, driver]
);
return { trackFunction, ...rest };
};

View File

@ -0,0 +1,5 @@
export type TrackableFunction = {
id: string;
name: string;
isVolatile: boolean;
};

View File

@ -0,0 +1,33 @@
import { IntrospectedFunction } from '../../DataSource';
import { QualifiedFunction } from '../../hasura-metadata-types';
export const adaptFunctionName = (
qualifiedFunction: QualifiedFunction
): string[] => {
if (Array.isArray(qualifiedFunction)) return qualifiedFunction;
// This is a safe assumption to make because the only native database that supports functions is postgres( and variants)
if (typeof qualifiedFunction === 'string')
return ['public', qualifiedFunction];
const { schema, name } = qualifiedFunction as {
schema: string;
name: string;
};
return [schema, name];
};
export const search = (
functions: IntrospectedFunction[],
searchText: string
) => {
if (!searchText.length) return functions;
return functions.filter(fn =>
adaptFunctionName(fn.qualifiedFunction)
.join(' / ')
.toLowerCase()
.includes(searchText.toLowerCase())
);
};

View File

@ -0,0 +1,13 @@
import { UseQueryOptions } from 'react-query';
import { APIError } from '../../hooks/error';
export const DEFAULT_STALE_TIME = 5 * 60000; // 5 minutes as default stale time
export const getDefaultQueryOptions = <
ReturnType,
FinalResult = ReturnType,
ErrorType = APIError
>(): UseQueryOptions<ReturnType, ErrorType, FinalResult> => ({
refetchOnWindowFocus: false,
staleTime: DEFAULT_STALE_TIME,
});

View File

@ -1,6 +1,9 @@
import { Table } from '../../hasura-metadata-types';
import {
defaultDatabaseProps,
defaultIntrospectionProps,
} from '../common/defaultDatabaseProps';
import { Database, GetVersionProps } from '..';
import { defaultDatabaseProps } from '../common/defaultDatabaseProps';
import {
getDatabaseConfiguration,
getTrackableTables,
@ -20,6 +23,7 @@ export type AlloyDbTable = { name: string; schema: string };
export const alloy: Database = {
...defaultDatabaseProps,
introspection: {
...defaultIntrospectionProps,
getVersion: async ({ dataSourceName, httpClient }: GetVersionProps) => {
const result = await runSQL({
source: {

View File

@ -1,6 +1,9 @@
import { Table } from '../../hasura-metadata-types';
import { Database, Feature } from '..';
import { defaultDatabaseProps } from '../common/defaultDatabaseProps';
import {
defaultDatabaseProps,
defaultIntrospectionProps,
} from '../common/defaultDatabaseProps';
import {
getTrackableTables,
getTableColumns,
@ -15,6 +18,7 @@ export type BigQueryTable = { name: string; dataset: string };
export const bigquery: Database = {
...defaultDatabaseProps,
introspection: {
...defaultIntrospectionProps,
getDriverInfo: async () => ({
name: 'bigquery',
displayName: 'BigQuery',

View File

@ -1,7 +1,10 @@
import { Table } from '../../hasura-metadata-types';
import { Database, Feature } from '..';
import { runSQL } from '../api';
import { defaultDatabaseProps } from '../common/defaultDatabaseProps';
import {
defaultDatabaseProps,
defaultIntrospectionProps,
} from '../common/defaultDatabaseProps';
import { adaptIntrospectedTables } from '../common/utils';
import { GetTrackableTablesProps, GetVersionProps } from '../types';
import {
@ -19,6 +22,7 @@ export type CitusTable = { name: string; schema: string };
export const citus: Database = {
...defaultDatabaseProps,
introspection: {
...defaultIntrospectionProps,
getVersion: async ({ dataSourceName, httpClient }: GetVersionProps) => {
const result = await runSQL({
source: {

View File

@ -1,7 +1,10 @@
import { Table } from '../../hasura-metadata-types';
import { Database, Feature } from '..';
import { runSQL } from '../api';
import { defaultDatabaseProps } from '../common/defaultDatabaseProps';
import {
defaultDatabaseProps,
defaultIntrospectionProps,
} from '../common/defaultDatabaseProps';
import { adaptIntrospectedTables } from '../common/utils';
import { GetTrackableTablesProps, GetVersionProps } from '../types';
import {
@ -19,6 +22,7 @@ export type CockroachDBTable = { name: string; schema: string };
export const cockroach: Database = {
...defaultDatabaseProps,
introspection: {
...defaultIntrospectionProps,
getVersion: async ({ dataSourceName, httpClient }: GetVersionProps) => {
const result = await runSQL({
source: {

View File

@ -1,24 +1,27 @@
import { Database, Feature } from '..';
export const defaultIntrospectionProps = {
getVersion: async () => Feature.NotImplemented,
getDriverInfo: async () => Feature.NotImplemented,
getDatabaseConfiguration: async () => Feature.NotImplemented,
getDriverCapabilities: async () => Feature.NotImplemented,
getTrackableTables: async () => Feature.NotImplemented,
getDatabaseHierarchy: async () => Feature.NotImplemented,
getTableColumns: async () => Feature.NotImplemented,
getFKRelationships: async () => Feature.NotImplemented,
getTablesListAsTree: async () => Feature.NotImplemented,
getSupportedOperators: async () => Feature.NotImplemented,
getTrackableFunctions: async () => Feature.NotImplemented,
getDatabaseSchemas: async () => Feature.NotImplemented,
getIsTableView: async () => Feature.NotImplemented,
};
export const defaultDatabaseProps: Database = {
config: {
getDefaultQueryRoot: async () => Feature.NotImplemented,
getSupportedQueryTypes: async () => Feature.NotImplemented,
},
introspection: {
getVersion: async () => Feature.NotImplemented,
getDriverInfo: async () => Feature.NotImplemented,
getDatabaseConfiguration: async () => Feature.NotImplemented,
getDriverCapabilities: async () => Feature.NotImplemented,
getTrackableTables: async () => Feature.NotImplemented,
getDatabaseHierarchy: async () => Feature.NotImplemented,
getTableColumns: async () => Feature.NotImplemented,
getFKRelationships: async () => Feature.NotImplemented,
getTablesListAsTree: async () => Feature.NotImplemented,
getSupportedOperators: async () => Feature.NotImplemented,
getDatabaseSchemas: async () => Feature.NotImplemented,
getIsTableView: async () => Feature.NotImplemented,
},
introspection: defaultIntrospectionProps,
query: {
getTableRows: async () => Feature.NotImplemented,
},

View File

@ -1,5 +1,8 @@
import { Table } from '../../hasura-metadata-types';
import { defaultDatabaseProps } from '../common/defaultDatabaseProps';
import {
defaultDatabaseProps,
defaultIntrospectionProps,
} from '../common/defaultDatabaseProps';
import { Database, Feature } from '../index';
import {
getTablesListAsTree,
@ -22,6 +25,7 @@ export type GDCTable = string[];
export const gdc: Database = {
...defaultDatabaseProps,
introspection: {
...defaultIntrospectionProps,
getDriverInfo: async () => Feature.NotImplemented,
getDatabaseConfiguration,
getDriverCapabilities,

View File

@ -21,7 +21,9 @@ import type {
GetTableColumnsProps,
GetTableRowsProps,
GetTablesListAsTreeProps,
GetTrackableFunctionProps,
GetTrackableTablesProps,
IntrospectedFunction,
GetVersionProps,
GetIsTableViewProps,
// Property,
@ -133,6 +135,9 @@ export type Database = {
getSupportedOperators: (
props: GetSupportedOperatorsProps
) => Promise<Operator[] | Feature.NotImplemented>;
getTrackableFunctions: (
props: GetTrackableFunctionProps
) => Promise<IntrospectedFunction[] | Feature.NotImplemented>;
getDatabaseSchemas: (
props: GetDatabaseSchemaProps
) => Promise<string[] | Feature.NotImplemented>;
@ -531,6 +536,13 @@ export const DataSource = (httpClient: AxiosInstance) => ({
driver
);
},
getTrackableFunctions: async (dataSourceName: string) => {
const database = await getDatabaseMethods({ dataSourceName, httpClient });
return database.introspection?.getTrackableFunctions({
dataSourceName,
httpClient,
});
},
getDatabaseSchemas: async ({
dataSourceName,
}: {

View File

@ -6,8 +6,11 @@ import {
} from '..';
import { Table } from '../../hasura-metadata-types';
import { NetworkArgs, runSQL } from '../api';
import {
defaultDatabaseProps,
defaultIntrospectionProps,
} from '../common/defaultDatabaseProps';
import { postgresCapabilities } from '../common/capabilities';
import { defaultDatabaseProps } from '../common/defaultDatabaseProps';
import { adaptIntrospectedTables } from '../common/utils';
import {
getDatabaseSchemas,
@ -31,6 +34,7 @@ const getDropSchemaSql = (schema: string) => {
export const mssql: Database = {
...defaultDatabaseProps,
introspection: {
...defaultIntrospectionProps,
getVersion: async ({ dataSourceName, httpClient }: GetVersionProps) => {
const result = await runSQL({
source: {

View File

@ -1,11 +1,15 @@
import { Database, Feature } from '..';
import { defaultDatabaseProps } from '../common/defaultDatabaseProps';
import {
defaultDatabaseProps,
defaultIntrospectionProps,
} from '../common/defaultDatabaseProps';
export type MySQLTable = { name: string };
export const mysql: Database = {
...defaultDatabaseProps,
introspection: {
...defaultIntrospectionProps,
getDriverInfo: async () => ({
name: 'mysql',
displayName: 'MySQL',

View File

@ -13,6 +13,7 @@ import {
getDatabaseSchemas,
getFKRelationships,
getSupportedOperators,
getTrackableFunctions,
getTableColumns,
getTablesListAsTree,
getTrackableTables,
@ -31,6 +32,7 @@ const getCreateSchemaSql = (schemaName: string) =>
export const postgres: Database = {
...defaultDatabaseProps,
introspection: {
getTrackableFunctions,
getVersion: async ({ dataSourceName, httpClient }: GetVersionProps) => {
const result = await runSQL({
source: {

View File

@ -0,0 +1,44 @@
import { runSQL, RunSQLResponse } from '../../api';
import { GetTrackableFunctionProps, IntrospectedFunction } from '../../types';
const adaptIntrospectedFunctions = (
sqlResponse: RunSQLResponse
): IntrospectedFunction[] => {
return (sqlResponse.result ?? []).slice(1).map(row => ({
name: row[0],
qualifiedFunction: { name: row[0], schema: row[1] },
isVolatile: row[2] === 'VOLATILE',
}));
};
export const getTrackableFunctions = async ({
dataSourceName,
httpClient,
}: GetTrackableFunctionProps) => {
const sql = `
SELECT
pgp.proname AS function_name,
pn.nspname AS schema,
CASE
WHEN pgp.provolatile::text = 'i'::character(1)::text THEN 'IMMUTABLE'::text
WHEN pgp.provolatile::text = 's'::character(1)::text THEN 'STABLE'::text
WHEN pgp.provolatile::text = 'v'::character(1)::text THEN 'VOLATILE'::text
ELSE NULL::text
END AS function_type
FROM
pg_proc pgp JOIN pg_namespace pn ON pgp.pronamespace = pn.oid
WHERE
pn.nspname NOT IN ('information_schema') AND pn.nspname NOT LIKE 'pg_%';
`;
const sqlResult = await runSQL({
source: {
name: dataSourceName,
kind: 'postgres',
},
sql,
httpClient,
});
return adaptIntrospectedFunctions(sqlResult);
};

View File

@ -4,5 +4,6 @@ export { getTableColumns } from './getTableColumns';
export { getFKRelationships } from './getFKRelationships';
export { getTablesListAsTree } from './getTablesListAsTree';
export { getSupportedOperators } from './getSupportedOperators';
export { getTrackableFunctions } from './getTrackableFunctions';
export { getDatabaseSchemas } from './getDatabaseSchemas';
export { getIsTableView } from './getIsTableView';

View File

@ -10,6 +10,7 @@ import {
SourceToSourceRelationship,
SupportedDrivers,
Table,
QualifiedFunction,
} from '../hasura-metadata-types';
import type { NetworkArgs } from './api';
@ -170,6 +171,15 @@ export type GetDefaultQueryRootProps = {
table: Table;
};
export type GetTrackableFunctionProps = {
dataSourceName: string;
} & NetworkArgs;
export type IntrospectedFunction = {
name: string;
qualifiedFunction: QualifiedFunction;
isVolatile: boolean;
};
export type GetDatabaseSchemaProps = {
dataSourceName: string;
} & NetworkArgs;

View File

@ -1,7 +1,7 @@
import { useState } from 'react';
import produce from 'immer';
export const useCheckRows = <T>(data: (T & { id: string })[]) => {
export const useCheckRows = <T extends { id: string }>(data: T[]) => {
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// Derived statuses

View File

@ -39,8 +39,8 @@ export type SourceCustomization = {
naming_convention?: NamingConvention;
};
export type PGFunction = {
function: string | { name: string; schema: string };
export type MetadataFunction = {
function: QualifiedFunction;
configuration?: {
custom_name?: string;
custom_root_fields?: {
@ -56,11 +56,11 @@ export type Source = {
name: string;
tables: MetadataTable[];
customization?: SourceCustomization;
functions?: MetadataFunction[];
} & (
| {
kind: 'postgres';
configuration: PostgresConfiguration;
functions?: PGFunction[];
}
| {
kind: 'mssql';
@ -83,3 +83,5 @@ export type Source = {
configuration: unknown;
}
);
export type QualifiedFunction = unknown;