console (feature): add support for native query relationships and editing

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9523
Co-authored-by: Matthew Goodwin <49927862+m4ttheweric@users.noreply.github.com>
GitOrigin-RevId: 968a45a82cddc3d7a7d90c8edb61454768644f98
This commit is contained in:
Vijay Prasanna 2023-07-13 22:28:04 +05:30 committed by hasura-bot
parent 65316ee5b9
commit 8aec505707
36 changed files with 1810 additions and 318 deletions

1
frontend/.gitignore vendored
View File

@ -42,6 +42,7 @@ Thumbs.db
# Env
.env
.serve.env
npm-debug.log*
yarn-debug.log*

View File

@ -43,10 +43,8 @@ import {
LogicalModelPermissionsRoute,
} from '../../../features/Data/LogicalModels';
import { ManageFunction } from '../../../features/Data/ManageFunction/ManageFunction';
import {
UpdateNativeQueryRoute,
AddNativeQueryRoute,
} from '../../../features/Data/LogicalModels/AddNativeQuery';
import { AddNativeQueryRoute } from '../../../features/Data/LogicalModels/AddNativeQuery';
import { NativeQueryLandingPage } from '../../../features/Data/LogicalModels/AddNativeQuery/NativeQueryLandingPage';
const makeDataRouter = (
connect,
@ -89,10 +87,7 @@ const makeDataRouter = (
<Route path="native-queries">
<IndexRoute component={NativeQueries} />
<Route path="create" component={AddNativeQueryRoute} />
<Route
path="native-query/:source/:name"
component={UpdateNativeQueryRoute}
/>
<Route path="logical-models">
<IndexRoute component={NativeQueries} />
<Redirect from=":source" to="/data/native-queries/logical-models" />
@ -104,11 +99,16 @@ const makeDataRouter = (
/>
</Route>
</Route>
<Route path="stored-procedures" component={NativeQueries} />
<Route
path="stored-procedures/track"
component={TrackStoredProcedureRoute}
/>
<Route path=":source/:name" component={NativeQueryLandingPage}>
<IndexRedirect to="details" />
<Route path=":tabName" component={NativeQueryLandingPage} />
</Route>
</Route>
<Route path="manage/connect" component={ConnectDatabase} />
<Route path="manage/create" component={ConnectedCreateDataSourcePage} />

View File

@ -7,7 +7,6 @@ import { NativeQuery } from '../../../hasura-metadata-types';
import { RouteWrapper } from '../components/RouteWrapper';
import { AddNativeQuery } from './AddNativeQuery';
import { nativeQueryHandlers } from './mocks';
import { normalizeArguments } from './utils';
type Story = StoryObj<typeof AddNativeQuery>;
@ -79,9 +78,7 @@ const fillAndSubmitForm: Story['play'] = async ({ canvasElement }) => {
};
const defaultArgs: Story['args'] = {
defaultFormValues: {
code: `SELECT * FROM (VALUES ('hello', 'world'), ('welcome', 'friend')) as t("one", "two")`,
},
defaultSql: `SELECT * FROM (VALUES ('hello', 'world'), ('welcome', 'friend')) as t("one", "two")`,
};
export const Basic: Story = {
@ -222,6 +219,8 @@ const existingNativeQuery: Required<NativeQuery> = {
code: "select\n AlbumId,\n a.ArtistId,\n Title [AlbumTitle],\n a.Name [Artist],\n (\n select\n count(*)\n from\n Track t\n where\n t.AlbumId = b.AlbumId\n ) [TrackCount]\n from\n album b\n join artist a on a.ArtistId = b.ArtistId\n \n \n -- search option for later: \n WHERE\n b.Title like '%' + {{query}} + '%'",
returns: 'hello_world',
root_field_name: 'AlbumDetail',
object_relationships: [],
array_relationships: [],
};
export const Update: Story = {
@ -233,11 +232,9 @@ export const Update: Story = {
}),
},
args: {
mode: 'update',
defaultFormValues: {
...existingNativeQuery,
source: 'postgres',
arguments: normalizeArguments(existingNativeQuery.arguments ?? {}),
editDetails: {
nativeQuery: existingNativeQuery,
dataSourceName: 'postgres',
},
},
play: async ({ canvasElement }) => {

View File

@ -1,50 +1,59 @@
import React from 'react';
import { FaPlusCircle, FaSave } from 'react-icons/fa';
import { FaSave } from 'react-icons/fa';
import { useHasuraAlert } from '../../../../new-components/Alert';
import { Button } from '../../../../new-components/Button';
import {
GraphQLSanitizedInputField,
InputField,
Select,
useConsoleForm,
} from '../../../../new-components/Form';
// import { FormDebugWindow } from '../../../../new-components/Form/dev-components/FormDebugWindow';
import Skeleton from 'react-loading-skeleton';
import { useConsoleForm } from '../../../../new-components/Form';
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
import { hasuraToast } from '../../../../new-components/Toasts';
import { usePushRoute } from '../../../ConnectDBRedesign/hooks';
import { Feature } from '../../../DataSource';
import {
useEnvironmentState,
usePushRoute,
} from '../../../ConnectDBRedesign/hooks';
import { Feature, nativeDrivers } from '../../../DataSource';
import { useMetadata } from '../../../hasura-metadata-api';
import { NativeQuery } from '../../../hasura-metadata-types';
import { useSupportedDataTypes } from '../../hooks/useSupportedDataTypes';
import { useTrackNativeQuery } from '../../hooks/useTrackNativeQuery';
import { LogicalModelWidget } from '../LogicalModelWidget/LogicalModelWidget';
import { ArgumentsField } from './components/ArgumentsField';
import { SqlEditorField } from './components/SqlEditorField';
import { NativeQueryFormFields } from './components/NativeQueryDetailsForm';
import { schema } from './schema';
import { NativeQueryForm } from './types';
import { transformFormOutputToMetadata } from './utils';
import { useSupportedDrivesForNativeQueries } from '../hook';
import { LimitedFeatureWrapper } from '../../../ConnectDBRedesign/components/LimitedFeatureWrapper/LimitedFeatureWrapper';
import { normalizeArguments, transformFormOutputToMetadata } from './utils';
import { useIsStorybook } from '../../../../utils/StoryUtils';
type AddNativeQueryProps = {
defaultFormValues?: Partial<NativeQueryForm>;
mode?: 'create' | 'update';
editDetails?: {
nativeQuery: NativeQuery;
dataSourceName: string;
};
// this is purely for storybook
defaultSql?: string;
};
export const AddNativeQuery = ({
defaultFormValues,
mode = 'create',
editDetails,
defaultSql,
}: AddNativeQueryProps) => {
const { mode, defaultFormValues } = useDefaultFormValues({
editDetails,
defaultSql,
});
const {
Form,
methods: { watch, setValue },
methods: { watch, setValue, formState },
} = useConsoleForm({
schema,
options: { defaultValues: defaultFormValues },
options: {
defaultValues: defaultFormValues,
},
});
const formHasChanges = Object.keys(formState.dirtyFields).length > 0;
const push = usePushRoute();
const allowedDrivers = useSupportedDrivesForNativeQueries();
const { consoleType } = useEnvironmentState();
const allowedDrivers = consoleType === 'oss' ? ['postgres'] : nativeDrivers;
const {
data: sources,
@ -54,8 +63,6 @@ export const AddNativeQuery = ({
return s.metadata.sources.filter(s => allowedDrivers.includes(s.kind));
});
const selectedSource = watch('source');
React.useEffect(() => {
const subscription = watch((value, { name, type }) => {
if (name === 'source' && type === 'change') {
@ -67,24 +74,26 @@ export const AddNativeQuery = ({
return () => subscription.unsubscribe();
}, [watch]);
const logicalModels = sources?.find(
s => s.name === selectedSource
)?.logical_models;
const selectedSource = watch('source');
const { trackNativeQuery, isLoading: isSaving } = useTrackNativeQuery();
const [isLogicalModelsDialogOpen, setIsLogicalModelsDialogOpen] =
React.useState(false);
const { hasuraConfirm } = useHasuraAlert();
const handleFormSubmit = (values: NativeQueryForm) => {
const metadataNativeQuery = transformFormOutputToMetadata(values);
trackNativeQuery({
data: { ...metadataNativeQuery, source: values.source },
// if this is an "edit", supply the original root_field_name:
editDetails: editDetails
? { rootFieldName: editDetails.nativeQuery.root_field_name }
: undefined,
onSuccess: () => {
hasuraToast({
type: 'success',
message: `Successfully tracked native query as: ${values.root_field_name}`,
message: `Successfully ${
mode === 'create' ? 'tracked' : 'updated'
} native query as: ${values.root_field_name}`,
title: 'Track Native Query',
toastOptions: { duration: 3000 },
});
@ -102,23 +111,6 @@ export const AddNativeQuery = ({
});
};
const logicalModelSelectPlaceholder = () => {
if (!selectedSource) {
return 'Select a database first...';
} else if (!!selectedSource && (logicalModels ?? []).length === 0) {
return `No logical models found for ${selectedSource}.`;
} else {
return `Select a logical model...`;
}
};
const { data: isThereBigQueryOrMssqlSource } = useMetadata(
m =>
!!m.metadata.sources.find(
s => s.kind === 'mssql' || s.kind === 'bigquery'
)
);
/**
* Options for the data source types
*/
@ -146,117 +138,86 @@ export const AddNativeQuery = ({
);
return (
<div>
{mode === 'update' && (
<IndicatorCard status="info">
The current release does not support editing Native Queries. This
feature will be available in a future release. You can still edit
directly by modifying the Metadata.
</IndicatorCard>
)}
<Form onSubmit={handleFormSubmit}>
<fieldset disabled={mode === 'update'}>
{/* <FormDebugWindow /> */}
<div className="max-w-xl flex flex-col">
<GraphQLSanitizedInputField
name="root_field_name"
label="Native Query Name"
placeholder="Name that exposes this model in GraphQL API"
hideTips
/>
<InputField
name="comment"
label="Comment"
placeholder="A description of this logical model"
/>
<Select
name="source"
label="Database"
// saving prop for future update
//noOptionsMessage="No databases found."
loading={isSourcesLoading}
options={(sources ?? []).map(m => ({
label: m.name,
value: m.name,
}))}
placeholder="Select a database..."
/>
</div>
<div className="max-w-4xl">
{isThereBigQueryOrMssqlSource && (
<LimitedFeatureWrapper
title="Looking to add Native Queries for SQL Server/Big Query databases?"
id="native-queries"
description="Get production-ready today with a 30-day free trial of Hasura EE, no credit card required."
/>
)}
</div>
<Form onSubmit={handleFormSubmit}>
<div className="py-2" />
<NativeQueryFormFields
isIntrospectionLoading={isIntrospectionLoading}
isSourcesLoading={isSourcesLoading}
typeOptions={typeOptions}
sources={sources}
/>
<div className="sticky bottom-0 z-10 bg-slate-50 p-3 border-t-slate-200 border-t flex flex-row justify-end gap-2 ">
<Button
type={'button'}
onClick={() => {
if (formHasChanges) {
hasuraConfirm({
title: 'Unsaved changes!',
confirmText: 'Discard Changes',
cancelText: 'Stay Here',
destructive: true,
message:
'Are you sure you want to leave this page? Your changes will not be saved.',
{isIntrospectionLoading ? (
<div>
<Skeleton />
<Skeleton />
</div>
) : (
<ArgumentsField types={typeOptions} />
)}
<SqlEditorField />
<div className="flex w-full">
{/* Logical Model Dropdown */}
<Select
name="returns"
selectClassName="max-w-xl"
// saving prop for future update
// noOptionsMessage={
// !selectedSource ? 'Select a database first.' : 'No models found.'
// }
// force component re-init on source change
//key={selectedSource}
label="Query Return Type"
placeholder={logicalModelSelectPlaceholder()}
loading={isSourcesLoading}
options={(logicalModels ?? []).map(m => ({
label: m.name,
value: m.name,
}))}
/>
<Button
icon={<FaPlusCircle />}
onClick={e => {
setIsLogicalModelsDialogOpen(true);
}}
>
Add Logical Model
</Button>
</div>
{isLogicalModelsDialogOpen ? (
<LogicalModelWidget
onCancel={() => {
setIsLogicalModelsDialogOpen(false);
}}
onSubmit={() => {
setIsLogicalModelsDialogOpen(false);
}}
asDialog
/>
) : null}
<div className="flex flex-row justify-end gap-2">
{/*
Validate Button will remain hidden until we have more information about how to handle standalone validation
Slack thread: https://hasurahq.slack.com/archives/C04LV93JNSH/p1682965503376129
*/}
{/* <Button icon={<FaPlay />}>Validate</Button> */}
<Button
type="submit"
icon={<FaSave />}
mode="primary"
isLoading={isSaving}
>
Save
</Button>
</div>
</fieldset>
</Form>
</div>
onClose: ({ confirmed }) => {
if (confirmed) push('/data/native-queries');
},
});
} else {
push('/data/native-queries');
}
}}
>
Cancel
</Button>
<Button
type="submit"
icon={<FaSave />}
mode="primary"
isLoading={isSaving}
>
Save
</Button>
</div>
</Form>
);
};
// this is kind of a distracting bit of logic for the component, so just hiding it here
// it
function useDefaultFormValues({
editDetails,
defaultSql,
}: AddNativeQueryProps) {
const mode = editDetails ? 'update' : 'create';
const _defaultFormValues: Partial<NativeQueryForm> = React.useMemo(
() =>
mode === 'update'
? {
...editDetails?.nativeQuery,
source: editDetails?.dataSourceName,
arguments: normalizeArguments(
editDetails?.nativeQuery?.arguments ?? {}
),
}
: {},
[mode]
);
const { isStorybook } = useIsStorybook();
const defaultFormValues = _defaultFormValues;
if (defaultSql) {
if (!isStorybook)
throw new Error('defaultSql prop is only allowed in Storybook.');
defaultFormValues.code = defaultSql;
}
return {
mode,
defaultFormValues,
};
}

View File

@ -0,0 +1,102 @@
import { InjectedRouter, withRouter } from 'react-router';
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
import { Tabs } from '../../../../new-components/Tabs';
import { usePushRoute } from '../../../ConnectDBRedesign/hooks';
import { MetadataSelectors, useMetadata } from '../../../hasura-metadata-api';
import { NativeQueryRelationships } from '../NativeQueryRelationships/NativeQueryRelationships';
import { RouteWrapper } from '../components/RouteWrapper';
import { injectRouteDetails } from '../components/route-wrapper-utils';
import { NATIVE_QUERY_ROUTES } from '../constants';
import { AddNativeQuery } from './AddNativeQuery';
type allowedTabs = 'details' | 'relationships';
export type NativeQueryLandingPage = {
params: { source: string; name: string; tabName?: allowedTabs };
location: Location;
router: InjectedRouter;
};
export const NativeQueryLandingPage = withRouter<{
location: Location;
router: InjectedRouter;
params: { source: string; name: string; tabName?: allowedTabs };
}>(props => {
const {
params: { source: dataSourceName, name: nativeQueryName, tabName },
} = props;
const { data: nativeQuery } = useMetadata(
MetadataSelectors.findNativeQuery(dataSourceName, nativeQueryName)
);
const push = usePushRoute();
const route: keyof typeof NATIVE_QUERY_ROUTES =
'/data/native-queries/{{source}}/{{name}}/{{tab}}';
if (!nativeQuery) {
return (
<IndicatorCard status="negative">
Native Query {nativeQueryName} not found in {dataSourceName}
</IndicatorCard>
);
}
const relationshipsCount =
(nativeQuery.array_relationships?.length ?? 0) +
(nativeQuery.object_relationships?.length ?? 0);
return (
<RouteWrapper
route={route}
itemSourceName={dataSourceName}
itemName={nativeQuery?.root_field_name}
itemTabName={tabName}
subtitle={
tabName === 'details'
? 'Make changes to your Native Query'
: 'Add/Remove relationships to your Native Query'
}
>
<Tabs
value={tabName ?? 'details'}
onValueChange={tab =>
push(
injectRouteDetails(route, {
itemName: nativeQuery.root_field_name,
itemSourceName: dataSourceName,
itemTabName: tab,
})
)
}
items={[
{
content: (
<AddNativeQuery editDetails={{ dataSourceName, nativeQuery }} />
),
label: 'Details',
value: 'details',
},
{
content: (
<NativeQueryRelationships
dataSourceName={dataSourceName}
nativeQueryName={nativeQueryName}
/>
),
label: (
<div className="flex items-center" data-testid="untracked-tab">
Relationships
<span className="bg-gray-300 ml-1 px-1.5 py-0.5 rounded text-xs">
{relationshipsCount}
</span>
</div>
),
value: 'relationships',
},
]}
/>
</RouteWrapper>
);
});

View File

@ -1,58 +0,0 @@
import { InjectedRouter, withRouter } from 'react-router';
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
import { MetadataUtils, useMetadata } from '../../../hasura-metadata-api';
import { RouteWrapper } from '../components/RouteWrapper';
import { AddNativeQuery } from './AddNativeQuery';
import { normalizeArguments } from './utils';
import Skeleton from 'react-loading-skeleton';
export const UpdateNativeQueryRoute = withRouter<{
location: Location;
router: InjectedRouter;
params: { source: string; name: string };
}>(({ location, router, params: { source, name } }) => {
if (!source || !name) {
return (
<IndicatorCard status="negative">
Unable to parse data from url.
</IndicatorCard>
);
}
const { data: sourceQueries, isLoading } = useMetadata(
m => MetadataUtils.findMetadataSource(source, m)?.native_queries
);
const nativeQuery = sourceQueries?.find(s => s.root_field_name === name);
if (!nativeQuery) {
return (
<IndicatorCard status="negative">
Native Query {name} not found in {source}
</IndicatorCard>
);
}
return (
<RouteWrapper
route={'/data/native-queries/{{source}}/{{name}}'}
itemSourceName={source}
itemName={name}
>
{isLoading ? (
<div>
<Skeleton count={10} />
</div>
) : (
<AddNativeQuery
mode={'update'}
defaultFormValues={{
...nativeQuery,
source: source,
arguments: normalizeArguments(nativeQuery.arguments ?? {}),
}}
/>
)}
</RouteWrapper>
);
});

View File

@ -0,0 +1,144 @@
import { FaPlusCircle } from 'react-icons/fa';
import { Button } from '../../../../../new-components/Button';
import {
GraphQLSanitizedInputField,
InputField,
Select,
} from '../../../../../new-components/Form';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import Skeleton from 'react-loading-skeleton';
import { Source } from '../../../../hasura-metadata-types';
import { LogicalModelWidget } from '../../LogicalModelWidget/LogicalModelWidget';
import { ArgumentsField } from '../components/ArgumentsField';
import { SqlEditorField } from '../components/SqlEditorField';
import { NativeQueryForm } from '../types';
import { LimitedFeatureWrapper } from '../../../../ConnectDBRedesign/components/LimitedFeatureWrapper/LimitedFeatureWrapper';
import { useMetadata } from '../../../../hasura-metadata-api';
export const NativeQueryFormFields = ({
isSourcesLoading,
sources,
isIntrospectionLoading,
typeOptions,
}: {
sources?: Source[];
isSourcesLoading: boolean;
isIntrospectionLoading: boolean;
typeOptions: string[];
}) => {
const { watch, setValue } = useFormContext<NativeQueryForm>();
const selectedSource = watch('source');
const logicalModels = sources?.find(
s => s.name === selectedSource
)?.logical_models;
const logicalModelSelectPlaceholder = () => {
if (!selectedSource) {
return 'Select a database first...';
}
if (logicalModels?.length === 0) {
return `No logical models found for ${selectedSource}.`;
}
return 'Select a logical model...';
};
const { data: isThereBigQueryOrMssqlSource } = useMetadata(
m =>
!!m.metadata.sources.find(
s => s.kind === 'mssql' || s.kind === 'bigquery'
)
);
const [isLogicalModelsDialogOpen, setIsLogicalModelsDialogOpen] =
React.useState(false);
return (
<>
<div className="max-w-xl flex flex-col">
<GraphQLSanitizedInputField
name="root_field_name"
label="Native Query Name"
placeholder="Name that exposes this model in GraphQL API"
hideTips
/>
<InputField
name="comment"
label="Comment"
placeholder="A description of this logical model"
/>
<Select
name="source"
label="Database"
// saving prop for future update
//noOptionsMessage="No databases found."
loading={isSourcesLoading}
options={(sources ?? []).map(m => ({
label: m.name,
value: m.name,
}))}
placeholder="Select a database..."
/>
</div>
<div className="max-w-4xl">
{isThereBigQueryOrMssqlSource && (
<LimitedFeatureWrapper
title="Looking to add Native Queries for SQL Server/Big Query databases?"
id="native-queries"
description="Get production-ready today with a 30-day free trial of Hasura EE, no credit card required."
/>
)}
</div>
{isIntrospectionLoading ? (
<div>
<Skeleton />
<Skeleton />
</div>
) : (
<ArgumentsField types={typeOptions} />
)}
<SqlEditorField />
<div className="flex w-full">
{/* Logical Model Dropdown */}
<Select
name="returns"
selectClassName="max-w-xl"
label="Query Return Type"
placeholder={logicalModelSelectPlaceholder()}
loading={isSourcesLoading}
options={(logicalModels ?? []).map(m => ({
label: m.name,
value: m.name,
}))}
/>
<Button
icon={<FaPlusCircle />}
onClick={e => {
setIsLogicalModelsDialogOpen(true);
}}
>
Add Logical Model
</Button>
</div>
{isLogicalModelsDialogOpen ? (
<LogicalModelWidget
defaultValues={{ dataSourceName: selectedSource }}
disabled={{ dataSourceName: !!selectedSource }}
onCancel={() => {
setIsLogicalModelsDialogOpen(false);
}}
onSubmit={data => {
if (data.dataSourceName !== selectedSource) {
setValue('source', data.dataSourceName);
}
setIsLogicalModelsDialogOpen(false);
}}
asDialog
/>
) : null}
</>
);
};

View File

@ -1,3 +1,3 @@
export { AddNativeQueryRoute } from './AddNativeQuery.route';
export { AddNativeQuery } from './AddNativeQuery';
export { UpdateNativeQueryRoute } from './UpdateNativeQuery.route';
// export { UpdateNativeQueryRoute } from './UpdateNativeQuery.route';

View File

@ -22,6 +22,11 @@ type HandlersOptions = {
enabledFeatureFlag?: boolean;
};
type BulkArgsType = {
type: string;
args: { root_field_name?: string; name?: string; source?: string };
};
export const nativeQueryHandlers = ({
metadataOptions,
trackNativeQueryResult = 'success',
@ -33,7 +38,7 @@ export const nativeQueryHandlers = ({
rest.post('http://localhost:8080/v1/metadata', async (req, res, ctx) => {
const reqBody = await req.json<{
type: string;
args: { root_field_name?: string; name?: string; source?: string };
args: BulkArgsType[];
}>();
const response = (
@ -45,7 +50,12 @@ export const nativeQueryHandlers = ({
return response(buildMetadata(metadataOptions));
}
if (reqBody.type.endsWith('_track_native_query')) {
// use the final bulk step as the command to reference:
const finalBulkStep = reqBody.args[reqBody.args.length - 1];
// get the type from the final step
const type = finalBulkStep.type;
if (type.endsWith('_track_native_query')) {
switch (trackNativeQueryResult) {
case 'success':
return response({ message: 'success' });
@ -53,7 +63,7 @@ export const nativeQueryHandlers = ({
return response(
{
code: 'already-tracked',
error: `Native query '${reqBody.args.root_field_name}' is already tracked.`,
error: `Native query '${finalBulkStep.args.root_field_name}' is already tracked.`,
path: '$.args',
},
400
@ -100,7 +110,7 @@ export const nativeQueryHandlers = ({
return response(
{
code: 'not-found',
error: `Native query "${reqBody.args.root_field_name}" not found in source "${reqBody.args.source}".`,
error: `Native query "${finalBulkStep.args.root_field_name}" not found in source "${finalBulkStep.args.source}".`,
path: '$.args',
},
400
@ -125,7 +135,7 @@ export const nativeQueryHandlers = ({
return response(
{
code: 'already-tracked',
error: `Logical model '${reqBody.args.name}' is already tracked.`,
error: `Logical model '${finalBulkStep.args.name}' is already tracked.`,
path: '$.args',
},
400
@ -149,7 +159,7 @@ export const nativeQueryHandlers = ({
return response(
{
code: 'not-found',
error: `Logical model "${reqBody.args.name}" not found in source "${reqBody.args.source}".`,
error: `Logical model "${finalBulkStep.args.name}" not found in source "${finalBulkStep.args.source}".`,
path: '$.args',
},
400
@ -158,7 +168,7 @@ export const nativeQueryHandlers = ({
return response(
{
code: 'constraint-violation',
error: `Custom type "${reqBody.args.name}" still being used by native query "hello_mssql_function".`,
error: `Custom type "${finalBulkStep.args.name}" still being used by native query "hello_mssql_function".`,
path: '$.args',
},
400

View File

@ -24,4 +24,36 @@ export const schema = implement<NativeQueryForm>().with({
.array(),
code: reqString('Sql Query'),
returns: reqString('Query Return Type'),
array_relationships: z
.array(
z.object({
name: z.string(),
using: z.object({
column_mapping: z.record(z.string()),
insertion_order: z.union([
z.literal('before_parent'),
z.literal('after_parent'),
z.literal(null),
]),
remote_native_query: z.string(),
}),
})
)
.optional(),
object_relationships: z
.array(
z.object({
name: z.string(),
using: z.object({
column_mapping: z.record(z.string()),
insertion_order: z.union([
z.literal('before_parent'),
z.literal('after_parent'),
z.literal(null),
]),
remote_native_query: z.string(),
}),
})
)
.optional(),
});

View File

@ -20,6 +20,14 @@ export const transformFormOutputToMetadata = (
};
}, {});
// remove source from the object and get the rest...
const { source, ...rest } = formValues;
return {
...rest,
arguments: queryArgsForMetadata,
};
const { code, returns, root_field_name, comment, type } = formValues;
return {

View File

@ -137,7 +137,7 @@ export const LandingPage = ({ pathname }: { pathname: string }) => {
isLoading={isLoading}
onEditClick={query => {
push?.(
`data/native-queries/native-query/${query.source.name}/${query.root_field_name}`
`data/native-queries/${query.source.name}/${query.root_field_name}`
);
}}
onRemoveClick={handleRemoveNativeQuery}

View File

@ -4,8 +4,7 @@ import {
useReactTable,
} from '@tanstack/react-table';
import React from 'react';
import { CgDetailsMore } from 'react-icons/cg';
import { FaTrash } from 'react-icons/fa';
import { FaEdit, FaTrash } from 'react-icons/fa';
import Skeleton from 'react-loading-skeleton';
import { Button } from '../../../../../new-components/Button';
import { CardedTableFromReactTable } from '../../components/CardedTableFromReactTable';
@ -46,14 +45,8 @@ export const ListNativeQueries = ({
header: 'Actions',
cell: ({ cell, row }) => (
<div className="flex flex-row gap-2">
<Button
// icon={<FaEdit />}
icon={<CgDetailsMore />}
onClick={() => onEditClick(row.original)}
>
{/* Edit */}
{/* Change back to Edit once we support it */}
View
<Button icon={<FaEdit />} onClick={() => onEditClick(row.original)}>
Edit
</Button>
<Button
mode="destructive"

View File

@ -25,9 +25,9 @@ import { formFieldToLogicalModelField } from './mocks/utils/formFieldToLogicalMo
import { useSupportedDrivesForNativeQueries } from '../hook';
export type AddLogicalModelDialogProps = {
defaultValues?: AddLogicalModelFormData;
defaultValues?: Partial<AddLogicalModelFormData>;
onCancel?: () => void;
onSubmit?: () => void;
onSubmit?: (data: AddLogicalModelFormData) => void;
disabled?: CreateBooleanMap<
AddLogicalModelFormData & {
callToAction?: boolean;
@ -115,7 +115,7 @@ export const LogicalModelWidget = (props: AddLogicalModelDialogProps) => {
type: 'success',
title: LOGICAL_MODEL_CREATE_SUCCESS,
});
props.onSubmit?.();
props.onSubmit?.(data);
},
onError: err => {
hasuraToast({

View File

@ -0,0 +1,200 @@
import {
LogicalModel,
LogicalModelField,
NativeQuery,
NativeQueryRelationship,
} from '../../hasura-metadata-types';
type CommonParams = {
dataSourceName: string;
driver: string;
};
interface MigrationPayloadBuilderParams<T extends NativeQuery | LogicalModel> {
entity: T;
commandEntity: 'logical_model' | 'native_query';
entityKey: keyof T;
}
/**
* This class lets you build a sequence of metadata commands for bulk_atomic
*
* The track() / untrack() methods should be used to add commands to the sequence
*
* The other methods mutate the nativeQuery initially passed in that will get used in the track() command
*/
export class MigrationPayloadBuilder<T extends NativeQuery | LogicalModel> {
protected payloadSequence: Record<string, unknown>[] = [];
protected source: string;
protected driver: string;
protected entity: T;
protected commandEntity: 'logical_model' | 'native_query';
protected entityKey: keyof T;
constructor({
dataSourceName,
driver,
entity,
commandEntity,
entityKey,
}: CommonParams & MigrationPayloadBuilderParams<T>) {
if (!dataSourceName) {
throw new Error('Source is required');
}
if (!driver) {
throw new Error('Driver is required');
}
if (!entity) {
throw new Error('Native Query is required');
}
if (!commandEntity) {
throw new Error('Command is required');
}
this.source = dataSourceName;
this.driver = driver;
this.entity = entity;
this.commandEntity = commandEntity;
this.entityKey = entityKey;
return this;
}
// can update with some or all propeties
updateEntity(newEntity: Partial<T>): this {
this.entity = { ...this.entity, ...newEntity };
return this;
}
/**
*
* @param entityKey Optional: can provide the name or it will default to the name of the entity passed into the constructor
*/
untrack(entityKey?: string): this {
this.payloadSequence = [
...this.payloadSequence,
{
type: `${this.driver}_untrack_${this.commandEntity}`,
args: {
source: this.source,
[this.entityKey]: entityKey ?? this.entity[this.entityKey],
},
},
];
return this;
}
track(): this {
this.payloadSequence = [
...this.payloadSequence,
{
type: `${this.driver}_track_${this.commandEntity}`,
args: {
source: this.source,
...this.entity,
},
},
];
return this;
}
// returns the payload sequence
payload(): Record<string, unknown>[] {
return this.payloadSequence;
}
}
export class NativeQueryMigrationBuilder extends MigrationPayloadBuilder<NativeQuery> {
constructor({
dataSourceName,
driver,
nativeQuery,
}: CommonParams & {
nativeQuery: NativeQuery;
}) {
super({
dataSourceName,
driver,
commandEntity: 'native_query',
entity: nativeQuery,
entityKey: 'root_field_name',
});
this.initializeRelationshipArrays();
return this;
}
private initializeRelationshipArrays() {
if (!this.entity.array_relationships) {
this.entity.array_relationships = [];
}
if (!this.entity.object_relationships) {
this.entity.object_relationships = [];
}
}
addRelationship(
type: 'object' | 'array',
relationshipDetails: NativeQueryRelationship
): this {
const relationshipsKey =
type === 'object' ? 'object_relationships' : 'array_relationships';
// call this just in case it was modified between constructor and here
this.initializeRelationshipArrays();
this.entity[relationshipsKey]?.push(relationshipDetails);
return this;
}
// removes any relationships that match a string name
removeRelationship(type: 'object' | 'array', name: string): this {
const relationshipsKey =
type === 'object' ? 'object_relationships' : 'array_relationships';
// call this just in case it was modified between constructor and here
this.initializeRelationshipArrays();
this.entity[relationshipsKey] = this.entity[relationshipsKey]?.filter(
rel => rel.name !== name
);
return this;
}
override track(): this {
// this makes sure it's marked as query which for now is hard coded but not part of the form or UI
this.entity.type = 'query';
return super.track();
}
}
export class LogicalModelMigrationBuilder extends MigrationPayloadBuilder<LogicalModel> {
constructor({
dataSourceName,
driver,
logicalModel,
}: CommonParams & { logicalModel: LogicalModel }) {
super({
dataSourceName,
driver,
entity: logicalModel,
entityKey: 'name',
commandEntity: 'logical_model',
});
return this;
}
addField(field: LogicalModelField): this {
this.entity.fields.push(field);
return this;
}
removeField(name: string): this {
this.entity.fields = this.entity.fields.filter(f => f.name === name);
return this;
}
}

View File

@ -0,0 +1,193 @@
import { AiOutlineReload } from 'react-icons/ai';
import { Button } from '../../../../new-components/Button';
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
import { hasuraToast } from '../../../../new-components/Toasts';
import { MetadataSelectors, useMetadata } from '../../../hasura-metadata-api';
import { DisplayToastErrorMessage } from '../../components/DisplayErrorMessage';
import { useTrackNativeQueryRelationships } from '../../hooks/useTrackNativeQueryRelationships/useTrackNativeQueryRelationships';
import { ListNativeQueryRelationships } from './components';
import { useWidget } from './hooks/useWidget';
import Skeleton from 'react-loading-skeleton';
import { ListNativeQueryRow } from './components/ListNativeQueryRelationships';
import { useDestructiveAlert } from '../../../../new-components/Alert';
import { useCallback } from 'react';
export type NativeQueryRelationshipProps = {
dataSourceName: string;
nativeQueryName: string;
};
export const NativeQueryRelationships = (
props: NativeQueryRelationshipProps
) => {
const { dataSourceName, nativeQueryName } = props;
const { untrackNativeQueryRelationship, trackNativeQueryRelationship } =
useTrackNativeQueryRelationships(dataSourceName, nativeQueryName);
const {
data: sourceNativeQuery,
isLoading: isMetadataLoading,
error: sourceNativeQueryNotFoundError,
refetch,
isRefetching,
} = useMetadata(m =>
MetadataSelectors.findNativeQuery(dataSourceName, nativeQueryName)(m)
);
const handleError = useCallback((err: Error) => {
const error: string = err.message;
try {
const parsed = JSON.parse(error);
if (Array.isArray(parsed) && Array.isArray(parsed[parsed.length - 1])) {
const lastItem = parsed[parsed.length - 1];
if (Array.isArray(lastItem)) {
const listOfReasons: string[] = [];
lastItem.forEach(err => {
if ('reason' in err) {
listOfReasons.push(err.reason);
}
});
hasuraToast({
type: 'error',
title: 'Failed to edit relationship',
children: (
<DisplayToastErrorMessage message={listOfReasons.join('\n')} />
),
});
}
} else {
throw new Error(
'Error message is not an array, falling back to showing error.message in raw string format'
);
}
} catch {
hasuraToast({
type: 'error',
title: 'Failed to edit relationship',
children: <DisplayToastErrorMessage message={err.message} />,
});
}
}, []);
const { WidgetUI, openCreate, openEdit, closeWidget } = useWidget({
nativeQueryName,
dataSourceName,
onSubmit: params => {
const { values, mode } = params;
trackNativeQueryRelationship({
data: {
name: values.name,
type: values.type,
using: {
column_mapping: values.columnMapping,
remote_native_query: values.toNativeQuery,
insertion_order: null,
},
},
editDetails:
params.mode === 'edit'
? {
name: params.originalRelationshipName,
type: params.originalRelationshipType,
}
: undefined,
onSuccess: () => {
closeWidget();
hasuraToast({
type: 'success',
title: `Successfully ${
mode === 'create' ? 'created' : 'edited'
} relationship "${values.name}"`,
});
},
onError: handleError,
});
},
});
const { destructiveConfirm } = useDestructiveAlert();
const handleDelete = (data: ListNativeQueryRow) => {
destructiveConfirm({
resourceName: data.name,
resourceType: 'relationship',
destroyTerm: 'remove',
onConfirm: () =>
new Promise(resolve => {
untrackNativeQueryRelationship({
data: {
name: data.name,
type: data.type,
},
onSuccess: () => {
resolve(true);
},
onError: err => {
resolve(false);
hasuraToast({
type: 'error',
title: 'Failed to delete relationship',
children: <DisplayToastErrorMessage message={err.message} />,
});
},
});
}),
});
};
const handleEdit = (data: ListNativeQueryRow) => {
openEdit({
name: data.name,
columnMapping: data.using.column_mapping,
type: data.type,
toNativeQuery: data.using.remote_native_query,
});
};
if (isMetadataLoading) return <Skeleton count={10} />;
if (sourceNativeQueryNotFoundError)
return (
<div>
<IndicatorCard status="negative">
{sourceNativeQueryNotFoundError.message ??
JSON.stringify(sourceNativeQueryNotFoundError)}
</IndicatorCard>
</div>
);
if (!sourceNativeQuery)
return (
<div>
<IndicatorCard status="info">
Could not find Native Query : {nativeQueryName} in metadata. Please
reload metadata and try again.
<Button
icon={<AiOutlineReload />}
onClick={() => refetch()}
isLoading={isRefetching}
>
Reload Metadata
</Button>
</IndicatorCard>
</div>
);
return (
<div>
<div className="h-4" />
<ListNativeQueryRelationships
dataSourceName={dataSourceName}
nativeQueryName={nativeQueryName}
onEditRow={handleEdit}
onDeleteRow={handleDelete}
/>
<Button mode="primary" onClick={() => openCreate()}>
Add Relationship
</Button>
{WidgetUI()}
</div>
);
};

View File

@ -0,0 +1,34 @@
import { StoryObj, Meta } from '@storybook/react';
import { ReactQueryDecorator } from '../../../../../storybook/decorators/react-query';
import { ListNativeQueryRelationships } from './ListNativeQueryRelationships';
import { ReduxDecorator } from '../../../../../storybook/decorators/redux-decorator';
import { nativeQueryHandlers } from '../../AddNativeQuery/mocks';
export default {
component: ListNativeQueryRelationships,
decorators: [
ReactQueryDecorator(),
ReduxDecorator({
tables: {},
}),
],
parameters: {
msw: nativeQueryHandlers({
metadataOptions: { postgres: { models: true, queries: true } },
trackNativeQueryResult: 'success',
}),
layout: 'fullscreen',
},
argTypes: {
onDeleteRow: { action: 'clicked delete' },
onEditRow: { action: 'clicked edit' },
},
} as Meta<typeof ListNativeQueryRelationships>;
export const DefaultView: StoryObj<typeof ListNativeQueryRelationships> = {
args: {
dataSourceName: 'postgres',
nativeQueryName: 'customer_native_query',
},
render: args => <ListNativeQueryRelationships {...args} />,
};

View File

@ -0,0 +1,116 @@
import Skeleton from 'react-loading-skeleton';
import {
MetadataSelectors,
useMetadata,
} from '../../../../hasura-metadata-api';
import {
createColumnHelper,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import React from 'react';
import { useCardedTableFromReactTableWithRef } from '../../components/CardedTableFromReactTable';
import { Button } from '../../../../../new-components/Button';
import { FaEdit, FaTrash } from 'react-icons/fa';
import { NativeQueryRelationship } from '../../../../hasura-metadata-types';
export type ListNativeQueryRow = NativeQueryRelationship & {
type: 'object' | 'array';
};
export type ListNativeQueryRelationships = {
dataSourceName: string;
nativeQueryName: string;
onDeleteRow?: (data: ListNativeQueryRow) => void;
onEditRow?: (data: ListNativeQueryRow) => void;
};
const columnHelper = createColumnHelper<ListNativeQueryRow>();
export const ListNativeQueryRelationships = (
props: ListNativeQueryRelationships
) => {
const { dataSourceName, nativeQueryName, onDeleteRow, onEditRow } = props;
const { data: nativeQueryRelationships = [], isLoading } = useMetadata<
ListNativeQueryRow[]
>(m => {
const currentNativeQuery = MetadataSelectors.findNativeQuery(
dataSourceName,
nativeQueryName
)(m);
return [
...(currentNativeQuery?.array_relationships?.map(relationship => ({
...relationship,
type: 'array' as ListNativeQueryRow['type'],
})) ?? []),
...(currentNativeQuery?.object_relationships?.map(relationship => ({
...relationship,
type: 'object' as ListNativeQueryRow['type'],
})) ?? []),
];
});
const tableRef = React.useRef<HTMLDivElement>(null);
const columns = React.useMemo(
() => [
columnHelper.accessor('name', {
id: 'name',
cell: data => <span>{data.getValue()}</span>,
header: 'Name',
}),
columnHelper.accessor('type', {
id: 'type',
cell: data => <span>{data.getValue()}</span>,
header: 'Type',
}),
columnHelper.display({
id: 'actions',
cell: ({ row }) => (
<div className="flex gap-8">
<Button
icon={<FaEdit />}
onClick={() => {
onEditRow?.(row.original);
}}
>
Edit
</Button>
<Button
icon={<FaTrash />}
mode="destructive"
onClick={() => {
onDeleteRow?.(row.original);
}}
>
Delete
</Button>
</div>
),
header: 'Actions',
}),
],
[onDeleteRow, onEditRow]
);
const relationshipsTable = useReactTable({
data: nativeQueryRelationships,
columns: columns,
getCoreRowModel: getCoreRowModel(),
});
const NativeQueryRelationshipsTable =
useCardedTableFromReactTableWithRef<ListNativeQueryRow>();
if (isLoading) return <Skeleton count={10} />;
return (
<NativeQueryRelationshipsTable
table={relationshipsTable}
ref={tableRef}
noRowsMessage={'No relationships added'}
/>
);
};

View File

@ -0,0 +1,165 @@
import Skeleton from 'react-loading-skeleton';
import { Button } from '../../../../../new-components/Button';
import { Dialog } from '../../../../../new-components/Dialog';
import { useConsoleForm } from '../../../../../new-components/Form';
import { MetadataUtils, useMetadata } from '../../../../hasura-metadata-api';
import {
NativeQueryRelationshipFormSchema,
nativeQueryRelationshipValidationSchema,
} from '../schema';
import { TrackNativeQueryRelationshipForm } from './TrackNativeQueryRelationshipForm';
import { useCallback } from 'react';
import { IndicatorCard } from '../../../../../new-components/IndicatorCard';
export type NativeQueryRelationshipWidgetProps = {
fromNativeQuery: string;
dataSourceName: string;
defaultValues?: NativeQueryRelationshipFormSchema;
onCancel?: () => void;
onSubmit?: (data: NativeQueryRelationshipFormSchema) => void;
mode: 'create' | 'edit';
asDialog?: boolean;
isLoading?: boolean;
};
export const NativeQueryRelationshipWidget = ({
fromNativeQuery,
dataSourceName,
asDialog,
...props
}: NativeQueryRelationshipWidgetProps) => {
const isEditMode = props.mode === 'edit';
const {
Form,
methods: { watch, handleSubmit },
} = useConsoleForm({
schema: nativeQueryRelationshipValidationSchema,
options: {
defaultValues: props.defaultValues,
},
});
const targetNativeQuery = watch();
const metadataSelector = useCallback(
m => {
const source = MetadataUtils.findMetadataSource(dataSourceName, m);
if (!source)
throw new Error(
`Unabled to find source ${dataSourceName} for Native Query Relationships Widget`
);
const data = {
models: source.logical_models ?? [],
queries: source.native_queries ?? [],
};
const fromQuery = data.queries.find(
q => q.root_field_name === fromNativeQuery
);
const targetQuery = data.queries.find(
q => q.root_field_name === targetNativeQuery.toNativeQuery
);
const fromModel = data.models.find(
model => model.name === fromQuery?.returns
);
const targetModel = data.models.find(
model => model.name === targetQuery?.returns
);
return {
...data,
fromQuery,
targetQuery,
fromModel,
targetModel,
};
},
[dataSourceName, fromNativeQuery, targetNativeQuery.toNativeQuery]
);
const { data, error: metadataError, status } = useMetadata(metadataSelector);
const onSubmit = (data: NativeQueryRelationshipFormSchema) => {
// add/remove relationship
props?.onSubmit?.(data);
};
const fields = () => {
if (status !== 'success') return null;
return (
<TrackNativeQueryRelationshipForm
fromNativeQuery={data.fromQuery?.root_field_name ?? ''}
nativeQueryOptions={data.queries
.map(q => q.root_field_name)
.filter(q => q !== fromNativeQuery)}
fromFieldOptions={data.fromModel?.fields.map(f => f.name) ?? []}
toFieldOptions={data.targetModel?.fields.map(f => f.name) ?? []}
/>
);
};
const body = () => {
if (status === 'loading') {
return <Skeleton count={8} height={20} />;
}
if (status === 'error') {
return (
<IndicatorCard>
A metadata related error has occurred:
<div>
{metadataError.message ?? JSON.stringify(metadataError, null, 2)}
</div>
</IndicatorCard>
);
}
return (
<div className="px-md">
<Form onSubmit={!asDialog ? onSubmit : () => {}}>
{fields()}
{!asDialog && (
<div className="flex justify-end">
<Button type="submit" mode="primary">
{isEditMode ? 'Edit Relationship' : 'Add Relationship'}
</Button>
</div>
)}
</Form>
</div>
);
};
// for transparency, as of this comment, there are no !asDialog implementations of this component.
// see ../useWidget.tsx for implemenation
if (!asDialog) {
return body();
}
return (
<Dialog
size="xl"
description="Create object or arrary relationships between Native Queries."
footer={{
onSubmit: () => {
handleSubmit(onSubmit)();
},
onClose: props.onCancel,
isLoading: props.isLoading,
callToDeny: 'Cancel',
callToAction: 'Save',
onSubmitAnalyticsName: 'actions-tab-generate-types-submit',
onCancelAnalyticsName: 'actions-tab-generate-types-cancel',
}}
title={`${props.mode === 'create' ? 'Add' : 'Edit'} Relationship`}
hasBackdrop
onClose={props.onCancel}
>
{body()}
</Dialog>
);
};

View File

@ -0,0 +1,46 @@
import { Meta, StoryObj } from '@storybook/react';
import { Button } from '../../../../../new-components/Button';
import { SimpleForm } from '../../../../../new-components/Form';
import { ReactQueryDecorator } from '../../../../../storybook/decorators/react-query';
import { ReduxDecorator } from '../../../../../storybook/decorators/redux-decorator';
import { TrackNativeQueryRelationshipForm } from './TrackNativeQueryRelationshipForm';
import { nativeQueryRelationshipValidationSchema } from '../schema';
export default {
component: TrackNativeQueryRelationshipForm,
decorators: [
ReactQueryDecorator(),
ReduxDecorator({
tables: {
// dataHeaders: {
// 'x-hasura-admin-secret': 'myadminsecretkey',
// } as any,
},
}),
],
} as Meta<typeof TrackNativeQueryRelationshipForm>;
export const DefaultView: StoryObj<typeof TrackNativeQueryRelationshipForm> = {
render: () => {
return (
<SimpleForm
schema={nativeQueryRelationshipValidationSchema}
onSubmit={data => {
console.log(data);
}}
>
<TrackNativeQueryRelationshipForm
name="relationship"
fromNativeQuery="get_authors"
nativeQueryOptions={['get_authors', 'get_articles']}
fromFieldOptions={['field1', 'field2']}
toFieldOptions={['field3', 'field4']}
/>
<Button type="submit">Submit</Button>
</SimpleForm>
);
},
};
// write more tests for this part

View File

@ -0,0 +1,62 @@
import {
GraphQLSanitizedInputField,
Select,
} from '../../../../../new-components/Form';
import { ListMap } from '../../../../../new-components/ListMap';
export const TrackNativeQueryRelationshipForm = ({
name,
fromNativeQuery,
nativeQueryOptions,
fromFieldOptions,
toFieldOptions,
}: {
fromNativeQuery: string;
nativeQueryOptions: string[];
name?: string;
fromFieldOptions: string[];
toFieldOptions: string[];
}) => {
const allowedNativeQueryOptions = nativeQueryOptions
.filter(nq => nq !== fromNativeQuery)
.map(nq => ({ value: nq, label: nq }));
return (
<div>
<GraphQLSanitizedInputField
hideTips
label="Relationship Name"
placeholder="Name your native query relationship"
name={'name'}
/>
<Select
name={'toNativeQuery'}
options={allowedNativeQueryOptions}
label="Target Native Query"
placeholder="Select target native query"
/>
<Select
name={'type'}
options={[
{ value: 'object', label: 'Object' },
{ value: 'array', label: 'Array' },
]}
label="Relationship Type"
/>
<ListMap
name={'columnMapping'}
from={{
options: fromFieldOptions,
label: 'Source Field',
}}
to={{
type: 'array',
options: toFieldOptions,
label: 'Target Field',
}}
/>
</div>
);
};

View File

@ -0,0 +1,3 @@
export { ListNativeQueryRelationships } from './ListNativeQueryRelationships';
export { NativeQueryRelationshipWidget } from './NativeQueryRelationshipWidget';
export { TrackNativeQueryRelationshipForm } from './TrackNativeQueryRelationshipForm';

View File

@ -0,0 +1,177 @@
import { useCallback, useState } from 'react';
import { useHasuraAlert } from '../../../../../new-components/Alert';
import {
MetadataSelectors,
MetadataUtils,
useMetadata,
} from '../../../../hasura-metadata-api';
import { NativeQueryRelationshipWidget } from '../components';
import { NativeQueryRelationshipFormSchema } from '../schema';
type WidgetMode = 'create' | 'edit';
type onSubmitParams = {
values: NativeQueryRelationshipFormSchema;
} & (
| {
mode: 'edit';
originalRelationshipName: string;
originalRelationshipType: 'object' | 'array';
}
| { mode: 'create' }
);
// the purpose of this hook is to enclose some of the complexity in working with the relationship widget and give the consumer a simpler API to interact with
export function useWidget({
nativeQueryName,
dataSourceName,
onSubmit,
}: {
nativeQueryName: string;
dataSourceName: string;
onSubmit: (params: onSubmitParams) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const [mode, setMode] = useState<WidgetMode>('create');
const [defaultValues, setDefaultValues] =
useState<NativeQueryRelationshipFormSchema>();
const metadataSelector = useCallback(
m => {
const source = MetadataUtils.findMetadataSource(dataSourceName, m);
if (!source) {
throw new Error(
`Unable to find source ${dataSourceName} for Relationship Widget`
);
}
const otherNativeQueries = (source.native_queries ?? []).filter(
q => q.root_field_name !== nativeQueryName
);
const thisNativeQuery = MetadataSelectors.findNativeQuery(
dataSourceName,
nativeQueryName
)(m);
return {
otherNativeQueries,
otherRelationships: {
object: thisNativeQuery?.object_relationships ?? [],
array: thisNativeQuery?.array_relationships ?? [],
},
};
},
[dataSourceName, nativeQueryName]
);
const { data: { otherNativeQueries, otherRelationships } = {} } =
useMetadata(metadataSelector);
const { hasuraAlert, hasuraConfirm } = useHasuraAlert();
const openCreate = () => {
if (!otherNativeQueries || otherNativeQueries.length === 0) {
alertUserNoPossibleQueries();
return;
}
setMode('create');
setIsOpen(true);
};
const openEdit = (initialValues: NativeQueryRelationshipFormSchema) => {
setDefaultValues(initialValues);
setMode('edit');
setIsOpen(true);
};
const closeWidget = () => setIsOpen(false);
const alertUserNoPossibleQueries = () => {
hasuraAlert({
message: (
<span>
There are no other native queries to create a relationship with.
Create another Native Queries on{' '}
<span className="font-bold">{dataSourceName}</span> and try again.
</span>
),
title: 'Error',
onClose: closeWidget,
});
};
const handleSubmit = (values: NativeQueryRelationshipFormSchema) => {
const doSubmit = () => {
if (mode === 'edit') {
if (!defaultValues?.name || !defaultValues.type) {
throw new Error(
'Name or type of original relationship was not able to be determined.'
);
}
onSubmit({
values,
mode,
originalRelationshipName: defaultValues.name,
originalRelationshipType: defaultValues.type,
});
} else {
onSubmit({ values, mode });
}
};
const hasSameName = otherRelationships?.[values.type]?.some(
r => r.name === values.name
);
const isCreating = mode === 'create';
const changedTypeWhileEditing =
mode === 'edit' && defaultValues?.type !== values.type;
const warnOfOverwrite =
hasSameName && (isCreating || changedTypeWhileEditing);
if (warnOfOverwrite) {
hasuraConfirm({
title: 'Overwrite Existing Relationship?',
message: (
<div>
There is already an existing <strong>{values.type}</strong>{' '}
relationship with the name <strong>{values.name}</strong>. Do you
want to overwrite this relationship?
</div>
),
confirmText: 'Overwrite',
destructive: true,
onClose: ({ confirmed }) => {
if (confirmed) doSubmit();
},
});
} else {
doSubmit();
}
};
const WidgetUI = () => (
<>
{isOpen && (
<NativeQueryRelationshipWidget
fromNativeQuery={nativeQueryName}
dataSourceName={dataSourceName}
defaultValues={mode === 'create' ? undefined : defaultValues}
mode={mode}
asDialog
onSubmit={handleSubmit}
onCancel={() => setIsOpen(false)}
/>
)}
</>
);
return {
WidgetUI,
closeWidget,
openCreate,
openEdit,
};
}

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
export const nativeQueryRelationshipValidationSchema = z.object({
name: z.string().min(1, 'Relationship name required!'),
toNativeQuery: z.string().min(1, 'Target Native Query is required!'),
type: z.union([z.literal('object'), z.literal('array')]),
columnMapping: z.record(z.string()),
});
export type NativeQueryRelationshipFormSchema = z.infer<
typeof nativeQueryRelationshipValidationSchema
>;

View File

@ -1,63 +1,53 @@
import startCase from 'lodash/startCase';
import React, { ReactNode } from 'react';
import React from 'react';
import { Breadcrumbs } from '../../../../new-components/Breadcrumbs';
import { usePushRoute } from '../../../ConnectDBRedesign/hooks';
import { LimitedFeatureWrapper } from '../../../ConnectDBRedesign/components/LimitedFeatureWrapper/LimitedFeatureWrapper';
import {
useEnvironmentState,
usePushRoute,
} from '../../../ConnectDBRedesign/hooks';
import { NATIVE_QUERY_ROUTES } from '../constants';
import { injectRouteDetails, pathsToBreadcrumbs } from './route-wrapper-utils';
export const RouteWrapper: React.FC<{
export type RouteWrapperProps = {
route: keyof typeof NATIVE_QUERY_ROUTES;
itemSourceName?: string;
itemName?: string;
}> = ({ children, route, itemSourceName, itemName }) => {
const paths =
route
?.split('/')
.filter(Boolean)
.filter(p => p !== '{{source}}') ?? [];
itemTabName?: string;
subtitle?: string;
};
export const RouteWrapper: React.FC<RouteWrapperProps> = props => {
const { children, route, subtitle: subtitleOverride } = props;
const paths = route?.split('/').filter(Boolean);
const { title, subtitle } = NATIVE_QUERY_ROUTES[route];
const push = usePushRoute();
// const { consoleType } = useEnvironmentState();
const { consoleType } = useEnvironmentState();
return (
<div className="py-md px-md w-full">
{/* <LimitedFeatureWrapper
<LimitedFeatureWrapper
title="Looking to add Native Queries?"
id="native-queries"
description="Get production-ready today with a 30-day free trial of Hasura EE, no credit card required."
override={consoleType === 'oss'}
> */}
<div className="flex flex-col">
<Breadcrumbs
items={paths.map((path: string, index) => {
return {
title: startCase(
path
// we don't need to display source
.replace('{{source}}', itemSourceName ?? '')
.replace('{{name}}', itemName ?? '')
),
onClick:
index === paths.length - 1
? undefined
: () => {
push(`/${paths.slice(0, index + 1).join('/')}`);
},
};
})}
/>
<div className="flex w-full justify-between px-2">
<div className="mb-sm">
<div className="text-xl font-bold mt-2">
{title.replace('{{name}}', itemName ?? '')}
>
<div className="flex flex-col">
<Breadcrumbs items={pathsToBreadcrumbs(paths, props, push)} />
<div className="flex w-full justify-between px-2">
<div className="mb-sm">
<div className="text-xl font-bold mt-2">
{injectRouteDetails(title, props)}
</div>
<div className="text-muted">{subtitleOverride ?? subtitle}</div>
</div>
<div className="text-muted">{subtitle}</div>
</div>
</div>
<div className="">{children}</div>
</div>
{/* </LimitedFeatureWrapper> */}
</LimitedFeatureWrapper>
</div>
);
};

View File

@ -0,0 +1,55 @@
import startCase from 'lodash/startCase';
import { BreadcrumbItem } from '../../../../new-components/Breadcrumbs/Breadcrumbs';
import { RouteWrapperProps } from './RouteWrapper';
export const injectRouteDetails = (
path: string,
{
itemName,
itemSourceName,
itemTabName,
}: Pick<RouteWrapperProps, 'itemName' | 'itemSourceName' | 'itemTabName'>
) => {
return path
.replace('{{source}}', itemSourceName ?? '')
.replace('{{name}}', itemName ?? '')
.replace('{{tab}}', itemTabName ?? '');
};
export const pathsToBreadcrumbs = (
paths: string[],
props: RouteWrapperProps,
_push: (path: string) => void
): BreadcrumbItem[] =>
paths.reduce<BreadcrumbItem[]>((prev, path, index, arr) => {
// skip source in path
if (path === '{{source}}') return prev;
let title = startCase(injectRouteDetails(path, props));
//prepend source to the item directly following the source so the source is visible in the breadcrumb but not it's own entry in the crumbs
if (arr[index - 1] === '{{source}}') {
const source = injectRouteDetails(arr[index - 1], props);
title = `${source} / ${title}`;
}
return [
...prev,
{
title,
onClick:
index === paths.length - 1
? undefined
: () => {
const pathIndex = paths.indexOf(path);
const newPath = injectRouteDetails(
`/${paths.slice(0, pathIndex + 1).join('/')}`,
props
);
_push(newPath);
},
},
];
}, []);

View File

@ -14,13 +14,16 @@ export const NATIVE_QUERY_ROUTES = {
title: 'Native Queries',
subtitle: 'Access more queries and operators through SQL on your database',
},
// create new native query
'/data/native-queries/create': {
title: 'Create Native Query',
subtitle: 'Access more queries and operators through SQL on your database',
},
'/data/native-queries/{{source}}/{{name}}': {
// edit/view native query:
'/data/native-queries/{{source}}/{{name}}/{{tab}}': {
title: '{{name}}',
subtitle: 'Access more queries and operators through SQL on your database',
//setting via the RouteWrapper props for this one so it can be dynamic based on tab
subtitle: '',
},
'/data/native-queries/logical-models': {
title: 'Logical Models',

View File

@ -1,4 +1,4 @@
import { Metadata } from '../../../hasura-metadata-types';
import { LogicalModel, Metadata } from '../../../hasura-metadata-types';
const testQueries = {
postgres: [
@ -14,6 +14,30 @@ const testQueries = {
returns: 'hello_world2',
root_field_name: 'hello_world_function2',
},
{
arguments: {},
code: 'select "CustomerId", "BillingCity" as "City", "BillingCountry" as "Country" from "public"."Invoice"',
returns: 'customer_location_model',
root_field_name: 'customer_location',
},
{
arguments: {},
code: 'select "CustomerId" as "Id", "FirstName" as "Name" from "public"."Customer"',
object_relationships: [
{
name: 'location',
using: {
column_mapping: {
Id: 'CustomerId',
},
insertion_order: null,
remote_native_query: 'customer_location',
},
},
],
returns: 'customer_model',
root_field_name: 'customer_native_query',
},
],
mssql: [
{
@ -31,19 +55,17 @@ const testQueries = {
],
};
const testModels = {
const testModels: Record<string, LogicalModel[]> = {
postgres: [
{
fields: [
{
name: 'one',
nullable: false,
type: 'text',
type: { scalar: 'string', nullable: true },
},
{
name: 'two',
nullable: false,
type: 'text',
type: { scalar: 'string', nullable: true },
},
],
name: 'hello_world',
@ -52,30 +74,72 @@ const testModels = {
fields: [
{
name: 'one',
nullable: false,
type: 'text',
type: { scalar: 'string', nullable: true },
},
{
name: 'two',
nullable: false,
type: 'text',
type: { scalar: 'string', nullable: true },
},
],
name: 'hello_world2',
},
// for testing relationships:
{
fields: [
{
name: 'CustomerId',
type: {
nullable: false,
scalar: 'integer',
},
},
{
name: 'City',
type: {
nullable: true,
scalar: 'varchar',
},
},
{
name: 'Country',
type: {
nullable: true,
scalar: 'varchar',
},
},
],
name: 'customer_location_model',
},
{
fields: [
{
name: 'Id',
type: {
nullable: false,
scalar: 'integer',
},
},
{
name: 'Name',
type: {
nullable: true,
scalar: 'varchar',
},
},
],
name: 'customer_model',
},
],
mssql: [
{
fields: [
{
name: 'one',
nullable: false,
type: 'text',
type: { scalar: 'string', nullable: true },
},
{
name: 'two',
nullable: false,
type: 'text',
type: { scalar: 'string', nullable: true },
},
],
name: 'hello_mssql',
@ -84,13 +148,11 @@ const testModels = {
fields: [
{
name: 'one',
nullable: false,
type: 'text',
type: { scalar: 'string', nullable: true },
},
{
name: 'two',
nullable: false,
type: 'text',
type: { scalar: 'string', nullable: true },
},
],
name: 'hello_mssql2',

View File

@ -3,6 +3,7 @@ import { useMetadataMigration } from '../../MetadataAPI';
import { MetadataMigrationOptions } from '../../MetadataAPI/hooks/useMetadataMigration';
import { useMetadata } from '../../hasura-metadata-api';
import { NativeQuery, Source } from '../../hasura-metadata-types';
import { NativeQueryMigrationBuilder } from '../LogicalModels/MigrationBuilder';
import { getSourceDriver } from './utils';
export type TrackNativeQuery = {
@ -35,16 +36,39 @@ export const useTrackNativeQuery = (
const trackNativeQuery = async ({
data: args,
editDetails,
...options
}: {
data: TrackNativeQuery;
editDetails?: { rootFieldName: string };
} & MetadataMigrationOptions) => {
const { source, ...nativeQuery } = args;
const driver = getSourceDriver(sources, args.source);
if (!driver) {
throw new Error('Source could not be found. Unable to identify driver.');
}
const builder = new NativeQueryMigrationBuilder({
dataSourceName: source,
driver,
nativeQuery,
});
// we need the untrack command to use the old root_field_name
// if a user is editing the native query, there's a chance that the root field name changed
// so, we have to manually set that when using untrack()
const argz = editDetails
? builder.untrack(editDetails.rootFieldName).track().payload()
: builder.track().payload();
mutate(
{
query: {
resource_version,
type: `${getSourceDriver(sources, args.source)}_track_native_query`,
args,
type: 'bulk_atomic',
args: argz,
},
},
options

View File

@ -0,0 +1,134 @@
import { transformErrorResponse } from '../../../ConnectDBRedesign/utils';
import { useMetadataMigration } from '../../../MetadataAPI';
import { MetadataMigrationOptions } from '../../../MetadataAPI/hooks/useMetadataMigration';
import { MetadataSelectors, useMetadata } from '../../../hasura-metadata-api';
import {
NativeQuery,
NativeQueryRelationship,
Source,
} from '../../../hasura-metadata-types';
import { NativeQueryMigrationBuilder } from '../../LogicalModels/MigrationBuilder';
export type TrackNativeQueryRelationshipsProps = NativeQueryRelationship & {
type: 'object' | 'array';
};
export type UntrackNativeQuery = { source: Source } & Pick<
NativeQuery,
'root_field_name'
>;
export const useTrackNativeQueryRelationships = (
dataSourceName: string,
nativeQueryName: string,
globalMutateOptions?: MetadataMigrationOptions
) => {
/**
* Get the required metadata variables - sources & resource_version
*/
const { data: { driver, originNativeQuery, resource_version } = {} } =
useMetadata(m => ({
driver: MetadataSelectors.findSource(dataSourceName)(m)?.kind,
originNativeQuery: MetadataSelectors.findSource(dataSourceName)(
m
)?.native_queries?.find(nq => nq.root_field_name === nativeQueryName),
resource_version: m.resource_version,
}));
const { mutate, ...rest } = useMetadataMigration({
...globalMutateOptions,
errorTransform: transformErrorResponse,
onSuccess: (data, variable, ctx) => {
globalMutateOptions?.onSuccess?.(data, variable, ctx);
},
});
const trackNativeQueryRelationship = async ({
data: args,
editDetails,
...options
}: {
data: TrackNativeQueryRelationshipsProps;
// these are the original name/type before edit.
// we need these b/c if the user alters either one, we have to do some special handling
editDetails?: { name: string; type: 'object' | 'array' };
} & MetadataMigrationOptions) => {
if (!driver || !originNativeQuery)
throw Error('Driver/Native Query not found');
const { type, ...relationshipDetails } = args;
const nativeQueryPayload = new NativeQueryMigrationBuilder({
driver,
nativeQuery: originNativeQuery,
dataSourceName,
}).untrack();
if (editDetails) {
// if editing, remove the relationship matching the original name and type prior to edit
// if a user changed the type or name, we need the originals to make sure we are dropping the right object from the correct array of relationships
nativeQueryPayload.removeRelationship(editDetails.type, editDetails.name);
}
nativeQueryPayload
// add a relationship to the native query with the details passed in
.addRelationship(type, relationshipDetails)
// add a track call
.track();
mutate(
{
query: {
resource_version,
type: `bulk_atomic`,
args: nativeQueryPayload.payload(),
},
},
options
);
};
const untrackNativeQueryRelationship = async ({
data: { name: relationshipName, type },
...options
}: {
data: Pick<TrackNativeQueryRelationshipsProps, 'name' | 'type'>;
} & MetadataMigrationOptions) => {
if (!driver || !originNativeQuery)
throw Error('Driver/Native Query not found');
const relationship = originNativeQuery[`${type}_relationships`]?.find(
n => n.name === relationshipName
);
if (!relationship) {
throw new Error('Unable to find relationship');
}
const nativeQueryPayload = new NativeQueryMigrationBuilder({
driver,
nativeQuery: originNativeQuery,
dataSourceName,
})
.untrack()
.removeRelationship(type, relationshipName)
.track();
mutate(
{
query: {
resource_version,
type: `bulk_atomic`,
args: nativeQueryPayload.payload(),
},
},
options
);
};
return {
trackNativeQueryRelationship,
untrackNativeQueryRelationship,
...rest,
};
};

View File

@ -27,6 +27,12 @@ export const getSources = () => (m: Metadata) => m?.metadata.sources;
export const findSource = (dataSourceName: string) => (m: Metadata) =>
utils.findMetadataSource(dataSourceName, m);
export const findNativeQuery =
(dataSourceName: string, nativeQueryName: string) => (m: Metadata) =>
utils
.findMetadataSource(dataSourceName, m)
?.native_queries?.find(nq => nq.root_field_name === nativeQueryName);
export const getTables = (dataSourceName: string) => (m: Metadata) =>
utils.findMetadataSource(dataSourceName, m)?.tables;

View File

@ -4,6 +4,15 @@ export type NativeQueryArgument = {
nullable?: boolean;
};
export type NativeQueryRelationship = {
name: string;
using: {
column_mapping: Record<string, string>;
insertion_order: 'before_parent' | 'after_parent' | null;
remote_native_query: string;
};
};
export type NativeQuery = {
root_field_name: string;
code: string;
@ -11,4 +20,6 @@ export type NativeQuery = {
arguments?: Record<string, NativeQueryArgument>;
type?: 'query' | 'mutation'; // only query supported for now
comment?: string;
object_relationships?: NativeQueryRelationship[];
array_relationships?: NativeQueryRelationship[];
};

View File

@ -112,3 +112,5 @@ export type BulkKeepGoingResponse = [
path: string;
}
];
export { NativeQueryRelationship } from './nativeQuery';

View File

@ -113,6 +113,7 @@ export const metadataQueryTypes = [
'untrack_tables',
'track_stored_procedure',
'untrack_stored_procedure',
'bulk_atomic',
] as const;
export type MetadataQueryType = (typeof metadataQueryTypes)[number];

View File

@ -70,3 +70,9 @@ export const cancelAlert = async (
timeout: removalTimout,
});
};
export const useIsStorybook = () => {
return {
isStorybook: !!process.env.STORYBOOK,
};
};