console: add db to local relationship widget

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4169
Co-authored-by: Vijay Prasanna <11921040+vijayprasanna13@users.noreply.github.com>
Co-authored-by: Varun Choudhary <68095256+Varun-Choudhary@users.noreply.github.com>
GitOrigin-RevId: 1c4ea2412d2f43dc3524f705d2b8f1847991c06d
This commit is contained in:
Abhijeet Khangarot 2022-04-22 18:08:37 +05:30 committed by hasura-bot
parent 1231d1145b
commit 4f9a08239d
28 changed files with 658 additions and 10 deletions

View File

@ -6,7 +6,7 @@ import {
useFeatureFlags,
availableFeatureFlagIds,
} from '@/features/FeatureFlags';
import { DatabaseRelationshipsTab } from '@/features/DatabaseRelationshipsTab';
import { DatabaseRelationshipsTab } from '@/features/DataRelationships';
import TableHeader from '../TableCommon/TableHeader';
import {
addNewRelClicked,

View File

@ -5,7 +5,7 @@ import {
useFeatureFlags,
availableFeatureFlagIds,
} from '@/features/FeatureFlags';
import { DatabaseRelationshipsTab } from '@/features/DatabaseRelationshipsTab';
import { DatabaseRelationshipsTab } from '@/features/DataRelationships';
import TableHeader from '../TableCommon/TableHeader';
import { getObjArrRelList } from './utils';
import { setTable, UPDATE_REMOTE_SCHEMA_MANUAL_REL } from '../DataActions';

View File

@ -9,7 +9,7 @@ import {
relSetType,
relSetColumns,
} from './state';
import { useTableColumns } from '@/features/SqlQueries/hooks/useTableColumns';
import { useTableColumns } from '@/features/SqlQueries';
import { getColumnNameArrayFromHookData } from './utils';
import { MetadataSelector, useMetadata } from '@/features/MetadataAPI';

View File

@ -107,6 +107,7 @@ export const DatabaseSelector = (props: Props) => {
data-testid={`${name}_database`}
icon={<FaDatabase />}
label={labels?.database ?? 'Source'}
name={`${name}_database`}
/>
</div>
@ -139,6 +140,7 @@ export const DatabaseSelector = (props: Props) => {
? labels?.dataset
: labels?.schema) ?? 'Schema'
}
name={`${name}_schema`}
/>
</div>
<div
@ -165,6 +167,7 @@ export const DatabaseSelector = (props: Props) => {
data-testid={`${name}_table`}
icon={<FaTable />}
label={labels?.table || 'Table'}
name={`${name}_table`}
/>
</div>
</div>

View File

@ -33,6 +33,7 @@ export const Select = ({
)}
disabled={disabled}
value={props.value}
id={props.name}
>
{placeholder ? (
<option disabled value="">

View File

@ -5,7 +5,7 @@ import React from 'react';
import { DatabaseRelationshipsTab } from './DatabaseRelationshipsTab';
export default {
title: 'Relationships / Database Relationships Tab',
title: 'Data Relationships/Database Relationships Tab',
component: DatabaseRelationshipsTab,
decorators: [ReactQueryDecorator()],
} as ComponentMeta<typeof DatabaseRelationshipsTab>;

View File

@ -0,0 +1,12 @@
import React from 'react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Form } from './Form';
export default {
title: 'Data Relationships/Form',
decorators: [ReactQueryDecorator()],
component: Form,
} as ComponentMeta<typeof Form>;
export const Primary: ComponentStory<typeof Form> = () => <Form />;

View File

@ -0,0 +1,65 @@
import { Button } from '@/new-components/Button';
import React, { useState } from 'react';
import { CardRadioGroup } from '@/new-components/CardRadioGroup';
import { LocalRelationshipWidget } from '../LocalDBRelationshipWidget';
type Value =
| 'Local Relationship'
| 'Remote Database Relationship'
| 'Remote Schema Relationship';
const data: { value: Value; title: string; body: string }[] = [
{
value: 'Local Relationship',
title: 'Local Relationship',
body: 'Relationships from this table to a local database table.',
},
{
value: 'Remote Database Relationship',
title: 'Remote Database Relationship',
body: 'Relationship from this local table to a remote database table.',
},
{
value: 'Remote Schema Relationship',
title: 'Remote Schema Relationship',
body: 'Relationship from this local table to a remote schema.',
},
];
export const Form = () => {
const [option, setOption] = useState('Local Relationship');
return (
<div className="w-full sm:w-9/12 bg-white shadow-sm rounded p-md border border-gray-300 shadow show">
<div className="flex items-center mb-md">
<Button size="sm">Cancel</Button>
<span className="font-semibold text-muted ml-1.5">
Create New Relationship
</span>
</div>
<hr className="mb-md border-gray-300" />
<div className="mb-md">
<p className="mb-sm text-muted font-semibold">
Select a Relationship Method
</p>
<CardRadioGroup
items={data}
onChange={relType => setOption(relType)}
value={option}
/>
{option === 'Local Relationship' ? (
<LocalRelationshipWidget
sourceTableInfo={{
database: 'default',
schema: 'public',
table: 'resident',
}}
/>
) : option === 'Remote Database Relationship' ? (
<>do something for remote DB relationships</>
) : (
<>do something for remote schema relationships</>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,106 @@
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { ListMap } from '@/new-components/ListMap';
import { DatabaseSelector } from '@/features/Data';
import { useTableColumns } from '@/features/SqlQueries';
import {
LinkBlockHorizontal,
LinkBlockVertical,
} from '@/new-components/LinkBlock';
import { Schema } from './schema';
export const FormElements = () => {
const { control, watch } = useFormContext<Schema>();
const [source, destination] = watch(['source', 'destination']);
const { data: sourceColumnData } = useTableColumns(source.database, {
name: source.table,
schema: source.schema ?? source.dataset ?? '',
});
const { data: referenceColumnData } = useTableColumns(destination.database, {
name: destination.table,
schema: destination.schema ?? destination.dataset ?? '',
});
return (
<>
<div className="grid grid-cols-12">
<div className="col-span-5">
<Controller
control={control}
name="source"
render={({ field: { onChange, value }, formState: { errors } }) => (
<DatabaseSelector
value={value}
onChange={onChange}
name="source"
errors={errors}
className="border-l-4 border-l-green-600"
hiddenKeys={['database']}
disabledKeys={['schema', 'table', 'database']}
labels={{
database: 'Source Database',
schema: 'Source Schema',
dataset: 'Source Dataset',
table: 'Source Table',
}}
/>
)}
/>
</div>
<LinkBlockHorizontal />
<div className="col-span-5">
<Controller
control={control}
name="destination"
render={({ field: { onChange, value }, formState: { errors } }) => (
<DatabaseSelector
value={value}
onChange={onChange}
name="destination"
errors={errors}
className="border-l-4 border-l-indigo-600"
hiddenKeys={['database']}
labels={{
database: 'Reference Database',
schema: 'Reference Schema',
dataset: 'Reference Dataset',
table: 'Reference Table',
}}
/>
)}
/>
</div>
</div>
<LinkBlockVertical title="Columns Mapped To" />
<Controller
control={control}
name="mapping"
render={({ field: { onChange, value } }) => (
<ListMap
onChange={onChange}
fromLabel="Source Column"
toLabel="Reference Column"
maps={value}
fromOptions={
sourceColumnData
? sourceColumnData?.slice(1).map((x: string[]) => x[3])
: []
}
toOptions={
referenceColumnData
? referenceColumnData?.slice(1).map((x: string[]) => x[3])
: []
}
name="mapping"
/>
)}
/>
</>
);
};

View File

@ -0,0 +1,81 @@
import React from 'react';
import { Story, Meta } from '@storybook/react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { within, userEvent } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import {
LocalRelationshipWidget,
LocalRelationshipWidgetProps,
} from './LocalRelationshipWidget';
import { handlers } from '../../../RemoteRelationships/RemoteSchemaRelationships/__mocks__/handlers.mock';
export default {
title:
'Data Relationships/Local DB Relationships/Local DB Relationships Form',
component: LocalRelationshipWidget,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers(),
},
} as Meta;
export const Primary: Story<LocalRelationshipWidgetProps> = args => (
<LocalRelationshipWidget {...args} />
);
Primary.args = {
sourceTableInfo: {
database: 'chinook',
schema: 'public',
table: 'Album',
},
};
export const WithExistingObjectRelationship: Story<LocalRelationshipWidgetProps> = args => (
<LocalRelationshipWidget {...args} />
);
WithExistingObjectRelationship.args = {
...Primary.args,
existingRelationshipName: 'relt1obj',
};
export const WithExistingArrayRelationship: Story<LocalRelationshipWidgetProps> = args => (
<LocalRelationshipWidget {...args} />
);
WithExistingArrayRelationship.args = {
...Primary.args,
existingRelationshipName: 'relt1array',
};
export const PrimaryWithTest: Story<LocalRelationshipWidgetProps> = args => (
<LocalRelationshipWidget {...args} />
);
PrimaryWithTest.args = { ...Primary.args };
PrimaryWithTest.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const submitButton = await canvas.findByText('Add Relationship');
userEvent.click(submitButton);
const nameError = await canvas.findByText('Name is required!');
const tableError = await canvas.findByText('Reference Table is required!');
// expect error messages
expect(nameError).toBeInTheDocument();
expect(tableError).toBeInTheDocument();
// update fields
const nameInput = await canvas.findByLabelText('Name');
userEvent.type(nameInput, 'test');
const typeLabel = await canvas.findByLabelText('Type');
const schemaLabel = await canvas.findByLabelText('Reference Schema');
const tableLabel = await canvas.findByLabelText('Reference Table');
userEvent.selectOptions(typeLabel, 'Array Relationship');
userEvent.selectOptions(schemaLabel, 'user');
userEvent.selectOptions(tableLabel, 'userAddress');
userEvent.click(submitButton);
};

View File

@ -0,0 +1,151 @@
import React from 'react';
import {
allowedMetadataTypes,
useMetadataMigration,
} from '@/features/MetadataAPI';
import { fireNotification } from '@/new-components/Notifications';
import { DataTarget } from '@/features/Datasources';
import { InputField, Select, Form } from '@/new-components/Form';
import { Button } from '@/new-components/Button';
import { IndicatorCard } from '@/new-components/IndicatorCard';
import { getMetadataQuery, MetadataQueryType } from '@/metadata/queryUtils';
import { schema, Schema } from './schema';
import { FormElements } from './FormElements';
import { useDefaultValues } from './hooks';
export type LocalRelationshipWidgetProps = {
sourceTableInfo: DataTarget;
existingRelationshipName?: string;
};
type MetadataPayloadType = {
type: allowedMetadataTypes;
args: { [key: string]: any };
version?: number;
};
export const LocalRelationshipWidget = ({
sourceTableInfo,
existingRelationshipName,
}: LocalRelationshipWidgetProps) => {
// hook to fetch data for existing relationship
const { data: defaultValues, isLoading, isError } = useDefaultValues({
sourceTableInfo,
existingRelationshipName,
});
const mutation = useMetadataMigration({
onSuccess: () => {
fireNotification({
title: 'Success!',
message: 'Relationship saved successfully',
type: 'success',
});
},
onError: () => {
fireNotification({
title: 'Error',
message: 'Error while creating the relationship',
type: 'error',
});
},
});
const submit = (values: Schema) => {
const remote_table: {
database?: string;
schema?: string;
dataset?: string;
table: string;
} = { ...values.destination };
delete remote_table.database;
const args = {
source: sourceTableInfo.database,
table: sourceTableInfo.table,
name: values.relationshipName,
using: {
manual_configuration: {
remote_table,
mapping: values.mapping,
},
},
};
const requestBody = getMetadataQuery(
values.relationshipType as MetadataQueryType,
sourceTableInfo.database,
args
);
mutation.mutate({
source: '',
query: requestBody as MetadataPayloadType,
migrationName: 'createLocalDBToDBRelationship',
});
};
if (isLoading) {
return <div>Loading relationship data...</div>;
}
if (isError) {
return <div>Something went wrong while loading relationship data</div>;
}
return (
<Form schema={schema} onSubmit={submit} options={{ defaultValues }}>
{options => (
<>
<div>
<div className="w-full sm:w-6/12 mb-md">
<div className="mb-md">
<InputField
name="relationshipName"
label="Name"
placeholder="Relationship name"
dataTest="local-db-to-db-rel-name"
/>
</div>
<div className="mb-md">
<Select
name="relationshipType"
label="Type"
dataTest="local-db-to-db-select-rel-type"
placeholder="Select a relationship type..."
options={[
{
label: 'Object Relationship',
value: 'create_object_relationship',
},
{
label: 'Array Relationship',
value: 'create_array_relationship',
},
]}
/>
</div>
</div>
<FormElements />
<Button
mode="primary"
type="submit"
isLoading={mutation.isLoading}
loadingText="Saving relationship"
data-test="add-local-db-relationship"
>
Add Relationship
</Button>
</div>
{!!Object.keys(options.formState.errors).length && (
<IndicatorCard status="negative">
Error saving relationship
</IndicatorCard>
)}
</>
)}
</Form>
);
};

View File

@ -0,0 +1,68 @@
import { MetadataSelector, useMetadata } from '@/features/MetadataAPI';
import { QualifiedTable } from '@/metadata/types';
import { DataTarget } from '@/features/Datasources';
import { Schema } from '../schema';
interface UseDefaultValuesProps {
sourceTableInfo: DataTarget;
existingRelationshipName?: string;
}
const getSchemaKey = (sourceTableInfo: DataTarget) => {
return 'dataset' in sourceTableInfo ? 'dataset' : 'schema';
};
type RelationshipType =
| 'create_object_relationship'
| 'create_array_relationship';
export const useDefaultValues = ({
sourceTableInfo,
existingRelationshipName,
}: UseDefaultValuesProps) => {
const { data: metadataTable, isLoading, isError } = useMetadata(
MetadataSelector.getTable(sourceTableInfo.database, {
name: sourceTableInfo.table,
schema:
(sourceTableInfo as any).schema ?? (sourceTableInfo as any).dataset,
})
);
const manual_relationships = [
...(metadataTable?.object_relationships?.map(rel => ({
...rel,
type: 'create_object_relationship' as RelationshipType,
})) ?? []),
...(metadataTable?.array_relationships?.map(rel => ({
...rel,
type: 'create_array_relationship' as RelationshipType,
})) ?? []),
];
const relationship = manual_relationships?.find(
rel => rel.name === existingRelationshipName
);
const defaultValues: Schema = {
relationshipType: relationship?.type ?? 'create_object_relationship',
relationshipName: existingRelationshipName ?? '',
source: {
database: sourceTableInfo.database,
[getSchemaKey(sourceTableInfo)]:
(sourceTableInfo as any).dataset ?? (sourceTableInfo as any).schema,
table: sourceTableInfo.table,
},
destination: {
database: sourceTableInfo.database,
[getSchemaKey(sourceTableInfo)]:
(relationship?.using?.manual_configuration
?.remote_table as QualifiedTable)?.schema ?? '',
table:
(relationship?.using?.manual_configuration
?.remote_table as QualifiedTable)?.name ?? '',
},
mapping: relationship?.using.manual_configuration?.column_mapping ?? {},
};
return { data: defaultValues, isLoading, isError };
};

View File

@ -0,0 +1 @@
export * from './LocalRelationshipWidget';

View File

@ -0,0 +1,23 @@
import { z } from 'zod';
export const schema = z.object({
relationshipType: z
.literal('create_array_relationship')
.or(z.literal('create_object_relationship')),
relationshipName: z.string().min(1, { message: 'Name is required!' }),
source: z.object({
database: z.string().min(1, 'Source Database is required!'),
schema: z.string().optional(),
dataset: z.string().optional(),
table: z.string().min(1, 'Source Table is required!'),
}),
destination: z.object({
database: z.string().min(1, 'Reference Database is required!'),
schema: z.string().optional(),
dataset: z.string().optional(),
table: z.string().min(1, 'Reference Table is required!'),
}),
mapping: z.record(z.string()),
});
export type Schema = z.infer<typeof schema>;

View File

@ -9,6 +9,8 @@ import type {
rsToRsRelDef,
TableEntry,
rsToDbRelDef,
ObjectRelationship,
ArrayRelationship,
} from '@/metadata/types';
import { MetadataResponse } from '..';
@ -171,6 +173,26 @@ export namespace MetadataSelector {
return remote_schema_relationships;
};
export const getLocalDBObjectRelationships = (
currentDataSource: string,
table: QualifiedTable
) => (m: MetadataResponse) => {
const metadataTable = getTable(currentDataSource, table)(m);
const object_relationships: ObjectRelationship[] =
metadataTable?.object_relationships ?? [];
return object_relationships;
};
export const getLocalDBArrayRelationships = (
currentDataSource: string,
table: QualifiedTable
) => (m: MetadataResponse) => {
const metadataTable = getTable(currentDataSource, table)(m);
const array_relationships: ArrayRelationship[] =
metadataTable?.array_relationships ?? [];
return array_relationships;
};
export const getAllDriversList = (m: MetadataResponse) =>
m.metadata?.sources.map(s => ({ source: s.name, kind: s.kind }));
}

View File

@ -1,3 +1,4 @@
import { MetadataQueryType } from '@/metadata/queryUtils';
import { HasuraMetadataV3 } from '@/metadata/types';
import { IntrospectionQuery } from 'graphql';
@ -35,4 +36,15 @@ export const allowedMetadataTypesArr = [
'bulk',
] as const;
export type allowedMetadataTypes = typeof allowedMetadataTypesArr[number];
type SupportedDataSourcesPrefix =
| 'mysql_'
| 'mssql_'
| 'bigquery_'
| 'citus_'
| 'pg_';
export type AllMetadataQueries = `${SupportedDataSourcesPrefix}${MetadataQueryType}`;
export type allowedMetadataTypes =
| typeof allowedMetadataTypesArr[number]
| AllMetadataQueries;

View File

@ -3,7 +3,12 @@ import { rest } from 'msw';
import { schema } from './schema';
import { countries } from './countries_schema';
import { metadata } from './metadata';
import { tableColumnsResult } from './tables';
import {
albumTableColumnsResult,
userAddressTableColumnsResult,
userInfoTableColumnsResult,
artistTableColumnsResult,
} from './tables';
const baseUrl = 'http://localhost:8080';
@ -55,10 +60,26 @@ export const handlers = (url = baseUrl) => [
return res(ctx.json({ message: 'success' }));
}
if (body.type === 'pg_create_object_relationship') {
return res(ctx.json({ message: 'success' }));
}
if (body.type === 'pg_create_array_relationship') {
return res(ctx.json({ message: 'success' }));
}
return res(ctx.json([{ message: 'success' }]));
}),
rest.post(`${url}/v2/query`, (req, res, ctx) => {
return res(ctx.json(tableColumnsResult));
const reqSql: string = (req?.body as Record<string, any>)?.args?.sql;
if (reqSql.toLowerCase().includes('album')) {
return res(ctx.json(albumTableColumnsResult));
} else if (reqSql.toLowerCase().includes('address')) {
return res(ctx.json(userAddressTableColumnsResult));
} else if (reqSql.toLowerCase().includes('artist')) {
return res(ctx.json(artistTableColumnsResult));
}
return res(ctx.json(userInfoTableColumnsResult));
}),
];

View File

@ -39,6 +39,42 @@ export const metadata = {
schema: 'public',
name: 'Album',
},
object_relationships: [
{
name: 'relt1obj',
using: {
manual_configuration: {
remote_table: {
schema: 'user',
name: 'userAddress',
},
insertion_order: null,
column_mapping: {
AlbumId: 'Id',
Title: 'Country',
},
},
},
},
],
array_relationships: [
{
name: 'relt1array',
using: {
manual_configuration: {
remote_table: {
schema: 'public',
name: 'Artist',
},
insertion_order: null,
column_mapping: {
AlbumId: 'Id',
Title: 'Name',
},
},
},
},
],
},
{
table: {
@ -106,6 +142,18 @@ export const metadata = {
name: 'comedies',
},
},
{
table: {
schema: 'user',
name: 'userAddress',
},
},
{
table: {
schema: 'user',
name: 'userInfo',
},
},
],
configuration: {
connection_info: {

View File

@ -8,7 +8,7 @@ export const tables = {
],
};
export const tableColumnsResult = {
export const albumTableColumnsResult = {
result_type: 'TuplesOk',
result: [
['database', 'table_schema', 'table_name', 'column_name', 'data_type'],
@ -17,3 +17,37 @@ export const tableColumnsResult = {
['chinook', 'public', 'Album', 'ArtistId', 'integer'],
],
};
export const artistTableColumnsResult = {
result_type: 'TuplesOk',
result: [
['database', 'table_schema', 'table_name', 'column_name', 'data_type'],
['chinook', 'public', 'Artist', 'Id', 'integer'],
['chinook', 'public', 'Artist', 'Name', 'character varying'],
['chinook', 'public', 'Artist', 'Age', 'integer'],
],
};
export const userInfoTableColumnsResult = {
result_type: 'TuplesOk',
result: [
['database', 'table_schema', 'table_name', 'column_name', 'data_type'],
['chinook', 'user', 'userInfo', 'Id', 'integer'],
['chinook', 'user', 'userInfo', 'FirstName', 'character varying'],
['chinook', 'user', 'userInfo', 'LastName', 'character varying'],
['chinook', 'user', 'userInfo', 'Age', 'integer'],
],
};
export const userAddressTableColumnsResult = {
result_type: 'TuplesOk',
result: [
['database', 'table_schema', 'table_name', 'column_name', 'data_type'],
['chinook', 'user', 'userAddress', 'Id', 'integer'],
['chinook', 'user', 'userAddress', 'Block', 'character varying'],
['chinook', 'user', 'userAddress', 'Street', 'character varying'],
['chinook', 'user', 'userAddress', 'City', 'character varying'],
['chinook', 'user', 'userAddress', 'Country', 'character varying'],
['chinook', 'user', 'userAddress', 'CountryCode', 'integer'],
],
};

View File

@ -1,8 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useRemoteSchema } from '@/features/MetadataAPI';
// eslint-disable-next-line no-restricted-imports
import { useTableColumns } from '@/features/SqlQueries/hooks/useTableColumns';
import { useTableColumns } from '@/features/SqlQueries';
import { InputField, Select } from '@/new-components/Form';
import { MapSelector } from '@/new-components/MapSelector';
import {

View File

@ -1,2 +1,3 @@
export { dataSourceSqlQueries } from './datasources';
export { useTableColumns } from './hooks/useTableColumns';
export type { DatasourceSqlQueries } from './datasources';