mirror of
https://github.com/supabase/supabase.git
synced 2026-09-09 19:42:46 +08:00
## What kind of change does this PR introduce? Feature. The last remaining piece of this PR's original scope, rebased onto current `master`. ## What is the current behavior? A BigQuery pipeline replicates every published table into a flat destination table. There is no way to say how those tables should be laid out in BigQuery, so partitioning and clustering have to be applied by hand after the fact, and are lost whenever a destination table is reset. The rest of this PR's original scope has since merged separately: #49841, #49842, #49843, #49844 and #49845. The branch now carries only the table layout work, and adds nothing to `packages/api-types`. It consumes the `table_options` contract that #49844 already brought in. ## What is the new behavior? The BigQuery destination form gains a "Table layout" section under Advanced settings. Every table in the selected publication appears as a row, and expanding one reveals optional partitioning and clustering: - Time-column partitioning, by hour, day, month or year - Integer-range partitioning, with start, end and interval - Ingestion-time partitioning - Clustering, up to four columns Rows are always present, so nothing implies a table can be excluded here. A collapsed row summarises what is applied: `Not configured`, `Daily by created_at`, `Integer range by id`, plus clustering counts. `Not configured` is dimmed a step further so configured rows are what the eye lands on in a long publication. Notes on behavior: - Partition columns are resolved from the published table's real columns, filtered to the types BigQuery accepts for each partition mode. - A row you expand but leave empty is dropped from the payload rather than saved half-configured. Choosing a partition mode without a column is a validation error, not a silent drop. - Clear returns a row to `Not configured` and keeps it in the list. Remove is reserved for stale configuration whose table has left the publication. - Updates send `null` to clear previously stored table options, since omitting the property leaves the stored value unchanged. - Layout applies when a destination table is first created or reset, matching the backend. Roughly 40% of the diff is tests. The bulk of the rest is four new files under `DestinationForm/BigQuery`, which are all one feature. The three commits are readable in order: types and payload builders, then the columns query, then the UI. ## To test Open a project's Database > Replication, then create or edit a BigQuery pipeline. 1. Expand Advanced settings. "Table layout" lists every table in the selected publication. 2. Expand a row, set Partition by to Time column and pick a column. Collapse. The row reads `Daily by <column>`. Hit Clear. The row returns to `Not configured` and stays in the list. 3. Set Partition by to Time column, leave Partition column empty, collapse, and Save. The row explains "Select a partition column" in red rather than saving and silently dropping it. 4. Expand a row and add clustering columns. The fifth is refused. 5. Switch publications and watch the loading state. The row list should not jump size when it resolves. 6. Narrow the sheet. The integer range Start, End and Interval fields should reflow rather than stay in three columns. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * BigQuery replication destinations now support per-table layout settings. * Configure partitioning by time column, integer range, or ingestion time. * Configure up to four clustering columns per table. * View available columns and validation feedback while editing table layouts. * Table settings are preserved when editing destinations and cleaned up when publications change. * **Bug Fixes** * Improved handling of invalid, unavailable, or removed table and column configurations. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com> Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
235 lines
7.0 KiB
TypeScript
235 lines
7.0 KiB
TypeScript
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import type { components } from 'api-types'
|
|
import { toast } from 'sonner'
|
|
|
|
import { replicationKeys } from './keys'
|
|
import type {
|
|
BigQueryDestinationConfig,
|
|
BigQueryTableOption,
|
|
DestinationConfig,
|
|
DucklakeDestinationConfig,
|
|
PipelineConfig,
|
|
} from './types'
|
|
import {
|
|
buildBigQueryTableOptionApiConfig,
|
|
buildPipelineApiConfig,
|
|
getConfiguredBigQueryTableOptions,
|
|
isDucklakeSupabaseConfig,
|
|
} from './utils'
|
|
import { handleError, post } from '@/data/fetchers'
|
|
import type { ResponseError, UseCustomMutationOptions } from '@/types'
|
|
|
|
type CreateDestinationPipelineBody =
|
|
components['schemas']['CreateReplicationDestinationPipelineBody']
|
|
type CreateDestinationApiConfig = CreateDestinationPipelineBody['destination_config']
|
|
|
|
type CreateBigQueryApiConfig = Extract<CreateDestinationApiConfig, { big_query: unknown }>
|
|
type CreateDucklakeApiConfig = Extract<CreateDestinationApiConfig, { ducklake: unknown }>
|
|
|
|
const buildBigQueryTableOptionsApiConfig = (tableOptions: BigQueryTableOption[] | undefined) => {
|
|
const configuredTableOptions = getConfiguredBigQueryTableOptions(tableOptions)
|
|
|
|
if (tableOptions === undefined || configuredTableOptions.length === 0) return undefined
|
|
return { tables: configuredTableOptions.map(buildBigQueryTableOptionApiConfig) }
|
|
}
|
|
|
|
// Maps the studio-side BigQuery config to the snake_case `{ big_query: ... }` payload accepted
|
|
// by the platform API. Shared by the create and validate mutations.
|
|
export function buildBigQueryApiConfig(config: BigQueryDestinationConfig): CreateBigQueryApiConfig {
|
|
return {
|
|
big_query: {
|
|
project_id: config.projectId,
|
|
dataset_id: config.datasetId,
|
|
service_account_key: config.serviceAccountKey,
|
|
connection_pool_size: config.connectionPoolSize,
|
|
max_staleness_mins: config.maxStalenessMins,
|
|
table_options: buildBigQueryTableOptionsApiConfig(config.tableOptions),
|
|
},
|
|
}
|
|
}
|
|
|
|
// Maps the studio-side DuckLake config to the snake_case `{ ducklake: ... }` payload accepted
|
|
// by the platform API. Shared by the create / update / validate mutations.
|
|
export function buildDucklakeApiConfig(config: DucklakeDestinationConfig): CreateDucklakeApiConfig {
|
|
if (isDucklakeSupabaseConfig(config)) {
|
|
return {
|
|
ducklake: {
|
|
// pool_size / metadata_schema live on the catalog so they apply to the selected
|
|
// Supabase Postgres catalog (the API resolves catalog-level values over top-level).
|
|
catalog: {
|
|
type: 'supabase_project',
|
|
project_ref: config.catalogProjectRef,
|
|
pool_size: config.poolSize,
|
|
metadata_schema: config.metadataSchema,
|
|
},
|
|
storage: {
|
|
type: 'supabase_storage',
|
|
project_ref: config.storageProjectRef,
|
|
bucket: config.bucket,
|
|
...(config.path ? { path: config.path } : {}),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
return {
|
|
ducklake: {
|
|
catalog_url: config.catalogUrl,
|
|
data_path: config.dataPath,
|
|
pool_size: config.poolSize,
|
|
s3_access_key_id: config.s3AccessKeyId,
|
|
s3_secret_access_key: config.s3SecretAccessKey,
|
|
s3_region: config.s3Region,
|
|
s3_endpoint: config.s3Endpoint,
|
|
s3_url_style: config.s3UrlStyle,
|
|
s3_use_ssl: config.s3UseSsl,
|
|
metadata_schema: config.metadataSchema,
|
|
},
|
|
}
|
|
}
|
|
|
|
export const buildCreateDestinationApiConfig = (
|
|
destinationConfig: DestinationConfig
|
|
): CreateDestinationApiConfig => {
|
|
if ('bigQuery' in destinationConfig) {
|
|
return buildBigQueryApiConfig(destinationConfig.bigQuery)
|
|
}
|
|
|
|
if ('iceberg' in destinationConfig) {
|
|
const {
|
|
projectRef,
|
|
namespace,
|
|
warehouseName,
|
|
catalogToken,
|
|
s3AccessKeyId,
|
|
s3SecretAccessKey,
|
|
s3Region,
|
|
} = destinationConfig.iceberg
|
|
|
|
return {
|
|
iceberg: {
|
|
supabase: {
|
|
namespace,
|
|
project_ref: projectRef,
|
|
warehouse_name: warehouseName,
|
|
catalog_token: catalogToken,
|
|
s3_access_key_id: s3AccessKeyId,
|
|
s3_secret_access_key: s3SecretAccessKey,
|
|
s3_region: s3Region,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
if ('ducklake' in destinationConfig) {
|
|
return buildDucklakeApiConfig(destinationConfig.ducklake)
|
|
}
|
|
|
|
if ('snowflake' in destinationConfig) {
|
|
const { accountId, user, privateKey, privateKeyPassphrase, database, schema, role } =
|
|
destinationConfig.snowflake
|
|
|
|
return {
|
|
snowflake: {
|
|
account_id: accountId,
|
|
user,
|
|
private_key: privateKey,
|
|
private_key_passphrase: privateKeyPassphrase,
|
|
database,
|
|
schema,
|
|
role,
|
|
},
|
|
}
|
|
}
|
|
|
|
if ('clickHouse' in destinationConfig) {
|
|
const { url, user, password, database, engine } = destinationConfig.clickHouse
|
|
|
|
return { clickhouse: { url, user, password, database, engine } }
|
|
}
|
|
|
|
throw new Error(
|
|
'Invalid destination config: must specify bigQuery, iceberg, ducklake, snowflake, or clickHouse'
|
|
)
|
|
}
|
|
|
|
export type CreateDestinationPipelineParams = {
|
|
projectRef: string
|
|
destinationName: string
|
|
destinationConfig: DestinationConfig
|
|
sourceId: number
|
|
pipelineConfig: PipelineConfig
|
|
}
|
|
|
|
async function createDestinationPipeline(
|
|
{
|
|
projectRef,
|
|
destinationName: destinationName,
|
|
destinationConfig,
|
|
pipelineConfig,
|
|
sourceId,
|
|
}: CreateDestinationPipelineParams,
|
|
signal?: AbortSignal
|
|
) {
|
|
if (!projectRef) throw new Error('projectRef is required')
|
|
|
|
const destination_config = buildCreateDestinationApiConfig(destinationConfig)
|
|
|
|
const pipeline_config = buildPipelineApiConfig(pipelineConfig)
|
|
|
|
const { data, error } = await post('/platform/replication/{ref}/destinations-pipelines', {
|
|
params: { path: { ref: projectRef } },
|
|
body: {
|
|
source_id: sourceId,
|
|
destination_name: destinationName,
|
|
destination_config,
|
|
pipeline_config,
|
|
},
|
|
signal,
|
|
})
|
|
|
|
if (error) handleError(error)
|
|
return data
|
|
}
|
|
|
|
type CreateDestinationPipelineData = Awaited<ReturnType<typeof createDestinationPipeline>>
|
|
|
|
export const useCreateDestinationPipelineMutation = ({
|
|
onSuccess,
|
|
onError,
|
|
...options
|
|
}: Omit<
|
|
UseCustomMutationOptions<
|
|
CreateDestinationPipelineData,
|
|
ResponseError,
|
|
CreateDestinationPipelineParams
|
|
>,
|
|
'mutationFn'
|
|
> = {}) => {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation<CreateDestinationPipelineData, ResponseError, CreateDestinationPipelineParams>(
|
|
{
|
|
mutationFn: (vars) => createDestinationPipeline(vars),
|
|
async onSuccess(data, variables, context) {
|
|
const { projectRef } = variables
|
|
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: replicationKeys.destinations(projectRef) }),
|
|
queryClient.invalidateQueries({ queryKey: replicationKeys.pipelines(projectRef) }),
|
|
])
|
|
|
|
await onSuccess?.(data, variables, context)
|
|
},
|
|
async onError(data, variables, context) {
|
|
if (onError === undefined) {
|
|
toast.error(`Failed to create destination or pipeline: ${data.message}`)
|
|
} else {
|
|
onError(data, variables, context)
|
|
}
|
|
},
|
|
...options,
|
|
}
|
|
)
|
|
}
|