feat (dashboard): add custom types to column types (#3442)

### **PR Type**
Enhancement


___

### **Description**
- Add custom column types to database tables

- Improve handling of user-defined data types

- Update UI components for custom type support

- Refactor column type normalization and validation


___

### Diagram Walkthrough


```mermaid
flowchart LR
  A["Column Type Handling"] -- "Extend" --> B["Custom Types"]
  B -- "Update" --> C["UI Components"]
  B -- "Refactor" --> D["Type Normalization"]
  D -- "Improve" --> E["Data Validation"]
```



<details> <summary><h3> File Walkthrough</h3></summary>

<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><details><summary>15
files</summary><table>
<tr>
<td><strong>Autocomplete.tsx</strong><dd><code>Enhance Autocomplete
component for custom types</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-b185666714ca832d5c45c366618b79862f6b4f03e4f7657c78afa38a52e7c4c2">+27/-3</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>BaseColumnForm.tsx</strong><dd><code>Update BaseColumnForm
to support custom column types</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-9750f922830f8637c2d1b81c5e40128bc4fca7a9349a5314e421353d73bf6f38">+43/-16</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>ColumnEditorRow.tsx</strong><dd><code>Modify ColumnEditorRow
to handle custom types</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-264f067037cfa5d08dbb97964a9ddb8f6296129441682b78f6984c37051ea3f8">+37/-10</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>DataBrowserGrid.tsx</strong><dd><code>Update DataBrowserGrid
to display full data type</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-5910fd8730fbe65c60aa5f54031989a7868e944d5958f69535e5684b72ca1396">+6/-11</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>DatabaseRecordInputGroup.tsx</strong><dd><code>Adjust
DatabaseRecordInputGroup for custom types</code>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-52b5499e9afc3c5e4929046b487de649d421dda3250a4131462ec710575abc12">+1/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>prepareCreateColumnQuery.ts</strong><dd><code>Modify
prepareCreateColumnQuery for custom types</code>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-9b3695fb28760e86fc966e2149082b798664f145a8b64ef66184e55a905f5071">+1/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>prepareCreateTableQuery.ts</strong><dd><code>Adjust
prepareCreateTableQuery for custom types</code>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-1458307108df70f7037fa516ccab3a028533cf23f752778fcb09ed8d326530e5">+1/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>fetchTable.ts</strong><dd><code>Modify fetchTable to include
full data type</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-a58cb7660972ff84991cdd9777de5cf0834485072cbd421f8809638227c36820">+36/-28</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>prepareUpdateColumnQuery.ts</strong><dd><code>Adjust
prepareUpdateColumnQuery for custom types</code>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-ef957000505e4ba437656c38b4371d4041471ce5a4c193ef381aa55e0c51c308">+1/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>dataBrowser.ts</strong><dd><code>Update type definitions for
custom column types</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-33c6810dbd7e2910c86a15009467a348f064380b0e1dd787ef320b4e7543403b">+2/-3</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>index.ts</strong><dd><code>Add new utility for normalizing
column types</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-df62570fe4a332639b789274e3db4ea98cc695bc306f6dc1692851280bdb2fde">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>normalizeColumnType.ts</strong><dd><code>Implement
normalizeColumnType utility function</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-6bc7935091971eb83f99ba700e11f7214599d4d86e41f96d0d8295bdd6441d8f">+11/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>normalizeDatabaseColumn.ts</strong><dd><code>Modify
normalizeDatabaseColumn to use new utility</code>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-e00d1c71fcbc63286896b597ce820388987e0a7edb005bda8a13bb0c0813434b">+2/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>postgresqlConstants.ts</strong><dd><code>Update PostgreSQL
type constants and groups</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-b497da90feca5bff94b0d38b69e519d171d43acc292098054d672a73a89b4717">+6/-8</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>DataGridTextCell.tsx</strong><dd><code>Adjust
DataGridTextCell for custom type handling</code>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-d1ed74fe8eb7a61053dfe908966311e13915ad2127ee107b62f725d6c5282492">+1/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

</table></details></td></tr><tr><td><strong>Tests</strong></td><td><details><summary>5
files</summary><table>
<tr>
<td><strong>prepareCreateTableQuery.test.ts</strong><dd><code>Update
tests for custom column type support</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-348ba7ca6fc037a9d0de76a24efc36846c634d82755bbf33dd5062a7face06ec">+232/-198</a></td>

</tr>

<tr>
<td><strong>prepareUpdateColumnQuery.test.ts</strong><dd><code>Update
tests for custom column type changes</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-65420c7003a95c03b31fc4b0e45f6a22387f81c1ce8e41a2d7cb89cc44dbda26">+557/-494</a></td>

</tr>

<tr>
<td><strong>prepareUpdateTableQuery.test.ts</strong><dd><code>Update
tests for custom column type support</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-57c2f882497d653700d68905bb54c891592a6bf302040d3008d624900f1bdf64">+2/-2</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>getInputType.test.ts</strong><dd><code>Update tests for
input type handling</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-01a507828b9440cd99bd0722ab5b577d8dd1774f2320168ad88222138960e831">+3/-3</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>normalizeDatabaseColumn.test.ts</strong><dd><code>Update
tests for database column normalization</code>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-a451c29ffa35243d4dbb462e3a048088c514f8056effedf782fcc57d5235e338">+5/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

</table></details></td></tr><tr><td><strong>Documentation</strong></td><td><details><summary>1
files</summary><table>
<tr>
<td><strong>rich-dragons-attend.md</strong><dd><code>Add changeset for
custom column types feature</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3442/files#diff-bb90b20cf816a7c7bfc628f9daae90b9deff5d8c00f36361190d6147b46fb6be">+5/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>
</table></details></td></tr></tr></tbody></table>

</details>

___
This commit is contained in:
robertkasza
2025-09-08 14:24:43 +02:00
committed by GitHub
parent 397bfc948c
commit a30da08e9b
22 changed files with 1090 additions and 834 deletions

View File

@@ -0,0 +1,5 @@
---
'@nhost/dashboard': minor
---
feat (dashboard): add custom types to column types

View File

@@ -4,6 +4,7 @@ import type {
} from '@/components/ui/v2/Autocomplete';
import { Autocomplete } from '@/components/ui/v2/Autocomplete';
import { isNotEmptyValue } from '@/lib/utils';
import type { MakeRequired } from '@/types/common';
import { callAll } from '@/utils/callAll';
import type { FilterOptionsState } from '@mui/material';
import type { ForwardedRef } from 'react';
@@ -53,6 +54,40 @@ export function defaultFilterOptions(
return result;
}
export function defaultFilterGroupedOptions(
options: AutocompleteOption<string>[],
{ inputValue }: FilterOptionsState<AutocompleteOption<string>>,
) {
const optionsWithGroup = options as MakeRequired<
AutocompleteOption<string>,
'group'
>[];
const inputValueLower = inputValue.toLowerCase();
const matchedSet = new Set<string>();
const otherOptionsSet = new Set<string>();
optionsWithGroup.forEach((option) => {
const optionLabelLower = option.label.toLowerCase();
if (optionLabelLower.includes(inputValueLower)) {
matchedSet.add(option.group);
otherOptionsSet.delete(option.group);
} else if (!matchedSet.has(option.group)) {
otherOptionsSet.add(option.group);
}
});
const matchedOptions = optionsWithGroup.filter((option) =>
matchedSet.has(option.group),
);
const otherOptions = optionsWithGroup.filter((option) =>
otherOptionsSet.has(option.group),
);
const result = [...matchedOptions, ...otherOptions];
return result;
}
function ControlledAutocomplete(
{
controllerProps,

View File

@@ -51,7 +51,7 @@ export interface AutocompleteProps<
TOption extends AutocompleteOption = AutocompleteOption,
> extends Omit<
MaterialAutocompleteProps<TOption, boolean, boolean, boolean>,
'renderInput' | 'autoSelect' | 'componentsProps'
'renderInput' | 'autoSelect' | 'componentsProps' | 'isOptionEqualToValue'
>,
Pick<
InputProps,
@@ -100,11 +100,20 @@ export interface AutocompleteProps<
*
* @default 'never'
*/
showCustomOption?: 'always' | 'never' | 'auto';
showCustomOption?: 'always' | 'never' | 'auto' | 'first';
/**
* Custom option label.
*/
customOptionLabel?: string | ((customOptionLabel: string) => string);
isOptionEqualToValue?: (
option: AutocompleteOption<string>,
value: string | AutocompleteOption<string>,
) => boolean;
sortByOptions?: (
option1: AutocompleteOption<string>,
option2: AutocompleteOption<string>,
) => number;
}
const StyledTag = styled(Chip)(({ theme }) => ({
@@ -213,6 +222,7 @@ function Autocomplete(
customOptionLabel: externalCustomOptionLabel,
showCustomOption = 'never',
'aria-label': ariaLabel,
sortByOptions,
...props
}: AutocompleteProps<AutocompleteOption>,
ref: ForwardedRef<HTMLInputElement>,
@@ -263,6 +273,7 @@ function Autocomplete(
openOnFocus
disablePortal
disableClearable
autoFocus={false}
componentsProps={{
...defaultComponentsProps,
popper: {
@@ -315,7 +326,7 @@ function Autocomplete(
}}
isOptionEqualToValue={(
option,
value: string | number | AutocompleteOption<string>,
value: string | AutocompleteOption<string>,
) => {
if (!value) {
return false;
@@ -391,7 +402,24 @@ function Autocomplete(
if (!inputValue) {
return filteredOptions;
}
if (showCustomOption === 'first') {
const isInputValueInOptions = filteredOptions.some(
(filteredOption) => filteredOption.label === inputValue,
);
return isInputValueInOptions
? filteredOptions
: [
{
value: inputValue,
label: inputValue,
dropdownLabel:
customOptionLabel || `Select "${inputValue}"`,
custom: Boolean(inputValue),
},
...filteredOptions,
];
}
if (showCustomOption === 'auto') {
const isInputValueInOptions = filteredOptions.some(
(filteredOption) => filteredOption.label === inputValue,

View File

@@ -1,5 +1,8 @@
import { useDialog } from '@/components/common/DialogProvider';
import { ControlledAutocomplete } from '@/components/form/ControlledAutocomplete';
import {
ControlledAutocomplete,
defaultFilterGroupedOptions,
} from '@/components/form/ControlledAutocomplete';
import { ControlledCheckbox } from '@/components/form/ControlledCheckbox';
import { Form } from '@/components/form/Form';
import { InlineCode } from '@/components/presentational/InlineCode';
@@ -90,6 +93,10 @@ export default function BaseColumnForm({
value: functionName,
}),
);
const [inputValue, setInputValue] = useState<string>();
useEffect(() => {
setInputValue(type?.label ?? '');
}, [type?.label]);
// react-hook-form's isDirty gets true even if an input field is focused, then
// immediately unfocused - we can't rely on that information
@@ -132,17 +139,50 @@ export default function BaseColumnForm({
id="type"
name="type"
control={control}
aria-label="Type"
fullWidth
placeholder="Select a column type"
label="Type"
helperText={errors.type?.message}
error={Boolean(errors.type)}
hideEmptyHelperText
autoHighlight
className="col-span-8 py-3"
variant="inline"
options={postgresTypeGroups}
groupBy={(option) => option.group ?? ''}
error={Boolean(errors.type)}
placeholder="Select a column type"
label="Type"
hideEmptyHelperText
className="col-span-8 py-3"
variant="inline"
autoHighlight
clearOnBlur
showCustomOption="first"
filterOptions={defaultFilterGroupedOptions}
freeSolo
inputValue={inputValue}
onInputChange={(_event, value) => {
// Keep the list scrolled to the top while searching
requestAnimationFrame(() => {
const listbox = document.querySelector('#type-listbox');
if (listbox) {
listbox.scrollTop = 0;
}
});
setInputValue(value);
}}
renderOption={(props, { label, value, custom }) => {
if (custom) {
return (
<OptionBase {...props}>
<span>Use type: &quot;{value}&quot;</span>
</OptionBase>
);
}
return (
<OptionBase {...props}>
<div className="grid grid-flow-col items-baseline justify-start justify-items-start gap-1.5">
<span>{label}</span>
<InlineCode>{value}</InlineCode>
</div>
</OptionBase>
);
}}
onChange={(_event, value) => {
setDefaultValueInputText('');
@@ -155,16 +195,6 @@ export default function BaseColumnForm({
setValue('defaultValue', null);
}
}}
noOptionsText="No types found"
renderOption={(props, { label, value }) => (
<OptionBase {...props}>
<div className="grid grid-flow-col items-baseline justify-start justify-items-start gap-1.5">
<span>{label}</span>
<InlineCode>{value}</InlineCode>
</div>
</OptionBase>
)}
/>
{identityTypes.includes(type?.value) && (

View File

@@ -1,4 +1,7 @@
import { ControlledAutocomplete } from '@/components/form/ControlledAutocomplete';
import {
ControlledAutocomplete,
defaultFilterGroupedOptions,
} from '@/components/form/ControlledAutocomplete';
import { ControlledCheckbox } from '@/components/form/ControlledCheckbox';
import { InlineCode } from '@/components/presentational/InlineCode';
import type { CheckboxProps } from '@/components/ui/v2/Checkbox';
@@ -83,9 +86,15 @@ function NameInput({ index }: FieldArrayInputProps) {
}
function TypeAutocomplete({ index }: FieldArrayInputProps) {
const [inputValue, setInputValue] = useState<string>();
const { setValue } = useFormContext();
const { errors } = useFormState({ name: `columns.${index}.type` });
const identityColumnIndex = useWatch({ name: 'identityColumnIndex' });
const type = useWatch({ name: `columns.${index}.type` });
useEffect(() => {
setInputValue(type?.label ?? '');
}, [type?.label]);
return (
<ControlledAutocomplete
@@ -96,19 +105,43 @@ function TypeAutocomplete({ index }: FieldArrayInputProps) {
groupBy={(option) => option.group ?? ''}
placeholder="Select type"
hideEmptyHelperText
autoHighlight
noOptionsText="No types found"
freeSolo
inputValue={inputValue}
onInputChange={(_event, value) => {
// Keep the list scrolled to the top while searching
requestAnimationFrame(() => {
const listbox = document.querySelector(
`[id="columns.${index}.type-listbox"]`,
);
if (listbox) {
listbox.scrollTop = 0;
}
});
setInputValue(value);
}}
clearOnBlur
showCustomOption="first"
filterOptions={defaultFilterGroupedOptions}
error={Boolean(errors?.columns?.[index]?.type)}
helperText={(errors?.columns?.[index]?.type as FieldError)?.message}
renderOption={(props, { label, value }) => (
<OptionBase {...props}>
<div className="grid grid-flow-col items-baseline justify-start justify-items-start gap-1.5">
<span>{label}</span>
renderOption={(optionProps, { label, value, custom }) => {
if (custom) {
return (
<OptionBase {...optionProps}>
<span>Use type: &quot;{value}&quot;</span>
</OptionBase>
);
}
return (
<OptionBase {...optionProps}>
<div className="grid grid-flow-col items-baseline justify-start justify-items-start gap-1.5">
<span>{label}</span>
<InlineCode>{value}</InlineCode>
</div>
</OptionBase>
)}
<InlineCode>{value}</InlineCode>
</div>
</OptionBase>
);
}}
onChange={(_event, value) => {
if (typeof value === 'string' || Array.isArray(value)) {
return;

View File

@@ -77,21 +77,16 @@ export function createDataGridColumn(
{column.column_name}
</span>
<InlineCode>
{column.udt_name}
{column.character_maximum_length
? `(${column.character_maximum_length})`
: null}
</InlineCode>
<InlineCode>{column.full_data_type}</InlineCode>
</div>
),
id: column.column_name,
accessor: column.column_name,
sortType: 'basic',
width: 200,
width: 250,
isEditable,
type: 'text',
specificType: column.udt_name,
specificType: column.full_data_type,
maxLength: column.character_maximum_length,
Cell: DataGridTextCell,
isPrimary: column.is_primary,
@@ -110,7 +105,7 @@ export function createDataGridColumn(
return {
...defaultColumnConfiguration,
type: 'number',
width: 200,
width: 250,
Cell: DataGridIntegerCell,
};
}
@@ -119,7 +114,7 @@ export function createDataGridColumn(
return {
...defaultColumnConfiguration,
type: 'text',
width: 200,
width: 250,
Cell: DataGridDecimalCell,
};
}
@@ -141,7 +136,7 @@ export function createDataGridColumn(
...defaultColumnConfiguration,
type: 'text',
isCopiable: true,
width: 200,
width: 250,
Cell: DataGridTextCell,
};
}

View File

@@ -112,7 +112,7 @@ export default function DatabaseRecordInputGroup({
const isMultiline =
specificType === 'text' ||
specificType === 'bpchar' ||
specificType === 'varchar' ||
specificType === 'character varying' ||
specificType === 'json' ||
specificType === 'jsonb';

View File

@@ -51,7 +51,7 @@ export default function prepareCreateColumnQuery({
let args: ReturnType<typeof getPreparedHasuraQuery>[] = [
getPreparedHasuraQuery(
dataSource,
'ALTER TABLE %I.%I ADD %I %I %s %s %s',
'ALTER TABLE %I.%I ADD %I %s %s %s %s',
schema,
table,
column.name,

View File

@@ -2,213 +2,246 @@ import type { DatabaseTable } from '@/features/orgs/projects/database/dataGrid/t
import { expect, test } from 'vitest';
import prepareCreateTableQuery from './prepareCreateTableQuery';
test('should prepare a simple query', () => {
const table: DatabaseTable = {
name: 'test_table',
columns: [
{
name: 'id',
type: { value: 'uuid', label: 'UUID' },
},
{
name: 'name',
type: { value: 'text', label: 'Text' },
},
],
primaryKey: ['id'],
};
const transaction = prepareCreateTableQuery({
dataSource: 'default',
schema: 'public',
table,
});
expect(transaction).toHaveLength(1);
expect(transaction[0].args.sql).toBe(
'CREATE TABLE public.test_table (id uuid NOT NULL, name text NOT NULL, PRIMARY KEY (id));',
);
});
test('should prepare a query with foreign keys', () => {
const table: DatabaseTable = {
name: 'test_table',
columns: [
{
name: 'id',
type: { value: 'uuid', label: 'UUID' },
},
{
name: 'name',
type: { value: 'text', label: 'Text' },
},
{
name: 'author_id',
type: { value: 'uuid', label: 'UUID' },
},
],
foreignKeyRelations: [
{
name: 'test_table_author_id_fkey',
columnName: 'author_id',
referencedSchema: 'public',
referencedTable: 'authors',
referencedColumn: 'id',
updateAction: 'RESTRICT',
deleteAction: 'RESTRICT',
},
],
primaryKey: ['id'],
};
const transaction = prepareCreateTableQuery({
dataSource: 'default',
schema: 'public',
table,
});
expect(transaction).toHaveLength(1);
expect(transaction[0].args.sql).toBe(
'CREATE TABLE public.test_table (id uuid NOT NULL, name text NOT NULL, author_id uuid NOT NULL, PRIMARY KEY (id), FOREIGN KEY (author_id) REFERENCES public.authors (id) ON UPDATE RESTRICT ON DELETE RESTRICT);',
);
});
test('should prepare a query with unique keys', () => {
const table: DatabaseTable = {
name: 'test_table',
columns: [
{
name: 'id',
type: { value: 'uuid', label: 'UUID' },
},
{
name: 'name',
type: { value: 'text', label: 'Text' },
isUnique: true,
},
],
primaryKey: ['id'],
};
const transaction = prepareCreateTableQuery({
dataSource: 'default',
schema: 'public',
table,
});
expect(transaction).toHaveLength(1);
expect(transaction[0].args.sql).toBe(
'CREATE TABLE public.test_table (id uuid NOT NULL, name text UNIQUE NOT NULL, PRIMARY KEY (id));',
);
});
test('should prepare a query with nullable columns', () => {
const table: DatabaseTable = {
name: 'test_table',
columns: [
{
name: 'id',
type: { value: 'uuid', label: 'UUID' },
},
{
name: 'name',
type: { value: 'text', label: 'Text' },
isNullable: true,
},
{
name: 'is_active',
type: { value: 'bool', label: 'Boolean' },
isNullable: true,
},
],
primaryKey: ['id'],
};
const transaction = prepareCreateTableQuery({
dataSource: 'default',
schema: 'public',
table,
});
expect(transaction).toHaveLength(1);
expect(transaction[0].args.sql).toBe(
'CREATE TABLE public.test_table (id uuid NOT NULL, name text, is_active bool, PRIMARY KEY (id));',
);
});
test('should prepare a query with default values', () => {
const table: DatabaseTable = {
name: 'test_table',
columns: [
{
name: 'id',
type: { value: 'uuid', label: 'UUID' },
// this is a default value preset
defaultValue: {
value: 'gen_random_uuid()',
label: 'gen_random_uuid()',
describe('prepareCreateTableQuery', () => {
test('should prepare a simple query', () => {
const table: DatabaseTable = {
name: 'test_table',
columns: [
{
name: 'id',
type: { value: 'uuid', label: 'UUID' },
},
},
{
name: 'name',
type: { value: 'text', label: 'Text' },
isNullable: true,
},
{
name: 'is_active',
type: { value: 'bool', label: 'Boolean' },
isNullable: true,
// this is a custom default value
defaultValue: { value: 'true', label: 'true', custom: true },
},
],
primaryKey: ['id'],
};
{
name: 'name',
type: { value: 'text', label: 'Text' },
},
],
primaryKey: ['id'],
};
const transaction = prepareCreateTableQuery({
dataSource: 'default',
schema: 'public',
table,
const transaction = prepareCreateTableQuery({
dataSource: 'default',
schema: 'public',
table,
});
expect(transaction).toHaveLength(1);
expect(transaction[0].args.sql).toBe(
'CREATE TABLE public.test_table (id uuid NOT NULL, name text NOT NULL, PRIMARY KEY (id));',
);
});
expect(transaction).toHaveLength(1);
expect(transaction[0].args.sql).toBe(
"CREATE TABLE public.test_table (id uuid DEFAULT gen_random_uuid() NOT NULL, name text, is_active bool DEFAULT 'true', PRIMARY KEY (id));",
);
});
test('should prepare a query with foreign keys', () => {
const table: DatabaseTable = {
name: 'test_table',
columns: [
{
name: 'id',
type: { value: 'uuid', label: 'UUID' },
},
{
name: 'name',
type: { value: 'text', label: 'Text' },
},
{
name: 'author_id',
type: { value: 'uuid', label: 'UUID' },
},
],
foreignKeyRelations: [
{
name: 'test_table_author_id_fkey',
columnName: 'author_id',
referencedSchema: 'public',
referencedTable: 'authors',
referencedColumn: 'id',
updateAction: 'RESTRICT',
deleteAction: 'RESTRICT',
},
],
primaryKey: ['id'],
};
test('should prepare a query with an identity column', () => {
const table: DatabaseTable = {
name: 'test_table',
columns: [
{
name: 'id',
type: { value: 'int4', label: 'Integer' },
},
{
name: 'name',
type: { value: 'text', label: 'Text' },
isNullable: true,
},
{
name: 'is_active',
type: { value: 'bool', label: 'Boolean' },
isNullable: true,
defaultValue: { value: 'true', label: 'true' },
},
],
primaryKey: ['id'],
identityColumn: 'id',
};
const transaction = prepareCreateTableQuery({
dataSource: 'default',
schema: 'public',
table,
});
const transaction = prepareCreateTableQuery({
dataSource: 'default',
schema: 'public',
table,
expect(transaction).toHaveLength(1);
expect(transaction[0].args.sql).toBe(
'CREATE TABLE public.test_table (id uuid NOT NULL, name text NOT NULL, author_id uuid NOT NULL, PRIMARY KEY (id), FOREIGN KEY (author_id) REFERENCES public.authors (id) ON UPDATE RESTRICT ON DELETE RESTRICT);',
);
});
expect(transaction).toHaveLength(1);
expect(transaction[0].args.sql).toBe(
'CREATE TABLE public.test_table (id int4 GENERATED ALWAYS AS IDENTITY, name text, is_active bool DEFAULT true, PRIMARY KEY (id));',
);
test('should prepare a query with unique keys', () => {
const table: DatabaseTable = {
name: 'test_table',
columns: [
{
name: 'id',
type: { value: 'uuid', label: 'UUID' },
},
{
name: 'name',
type: { value: 'text', label: 'Text' },
isUnique: true,
},
],
primaryKey: ['id'],
};
const transaction = prepareCreateTableQuery({
dataSource: 'default',
schema: 'public',
table,
});
expect(transaction).toHaveLength(1);
expect(transaction[0].args.sql).toBe(
'CREATE TABLE public.test_table (id uuid NOT NULL, name text UNIQUE NOT NULL, PRIMARY KEY (id));',
);
});
test('should prepare a query with nullable columns', () => {
const table: DatabaseTable = {
name: 'test_table',
columns: [
{
name: 'id',
type: { value: 'uuid', label: 'UUID' },
},
{
name: 'name',
type: { value: 'text', label: 'Text' },
isNullable: true,
},
{
name: 'is_active',
type: { value: 'bool', label: 'Boolean' },
isNullable: true,
},
],
primaryKey: ['id'],
};
const transaction = prepareCreateTableQuery({
dataSource: 'default',
schema: 'public',
table,
});
expect(transaction).toHaveLength(1);
expect(transaction[0].args.sql).toBe(
'CREATE TABLE public.test_table (id uuid NOT NULL, name text, is_active bool, PRIMARY KEY (id));',
);
});
test('should prepare a query with default values', () => {
const table: DatabaseTable = {
name: 'test_table',
columns: [
{
name: 'id',
type: { value: 'uuid', label: 'UUID' },
// this is a default value preset
defaultValue: {
value: 'gen_random_uuid()',
label: 'gen_random_uuid()',
},
},
{
name: 'name',
type: { value: 'text', label: 'Text' },
isNullable: true,
},
{
name: 'is_active',
type: { value: 'bool', label: 'Boolean' },
isNullable: true,
// this is a custom default value
defaultValue: { value: 'true', label: 'true', custom: true },
},
],
primaryKey: ['id'],
};
const transaction = prepareCreateTableQuery({
dataSource: 'default',
schema: 'public',
table,
});
expect(transaction).toHaveLength(1);
expect(transaction[0].args.sql).toBe(
"CREATE TABLE public.test_table (id uuid DEFAULT gen_random_uuid() NOT NULL, name text, is_active bool DEFAULT 'true', PRIMARY KEY (id));",
);
});
test('should prepare a query with an identity column', () => {
const table: DatabaseTable = {
name: 'test_table',
columns: [
{
name: 'id',
type: { value: 'int4', label: 'Integer' },
},
{
name: 'name',
type: { value: 'text', label: 'Text' },
isNullable: true,
},
{
name: 'is_active',
type: { value: 'bool', label: 'Boolean' },
isNullable: true,
defaultValue: { value: 'true', label: 'true' },
},
],
primaryKey: ['id'],
identityColumn: 'id',
};
const transaction = prepareCreateTableQuery({
dataSource: 'default',
schema: 'public',
table,
});
expect(transaction).toHaveLength(1);
expect(transaction[0].args.sql).toBe(
'CREATE TABLE public.test_table (id int4 GENERATED ALWAYS AS IDENTITY, name text, is_active bool DEFAULT true, PRIMARY KEY (id));',
);
});
test('should prepare a query with no primary key', () => {
const table: DatabaseTable = {
name: 'test_table',
primaryKey: [],
columns: [
{
name: 'id',
type: { value: 'uuid', label: 'UUID' },
},
{
name: 'name',
type: {
value: 'varchar(10)' as any,
label: 'varchar(10)',
},
},
],
// No primaryKey property set
};
const transaction = prepareCreateTableQuery({
dataSource: 'default',
schema: 'public',
table,
});
expect(transaction).toHaveLength(1);
expect(transaction[0].args.sql).toBe(
'CREATE TABLE public.test_table (id uuid NOT NULL, name varchar(10) NOT NULL);',
);
});
});

View File

@@ -27,7 +27,7 @@ export default function prepareCreateTableQuery({
}: PrepareCreateTableQueryVariables) {
let columnsAndConstraints = table.columns
.map((column) => {
const columnBase = format('%I %I', column.name, column.type.value);
const columnBase = format('%I %s', column.name, column.type.value);
const isIdentity = table.identityColumn === column.name;
if (isIdentity) {

View File

@@ -114,34 +114,42 @@ export default async function fetchTable({
args: [
getPreparedReadOnlyHasuraQuery(
dataSource,
`SELECT ROW_TO_JSON(TABLE_DATA) FROM (\
SELECT *,\
EXISTS (\
SELECT NSP.NSPNAME, CLS.RELNAME, ATTR.ATTNAME\
FROM PG_INDEX IND\
JOIN PG_CLASS CLS ON CLS.OID = IND.INDRELID\
JOIN PG_ATTRIBUTE ATTR ON ATTR.ATTRELID = CLS.OID\
AND ATTR.ATTNUM = ANY(IND.INDKEY)\
JOIN PG_NAMESPACE NSP ON NSP.OID = CLS.RELNAMESPACE\
WHERE NSPNAME = %1$L AND RELNAME = %2$L AND ATTR.ATTNAME = COLS.COLUMN_NAME AND INDISPRIMARY\
) AS IS_PRIMARY,\
EXISTS (\
SELECT NSP.NSPNAME, CLS.RELNAME, ATTR.ATTNAME\
FROM PG_INDEX IND\
JOIN PG_CLASS CLS ON CLS.OID = IND.INDRELID\
JOIN PG_ATTRIBUTE ATTR ON ATTR.ATTRELID = CLS.OID\
AND ATTR.ATTNUM = ANY(IND.INDKEY)\
JOIN PG_NAMESPACE NSP ON NSP.OID = CLS.RELNAMESPACE\
WHERE NSPNAME = %1$L AND RELNAME = %2$L AND ATTR.ATTNAME = COLS.COLUMN_NAME AND INDISUNIQUE\
) AS IS_UNIQUE,\
(\
SELECT PG_CATALOG.COL_DESCRIPTION(CLS.OID, COLS.ORDINAL_POSITION::INT)\
FROM PG_CATALOG.PG_CLASS CLS\
WHERE CLS.OID = (SELECT '%1$I.%2$I'::REGCLASS::OID) AND CLS.RELNAME = COLS.TABLE_NAME\
) AS COLUMN_COMMENT\
FROM INFORMATION_SCHEMA.COLUMNS COLS\
WHERE TABLE_SCHEMA = %1$L AND TABLE_NAME = %2$L\
) TABLE_DATA`,
`
SELECT ROW_TO_JSON(TABLE_DATA) FROM (
SELECT *,
PG_CATALOG.FORMAT_TYPE(
(SELECT ATTTYPID FROM PG_ATTRIBUTE
WHERE ATTRELID = (SELECT OID FROM PG_CLASS WHERE RELNAME = %2$L AND RELNAMESPACE = (SELECT OID FROM PG_NAMESPACE WHERE NSPNAME = %1$L))
AND ATTNAME = COLS.COLUMN_NAME),
(SELECT ATTTYPMOD FROM PG_ATTRIBUTE
WHERE ATTRELID = (SELECT OID FROM PG_CLASS WHERE RELNAME = %2$L AND RELNAMESPACE = (SELECT OID FROM PG_NAMESPACE WHERE NSPNAME = %1$L))
AND ATTNAME = COLS.COLUMN_NAME)
) AS FULL_DATA_TYPE,
EXISTS (
SELECT NSP.NSPNAME, CLS.RELNAME, ATTR.ATTNAME
FROM PG_INDEX IND
JOIN PG_CLASS CLS ON CLS.OID = IND.INDRELID
JOIN PG_ATTRIBUTE ATTR ON ATTR.ATTRELID = CLS.OID AND ATTR.ATTNUM = ANY(IND.INDKEY)
JOIN PG_NAMESPACE NSP ON NSP.OID = CLS.RELNAMESPACE
WHERE NSPNAME = %1$L AND RELNAME = %2$L AND ATTR.ATTNAME = COLS.COLUMN_NAME AND INDISPRIMARY
) AS IS_PRIMARY,
EXISTS (
SELECT NSP.NSPNAME, CLS.RELNAME, ATTR.ATTNAME
FROM PG_INDEX IND
JOIN PG_CLASS CLS ON CLS.OID = IND.INDRELID
JOIN PG_ATTRIBUTE ATTR ON ATTR.ATTRELID = CLS.OID AND ATTR.ATTNUM = ANY(IND.INDKEY)
JOIN PG_NAMESPACE NSP ON NSP.OID = CLS.RELNAMESPACE
WHERE NSPNAME = %1$L AND RELNAME = %2$L AND ATTR.ATTNAME = COLS.COLUMN_NAME AND INDISUNIQUE
) AS IS_UNIQUE,
(
SELECT PG_CATALOG.COL_DESCRIPTION(CLS.OID, COLS.ORDINAL_POSITION::INT)
FROM PG_CATALOG.PG_CLASS CLS
WHERE CLS.OID = (SELECT '%1$s.%2$s'::REGCLASS::OID) AND CLS.RELNAME = COLS.TABLE_NAME
) AS COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS COLS
WHERE TABLE_SCHEMA = %1$L AND TABLE_NAME = %2$L
) TABLE_DATA;
`,
schema,
table,
),

View File

@@ -57,7 +57,7 @@ export default function prepareUpdateColumnQuery({
),
getPreparedHasuraQuery(
dataSource,
'ALTER TABLE %I.%I ALTER COLUMN %3$I TYPE %4$I USING %3$I::%4$I',
'ALTER TABLE %I.%I ALTER COLUMN %3$I TYPE %4$s USING %3$I::%4$s',
schema,
table,
originalColumn.id,

View File

@@ -166,7 +166,7 @@ describe('prepareUpdateTableQuery', () => {
{
id: 'author_id',
name: 'age',
type: { value: 'int2', label: 'int2' },
type: { value: 'numeric(10,2)' as any, label: 'numeric(10,2)' },
},
],
foreignKeyRelations: [],
@@ -186,7 +186,7 @@ describe('prepareUpdateTableQuery', () => {
'ALTER TABLE public.test_table ALTER COLUMN author_id DROP DEFAULT;',
);
expect(transaction[1].args.sql).toBe(
'ALTER TABLE public.test_table ALTER COLUMN author_id TYPE int2 USING author_id::int2;',
'ALTER TABLE public.test_table ALTER COLUMN author_id TYPE numeric(10,2) USING author_id::numeric(10,2);',
);
expect(transaction[2].args.sql).toBe(
'ALTER TABLE public.test_table RENAME COLUMN author_id TO age;',

View File

@@ -220,7 +220,7 @@ export interface ColumnInsertOptions {
/**
* User defined column type of a character field in PostgreSQL.
*/
export type CharacterColumnType = 'varchar' | 'bpchar' | 'text';
export type CharacterColumnType = 'character varying' | 'bpchar' | 'text';
/**
* User defined column type of a boolean field in PostgreSQL.
@@ -249,7 +249,6 @@ export type DateColumnType =
export type NumericColumnType =
| 'oid'
| 'numeric'
| 'decimal'
| 'int'
| 'int2'
| 'int4'

View File

@@ -17,9 +17,9 @@ describe('getInputType', () => {
test('should return "text" if the column is text based', () => {
expect(getInputType({ type: 'text', specificType: 'text' })).toBe('text');
expect(getInputType({ type: 'text', specificType: 'varchar' })).toBe(
'text',
);
expect(
getInputType({ type: 'text', specificType: 'character varying' }),
).toBe('text');
expect(getInputType({ type: 'text', specificType: 'bpchar' })).toBe('text');
});

View File

@@ -0,0 +1 @@
export { default as normalizeColumnType } from './normalizeColumnType';

View File

@@ -0,0 +1,17 @@
import type { NormalizedQueryDataRow } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
import { postgresTypeGroups } from '@/features/orgs/projects/database/dataGrid/utils/postgresqlConstants';
function normalizeColumnType(column: NormalizedQueryDataRow) {
const label =
postgresTypeGroups.find((pt) => pt.value === column.full_data_type)
?.label || column.full_data_type;
return {
label,
value: column.full_data_type,
custom:
column.data_type === 'USER-DEFINED' ||
column.full_data_type !== column.udt_name,
};
}
export default normalizeColumnType;

View File

@@ -33,6 +33,7 @@ const rawColumn: NormalizedQueryDataRow = {
udt_catalog: 'postgres',
udt_schema: 'pg_catalog',
udt_name: 'uuid',
full_data_type: 'uuid',
scope_catalog: null,
scope_schema: null,
scope_name: null,
@@ -90,6 +91,7 @@ test('should set identity to true if the column is an identity column', () => {
data_type: 'int4',
column_default: null,
is_identity: 'YES',
full_data_type: 'int4',
};
const column = normalizeDatabaseColumn(rawIdentityColumn);
@@ -103,7 +105,8 @@ test('should set identity to true if the column is an identity column', () => {
isNullable: false,
type: {
value: 'int4',
label: 'int4',
label: 'integer',
custom: false,
},
defaultValue: null,
comment: null,

View File

@@ -4,6 +4,7 @@ import type {
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
import { normalizeDefaultValue } from '@/features/orgs/projects/database/dataGrid/utils/normalizeDefaultValue';
import { normalizeColumnType } from '@/features/orgs/projects/database/dataGrid/utils/normalizeColumnType';
/**
* Converts a raw database column to a normalized database column.
*
@@ -19,7 +20,7 @@ export default function normalizeDatabaseColumn(
return {
id: rawColumn.column_name,
name: rawColumn.column_name,
type: { value: rawColumn.udt_name, label: rawColumn.udt_name },
type: normalizeColumnType(rawColumn),
isPrimary: rawColumn.is_primary,
isIdentity: rawColumn.is_identity === 'YES',
isNullable: rawColumn.is_nullable === 'YES',

View File

@@ -29,12 +29,7 @@ export const POSTGRESQL_INTEGER_TYPES = [
'oid',
];
export const POSTGRESQL_DECIMAL_TYPES = [
'decimal',
'numeric',
'real',
'double precision',
];
export const POSTGRESQL_DECIMAL_TYPES = ['numeric', 'real', 'double precision'];
/**
* Character data types in PostgreSQL.
@@ -77,7 +72,11 @@ export const postgresTypeGroups: {
value: ColumnType;
}[] = [
{ group: 'String types', label: 'text', value: 'text' },
{ group: 'String types', label: 'character varying', value: 'varchar' },
{
group: 'String types',
label: 'character varying',
value: 'character varying',
},
{ group: 'String types', label: 'character', value: 'bpchar' },
{ group: 'UUID types', label: 'uuid', value: 'uuid' },
@@ -86,10 +85,13 @@ export const postgresTypeGroups: {
{ group: 'Numeric types', label: 'smallint', value: 'int2' },
{ group: 'Numeric types', label: 'integer', value: 'int4' },
{ group: 'Numeric types', label: 'bigint', value: 'int8' },
{ group: 'Numeric types', label: 'decimal', value: 'decimal' },
{ group: 'Numeric types', label: 'numeric', value: 'numeric' },
{ group: 'Numeric types', label: 'real', value: 'float4' },
{ group: 'Numeric types', label: 'double precision', value: 'float8' },
{
group: 'Numeric types',
label: 'double precision',
value: 'float8',
},
{ group: 'Boolean types', label: 'boolean', value: 'bool' },
{ group: 'Date types', label: 'date', value: 'date' },
{
@@ -102,8 +104,16 @@ export const postgresTypeGroups: {
label: 'timestamp with time zone',
value: 'timestamptz',
},
{ group: 'Date types', label: 'time without time zone', value: 'time' },
{ group: 'Date types', label: 'time with time zone', value: 'timetz' },
{
group: 'Date types',
label: 'time without time zone',
value: 'time',
},
{
group: 'Date types',
label: 'time with time zone',
value: 'timetz',
},
{ group: 'Date types', label: 'interval', value: 'interval' },
{ group: 'Binary types', label: 'bytea', value: 'bytea' },
{ group: 'Geometric types', label: 'point', value: 'point' },
@@ -164,7 +174,11 @@ export const postgresTypeGroups: {
label: 'function with argument types',
value: 'regprocedure',
},
{ group: 'Object Identifier types', label: 'role name', value: 'regrole' },
{
group: 'Object Identifier types',
label: 'role name',
value: 'regrole',
},
{
group: 'Object Identifier types',
label: 'data type name',

View File

@@ -25,7 +25,7 @@ export default function DataGridTextCell<TData extends object>({
const isMultiline =
specificType === 'text' ||
specificType === 'bpchar' ||
specificType === 'varchar' ||
specificType === 'character varying' ||
specificType === 'json' ||
specificType === 'jsonb';