Initlal work on DuckDB wrapper.

This commit is contained in:
Ivan Vasilov authored and Ali Waseem committed 2025-10-30 08:49:08 -06:00
1 parent 22049ddd2a
commit 28401c6bea
4 files changed
+654 -1

No files matched your search

@@ -0,0 +1,485 @@
import { isEmpty } from 'lodash'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { useSchemaCreateMutation } from 'data/database/schema-create-mutation'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import {
Button,
Form,
Input,
RadioGroupStacked,
RadioGroupStackedItem,
Separator,
SheetFooter,
SheetHeader,
SheetTitle,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { CreateWrapperSheetProps } from './CreateWrapperSheet'
import InputField from './InputField'
import { makeValidateRequired } from './Wrappers.utils'
const FORM_ID = 'create-wrapper-form'
const requiredFields: Record<Target, { name: string; required: boolean }[]> = {
s3: [
{ name: 'vault_key_id', required: true },
{ name: 'vault_secret', required: true },
{ name: 'region', required: false },
{ name: 'endpoint', required: false },
{ name: 'session_token', required: false },
{ name: 'url_compatibility_mode', required: false },
{ name: 'url_style', required: false },
{ name: 'use_ssl', required: false },
{ name: 'kms_key_id', required: false },
],
s3_tables: [
{ name: 'vault_key_id', required: true },
{ name: 'vault_secret', required: true },
{ name: 's3_tables_arn', required: true },
{ name: 'region', required: false },
],
r2: [
{ name: 'vault_key_id', required: true },
{ name: 'vault_secret', required: true },
{ name: 'account_id', required: true },
],
r2_catalog: [
{ name: 'vault_token', required: true },
{ name: 'warehouse', required: true },
{ name: 'catalog_uri', required: true },
],
polaris: [
{ name: 'vault_client_id', required: true },
{ name: 'vault_client_secret', required: true },
{ name: 'warehouse', required: true },
{ name: 'catalog_uri', required: true },
],
lakekeeper: [
{ name: 'vault_client_id', required: true },
{ name: 'vault_client_secret', required: true },
{ name: 'oauth2_scope', required: true },
{ name: 'oauth2_server_uri', required: true },
{ name: 'warehouse', required: true },
{ name: 'catalog_uri', required: true },
],
iceberg: [
{ name: 'vault_client_id', required: false },
{ name: 'vault_client_secret', required: false },
{ name: 'vault_token', required: false },
{ name: 'oauth2_scope', required: false },
{ name: 'oauth2_server_uri', required: false },
{ name: 'warehouse', required: true },
{ name: 'catalog_uri', required: true },
],
} as const
type Target = 's3' | 's3_tables' | 'r2' | 'r2_catalog' | 'polaris' | 'lakekeeper' | 'iceberg'
export const CreateDuckDbWrapperSheet = ({
wrapperMeta: wrapperMetaOriginal,
isClosing,
setIsClosing,
onClose,
}: CreateWrapperSheetProps) => {
const { project } = useProjectContext()
const { data: org } = useSelectedOrganizationQuery()
const { mutate: sendEvent } = useSendEventMutation()
const [selectedTarget, setSelectedTarget] = useState<Target>('s3')
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
const { mutateAsync: createFDW, isLoading: isCreatingWrapper } = useFDWCreateMutation({
onSuccess: () => {
toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
onClose()
},
})
const wrapperMeta = useMemo(() => {
const fields = requiredFields[selectedTarget]
return {
...wrapperMetaOriginal,
server: {
options: wrapperMetaOriginal.server.options
// when the target changes, change the wrapperMeta field
.filter((option) => fields.find((field) => field.name === option.name))
.map((option) => {
const field = fields.find((field) => field.name === option.name)
return {
...option,
required: field?.required ?? false,
}
}),
},
}
}, [wrapperMetaOriginal, selectedTarget])
const { data: schemas } = useSchemasQuery({
projectRef: project?.ref!,
connectionString: project?.connectionString,
})
const initialValues = {
wrapper_name: '',
server_name: '',
source_schema: wrapperMeta.sourceSchemaOption?.defaultValue ?? '',
target_schema: '',
...Object.fromEntries(
wrapperMeta.server.options.map((option) => [option.name, option.defaultValue ?? ''])
),
}
const { mutateAsync: createSchema, isLoading: isCreatingSchema } = useSchemaCreateMutation()
const onSubmit = async (values: any) => {
const validate = makeValidateRequired(wrapperMeta.server.options)
const errors: any = validate(values)
if (selectedTarget === 's3') {
if (
values.url_compatibility_mode &&
values.url_compatibility_mode !== 'true' &&
values.url_compatibility_mode !== 'false'
) {
errors.url_compatibility_mode = 'Please set it true or false'
}
if (values.url_style && values.url_style !== 'vhost' && values.url_style !== 'path') {
errors.url_style = 'Please set it vhost or path'
}
if (values.use_ssl && values.use_ssl !== 'true' && values.use_ssl !== 'false') {
errors.use_ssl = 'Please set it true or false'
}
}
if (values.source_schema.length === 0) {
errors.source_schema = 'Please provide a namespace name'
}
if (values.wrapper_name.length === 0) {
errors.wrapper_name = 'Please provide a name for your wrapper'
}
if (values.target_schema.length === 0) {
errors.target_schema = 'Please provide an unique target schema'
}
const foundSchema = schemas?.find((s) => s.name === values.target_schema)
if (foundSchema) {
errors.target_schema = 'This schema already exists. Please specify a unique schema name.'
}
setFormErrors(errors)
if (!isEmpty(errors)) {
return
}
try {
await createSchema({
projectRef: project?.ref,
connectionString: project?.connectionString,
name: values.target_schema,
})
await createFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapperMeta,
formState: {
type: selectedTarget,
...values,
server_name: `${values.wrapper_name}_server`,
},
mode: 'schema',
tables: [],
sourceSchema: values.source_schema,
targetSchema: values.target_schema,
})
sendEvent({
action: 'foreign_data_wrapper_created',
properties: {
wrapperType: wrapperMeta.label,
},
groups: {
project: project?.ref ?? 'Unknown',
organization: org?.slug ?? 'Unknown',
},
})
} catch (error) {
console.error(error)
// The error will be handled by the mutation onError callback (toast.error)
}
}
const isLoading = isCreatingWrapper || isCreatingSchema
return (
<>
<div className="h-full" tabIndex={-1}>
<Form
id={FORM_ID}
initialValues={initialValues}
onSubmit={onSubmit}
className="flex-grow flex flex-col h-full"
>
{({ values, initialValues, setFieldValue }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
const onClosePanel = () => {
if (hasChanges) {
setIsClosing(true)
} else {
onClose()
}
}
// if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet
if (!hasChanges && isClosing) {
onClose()
}
return (
<>
<SheetHeader>
<SheetTitle>Create a {wrapperMeta.label} wrapper</SheetTitle>
</SheetHeader>
<div className="flex-grow overflow-y-auto">
<FormSection header={<FormSectionLabel>Wrapper Configuration</FormSectionLabel>}>
<FormSectionContent loading={false}>
<Input
id="wrapper_name"
label="Wrapper Name"
error={formErrors.wrapper_name}
descriptionText={
(values?.wrapper_name ?? '').length > 0 ? (
<>
Your wrapper's server name will be{' '}
<code className="text-xs">{values.wrapper_name}_server</code>
</>
) : (
''
)
}
/>
</FormSectionContent>
</FormSection>
<Separator />
<FormSection header={<FormSectionLabel>Data target</FormSectionLabel>}>
<FormSectionContent loading={false} className="text-sm">
<RadioGroupStacked
value={selectedTarget}
onValueChange={(value) => setSelectedTarget(value as Target)}
>
<RadioGroupStackedItem
key="s3"
value="s3"
label="AWS S3"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
AWS S3 storage that's optimized for analytics workloads.
</p>
</div>
</div>
</RadioGroupStackedItem>
<RadioGroupStackedItem
key="s3_tables"
value="s3_tables"
label="AWS S3 Tables"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
AWS S3 storage that's optimized for analytics workloads.
</p>
</div>
</div>
</RadioGroupStackedItem>
<RadioGroupStackedItem
key="r2"
value="r2"
label="Cloudflare R2"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Managed Apache Iceberg built directly into your R2 bucket.
</p>
</div>
</div>
</RadioGroupStackedItem>
<RadioGroupStackedItem
key="r2_catalog"
value="r2_catalog"
label="Cloudflare R2 Catalog"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Managed Apache Iceberg built directly into your R2 bucket.
</p>
</div>
</div>
</RadioGroupStackedItem>
<RadioGroupStackedItem
key="polaris"
value="polaris"
label="Polaris"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Something about Polaris.
</p>
</div>
</div>
</RadioGroupStackedItem>
<RadioGroupStackedItem
key="lakekeeper"
value="lakekeeper"
label="Lakekeeper"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Can be used with any S3-compatible storage.
</p>
</div>
</div>
</RadioGroupStackedItem>
<RadioGroupStackedItem
key="iceberg"
value="iceberg"
label="Iceberg"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Can be used with any S3-compatible storage.
</p>
</div>
</div>
</RadioGroupStackedItem>
</RadioGroupStacked>
</FormSectionContent>
</FormSection>
<FormSection
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
>
<FormSectionContent loading={false}>
{wrapperMeta.server.options
.filter((option) => !option.hidden)
.map((option) => (
<InputField
key={option.name}
option={option}
loading={false}
error={formErrors[option.name]}
/>
))}
</FormSectionContent>
</FormSection>
<Separator />
<FormSection
header={
<FormSectionLabel>
<p>Foreign Schema</p>
<p className="text-foreground-light mt-2 w-[90%]">
All wrapper tables will be created in the specified target schema.
</p>
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
{wrapperMeta.sourceSchemaOption && (
<div>
<InputField
key="source_schema"
option={wrapperMeta.sourceSchemaOption}
loading={false}
error={formErrors['source_schema']}
/>
<p className="text-foreground-lighter text-sm">
{wrapperMeta.sourceSchemaOption.description}
</p>
</div>
)}
<div className="flex flex-col gap-2">
<InputField
key="target_schema"
option={{
name: 'target_schema',
label: 'Target Schema',
required: true,
encrypted: false,
secureEntry: false,
}}
loading={false}
error={formErrors['target_schema']}
/>
<p className="text-foreground-lighter text-sm">
A new schema will be created. All wrapper tables will be created in the
specified target schema.
</p>
</div>
</FormSectionContent>
</FormSection>
</div>
<SheetFooter>
<Button
size="tiny"
type="default"
htmlType="button"
onClick={onClosePanel}
disabled={isLoading}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
form={FORM_ID}
htmlType="submit"
loading={isLoading}
>
Create wrapper
</Button>
</SheetFooter>
</>
)
}}
</Form>
</div>
<ConfirmationModal
visible={isClosing}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosing(false)}
onConfirm={() => onClose()}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
</>
)
}
@@ -14,6 +14,7 @@ export const WRAPPER_HANDLERS = {
MSSQL: 'mssql_fdw_handler',
REDIS: 'redis_fdw_handler',
ICEBERG: 'iceberg_fdw_handler',
DUCKDB: 'duckdb_fdw_handler',
PADDLE: 'wasm_fdw_handler',
SNOWFLAKE: 'wasm_fdw_handler',
CAL: 'wasm_fdw_handler',
@@ -1960,6 +1961,165 @@ export const WRAPPERS: WrapperMeta[] = [
},
],
},
{
name: 'duckdb_wrapper',
handlerName: WRAPPER_HANDLERS.DUCKDB,
validatorName: 'duckdb_fdw_validator',
icon: `${BASE_PATH}/img/icons/duckdb-icon.svg`,
description: "Query and transform your data anywhere using DuckDB's feature-rich SQL dialect.",
extensionName: 'duckdbFdw',
label: 'DuckDB',
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/duckdb',
minimumExtensionVersion: '0.5.3',
customComponent: true,
server: {
options: [
{
name: 'vault_key_id',
required: false,
encrypted: true,
secureEntry: true,
label: 'Key ID',
},
{
name: 'vault_secret',
required: false,
encrypted: true,
secureEntry: true,
label: 'Secret',
},
{
name: 'region',
required: false,
encrypted: false,
secureEntry: false,
defaultValue: 'us-east-1',
label: 'Region',
},
{
name: 'endpoint',
required: false,
encrypted: false,
secureEntry: false,
defaultValue: 's3.amazonaws.com',
label: 'Endpoint',
},
{
name: 'session_token',
required: false,
encrypted: false,
secureEntry: false,
label: 'Session Token',
},
{
name: 'url_compatibility_mode',
required: false,
encrypted: false,
secureEntry: false,
defaultValue: 'true',
label: 'URL Compatibility Mode',
},
{
name: 'url_style',
required: false,
encrypted: false,
secureEntry: false,
defaultValue: 'vhost',
label: 'URL Style',
},
{
name: 'use_ssl',
required: false,
encrypted: false,
secureEntry: false,
defaultValue: 'true',
label: 'Use SSL',
},
{
name: 'kms_key_id',
required: false,
encrypted: false,
secureEntry: false,
label: 'KMS Key ID',
},
{
name: 's3_tables_arn',
required: false,
encrypted: false,
secureEntry: false,
label: 'S3 Tables ARN',
},
{
name: 'account_id',
required: false,
encrypted: false,
secureEntry: false,
label: 'Account ID',
},
{
name: 'vault_token',
required: false,
encrypted: true,
secureEntry: true,
label: 'Token',
},
{
name: 'warehouse',
required: false,
encrypted: false,
secureEntry: false,
label: 'Warehouse',
},
{
name: 'catalog_uri',
required: false,
encrypted: false,
secureEntry: false,
label: 'Catalog URI',
},
{
name: 'vault_client_id',
required: false,
encrypted: true,
secureEntry: true,
label: 'Client ID',
},
{
name: 'vault_client_secret',
required: false,
encrypted: true,
secureEntry: true,
label: 'Client Secret',
},
{
name: 'oauth2_scope',
required: false,
encrypted: false,
secureEntry: false,
label: 'OAuth2 Scope',
},
{
name: 'oauth2_server_uri',
required: false,
encrypted: false,
secureEntry: false,
label: 'OAuth2 Server URI',
},
],
},
canTargetSchema: true,
sourceSchemaOption: {
name: 'source_schema',
label: 'Source Schema',
description: 'It should match the namespace of the Iceberg catalog.',
required: true,
encrypted: false,
secureEntry: false,
defaultValue: '',
},
tables: [],
},
{
name: 'paddle_wrapper',
handlerName: WRAPPER_HANDLERS.PADDLE,
@@ -2169,7 +2329,7 @@ export const WRAPPERS: WrapperMeta[] = [
handlerName: WRAPPER_HANDLERS.ICEBERG,
validatorName: 'iceberg_fdw_validator',
icon: `${BASE_PATH}/img/icons/iceberg-icon.svg`,
description: 'Iceberg is a data warehouse',
description: 'Connect to Iceberg-compatible data lakes.',
extensionName: 'icebergFdw',
label: 'Iceberg',
docsUrl: `${DOCS_URL}/guides/database/extensions/wrappers/iceberg`,
@@ -0,0 +1,5 @@
<svg width="335" height="335" viewBox="0 0 335 335" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M167.2 0.5C259.424 0.500107 333.9 74.9764 333.9 167.2C333.9 259.424 259.424 333.9 167.2 333.9C74.9764 333.9 0.500107 259.424 0.5 167.2C0.5 74.9763 74.9763 0.5 167.2 0.5Z" fill="black" stroke="black"/>
<path d="M256.6 142.9C269.933 142.9 281 153.585 281 167C281 180.323 269.924 191.4 256.6 191.4H224.3V142.9H256.6Z" fill="#FFF100" stroke="black"/>
<path d="M132.4 98.4C170.324 98.4 201.199 129.276 201.2 167.2C201.2 205.124 170.324 236 132.4 236C94.476 235.999 63.6 205.124 63.6 167.2C63.6001 129.276 94.4761 98.4001 132.4 98.4Z" fill="#FFF100" stroke="black"/>
</svg>

After

Width:  |  Height:  |  Size: 677 B

@@ -0,0 +1,3 @@
DuckDB is an open-source column-oriented Relational Database Management System. Unlike traditional databases that require a separate server process, DuckDB runs inside your database and works directly on data in memory or on disk, making it ideal for embedded analytics, data science workflows, and interactive analysis. It supports standard SQL syntax and can query data from multiple formats like CSV, Parquet, and even Pandas DataFrames or Arrow tables without needing to import them into a separate database.
Its architecture is optimized for analytical (OLAP) workloads rather than transactional (OLTP) ones. DuckDB uses a columnar storage format and vectorized execution engine, which allows it to perform complex aggregations and scans efficiently. Thanks to its lightweight nature and minimal setup, it's widely adopted in environments like Python, R, or even browser-based tools, serving as a powerful alternative to tools like SQLite for analytics tasks.