From 9aae037dff85c2f70ed2a9b335c396708713df3d Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 29 Sep 2026 19:47:57 +0800 Subject: [PATCH] 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 ## 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. --- .../Wrappers/CreateWrapperSheet.tsx | 7 +- .../Wrappers/EditWrapperSheet.tsx | 53 +- .../Integrations/Wrappers/InputField.tsx | 4 +- .../Integrations/Wrappers/WrapperRow.tsx | 3 +- .../pg-meta/src/sql/studio/database/fdw.ts | 516 +++++++++++++----- packages/pg-meta/test/sql/studio/fdw.test.ts | 248 ++++++++- 6 files changed, 674 insertions(+), 157 deletions(-) diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx index e655d64d9b6..fd16e97dd6d 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx @@ -312,7 +312,12 @@ export const CreateWrapperSheet = ({ {wrapperMeta.server.options .filter((option) => !option.hidden) .map((option) => ( - + ))} diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx index ca2a703b9fd..fafb290e561 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx @@ -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[]).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 = ({ - + @@ -352,11 +377,11 @@ export const EditWrapperSheet = ({ { setIsUpdateConfirmationOpen(false) @@ -376,9 +401,7 @@ export const EditWrapperSheet = ({ }} >

- 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.

Are you sure you want to continue?

diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/InputField.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/InputField.tsx index c34eb91a652..d5ed4a6e7b7 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/InputField.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/InputField.tsx @@ -11,6 +11,7 @@ interface InputFieldProps { option: ServerOption control: Control loading?: boolean + placeholder?: string } const InputField = < @@ -20,6 +21,7 @@ const InputField = < control, option, loading = false, + placeholder, }: InputFieldProps) => { return ( ) : ( - + )} diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperRow.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperRow.tsx index 2fbcbdef192..48b378edd35 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperRow.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperRow.tsx @@ -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) => { > {metadata.label}: - {serverOptions[metadata.name]} + {serverOptions[metadata.name] ?? metadata.defaultValue} ))} diff --git a/packages/pg-meta/src/sql/studio/database/fdw.ts b/packages/pg-meta/src/sql/studio/database/fdw.ts index 43e621c7122..dc854a03704 100644 --- a/packages/pg-meta/src/sql/studio/database/fdw.ts +++ b/packages/pg-meta/src/sql/studio/database/fdw.ts @@ -13,6 +13,209 @@ type SimplifiedWrapperMeta = { server: { options: { name: string; encrypted: boolean }[] } } +type WrapperExistingTable = { + id: string | number + schema: string + name: string + columns: { name: string; type: string }[] + options: string[] | null +} + +type WrapperFormTable = { + schema_name: string + table_name: string + columns: { name: string; type: string }[] + is_new_schema?: boolean +} & Record + +// Old wrappers has an implicit dependency on pgsodium. For new wrappers we use Vault directly. +const LEGACY_WRAPPER_EXTENSION_VERSIONS = [ + '0.1.0', + '0.1.1', + '0.1.4', + '0.1.5', + '0.1.6', + '0.1.7', + '0.1.8', + '0.1.9', + '0.1.10', + '0.1.11', + '0.1.12', + '0.1.14', + '0.1.15', + '0.1.16', + '0.1.17', + '0.1.18', + '0.1.19', + '0.2.0', + '0.3.0', + '0.3.1', + '0.4.0', + '0.4.1', + '0.4.2', + '0.4.3', + '0.4.4', + '0.4.5', +] + +const TABLE_META_KEYS = new Set([ + 'table_name', + 'schema_name', + 'schema', + 'id', + 'columns', + 'index', + 'is_new_schema', +]) + +const isUsingOldWrappersSql = safeSql` + (select extversion from pg_extension where extname = 'wrappers') in (${joinSqlFragments( + LEGACY_WRAPPER_EXTENSION_VERSIONS.map((version) => literal(version)), + ',' + )}) +` + +function parseOptionsArray(options?: string[] | null): Record { + return Object.fromEntries( + (options ?? []).map((entry) => { + const index = entry.indexOf('=') + return index === -1 ? [entry, ''] : [entry.slice(0, index), entry.slice(index + 1)] + }) + ) +} + +function getTableOptionsMap(table: Record): Record { + return Object.fromEntries( + Object.entries(table).filter(([key, value]) => !TABLE_META_KEYS.has(key) && Boolean(value)) as [ + string, + string, + ][] + ) +} + +function buildCreateForeignTableSql( + table: { + schema_name: string + table_name: string + columns: { name: string; type: string }[] + } & Record, + serverName: string +): SafeSqlFragment { + const optionsMap = getTableOptionsMap(table) + + return safeSql` + create foreign table ${ident(table.schema_name)}.${ident(table.table_name)} ( + ${joinSqlFragments( + table.columns.map((column) => safeSql`${ident(column.name)} ${keyword(column.type)}`), + ',' + )} + ) + server ${ident(serverName)} + options ( + ${joinSqlFragments( + Object.entries(optionsMap).map(([key, value]) => safeSql`${ident(key)} ${literal(value)}`), + ',' + )} + ); + ` +} + +function tableKey(schema: string, table: string): string { + return `${schema}.${table}` +} + +// ALTER FOREIGN TABLE doesn't support changing a column's type in place, so a +// retyped column is dropped and re-added with the new type. +function diffTableColumns( + currentColumns: { name: string; type: string }[], + desiredColumns: { name: string; type: string }[] +) { + const currentByName = new Map(currentColumns.map((column) => [column.name, column.type])) + const desiredByName = new Map(desiredColumns.map((column) => [column.name, column.type])) + + const columnsToAdd = desiredColumns.filter( + (column) => currentByName.get(column.name) !== column.type + ) + const columnsToDrop = currentColumns.filter( + (column) => desiredByName.get(column.name) !== column.type + ) + + return { columnsToAdd, columnsToDrop } +} + +function buildOptionsDiffClauses( + currentOptions: Record, + desiredOptions: Record +): SafeSqlFragment[] { + const clauses: SafeSqlFragment[] = [] + + for (const [key, value] of Object.entries(desiredOptions)) { + if (!(key in currentOptions)) { + clauses.push(safeSql`add ${ident(key)} ${literal(value)}`) + } else if (currentOptions[key] !== value) { + clauses.push(safeSql`set ${ident(key)} ${literal(value)}`) + } + } + for (const key of Object.keys(currentOptions)) { + if (!(key in desiredOptions)) { + clauses.push(safeSql`drop ${ident(key)}`) + } + } + + return clauses +} + +function buildAlterForeignTableSql( + schema: string, + table: string, + current: WrapperExistingTable, + desired: WrapperFormTable +): SafeSqlFragment { + const { columnsToAdd, columnsToDrop } = diffTableColumns(current.columns, desired.columns) + + const dropColumnsSql = + columnsToDrop.length > 0 + ? safeSql` + alter foreign table ${ident(schema)}.${ident(table)} + ${joinSqlFragments( + columnsToDrop.map((column) => safeSql`drop column ${ident(column.name)}`), + ', ' + )}; + ` + : safeSql`` + + const addColumnsSql = + columnsToAdd.length > 0 + ? safeSql` + alter foreign table ${ident(schema)}.${ident(table)} + ${joinSqlFragments( + columnsToAdd.map( + (column) => safeSql`add column ${ident(column.name)} ${keyword(column.type)}` + ), + ', ' + )}; + ` + : safeSql`` + + const optionsClauses = buildOptionsDiffClauses( + parseOptionsArray(current.options), + getTableOptionsMap(desired) + ) + const optionsSql = + optionsClauses.length > 0 + ? safeSql` + alter foreign table ${ident(schema)}.${ident(table)} + options (${joinSqlFragments(optionsClauses, ', ')}); + ` + : safeSql`` + + return safeSql` + ${dropColumnsSql} + ${addColumnsSql} + ${optionsSql} + ` +} + export const getFDWsSql = (): SafeSqlFragment => { const sql = safeSql` select @@ -100,36 +303,7 @@ export function getCreateFDWSql({ return safeSql` do $$ begin - -- Old wrappers has an implicit dependency on pgsodium. For new wrappers - -- we use Vault directly. - if (select extversion from pg_extension where extname = 'wrappers') in ( - '0.1.0', - '0.1.1', - '0.1.4', - '0.1.5', - '0.1.6', - '0.1.7', - '0.1.8', - '0.1.9', - '0.1.10', - '0.1.11', - '0.1.12', - '0.1.14', - '0.1.15', - '0.1.16', - '0.1.17', - '0.1.18', - '0.1.19', - '0.2.0', - '0.3.0', - '0.3.1', - '0.4.0', - '0.4.1', - '0.4.2', - '0.4.3', - '0.4.4', - '0.4.5' - ) then + if ${isUsingOldWrappersSql} then create extension if not exists pgsodium; perform pgsodium.create_key( @@ -180,34 +354,7 @@ export function getCreateFDWSql({ '\n' )} begin - is_using_old_wrappers := (select extversion from pg_extension where extname = 'wrappers') in ( - '0.1.0', - '0.1.1', - '0.1.4', - '0.1.5', - '0.1.6', - '0.1.7', - '0.1.8', - '0.1.9', - '0.1.10', - '0.1.11', - '0.1.12', - '0.1.14', - '0.1.15', - '0.1.16', - '0.1.17', - '0.1.18', - '0.1.19', - '0.2.0', - '0.3.0', - '0.3.1', - '0.4.0', - '0.4.1', - '0.4.2', - '0.4.3', - '0.4.4', - '0.4.5' - ); + is_using_old_wrappers := ${isUsingOldWrappersSql}; ${joinSqlFragments( encryptedOptions.map( (option) => safeSql` @@ -237,37 +384,7 @@ export function getCreateFDWSql({ ` const createTablesSql = joinSqlFragments( - tables.map((newTable) => { - const columns = newTable.columns - - return safeSql` - create foreign table ${ident(newTable.schema_name)}.${ident(newTable.table_name)} ( - ${joinSqlFragments( - columns.map((column) => safeSql`${ident(column.name)} ${keyword(column.type)}`), - ',' - )} - ) - server ${ident(formState.server_name)} - options ( - ${joinSqlFragments( - Object.entries(newTable) - .filter( - ([key, value]) => - key !== 'table_name' && - key !== 'schema_name' && - key !== 'schema' && - key !== 'id' && - key !== 'columns' && - key !== 'index' && - key !== 'is_new_schema' && - Boolean(value) - ) - .map(([key, value]) => safeSql`${ident(key)} ${literal(value)}`), - ',' - )} - ); - ` - }), + tables.map((newTable) => buildCreateForeignTableSql(newTable, formState.server_name)), '\n\n' ) @@ -314,36 +431,7 @@ export const getDeleteFDWSql = ({ if not exists ( select 1 from pg_catalog.pg_foreign_data_wrapper where fdwname = ${literal(wrapper.name)} ) then - -- Old wrappers has an implicit dependency on pgsodium. For new wrappers - -- we use Vault directly. - if (select extversion from pg_extension where extname = 'wrappers') in ( - '0.1.0', - '0.1.1', - '0.1.4', - '0.1.5', - '0.1.6', - '0.1.7', - '0.1.8', - '0.1.9', - '0.1.10', - '0.1.11', - '0.1.12', - '0.1.14', - '0.1.15', - '0.1.16', - '0.1.17', - '0.1.18', - '0.1.19', - '0.2.0', - '0.3.0', - '0.3.1', - '0.4.0', - '0.4.1', - '0.4.2', - '0.4.3', - '0.4.4', - '0.4.5' - ) then + if ${isUsingOldWrappersSql} then delete from vault.secrets where key_id = (select id from pgsodium.valid_key where name = ${literal(key)}); delete from pgsodium.key where name = ${literal(key)}; @@ -401,10 +489,16 @@ export const getUpdateFDWSql = ({ formState, tables, }: { - wrapper: { id: number; name: string; server_name: string } + wrapper: { + id: number + name: string + server_name: string + server_options?: string[] | null + tables?: WrapperExistingTable[] | null + } wrapperMeta: SimplifiedWrapperMeta formState: { [k: string]: string } - tables: any[] + tables: WrapperFormTable[] }): SafeSqlFragment => { const ensureWrapperIsNotSharedSql = safeSql` do $$ @@ -420,22 +514,176 @@ export const getUpdateFDWSql = ({ end if; end $$; ` - const deleteWrapperSql = getDeleteFDWSql({ wrapper, wrapperMeta }) - const createWrapperSql = getCreateFDWSql({ - wrapperMeta, - formState, - tables, - mode: 'tables', - sourceSchema: '', - targetSchema: '', - }) + + const nameChanged = formState.wrapper_name !== wrapper.name + const renameWrapperSql = nameChanged + ? safeSql`alter foreign data wrapper ${ident(wrapper.name)} rename to ${ident(formState.wrapper_name)};` + : safeSql`` + + // formState.server_name is never actually re-derived from wrapper_name before + // submit (see EditWrapperSheet), so the server itself is never renamed here - + // wrapper.server_name (the real, current name) is the only name we alter/target. + const encryptedOptions = wrapperMeta.server.options.filter((option) => option.encrypted) + const unencryptedOptions = wrapperMeta.server.options.filter((option) => !option.encrypted) + const currentServerOptions = parseOptionsArray(wrapper.server_options) + + const serverOptionClauses: SafeSqlFragment[] = [] + const encryptedOptionSqlArray: SafeSqlFragment[] = [] + + for (const option of unencryptedOptions) { + const currentValue = currentServerOptions[option.name] + const newValue = formState[option.name] + + if (newValue) { + if (currentValue === undefined) { + serverOptionClauses.push(safeSql`add ${ident(option.name)} ${literal(newValue)}`) + } else if (currentValue !== newValue) { + serverOptionClauses.push(safeSql`set ${ident(option.name)} ${literal(newValue)}`) + } + } else if (currentValue !== undefined) { + serverOptionClauses.push(safeSql`drop ${ident(option.name)}`) + } + } + + for (const option of encryptedOptions) { + const existingSecretId = currentServerOptions[option.name] + const newValue = formState[option.name] + const newSecretName = `${formState.wrapper_name}_${option.name}` + + if (newValue && existingSecretId !== undefined) { + // Secret already exists: update its value (and name, in case the wrapper + // was renamed) in place instead of deleting and recreating it. + encryptedOptionSqlArray.push(safeSql` + do $$ + declare + v_secret_id uuid; + begin + if ${isUsingOldWrappersSql} then + select id into v_secret_id from vault.secrets where key_id = ${literal(existingSecretId)} limit 1; + else + v_secret_id := ${literal(existingSecretId)}::uuid; + end if; + + perform vault.update_secret( + secret_id := v_secret_id, + new_secret := ${literal(newValue)}, + new_name := ${literal(newSecretName)} + ); + end $$; + `) + } else if (newValue && existingSecretId === undefined) { + // Option newly populated: create the secret then wire it up as a server option. + encryptedOptionSqlArray.push(safeSql` + do $$ + begin + if ${isUsingOldWrappersSql} then + create extension if not exists pgsodium; + + perform pgsodium.create_key(name := ${literal(newSecretName)}); + + perform vault.create_secret( + new_secret := ${literal(newValue)}, + new_name := ${literal(newSecretName)}, + new_key_id := (select id from pgsodium.valid_key where name = ${literal(newSecretName)}) + ); + else + perform vault.create_secret( + new_secret := ${literal(newValue)}, + new_name := ${literal(newSecretName)} + ); + end if; + end $$; + + do $$ + declare + v_secret_ref text; + begin + if ${isUsingOldWrappersSql} then + select id::text into v_secret_ref from pgsodium.valid_key where name = ${literal(newSecretName)} limit 1; + else + select id::text into v_secret_ref from vault.secrets where name = ${literal(newSecretName)} limit 1; + end if; + + execute format('alter server ${ident(wrapper.server_name)} options (add ${ident(option.name)} %L)', v_secret_ref); + end $$; + `) + } else if (!newValue && existingSecretId !== undefined) { + // Option cleared: drop the server option and delete the now-orphaned secret. + serverOptionClauses.push(safeSql`drop ${ident(option.name)}`) + encryptedOptionSqlArray.push(safeSql` + do $$ + begin + if ${isUsingOldWrappersSql} then + delete from vault.secrets where key_id = ${literal(existingSecretId)}; + delete from pgsodium.key where id = ${literal(existingSecretId)}; + else + delete from vault.secrets where id = ${literal(existingSecretId)}::uuid; + end if; + end $$; + `) + } + } + + const alterServerOptionsSql = + serverOptionClauses.length > 0 + ? safeSql` + alter server ${ident(wrapper.server_name)} + options (${joinSqlFragments(serverOptionClauses, ', ')}); + ` + : safeSql`` + const encryptedOptionsSql = joinSqlFragments(encryptedOptionSqlArray, '\n') + + const currentTables = wrapper.tables ?? [] + const currentTableByKey = new Map( + currentTables.map((table) => [tableKey(table.schema, table.name), table]) + ) + const desiredTableByKey = new Map( + tables.map((table) => [tableKey(table.schema_name, table.table_name), table]) + ) + + const tablesToCreate = tables.filter( + (table) => !currentTableByKey.has(tableKey(table.schema_name, table.table_name)) + ) + const tablesToDrop = currentTables.filter( + (table) => !desiredTableByKey.has(tableKey(table.schema, table.name)) + ) + + const newSchemasSql = joinSqlFragments( + tablesToCreate + .filter((table) => table.is_new_schema) + .map((table) => safeSql`create schema if not exists ${ident(table.schema_name)};`), + '\n' + ) + const createTablesSql = joinSqlFragments( + tablesToCreate.map((table) => buildCreateForeignTableSql(table, wrapper.server_name)), + '\n' + ) + const dropTablesSql = joinSqlFragments( + tablesToDrop.map((table) => + getDropForeignTableSql({ schema: table.schema, table: table.name }) + ), + '\n' + ) + const alterTablesSql = joinSqlFragments( + tables + .map((desired) => { + const current = currentTableByKey.get(tableKey(desired.schema_name, desired.table_name)) + if (!current) return null + return buildAlterForeignTableSql(desired.schema_name, desired.table_name, current, desired) + }) + .filter((sql): sql is SafeSqlFragment => sql !== null), + '\n' + ) const sql = safeSql` ${ensureWrapperIsNotSharedSql} - - ${deleteWrapperSql} - - ${createWrapperSql} + ${renameWrapperSql} + ${alterServerOptionsSql} + ${encryptedOptionsSql} + ${newSchemasSql} + ${dropTablesSql} + ${createTablesSql} + ${alterTablesSql} ` return sql diff --git a/packages/pg-meta/test/sql/studio/fdw.test.ts b/packages/pg-meta/test/sql/studio/fdw.test.ts index 741c6f9c3de..b850337a672 100644 --- a/packages/pg-meta/test/sql/studio/fdw.test.ts +++ b/packages/pg-meta/test/sql/studio/fdw.test.ts @@ -76,20 +76,258 @@ test('deleting a wrapper row drops its server and preserves shared wrapper depen expect(sql).toContain("where fdwname = 'bigquery_fdw'") }) -test('editing a shared wrapper fails before any server is dropped', () => { +test('editing a shared wrapper fails before any other statement runs', () => { const sql = getUpdateFDWSql({ - wrapper: { id: 42, name: 'bigquery_fdw', server_name: 'selected_bigquery_server' }, + wrapper: { + id: 42, + name: 'bigquery_fdw', + server_name: 'selected_bigquery_server', + server_options: ['project_id=old-project'], + }, + wrapperMeta: { + handlerName: 'big_query_fdw_handler', + validatorName: 'big_query_fdw_validator', + server: { options: [{ name: 'project_id', encrypted: false }] }, + }, + formState: { + wrapper_name: 'bigquery_fdw', + server_name: 'selected_bigquery_server', + project_id: 'new-project', + }, + tables: [], + }) + + expect(sql).toContain("s.srvname <> 'selected_bigquery_server'") + expect(sql.indexOf('raise exception')).toBeLessThan(sql.indexOf('alter server')) +}) + +test('updating a wrapper alters the server and FDW instead of dropping and recreating them', () => { + const sql = getUpdateFDWSql({ + wrapper: { + id: 42, + name: 'bigquery_fdw', + server_name: 'bigquery_server', + server_options: ['project_id=old-project'], + tables: [], + }, + wrapperMeta: { + handlerName: 'big_query_fdw_handler', + validatorName: 'big_query_fdw_validator', + server: { options: [{ name: 'project_id', encrypted: false }] }, + }, + formState: { + wrapper_name: 'bigquery_fdw', + server_name: 'bigquery_server', + project_id: 'new-project', + }, + tables: [], + }) + + expect(sql).toContain( + "alter server bigquery_server\n options (set project_id 'new-project')" + ) + expect(sql).not.toContain('drop server') + expect(sql).not.toContain('drop foreign data wrapper') + expect(sql).not.toContain('create foreign data wrapper') +}) + +test('renaming the wrapper name only renames the FDW, not the server', () => { + const sql = getUpdateFDWSql({ + wrapper: { + id: 42, + name: 'old_wrapper', + server_name: 'old_wrapper_server', + server_options: [], + tables: [], + }, wrapperMeta: { handlerName: 'big_query_fdw_handler', validatorName: 'big_query_fdw_validator', server: { options: [] }, }, - formState: { wrapper_name: 'bigquery_fdw', server_name: 'selected_bigquery_server' }, + formState: { + wrapper_name: 'new_wrapper', + server_name: 'old_wrapper_server', + }, tables: [], }) - expect(sql).toContain("s.srvname <> 'selected_bigquery_server'") - expect(sql.indexOf('raise exception')).toBeLessThan(sql.indexOf('drop server')) + expect(sql).toContain('alter foreign data wrapper old_wrapper rename to new_wrapper') + expect(sql).not.toContain('rename to new_wrapper_server') +}) + +test('adding a server option not previously set uses ADD, not SET', () => { + const sql = getUpdateFDWSql({ + wrapper: { + id: 42, + name: 'bigquery_fdw', + server_name: 'bigquery_server', + server_options: [], + tables: [], + }, + wrapperMeta: { + handlerName: 'big_query_fdw_handler', + validatorName: 'big_query_fdw_validator', + server: { options: [{ name: 'project_id', encrypted: false }] }, + }, + formState: { + wrapper_name: 'bigquery_fdw', + server_name: 'bigquery_server', + project_id: 'a-project', + }, + tables: [], + }) + + expect(sql).toContain("add project_id 'a-project'") +}) + +test('clearing a server option uses DROP', () => { + const sql = getUpdateFDWSql({ + wrapper: { + id: 42, + name: 'bigquery_fdw', + server_name: 'bigquery_server', + server_options: ['project_id=a-project'], + tables: [], + }, + wrapperMeta: { + handlerName: 'big_query_fdw_handler', + validatorName: 'big_query_fdw_validator', + server: { options: [{ name: 'project_id', encrypted: false }] }, + }, + formState: { + wrapper_name: 'bigquery_fdw', + server_name: 'bigquery_server', + project_id: '', + }, + tables: [], + }) + + expect(sql).toContain('drop project_id') +}) + +test('an encrypted option that already has a secret is updated in place via vault.update_secret', () => { + const sql = getUpdateFDWSql({ + wrapper: { + id: 42, + name: 'bigquery_fdw', + server_name: 'bigquery_server', + server_options: ['sa_key_id=123e4567-e89b-12d3-a456-426614174000'], + tables: [], + }, + wrapperMeta: { + handlerName: 'big_query_fdw_handler', + validatorName: 'big_query_fdw_validator', + server: { options: [{ name: 'sa_key_id', encrypted: true }] }, + }, + formState: { + wrapper_name: 'bigquery_fdw', + server_name: 'bigquery_server', + sa_key_id: 'new-secret-value', + }, + tables: [], + }) + + expect(sql).toContain('vault.update_secret') + expect(sql).not.toContain('vault.create_secret') + expect(sql).not.toContain('delete from vault.secrets') +}) + +test('foreign table diffing: creates new tables, drops removed tables, and skips unchanged tables', () => { + const sql = getUpdateFDWSql({ + wrapper: { + id: 42, + name: 'bigquery_fdw', + server_name: 'bigquery_server', + server_options: [], + tables: [ + { + id: 1, + schema: 'public', + name: 'unchanged_table', + columns: [{ name: 'id', type: 'text' }], + options: ['table=unchanged_table'], + }, + { + id: 2, + schema: 'public', + name: 'removed_table', + columns: [{ name: 'id', type: 'text' }], + options: ['table=removed_table'], + }, + ], + }, + wrapperMeta: { + handlerName: 'big_query_fdw_handler', + validatorName: 'big_query_fdw_validator', + server: { options: [] }, + }, + formState: { + wrapper_name: 'bigquery_fdw', + server_name: 'bigquery_server', + }, + tables: [ + { + schema_name: 'public', + table_name: 'unchanged_table', + columns: [{ name: 'id', type: 'text' }], + is_new_schema: false, + table: 'unchanged_table', + }, + { + schema_name: 'public', + table_name: 'new_table', + columns: [{ name: 'id', type: 'text' }], + is_new_schema: false, + table: 'new_table', + }, + ], + }) + + expect(sql).toContain('create foreign table public.new_table') + expect(sql).toContain('drop foreign table if exists public.removed_table') + expect(sql).not.toContain('unchanged_table (') + expect(sql).not.toContain('drop foreign table if exists public.unchanged_table') +}) + +test('foreign table diffing: retyping a column drops and re-adds it, since ALTER COLUMN TYPE is unsupported for foreign tables', () => { + const sql = getUpdateFDWSql({ + wrapper: { + id: 42, + name: 'bigquery_fdw', + server_name: 'bigquery_server', + server_options: [], + tables: [ + { + id: 1, + schema: 'public', + name: 'orders', + columns: [{ name: 'amount', type: 'integer' }], + options: [], + }, + ], + }, + wrapperMeta: { + handlerName: 'big_query_fdw_handler', + validatorName: 'big_query_fdw_validator', + server: { options: [] }, + }, + formState: { + wrapper_name: 'bigquery_fdw', + server_name: 'bigquery_server', + }, + tables: [ + { + schema_name: 'public', + table_name: 'orders', + columns: [{ name: 'amount', type: 'numeric' }], + is_new_schema: false, + }, + ], + }) + + expect(sql).toContain('alter foreign table public.orders\n drop column amount') + expect(sql).toContain('alter foreign table public.orders\n add column amount numeric') }) test('editing an existing foreign table excludes catalog fields from its options', () => {