feat (console): add connect db UI that works via the data abstraction layer

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4763
GitOrigin-RevId: 6fae278b86d7cc56d4f7980ddeb5bea7afd55f1a
This commit is contained in:
Vijay Prasanna 2022-06-28 15:02:27 +05:30 committed by hasura-bot
parent c2d0d272ee
commit 3d2ad8fdbb
23 changed files with 696 additions and 1021 deletions

View File

@ -0,0 +1,232 @@
/* eslint-disable no-underscore-dangle */
import React, { useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import {
Ref,
Property,
DataSource,
SupportedDrivers,
Feature,
OneOf,
} from '@/features/DataSource';
import get from 'lodash.get';
import axios from 'axios';
import { Switch } from '@/new-components/Switch';
import { InputField, Select } from '@/new-components/Form';
import { useQuery } from 'react-query';
import {
getPropertyByRef,
isOneOf,
isProperty,
isRef,
} from '../DataSource/utils';
const useConfigSchema = (driver: SupportedDrivers) => {
const fetch = axios.create();
return useQuery({
queryKey: [driver, 'configSchema'],
queryFn: async () => {
return DataSource(fetch).connectDB.getConfigSchema(driver);
},
enabled: !!driver,
});
};
const RadioGroup = ({
property,
otherSchemas,
name,
}: {
name: string;
property: OneOf;
otherSchemas: Record<string, Property>;
}) => {
const { setValue } = useFormContext();
// const currentOption: number = watch(`extra_props.${name}.radio_group_option`);
const options = property.oneOf.map((_property, i) => {
if (isRef(_property))
return (
getPropertyByRef(_property, otherSchemas).description ?? `Option-${i}`
);
return _property.description ?? `Option-${i}`;
});
const [currentOption, setCurrentOption] = useState<undefined | number>();
const currentProperty =
currentOption !== undefined ? property.oneOf[currentOption] : undefined;
return (
<>
<div className="my-8 rounded border bg-white border-gray-300 p-4">
<div className="text-gray-600 font-semibold py-3">
{property.description}
</div>
{options.map((option, i) => {
return (
<span key={i} className="mr-md">
<input
type="radio"
onChange={() => {
setCurrentOption(i);
setValue(name, undefined);
}}
name={`${name}-select`}
className="radio radio-primary"
value={i}
/>
<label className="ml-sm">{option}</label>
</span>
);
})}
<div>
{currentProperty ? (
<div className="grid card bg-base-300 rounded-box my-4">
<Field
property={currentProperty}
otherSchemas={otherSchemas}
name={name}
/>
</div>
) : (
<div className="my-3 italic">Select an option</div>
)}
</div>
</div>
</>
);
};
const Field = ({
name,
property,
otherSchemas,
}: {
name: string;
property: Ref | Property | { oneOf: (Property | Ref)[] };
otherSchemas: Record<string, Property>;
}) => {
if (isProperty(property)) {
if (property.type === 'string') {
if (property.enum) {
return (
<div className="my-4">
<Select
options={property.enum.map(option => ({
value: option,
label: option,
}))}
name={name}
label={property.description ?? name}
/>
</div>
);
}
return (
<InputField
type="text"
name={name}
label={property.description ?? name}
className="my-4"
/>
);
}
if (property.type === 'number')
return (
<InputField
type="number"
name={name}
label={property.description ?? name}
className="my-4"
/>
);
if (property.type === 'boolean')
return (
<div className="max-w-xl flex justify-between my-4">
<label className="font-semibold text-gray-600">
{property.description ?? name}
</label>
<Controller
name={name}
render={({ field: { onChange, value } }) => (
<Switch checked={value} onCheckedChange={onChange} />
)}
/>
</div>
);
if (property.type === 'object')
return (
<div>
{Object.entries(property.properties).map(([key, _property], i) => {
return (
<div key={`${name}.${key}.${i}`}>
<Field
property={_property}
otherSchemas={otherSchemas}
name={`${name}.${key}`}
/>
</div>
);
})}
</div>
);
}
if (isRef(property)) {
const ref = property.$ref;
const _property = get(otherSchemas, ref.split('/').slice(2).join('.'));
return (
<Field property={_property} otherSchemas={otherSchemas} name={name} />
);
}
if (isOneOf(property)) {
return (
<RadioGroup property={property} otherSchemas={otherSchemas} name={name} />
);
}
return null;
};
export const Configuration = ({ name }: { name: string }) => {
const { watch } = useFormContext();
const driver: SupportedDrivers = watch('driver');
const { data: schema, isLoading, isError } = useConfigSchema(driver);
if (schema === Feature.NotImplemented)
return <>Feature is not available for this {driver}</>;
if (!driver) return <>Driver not selected</>;
if (isLoading) return <>Loading configuration info...</>;
if (isError) return <>welp something broke ! </>;
if (!schema) return <>Unable to find any schema for the {driver}</>;
if (schema.configSchema.type !== 'object') return null;
return (
<div>
{Object.entries(schema.configSchema.properties).map(([key, value], i) => {
return (
<Field
property={value}
otherSchemas={schema.otherSchemas}
key={i}
name={`${name}.${key}`}
/>
);
})}
</div>
);
};

View File

@ -0,0 +1,18 @@
// Button.stories.ts|tsx
import React from 'react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Connect } from './Connect';
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: 'Data/Connect',
component: Connect,
decorators: [ReactQueryDecorator()],
} as ComponentMeta<typeof Connect>;
export const Primary: ComponentStory<typeof Connect> = () => <Connect />;

View File

@ -0,0 +1,60 @@
import React from 'react';
import axios from 'axios';
import { DataSource } from '@/features/DataSource';
import { Button } from '@/new-components/Button';
import { Form } from '@/new-components/Form';
import { useQuery } from 'react-query';
import { Driver } from './Driver';
import { Name } from './Name';
import { Configuration } from './Configuration';
import { useMetadataMigration } from '../MetadataAPI';
const usePossibleFormSchemas = () => {
const fetch = axios.create();
return useQuery({
queryKey: ['validation-schemas'],
queryFn: async () => {
return DataSource(fetch).connectDB.getFormSchema();
},
});
};
export const Connect = () => {
const metadataMutation = useMetadataMigration();
const { data: schemas } = usePossibleFormSchemas();
if (!schemas) return <>unable to retrieve any validation schema</>;
return (
<Form
schema={schemas}
onSubmit={values => {
console.log('form data', values);
metadataMutation.mutate({
query: {
type: 'pg_add_source',
args: values,
},
});
}}
options={{
defaultValues: {
name: '',
driver: 'postgres',
},
}}
>
{options => {
console.log(options.formState.errors);
return (
<div className="w-1/2">
<Driver name="driver" />
<Name name="name" />
<Configuration name="configuration" />
<Button type="submit">Connect</Button>
</div>
);
}}
</Form>
);
};

View File

@ -0,0 +1,35 @@
import { useQuery } from 'react-query';
import { DataSource } from '@/features/DataSource';
import React from 'react';
import axios from 'axios';
import { Select } from '@/new-components/Form';
const useAvailableDrivers = () => {
const fetch = axios.create();
return useQuery({
queryKey: ['getDrivers'],
queryFn: async () => {
return DataSource(fetch).driver.getSupportedDrivers();
},
});
};
export const Driver = ({ name }: { name: string }) => {
const { data: drivers, isLoading, isError } = useAvailableDrivers();
if (isLoading) return <>Fetching driver info...</>;
if (isError) return <>Something went wrong while fetching drivers</>;
if (!drivers) return <>No available drivers were found</>;
return (
<div className="py-4">
<Select
options={drivers.map(driver => ({ value: driver, label: driver }))}
name={name}
label="Driver"
/>
</div>
);
};

View File

@ -0,0 +1,14 @@
import { InputField } from '@/new-components/Form';
import React from 'react';
export const Name = ({ name }: { name: string }) => {
return (
<InputField
type="text"
placeholder="Enter a display name"
name={name}
label="Display Name"
className="py-4"
/>
);
};

View File

@ -1,99 +0,0 @@
const name = {
label: 'Name',
key: 'key',
type: 'text',
};
const service_account = {
key: 'service_account',
label: 'Connection Details',
type: 'radio-group-with-inputs',
options: [
{
key: 'env_var',
label: 'Enviroment Variable',
fields: [
{
label: 'Env var',
key: 'env_var',
type: 'text',
placeholder: 'SERVICE_ACCOUNT_KEY_FROM_ENV',
},
],
},
{
key: 'connection_parameters',
label: 'Connection Parameters',
fields: [
{
label: 'Service Account Key',
key: 'service_account_key',
type: 'json',
},
],
expand_keys: true,
},
],
};
const configuration = {
key: 'configuration',
label: 'Connect Database Via',
fields: [
service_account,
{
label: 'Project Id',
key: 'project_id',
type: 'text',
placeholder: 'project_id',
},
{
label: 'Datasets',
key: 'datasets',
type: 'text',
placeholder: 'dataset_1, dataset_2',
},
{
label: 'Global Select Limit',
key: 'global_select_limit',
type: 'number',
placeholder: 1000,
},
],
};
const customization = {
key: 'customization',
label: 'GraphQL Customization',
fields: [
{
label: 'Namespace',
key: 'root_fields.namespace',
type: 'text',
},
{
label: 'Prefix',
key: 'root_fields.prefix',
type: 'text',
},
{
label: 'Suffix',
key: 'root_fields.suffix',
type: 'text',
},
{
label: 'Prefix',
key: 'type_names.prefix',
type: 'text',
},
{
label: 'Suffix',
key: 'type_names.suffix',
type: 'text',
},
],
};
export const getUISchema = async () => {
return [name, configuration, customization];
};

View File

@ -1,48 +0,0 @@
import { z } from 'zod';
const service_account = z.discriminatedUnion('type', [
z.object({
type: z.literal('env_var'),
details: z.object({
value: z.string().min(1, 'Environment variable name cannot be empty!'),
}),
}),
z.object({
type: z.literal('json'),
details: z.object({
value: z.string().min(1, 'Service Account Key is required!'),
}),
}),
]);
const schema = z.object({
driver: z.literal('bigquery'),
name: z.string().min(1, 'Name is a required field!'),
configuration: z.object({
service_account,
project_id: z.string().min(1, 'Project ID cannot be empty!'),
datasets: z.string().min(1, 'Datasets cannot be empty'),
}),
replace_configuration: z.preprocess(x => {
if (!x) return false;
return true;
}, z.boolean()),
customization: z
.object({
root_fields: z.object({
namespace: z.string().optional(),
prefix: z.string().optional(),
suffix: z.string().optional(),
}),
type_names: z.object({
prefix: z.string().optional(),
suffix: z.string().optional(),
}),
})
.optional(),
});
export const getValidationSchema = async () => {
return schema;
};

View File

@ -1,10 +1,9 @@
import { Database } from '..';
import { getUISchema } from './connectDB/getUISchema';
import { getValidationSchema } from './connectDB/getValidationSchema';
import { Database, Feature } from '..';
export const bigquery: Database = {
connectDB: {
getUISchema,
getValidationSchema,
getConfigSchema: async () => {
return Feature.NotImplemented;
},
},
};

View File

@ -1,191 +0,0 @@
const name = {
label: 'Name',
key: 'key',
type: 'text',
};
const pool_settings = {
key: 'pool_settings',
label: 'Connection Settings',
fields: [
{
key: 'max_connections',
label: 'Max Connections',
type: 'number',
tooltip: 'Maximum number of connections to be kept in the pool',
placeholder: 50,
},
{
key: 'idle_timeout',
label: 'Idle Timeout',
type: 'number',
tooltip: 'The idle timeout (in seconds) per connection',
placeholder: 180,
},
{
key: 'retries',
label: 'Retries',
type: 'number',
tooltip: 'Number of retries to perform',
placeholder: 1,
},
{
key: 'pool_timeout',
label: 'Pool Timeout',
type: 'number',
tooltip:
'Maximum time (in seconds) to wait while acquiring a Postgres connection from the pool',
placeholder: 360,
},
{
key: 'connection_lifetime',
label: 'Connection Lifetime',
type: 'number',
tooltip:
'Time (in seconds) from connection creation after which the connection should be destroyed and a new one created. A value of 0 indicates we should never destroy an active connection. If 0 is passed, memory from large query results may not be reclaimed.',
placeholder: 600,
},
],
};
const database_url = {
key: 'database_url',
label: '',
type: 'radio-group-with-inputs',
options: [
{
key: 'env_var',
label: 'Enviroment Variable',
fields: [
{
label: 'Env var',
key: 'env_var',
type: 'text',
placeholder: 'HASURA_DB_URL_FROM_ENV',
},
],
},
{
key: 'database_url',
label: 'Database URL',
fields: [
{
label: 'URL',
key: 'database_url',
type: 'text',
placeholder: 'postgresql://username:password@hostname:5432/database',
},
],
expand_keys: true,
},
{
key: 'connection_parameters',
label: 'Connection Parameters',
fields: [
{
label: 'Username',
key: 'username',
type: 'text',
placeholder: 'postgres_user',
},
{
label: 'Password',
key: 'password',
type: 'password',
placeholder: 'postgrespassword',
},
{
label: 'Database Name',
key: 'database',
type: 'text',
placeholder: 'postgres',
},
{
label: 'Host',
key: 'host',
type: 'text',
placeholder: 'localhost',
},
{
label: 'Port',
key: 'port',
type: 'number',
placeholder: '5432',
},
],
expand_keys: true,
},
],
};
const connection_info = {
key: 'connection_info',
label: 'Connection Details',
fields: [
database_url,
pool_settings,
{
key: 'isolation_level',
label: 'Isolation Level',
type: 'select',
tooltip:
'The transaction isolation level in which the queries made to the source will be run',
options: ['read-commited', 'repeatable-read', 'serializable'],
},
{
key: 'prepared_statements',
label: 'Use Prepared Statements',
type: 'boolean',
tooltip: 'Prepared statements are disabled by default',
},
],
};
const read_replicas = {
key: 'read_replicas',
label: 'Read Replicas',
type: 'array',
fields: [connection_info],
};
const configuration = {
key: 'configuration',
label: 'Connect Database Via',
fields: [connection_info, read_replicas],
};
const customization = {
key: 'customization',
label: 'GraphQL Customization',
fields: [
{
label: 'Namespace',
key: 'root_fields.namespace',
type: 'text',
},
{
label: 'Prefix',
key: 'root_fields.prefix',
type: 'text',
},
{
label: 'Suffix',
key: 'root_fields.suffix',
type: 'text',
},
{
label: 'Prefix',
key: 'type_names.prefix',
type: 'text',
},
{
label: 'Suffix',
key: 'type_names.suffix',
type: 'text',
},
],
};
export const getUISchema = async () => {
return [name, configuration, customization];
};

View File

@ -1,87 +0,0 @@
import { z } from 'zod';
const connection_types = {
env_var: z.object({
type: z.literal('env_var'),
details: z.object({
env_var: z.string().min(1, 'Please provide a environment variable'),
}),
}),
connection_parameters: z.object({
type: z.literal('connection_parameters'),
details: z.object({
host: z.string().min(1, 'Host is a required Field'),
port: z
.string()
.min(1, 'Port is a required Field!')
.transform(x => parseInt(x, 10)),
username: z.string().min(1, 'Username is a required Field'),
password: z.string().min(1, 'Password is a required Field'),
db_name: z.string().min(1, 'Database Name is a required Field'),
}),
}),
url: z.object({
type: z.literal('value'),
details: z.object({
database_url: z.string().min(1, 'Database URL is a required Field'),
}),
}),
};
const database_url = z.discriminatedUnion('type', [
connection_types.connection_parameters,
connection_types.url,
connection_types.env_var,
]);
const connection_info = z.object({
database_url,
pool_settings: z
.object({
max_connections: z.string().transform(x => parseInt(x, 10)),
idle_timeout: z.string().transform(x => parseInt(x, 10)),
retries: z.string().transform(x => parseInt(x, 10)),
pool_timeout: z.string().transform(x => parseInt(x, 10)),
connection_lifetime: z.string().transform(x => parseInt(x, 10)),
})
.optional(),
isolation_level: z.string().transform(x => {
if (!x) return 'read-commited';
return x;
}),
prepared_statements: z.preprocess(x => {
if (!x) return false;
return true;
}, z.boolean()),
});
const schema = z.object({
driver: z.literal('postgres'),
name: z.string().min(1, 'Name is a required field!'),
configuration: z.object({
connection_info,
read_replicas: z.array(connection_info).optional(),
}),
replace_configuration: z.preprocess(x => {
if (!x) return false;
return true;
}, z.boolean()),
customization: z
.object({
root_fields: z.object({
namespace: z.string().optional(),
prefix: z.string().optional(),
suffix: z.string().optional(),
}),
type_names: z.object({
prefix: z.string().optional(),
suffix: z.string().optional(),
}),
})
.optional(),
});
export const getValidationSchema = async () => {
return schema;
};

View File

@ -1,10 +1,9 @@
import { getValidationSchema } from './connectDB/getValidationSchema';
import { getUISchema } from './connectDB/getUISchema';
import { Database } from '..';
import { Database, Feature } from '..';
export const citus: Database = {
connectDB: {
getUISchema,
getValidationSchema,
getConfigSchema: async () => {
return Feature.NotImplemented;
},
},
};

View File

@ -2,10 +2,7 @@ import { Database, Feature } from '../index';
export const gdc: Database = {
connectDB: {
getValidationSchema: async () => {
return Feature.NotImplemented;
},
getUISchema: async () => {
getConfigSchema: async () => {
return Feature.NotImplemented;
},
},

View File

@ -1,16 +1,12 @@
import { Primitive, ZodDiscriminatedUnionOption } from 'zod';
import { AxiosInstance } from 'axios';
import { z, ZodSchema } from 'zod';
import { postgres } from './postgres';
import { bigquery } from './bigquery';
import { citus } from './citus';
import { mssql } from './mssql';
import { gdc } from './gdc';
// This will be modified in the upcoming changes to connectDB GDC feature
type UISchema = {
key: string;
label: string;
};
import { Property, Ref, OneOf } from './types';
import { getZodSchema } from './utils';
export enum Feature {
NotImplemented = 'Not Implemented',
@ -35,13 +31,11 @@ export type Database = {
/* This section defines how a database is connected to Hasura */
connectDB: {
/* Expose Methods that are used in the DB connection part */
getUISchema: (
fetch: AxiosInstance
) => Promise<UISchema[] | Feature.NotImplemented>;
getValidationSchema: (
getConfigSchema: (
fetch: AxiosInstance
) => Promise<
ZodDiscriminatedUnionOption<'driver', Primitive> | Feature.NotImplemented
| { configSchema: Property; otherSchemas: Record<string, Property> }
| Feature.NotImplemented
>;
};
};
@ -61,11 +55,55 @@ export const DataSource = (hasuraFetch: AxiosInstance) => ({
},
},
connectDB: {
getUISchema: async (driver: SupportedDrivers) => {
return drivers[driver].connectDB.getUISchema(hasuraFetch);
getConfigSchema: async (driver: SupportedDrivers) => {
return drivers[driver].connectDB.getConfigSchema(hasuraFetch);
},
getValidationSchema: async (driver: SupportedDrivers) => {
return drivers[driver].connectDB.getValidationSchema(hasuraFetch);
getFormSchema: async () => {
const schemas = await Promise.all(
Object.values(drivers).map(database =>
database.connectDB.getConfigSchema(hasuraFetch)
)
);
let res: ZodSchema | undefined;
schemas.forEach(schema => {
if (schema === Feature.NotImplemented) return;
if (!res) {
res = z.object({
driver: z.literal('postgres'),
name: z.string().min(1, 'Name is a required field!'),
replace_configuration: z.preprocess(x => {
if (!x) return false;
return true;
}, z.boolean()),
configuration: getZodSchema(
schema.configSchema,
schema.otherSchemas
),
});
} else {
res = res.or(
z.object({
driver: z.literal('postgres'),
name: z.string().min(1, 'Name is a required field!'),
replace_configuration: z.preprocess(x => {
if (!x) return false;
return true;
}, z.boolean()),
configuration: getZodSchema(
schema.configSchema,
schema.otherSchemas
),
})
);
}
});
return res;
},
},
});
export { Property, Ref, OneOf };

View File

@ -1,191 +0,0 @@
const name = {
label: 'Name',
key: 'key',
type: 'text',
};
const pool_settings = {
key: 'pool_settings',
label: 'Connection Settings',
fields: [
{
key: 'max_connections',
label: 'Max Connections',
type: 'number',
tooltip: 'Maximum number of connections to be kept in the pool',
placeholder: 50,
},
{
key: 'idle_timeout',
label: 'Idle Timeout',
type: 'number',
tooltip: 'The idle timeout (in seconds) per connection',
placeholder: 180,
},
{
key: 'retries',
label: 'Retries',
type: 'number',
tooltip: 'Number of retries to perform',
placeholder: 1,
},
{
key: 'pool_timeout',
label: 'Pool Timeout',
type: 'number',
tooltip:
'Maximum time (in seconds) to wait while acquiring a Postgres connection from the pool',
placeholder: 360,
},
{
key: 'connection_lifetime',
label: 'Connection Lifetime',
type: 'number',
tooltip:
'Time (in seconds) from connection creation after which the connection should be destroyed and a new one created. A value of 0 indicates we should never destroy an active connection. If 0 is passed, memory from large query results may not be reclaimed.',
placeholder: 600,
},
],
};
const database_url = {
key: 'database_url',
label: '',
type: 'radio-group-with-inputs',
options: [
{
key: 'env_var',
label: 'Enviroment Variable',
fields: [
{
label: 'Env var',
key: 'env_var',
type: 'text',
placeholder: 'HASURA_DB_URL_FROM_ENV',
},
],
},
{
key: 'database_url',
label: 'Database URL',
fields: [
{
label: 'URL',
key: 'database_url',
type: 'text',
placeholder: 'postgresql://username:password@hostname:5432/database',
},
],
expand_keys: true,
},
{
key: 'connection_parameters',
label: 'Connection Parameters',
fields: [
{
label: 'Username',
key: 'username',
type: 'text',
placeholder: 'postgres_user',
},
{
label: 'Password',
key: 'password',
type: 'password',
placeholder: 'postgrespassword',
},
{
label: 'Database Name',
key: 'database',
type: 'text',
placeholder: 'postgres',
},
{
label: 'Host',
key: 'host',
type: 'text',
placeholder: 'localhost',
},
{
label: 'Port',
key: 'port',
type: 'number',
placeholder: '5432',
},
],
expand_keys: true,
},
],
};
const connection_info = {
key: 'connection_info',
label: 'Connection Details',
fields: [
database_url,
pool_settings,
{
key: 'isolation_level',
label: 'Isolation Level',
type: 'select',
tooltip:
'The transaction isolation level in which the queries made to the source will be run',
options: ['read-commited', 'repeatable-read', 'serializable'],
},
{
key: 'prepared_statements',
label: 'Use Prepared Statements',
type: 'boolean',
tooltip: 'Prepared statements are disabled by default',
},
],
};
const read_replicas = {
key: 'read_replicas',
label: 'Read Replicas',
type: 'array',
fields: [connection_info],
};
const configuration = {
key: 'configuration',
label: 'Connect Database Via',
fields: [connection_info, read_replicas],
};
const customization = {
key: 'customization',
label: 'GraphQL Customization',
fields: [
{
label: 'Namespace',
key: 'root_fields.namespace',
type: 'text',
},
{
label: 'Prefix',
key: 'root_fields.prefix',
type: 'text',
},
{
label: 'Suffix',
key: 'root_fields.suffix',
type: 'text',
},
{
label: 'Prefix',
key: 'type_names.prefix',
type: 'text',
},
{
label: 'Suffix',
key: 'type_names.suffix',
type: 'text',
},
],
};
export const getUISchema = async () => {
return [name, configuration, customization];
};

View File

@ -1,87 +0,0 @@
import { z } from 'zod';
const connection_types = {
env_var: z.object({
type: z.literal('env_var'),
details: z.object({
env_var: z.string().min(1, 'Please provide a environment variable'),
}),
}),
connection_parameters: z.object({
type: z.literal('connection_parameters'),
details: z.object({
host: z.string().min(1, 'Host is a required Field'),
port: z
.string()
.min(1, 'Port is a required Field!')
.transform(x => parseInt(x, 10)),
username: z.string().min(1, 'Username is a required Field'),
password: z.string().min(1, 'Password is a required Field'),
db_name: z.string().min(1, 'Database Name is a required Field'),
}),
}),
url: z.object({
type: z.literal('value'),
details: z.object({
database_url: z.string().min(1, 'Database URL is a required Field'),
}),
}),
};
const database_url = z.discriminatedUnion('type', [
connection_types.connection_parameters,
connection_types.url,
connection_types.env_var,
]);
const connection_info = z.object({
database_url,
pool_settings: z
.object({
max_connections: z.string().transform(x => parseInt(x, 10)),
idle_timeout: z.string().transform(x => parseInt(x, 10)),
retries: z.string().transform(x => parseInt(x, 10)),
pool_timeout: z.string().transform(x => parseInt(x, 10)),
connection_lifetime: z.string().transform(x => parseInt(x, 10)),
})
.optional(),
isolation_level: z.string().transform(x => {
if (!x) return 'read-commited';
return x;
}),
prepared_statements: z.preprocess(x => {
if (!x) return false;
return true;
}, z.boolean()),
});
const schema = z.object({
driver: z.literal('postgres'),
name: z.string().min(1, 'Name is a required field!'),
configuration: z.object({
connection_info,
read_replicas: z.array(connection_info).optional(),
}),
replace_configuration: z.preprocess(x => {
if (!x) return false;
return true;
}, z.boolean()),
customization: z
.object({
root_fields: z.object({
namespace: z.string().optional(),
prefix: z.string().optional(),
suffix: z.string().optional(),
}),
type_names: z.object({
prefix: z.string().optional(),
suffix: z.string().optional(),
}),
})
.optional(),
});
export const getValidationSchema = async () => {
return schema;
};

View File

@ -1,10 +1,9 @@
import { getValidationSchema } from './connectDB/getValidationSchema';
import { getUISchema } from './connectDB/getUISchema';
import { Database } from '..';
import { Database, Feature } from '..';
export const mssql: Database = {
connectDB: {
getUISchema,
getValidationSchema,
getConfigSchema: async () => {
return Feature.NotImplemented;
},
},
};

View File

@ -0,0 +1,128 @@
import { Property } from '../../types';
export const getConfigSchema = async () => {
const {
configSchema,
otherSchemas,
}: { configSchema: Property; otherSchemas: Record<string, Property> } = {
configSchema: {
type: 'object',
nullable: false,
description: 'Configuration',
properties: {
connection_info: { $ref: '#/otherSchemas/ConnectionInfo' },
},
},
otherSchemas: {
PoolSettings: {
type: 'object',
description: 'Pool Settings',
nullable: true,
properties: {
max_connections: {
description: 'Max connections',
type: 'number',
nullable: true,
},
idle_timeout: {
description: 'Idle Timeout',
type: 'number',
nullable: true,
},
retries: {
description: 'Retries',
type: 'number',
nullable: true,
},
pool_timeout: {
description: 'Pool Timeout',
type: 'number',
nullable: true,
},
connection_lifetime: {
description: 'Connection Lifetime',
type: 'number',
nullable: true,
},
},
},
DatabaseURL: {
description: 'Database URL',
type: 'string',
nullable: false,
},
EnvironmentVariable: {
description: 'Enviroment Variable',
type: 'object',
nullable: false,
properties: {
from_env: {
description: 'Enviroment Variable',
type: 'string',
nullable: false,
},
},
},
ConnectionInfo: {
description: 'Connection details',
type: 'object',
nullable: false,
properties: {
database_url: {
description: 'Connect DB via',
oneOf: [
{ $ref: '#/otherSchemas/DatabaseURL' },
{
type: 'object',
nullable: false,
description: 'Connection Parameters',
properties: {
username: {
description: 'Username',
type: 'string',
nullable: false,
},
password: {
description: 'Password',
type: 'string',
nullable: false,
},
host: {
description: 'Host',
type: 'string',
nullable: false,
},
database: {
description: 'Database',
type: 'string',
nullable: false,
},
port: {
description: 'Port',
type: 'number',
nullable: false,
},
},
},
{ $ref: '#/otherSchemas/EnvironmentVariable' },
],
},
pool_settings: { $ref: '#/otherSchemas/PoolSettings' },
use_prepared_statements: {
description: 'Use Prepared Statements',
type: 'boolean',
nullable: true,
},
isolation_level: {
description: 'Isolation Level',
type: 'string',
nullable: true,
enum: ['read-committed', 'repeatable-read', 'serializable'],
},
},
},
},
};
return { configSchema, otherSchemas };
};

View File

@ -1,191 +0,0 @@
const name = {
label: 'Name',
key: 'key',
type: 'text',
};
const pool_settings = {
key: 'pool_settings',
label: 'Connection Settings',
fields: [
{
key: 'max_connections',
label: 'Max Connections',
type: 'number',
tooltip: 'Maximum number of connections to be kept in the pool',
placeholder: 50,
},
{
key: 'idle_timeout',
label: 'Idle Timeout',
type: 'number',
tooltip: 'The idle timeout (in seconds) per connection',
placeholder: 180,
},
{
key: 'retries',
label: 'Retries',
type: 'number',
tooltip: 'Number of retries to perform',
placeholder: 1,
},
{
key: 'pool_timeout',
label: 'Pool Timeout',
type: 'number',
tooltip:
'Maximum time (in seconds) to wait while acquiring a Postgres connection from the pool',
placeholder: 360,
},
{
key: 'connection_lifetime',
label: 'Connection Lifetime',
type: 'number',
tooltip:
'Time (in seconds) from connection creation after which the connection should be destroyed and a new one created. A value of 0 indicates we should never destroy an active connection. If 0 is passed, memory from large query results may not be reclaimed.',
placeholder: 600,
},
],
};
const database_url = {
key: 'database_url',
label: '',
type: 'radio-group-with-inputs',
options: [
{
key: 'env_var',
label: 'Enviroment Variable',
fields: [
{
label: 'Env var',
key: 'env_var',
type: 'text',
placeholder: 'HASURA_DB_URL_FROM_ENV',
},
],
},
{
key: 'database_url',
label: 'Database URL',
fields: [
{
label: 'URL',
key: 'database_url',
type: 'text',
placeholder: 'postgresql://username:password@hostname:5432/database',
},
],
expand_keys: true,
},
{
key: 'connection_parameters',
label: 'Connection Parameters',
fields: [
{
label: 'Username',
key: 'username',
type: 'text',
placeholder: 'postgres_user',
},
{
label: 'Password',
key: 'password',
type: 'password',
placeholder: 'postgrespassword',
},
{
label: 'Database Name',
key: 'database',
type: 'text',
placeholder: 'postgres',
},
{
label: 'Host',
key: 'host',
type: 'text',
placeholder: 'localhost',
},
{
label: 'Port',
key: 'port',
type: 'number',
placeholder: '5432',
},
],
expand_keys: true,
},
],
};
const connection_info = {
key: 'connection_info',
label: 'Connection Details',
fields: [
database_url,
pool_settings,
{
key: 'isolation_level',
label: 'Isolation Level',
type: 'select',
tooltip:
'The transaction isolation level in which the queries made to the source will be run',
options: ['read-commited', 'repeatable-read', 'serializable'],
},
{
key: 'prepared_statements',
label: 'Use Prepared Statements',
type: 'boolean',
tooltip: 'Prepared statements are disabled by default',
},
],
};
const read_replicas = {
key: 'read_replicas',
label: 'Read Replicas',
type: 'array',
fields: [connection_info],
};
const configuration = {
key: 'configuration',
label: 'Connect Database Via',
fields: [connection_info, read_replicas],
};
const customization = {
key: 'customization',
label: 'GraphQL Customization',
fields: [
{
label: 'Namespace',
key: 'root_fields.namespace',
type: 'text',
},
{
label: 'Prefix',
key: 'root_fields.prefix',
type: 'text',
},
{
label: 'Suffix',
key: 'root_fields.suffix',
type: 'text',
},
{
label: 'Prefix',
key: 'type_names.prefix',
type: 'text',
},
{
label: 'Suffix',
key: 'type_names.suffix',
type: 'text',
},
],
};
export const getUISchema = async () => {
return [name, configuration, customization];
};

View File

@ -1,87 +0,0 @@
import { z } from 'zod';
const connection_types = {
env_var: z.object({
type: z.literal('env_var'),
details: z.object({
env_var: z.string().min(1, 'Please provide a environment variable'),
}),
}),
connection_parameters: z.object({
type: z.literal('connection_parameters'),
details: z.object({
host: z.string().min(1, 'Host is a required Field'),
port: z
.string()
.min(1, 'Port is a required Field!')
.transform(x => parseInt(x, 10)),
username: z.string().min(1, 'Username is a required Field'),
password: z.string().min(1, 'Password is a required Field'),
db_name: z.string().min(1, 'Database Name is a required Field'),
}),
}),
url: z.object({
type: z.literal('value'),
details: z.object({
database_url: z.string().min(1, 'Database URL is a required Field'),
}),
}),
};
const database_url = z.discriminatedUnion('type', [
connection_types.connection_parameters,
connection_types.url,
connection_types.env_var,
]);
const connection_info = z.object({
database_url,
pool_settings: z
.object({
max_connections: z.string().transform(x => parseInt(x, 10)),
idle_timeout: z.string().transform(x => parseInt(x, 10)),
retries: z.string().transform(x => parseInt(x, 10)),
pool_timeout: z.string().transform(x => parseInt(x, 10)),
connection_lifetime: z.string().transform(x => parseInt(x, 10)),
})
.optional(),
isolation_level: z.string().transform(x => {
if (!x) return 'read-commited';
return x;
}),
prepared_statements: z.preprocess(x => {
if (!x) return false;
return true;
}, z.boolean()),
});
const schema = z.object({
driver: z.literal('postgres'),
name: z.string().min(1, 'Name is a required field!'),
configuration: z.object({
connection_info,
read_replicas: z.array(connection_info).optional(),
}),
replace_configuration: z.preprocess(x => {
if (!x) return false;
return true;
}, z.boolean()),
customization: z
.object({
root_fields: z.object({
namespace: z.string().optional(),
prefix: z.string().optional(),
suffix: z.string().optional(),
}),
type_names: z.object({
prefix: z.string().optional(),
suffix: z.string().optional(),
}),
})
.optional(),
});
export const getValidationSchema = async () => {
return schema;
};

View File

@ -1,10 +1,9 @@
import { getValidationSchema } from './connectDB/getValidationSchema';
import { getUISchema } from './connectDB/getUISchema';
import { getConfigSchema } from './connectDB/getConfigSchema';
import { Database } from '..';
export const postgres: Database = {
connectDB: {
getUISchema,
getValidationSchema,
getConfigSchema,
},
};

View File

@ -0,0 +1,23 @@
export type Ref = { $ref: string };
export type OneOf = { oneOf: (Property | Ref)[]; description?: string };
export type Property = {
description?: string;
nullable: boolean;
} & (
| {
type: 'object';
properties: Record<string, Ref | Property | OneOf>;
}
| {
type: 'string';
enum?: string[];
}
| {
type: 'number';
}
| {
type: 'boolean';
}
);

View File

@ -0,0 +1,115 @@
/* eslint-disable no-underscore-dangle */
import pickBy from 'lodash.pickby';
import get from 'lodash.get';
import { z, ZodSchema } from 'zod';
import { Property, Ref } from './types';
export const isProperty = (
value: Ref | Property | { oneOf: (Property | Ref)[] }
): value is Property => {
return 'type' in value;
};
export const isRef = (
value: Ref | Property | { oneOf: (Property | Ref)[] }
): value is Ref => {
return Object.keys(value).includes('$ref');
};
export const isOneOf = (
value: Ref | Property | { oneOf: (Property | Ref)[] }
): value is { oneOf: (Property | Ref)[] } => {
return Object.keys(value).includes('oneOf');
};
export const getPropertyByRef = (
property: Ref,
otherSchemas: Record<string, Property>
) => {
const ref = property.$ref;
const _property = get(otherSchemas, ref.split('/').slice(2).join('.'));
return _property;
};
export const getZodSchema = (
property: Property,
otherSchemas: Record<string, Property>
): ZodSchema => {
if (property.type === 'string') {
const t = z.string();
if (!property.nullable)
return t.min(
1,
property.description
? `${property.description} is Required`
: 'Required!'
);
return t;
}
if (property.type === 'boolean') {
return z.preprocess(x => {
if (!x) return false;
return true;
}, z.boolean());
}
if (property.type === 'number') {
if (!property.nullable)
return z
.string()
.min(1, 'Required')
.transform(x => parseInt(x, 10) || '');
return z.string().transform(x => parseInt(x, 10) || '');
}
if (property.type === 'object')
return z
.object({
...Object.entries(property.properties).reduce(
(acc, [key, _property]) => {
if (isRef(_property)) {
const _refProperty = getPropertyByRef(_property, otherSchemas);
return {
...acc,
[key]: getZodSchema(_refProperty, otherSchemas),
};
}
// console.log(_property, "here!1");
if (isOneOf(_property)) {
// console.log(_property, "here!");
let _unions: ZodSchema = z.void();
_property.oneOf.forEach((_oneOfProperty, i) => {
// eslint-disable-next-line @typescript-eslint/no-shadow
let temp: Property;
if (isRef(_oneOfProperty))
temp = getPropertyByRef(_oneOfProperty, otherSchemas);
else temp = _oneOfProperty;
if (i === 0) {
_unions = getZodSchema(temp, otherSchemas);
} else {
_unions = _unions.or(getZodSchema(temp, otherSchemas));
}
});
return { ...acc, [key]: _unions };
}
return {
...acc,
[key]: getZodSchema(_property, otherSchemas),
};
},
{}
),
})
.transform((value: any) => pickBy(value, (d: any) => d !== ''));
console.log('!', property);
return z.void();
};

View File

@ -6,7 +6,7 @@ import { FieldWrapper, FieldWrapperPassThroughProps } from './FieldWrapper';
export type InputFieldProps = FieldWrapperPassThroughProps & {
name: string;
type?: 'text' | 'email' | 'password';
type?: 'text' | 'email' | 'password' | 'number';
className?: string;
icon?: ReactElement;
iconPosition?: 'start' | 'end';