Joshenlim/fe 4475 fdw update sql to run proper alter statements instead of (#50988)

## Context

Currently for FDWs under integrations, editing an FDW involves tearing
it down then re-creating it - which while conveniently works has a lot
of problems like:
- Blast radius is way bigger than the edit
  - Everything is recreated, including vault secrets
- Silent drops grants/comments/ownership
- Cascades on dependent objects
- Views or functions built on top of foreign tables would get dropped
along with it

Changes in this PR hence updates `getUpdateFDWSql` to diff the current
wrapper state against the form state and generate targeted `ALTER`
statements for only what actually changed

## To test
- [ ] Verify that updating an existing wrapper still works as expected

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Wrapper changes can be saved in place, including server options,
encrypted values, foreign tables, and column definitions.
* Input fields can display placeholder text, and missing server-option
values display their defaults.

* **Improvements**
* Saving is unavailable until encrypted values are ready; a waiting
message appears while they load.
* The edit panel closes after a successful save. Confirmation text
explains that table or column changes may affect dependent
functionality.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-09-29 19:47:57 +08:00
1 parent b91870b012
commit 9aae037dff
6 files changed
+674 -157

No files matched your search

@@ -312,7 +312,12 @@ export const CreateWrapperSheet = ({
{wrapperMeta.server.options
.filter((option) => !option.hidden)
.map((option) => (
<InputField option={option} control={form.control} key={option.name} />
<InputField
key={option.name}
option={option}
control={form.control}
placeholder={option.defaultValue}
/>
))}
</FormSectionContent>
</FormSection>
@@ -34,6 +34,7 @@ import {
} from './Wrappers.utils'
import WrapperTableEditor from './WrapperTableEditor'
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import {
FormSection,
FormSectionContent,
@@ -70,10 +71,11 @@ export const EditWrapperSheet = ({
const { mutate: updateFDW, isPending: isSaving } = useFDWUpdateMutation({
onSuccess: () => {
toast.success(`Successfully updated ${wrapperMeta?.label} foreign data wrapper`)
const { tables } = getValues()
const hasNewSchema = (tables as Record<string, any>[]).some((table) => table.is_new_schema)
if (hasNewSchema) invalidateSchemasQuery(queryClient, project?.ref)
onClose()
},
})
@@ -151,7 +153,11 @@ export const EditWrapperSheet = ({
const wrapper_name = useWatch({ name: 'wrapper_name', control: form.control })
const [isLoadingSecrets, setIsLoadingSecrets] = useState(false)
const [secretsReady, setSecretsReady] = useState(false)
useEffect(() => {
let isCurrent = true
const encryptedOptions = wrapperMeta.server.options.filter((option) => option.encrypted)
const encryptedIdsToFetch = compact(
@@ -160,7 +166,14 @@ export const EditWrapperSheet = ({
return value ?? null
})
).filter((x) => UUID_REGEX.test(x))
// [Joshen] ^ Validate UUID to filter out already decrypted values
if (encryptedIdsToFetch.length === 0) {
setSecretsReady(true)
setIsLoadingSecrets(false)
return
}
setSecretsReady(false)
const fetchEncryptedValues = async (ids: string[]) => {
try {
@@ -171,21 +184,26 @@ export const EditWrapperSheet = ({
connectionString: project?.connectionString,
ids: ids,
})
if (!isCurrent) return
encryptedOptions.forEach((option) => {
const encryptedId = initialValues[option.name]
resetField(option.name, { defaultValue: decryptedValues[encryptedId] })
})
setSecretsReady(true)
} catch (error) {
if (!isCurrent) return
toast.error('Failed to fetch encrypted values')
} finally {
setIsLoadingSecrets(false)
if (isCurrent) setIsLoadingSecrets(false)
}
}
if (encryptedIdsToFetch.length > 0) {
fetchEncryptedValues(encryptedIdsToFetch)
fetchEncryptedValues(encryptedIdsToFetch)
return () => {
isCurrent = false
}
}, [initialValues, wrapperMeta, resetField, project?.ref, project?.connectionString])
@@ -248,6 +266,7 @@ export const EditWrapperSheet = ({
key={option.name}
option={option}
control={form.control}
placeholder={option.defaultValue}
loading={option.secureEntry ? isLoadingSecrets : undefined}
/>
))}
@@ -335,16 +354,22 @@ export const EditWrapperSheet = ({
<Button size="tiny" type="button" onClick={confirmOnClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
<ButtonTooltip
size="tiny"
variant="primary"
form={FORM_ID}
type="submit"
disabled={isSubmitting || !isDirty}
disabled={isSubmitting || !isDirty || !secretsReady}
loading={isSubmitting}
tooltip={{
content: {
side: 'top',
text: !secretsReady ? 'Waiting for encrypted values to load' : undefined,
},
}}
>
Save wrapper
</Button>
</ButtonTooltip>
</SheetFooter>
</form>
</Form>
@@ -352,11 +377,11 @@ export const EditWrapperSheet = ({
<ConfirmationModal
visible={isUpdateConfirmationOpen}
title="Recreate wrapper?"
size="medium"
title="Save wrapper changes?"
size="small"
variant="warning"
confirmLabel="Recreate wrapper"
confirmLabelLoading="Recreating wrapper"
confirmLabel="Save changes"
confirmLabelLoading="Saving changes"
loading={isSaving}
onCancel={() => {
setIsUpdateConfirmationOpen(false)
@@ -376,9 +401,7 @@ export const EditWrapperSheet = ({
}}
>
<p className="text-sm text-foreground-light">
Saving changes will drop the existing wrapper and recreate it. Foreign servers and tables
will be recreated, and dependent objects like functions or views that reference those
tables may need to be updated manually afterwards.
Removing a table or retyping a column may break views or functions that reference it.
</p>
<p className="text-sm text-foreground-light mt-2">Are you sure you want to continue?</p>
</ConfirmationModal>
@@ -11,6 +11,7 @@ interface InputFieldProps<TFieldValues extends FieldValues = FieldValues> {
option: ServerOption
control: Control<TFieldValues>
loading?: boolean
placeholder?: string
}
const InputField = <
@@ -20,6 +21,7 @@ const InputField = <
control,
option,
loading = false,
placeholder,
}: InputFieldProps<TFieldValues>) => {
return (
<FormField
@@ -57,7 +59,7 @@ const InputField = <
) : option.secureEntry ? (
<PasswordInput copy reveal {...field} />
) : (
<Input {...field} />
<Input {...field} placeholder={placeholder} />
)}
</FormControl>
</FormItemLayout>
@@ -41,6 +41,7 @@ export const WrapperRow = ({ wrapper, isShared }: WrapperRowProps) => {
const _tables = formatWrapperTables(wrapper, integration?.meta)
const canEdit = canManageWrappers && !isShared
let editTooltip = 'Edit wrapper'
if (!canManageWrappers) editTooltip = 'You need additional permissions to edit wrappers'
else if (isShared) editTooltip = 'Shared wrappers cannot be edited in the dashboard'
@@ -87,7 +88,7 @@ export const WrapperRow = ({ wrapper, isShared }: WrapperRowProps) => {
>
<span className="text-foreground-lighter text-nowrap">{metadata.label}:</span>
<span className="truncate max-w-72" title={serverOptions[metadata.name]}>
{serverOptions[metadata.name]}
{serverOptions[metadata.name] ?? metadata.defaultValue}
</span>
</div>
))}