Skip to main content

risingwave_connector/connector_common/
postgres.rs

1// Copyright 2024 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::{BTreeMap, HashMap};
16use std::fmt;
17
18use anyhow::{Context, anyhow};
19use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode};
20use postgres_openssl::MakeTlsConnector;
21use risingwave_common::bail;
22use risingwave_common::catalog::{ColumnDesc, ColumnId};
23use risingwave_common::types::{DataType, ScalarImpl, StructType};
24use sea_schema::postgres::def::{ColumnType as SeaType, TableDef, TableInfo};
25use sea_schema::postgres::discovery::SchemaDiscovery;
26use sea_schema::sea_query::{Alias, IntoIden};
27use serde::Deserialize;
28use serde_with::{DisplayFromStr, serde_as};
29use sqlx::postgres::{PgConnectOptions, PgSslMode};
30use sqlx::{PgPool, Row};
31use thiserror_ext::AsReport;
32use tokio_postgres::types::Kind as PgKind;
33use tokio_postgres::{Client as PgClient, NoTls};
34
35#[cfg(not(madsim))]
36use super::maybe_tls_connector::MaybeMakeTlsConnector;
37use crate::error::ConnectorResult;
38
39/// SQL query to discover primary key columns directly from PostgreSQL system tables.
40/// This bypasses querying `information_schema.table_constraints` to avoid permission issues.
41/// Match `pg_class` and `pg_namespace` by exact catalog names instead of casting a
42/// constructed string to `regclass`, as unquoted `regclass` input folds mixed-case
43/// table names to lower case.
44const DISCOVER_PRIMARY_KEY_QUERY: &str = r#"
45    SELECT a.attname as column_name
46    FROM pg_index i
47    JOIN pg_class c ON c.oid = i.indrelid
48    JOIN pg_namespace n ON n.oid = c.relnamespace
49    JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
50    WHERE n.nspname = $1
51      AND c.relname = $2
52      AND i.indisprimary = true
53    ORDER BY array_position(i.indkey, a.attnum)
54"#;
55
56/// Discover pgvector columns with both `atttypmod` (dimension) and `format_type` text.
57/// `vector(n)` is stored as `atttypmod = n`, while dimension-less `vector` uses `-1`.
58/// We rely on this to keep user-defined type modifiers that are not preserved by sea-schema.
59const DISCOVER_PGVECTOR_COLUMNS_QUERY: &str = r#"
60    SELECT
61      a.attname as column_name,
62      a.atttypmod as atttypmod,
63      format_type(a.atttypid, a.atttypmod) as formatted_type
64    FROM pg_attribute a
65    JOIN pg_class c ON c.oid = a.attrelid
66    JOIN pg_namespace n ON n.oid = c.relnamespace
67    JOIN pg_type t ON t.oid = a.atttypid
68    WHERE n.nspname = $1
69      AND c.relname = $2
70      AND t.typname = 'vector'
71      AND a.attnum > 0
72      AND NOT a.attisdropped
73    ORDER BY a.attnum
74"#;
75
76const CHECK_TABLE_PRIVILEGE_QUERY: &str = r#"
77    SELECT
78      current_user::text AS user_name,
79      n.oid IS NOT NULL AS schema_exists,
80      c.oid IS NOT NULL AS table_exists,
81      COALESCE(has_schema_privilege(current_user, n.oid, 'USAGE'), false) AS has_schema_usage,
82      COALESCE(has_table_privilege(current_user, c.oid, $3), false) AS has_table_privilege,
83      COALESCE(has_any_column_privilege(current_user, c.oid, $3), false) AS has_any_column_privilege
84    FROM (SELECT 1) AS one
85    LEFT JOIN pg_namespace n ON n.nspname = $1
86    LEFT JOIN pg_class c ON c.relnamespace = n.oid
87      AND c.relname = $2
88      AND c.relkind IN ('r', 'p')
89    LIMIT 1
90"#;
91
92/// Canonical Postgres connection parameters shared across sink, source CDC, batch
93/// executor, and frontend `postgres_query` table function. Each caller constructs
94/// this from its own user-facing config struct before invoking the shared helpers
95/// like `create_pg_client` or `PostgresExternalTable::connect`.
96#[derive(Debug, Clone)]
97pub struct PgConnectionConfig {
98    pub host: String,
99    pub port: u16,
100    pub user: String,
101    pub password: String,
102    pub database: String,
103    pub ssl_mode: SslMode,
104    pub ssl_root_cert: Option<String>,
105}
106
107impl PgConnectionConfig {
108    fn to_sqlx_connect_options(&self) -> PgConnectOptions {
109        let mut options = PgConnectOptions::new()
110            .username(&self.user)
111            .password(&self.password)
112            .host(&self.host)
113            .port(self.port)
114            .database(&self.database)
115            .ssl_mode(match self.ssl_mode {
116                SslMode::Disabled => PgSslMode::Disable,
117                SslMode::Preferred => PgSslMode::Prefer,
118                SslMode::Required => PgSslMode::Require,
119                SslMode::VerifyCa => PgSslMode::VerifyCa,
120                SslMode::VerifyFull => PgSslMode::VerifyFull,
121            });
122
123        if matches!(self.ssl_mode, SslMode::VerifyCa | SslMode::VerifyFull)
124            && let Some(root_cert) = &self.ssl_root_cert
125        {
126            options = options.ssl_root_cert(root_cert.as_str());
127        }
128
129        options
130    }
131}
132
133/// TCP keepalive knobs for the long-lived Postgres client used by the sink.
134/// Lives in `connector_common` so both the sink config and the shared
135/// `create_pg_client` helper reference the same definition.
136#[serde_as]
137#[derive(Debug, Clone, Deserialize)]
138pub struct TcpKeepaliveConfig {
139    #[serde(rename = "tcp.keepalive.idle")]
140    #[serde_as(as = "DisplayFromStr")]
141    pub tcp_keepalive_idle: u32,
142    #[serde(rename = "tcp.keepalive.interval")]
143    #[serde_as(as = "DisplayFromStr")]
144    pub tcp_keepalive_interval: u32,
145    #[serde(rename = "tcp.keepalive.count")]
146    #[serde_as(as = "DisplayFromStr")]
147    pub tcp_keepalive_count: u32,
148}
149
150impl Default for TcpKeepaliveConfig {
151    fn default() -> Self {
152        Self {
153            tcp_keepalive_idle: 10 * 60,
154            tcp_keepalive_interval: 10,
155            tcp_keepalive_count: 3,
156        }
157    }
158}
159
160pub fn pg_connection_config_from_properties(
161    props: &BTreeMap<String, String>,
162) -> ConnectorResult<PgConnectionConfig> {
163    Ok(PgConnectionConfig {
164        host: props
165            .get("hostname")
166            .context("missing `hostname` in postgres-cdc properties")?
167            .clone(),
168        port: {
169            let raw = props
170                .get("port")
171                .context("missing `port` in postgres-cdc properties")?;
172            raw.parse::<u16>()
173                .with_context(|| format!("invalid postgres port `{}`", raw))?
174        },
175        user: props
176            .get("username")
177            .context("missing `username` in postgres-cdc properties")?
178            .clone(),
179        password: props.get("password").cloned().unwrap_or_default(),
180        database: props
181            .get("database.name")
182            .context("missing `database.name` in postgres-cdc properties")?
183            .clone(),
184        ssl_mode: props
185            .get("ssl.mode")
186            .and_then(|v| v.parse::<SslMode>().ok())
187            .unwrap_or_default(),
188        ssl_root_cert: props.get("ssl.root.cert").cloned(),
189    })
190}
191
192pub async fn create_pg_client_from_properties(
193    props: &BTreeMap<String, String>,
194    tcp_keepalive: Option<TcpKeepaliveConfig>,
195) -> ConnectorResult<PgClient> {
196    let config = pg_connection_config_from_properties(props)?;
197    create_pg_client(&config, tcp_keepalive)
198        .await
199        .map_err(Into::into)
200}
201
202pub async fn discover_pgvector_dimensions(
203    client: &PgClient,
204    schema: &str,
205    table: &str,
206) -> ConnectorResult<HashMap<String, usize>> {
207    let rows = client
208        .query(DISCOVER_PGVECTOR_COLUMNS_QUERY, &[&schema, &table])
209        .await?;
210
211    let mut dims = HashMap::new();
212    for row in rows {
213        let col_name: String = row.get("column_name");
214        let atttypmod: i32 = row.get("atttypmod");
215        if atttypmod > 0
216            && let Ok(dim) = usize::try_from(atttypmod)
217        {
218            dims.insert(col_name, dim);
219        }
220    }
221    Ok(dims)
222}
223
224#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
225#[serde(rename_all = "lowercase")]
226pub enum SslMode {
227    #[serde(alias = "disable")]
228    Disabled,
229    #[serde(alias = "prefer")]
230    #[default]
231    Preferred,
232    #[serde(alias = "require")]
233    Required,
234    /// verify that the server is trustworthy by checking the certificate chain
235    /// up to the root certificate stored on the client.
236    #[serde(alias = "verify-ca")]
237    VerifyCa,
238    /// Besides verify the certificate, will also verify that the serverhost name
239    /// matches the name stored in the server certificate.
240    #[serde(alias = "verify-full")]
241    VerifyFull,
242}
243
244pub struct PostgresExternalTable {
245    column_descs: Vec<ColumnDesc>,
246    pk_names: Vec<String>,
247}
248
249struct PostgresTablePrivilege {
250    user_name: String,
251    schema_exists: bool,
252    table_exists: bool,
253    has_schema_usage: bool,
254    has_table_privilege: bool,
255    has_any_column_privilege: bool,
256}
257
258impl PostgresExternalTable {
259    /// Discover primary key columns directly from PostgreSQL system tables.
260    /// This bypasses querying `information_schema.table_constraints` to avoid requiring table owner permissions.
261    async fn discover_primary_key(
262        connection: &PgPool,
263        schema_name: &str,
264        table_name: &str,
265    ) -> ConnectorResult<Vec<String>> {
266        let rows = sqlx::query(DISCOVER_PRIMARY_KEY_QUERY)
267            .bind(schema_name)
268            .bind(table_name)
269            .fetch_all(connection)
270            .await
271            .context("Failed to discover primary key columns")?;
272
273        let pk_columns = rows
274            .into_iter()
275            .map(|row| row.get::<String, _>("column_name"))
276            .collect();
277
278        Ok(pk_columns)
279    }
280
281    /// Discover schema with workaround for primary key discovery
282    /// This method uses direct PostgreSQL system table queries for primary keys
283    /// to avoid permission issues when querying `information_schema.table_constraints`
284    async fn discover_pk_and_full_columns(
285        config: &PgConnectionConfig,
286        schema: &str,
287        table: &str,
288        required_table_privilege: Option<&str>,
289    ) -> ConnectorResult<(Vec<sea_schema::postgres::def::ColumnInfo>, Vec<String>)> {
290        let options = config.to_sqlx_connect_options();
291        let connection = PgPool::connect_with(options).await?;
292
293        // Keep using sea-schema for column discovery, then run targeted access diagnostics below.
294        let schema_discovery = SchemaDiscovery::new(connection.clone(), schema);
295        let empty_map: HashMap<String, Vec<String>> = HashMap::new();
296        let columns = schema_discovery
297            .discover_columns(
298                Alias::new(schema).into_iden(),
299                Alias::new(table).into_iden(),
300                &empty_map,
301            )
302            .await?;
303
304        let pgvector_columns = sqlx::query(DISCOVER_PGVECTOR_COLUMNS_QUERY)
305            .bind(schema)
306            .bind(table)
307            .fetch_all(&connection)
308            .await
309            .context("Failed to discover PostgreSQL pgvector columns")?;
310        let formatted_type_by_column: HashMap<String, String> = pgvector_columns
311            .into_iter()
312            .map(|row| {
313                (
314                    row.get::<String, _>("column_name"),
315                    row.get::<String, _>("formatted_type"),
316                )
317            })
318            .collect();
319
320        // sea-schema reports pgvector as `Unknown("vector")` and drops the dimension.
321        // Patch it with PostgreSQL's formatted type text so we can derive vector(n).
322        let mut columns = columns;
323        if let Some(privilege) = required_table_privilege {
324            Self::ensure_table_privilege(&connection, schema, table, privilege).await?;
325        }
326
327        for col in &mut columns {
328            if let SeaType::Unknown(name) = &col.col_type
329                && name.eq_ignore_ascii_case("vector")
330                && let Some(formatted_type) = formatted_type_by_column.get(&col.name)
331            {
332                col.col_type = SeaType::Unknown(formatted_type.clone());
333            }
334        }
335
336        // Use direct system table query for primary key discovery
337        let pk_columns = Self::discover_primary_key(&connection, schema, table).await?;
338
339        Ok((columns, pk_columns))
340    }
341
342    async fn ensure_table_privilege(
343        connection: &PgPool,
344        schema: &str,
345        table: &str,
346        required_privilege: &str,
347    ) -> ConnectorResult<()> {
348        let row = sqlx::query(CHECK_TABLE_PRIVILEGE_QUERY)
349            .bind(schema)
350            .bind(table)
351            .bind(required_privilege)
352            .fetch_one(connection)
353            .await
354            .context("Failed to check PostgreSQL table privileges")?;
355
356        let privilege_status = PostgresTablePrivilege {
357            user_name: row.get("user_name"),
358            schema_exists: row.get("schema_exists"),
359            table_exists: row.get("table_exists"),
360            has_schema_usage: row.get("has_schema_usage"),
361            has_table_privilege: row.get("has_table_privilege"),
362            has_any_column_privilege: row.get("has_any_column_privilege"),
363        };
364
365        if !privilege_status.schema_exists {
366            return Err(anyhow!("PostgreSQL schema `{schema}` does not exist").into());
367        }
368
369        if !privilege_status.table_exists {
370            return Err(anyhow!("PostgreSQL table `{schema}`.`{table}` does not exist").into());
371        }
372
373        if !privilege_status.has_schema_usage {
374            return Err(anyhow!(
375                "PostgreSQL table {} exists, but the connection user `{}` does not have USAGE privilege on schema `{}`. Grant privileges on the upstream PostgreSQL database: {}",
376                format_pg_table_name(schema, table),
377                privilege_status.user_name,
378                schema,
379                format_grant_usage(schema, &privilege_status.user_name),
380            )
381            .into());
382        }
383
384        if !privilege_status.has_table_privilege {
385            let column_privilege_msg = if privilege_status.has_any_column_privilege {
386                " The user has column-level privilege on at least one column, but RisingWave requires table-level privilege for CDC schema discovery and snapshot reads."
387            } else {
388                ""
389            };
390            return Err(anyhow!(
391                "PostgreSQL table {} exists, but the connection user `{}` does not have {} privilege on it.{} Grant privileges on the upstream PostgreSQL database: {}",
392                format_pg_table_name(schema, table),
393                privilege_status.user_name,
394                required_privilege,
395                column_privilege_msg,
396                format_required_table_grants(
397                    schema,
398                    table,
399                    &privilege_status.user_name,
400                    required_privilege
401                ),
402            )
403            .into());
404        }
405
406        Ok(())
407    }
408
409    async fn discover_schema(
410        config: &PgConnectionConfig,
411        schema: &str,
412        table: &str,
413    ) -> ConnectorResult<TableDef> {
414        let options = config.to_sqlx_connect_options();
415        let connection = PgPool::connect_with(options).await?;
416        let schema_discovery = SchemaDiscovery::new(connection, schema);
417        // fetch column schema and primary key
418        let empty_map = HashMap::new();
419        let table_schema = schema_discovery
420            .discover_table(
421                TableInfo {
422                    name: table.to_owned(),
423                    of_type: None,
424                },
425                &empty_map,
426            )
427            .await?;
428        Ok(table_schema)
429    }
430
431    pub async fn connect(
432        config: &PgConnectionConfig,
433        schema: &str,
434        table: &str,
435        is_append_only: bool,
436        required_table_privilege: Option<&str>,
437    ) -> ConnectorResult<Self> {
438        tracing::debug!("connect to postgres external table");
439
440        let (columns, pk_names) =
441            Self::discover_pk_and_full_columns(config, schema, table, required_table_privilege)
442                .await?;
443
444        let mut column_descs = vec![];
445        for col in &columns {
446            let rw_data_type = sea_type_to_rw_type(&col.col_type)?;
447            let column_desc = if let Some(ref default_expr) = col.default {
448                // parse the value of "column_default" field in information_schema.columns,
449                // non number data type will be stored as "'value'::type"
450                let val_text = default_expr
451                    .0
452                    .split("::")
453                    .map(|s| s.trim_matches('\''))
454                    .next()
455                    .expect("default value expression");
456
457                match ScalarImpl::from_text(val_text, &rw_data_type) {
458                    Ok(scalar) => ColumnDesc::named_with_default_value(
459                        col.name.clone(),
460                        ColumnId::placeholder(),
461                        rw_data_type.clone(),
462                        Some(scalar),
463                    ),
464                    Err(err) => {
465                        tracing::warn!(error=%err.as_report(), "failed to parse postgres default value expression, only constant is supported");
466                        ColumnDesc::named(col.name.clone(), ColumnId::placeholder(), rw_data_type)
467                    }
468                }
469            } else {
470                ColumnDesc::named(col.name.clone(), ColumnId::placeholder(), rw_data_type)
471            };
472            column_descs.push(column_desc);
473        }
474
475        // Check primary key existence using the directly discovered pk_names
476        if !is_append_only && pk_names.is_empty() {
477            return Err(anyhow!(
478                "Postgres table should define the primary key for non-append-only tables"
479            )
480            .into());
481        }
482
483        Ok(Self {
484            column_descs,
485            pk_names,
486        })
487    }
488
489    // return the mapping from column name to pg type, the pg type is used for writing data to postgres
490    pub async fn type_mapping(
491        config: &PgConnectionConfig,
492        schema: &str,
493        table: &str,
494        is_append_only: bool,
495    ) -> ConnectorResult<HashMap<String, tokio_postgres::types::Type>> {
496        tracing::debug!("connect to postgres external table to get type mapping");
497        let table_schema = Self::discover_schema(config, schema, table).await?;
498        let mut column_name_to_pg_type = HashMap::new();
499        for col in &table_schema.columns {
500            let pg_type = sea_type_to_pg_type(&col.col_type)?;
501            column_name_to_pg_type.insert(col.name.clone(), pg_type);
502        }
503        if !is_append_only && table_schema.primary_key_constraints.is_empty() {
504            return Err(anyhow!(
505                "Postgres table should define the primary key for non-append-only tables"
506            )
507            .into());
508        }
509        Ok(column_name_to_pg_type)
510    }
511
512    pub fn column_descs(&self) -> &Vec<ColumnDesc> {
513        &self.column_descs
514    }
515
516    pub fn pk_names(&self) -> &Vec<String> {
517        &self.pk_names
518    }
519}
520
521fn format_pg_table_name(schema: &str, table: &str) -> String {
522    format!(
523        "{}.{}",
524        quote_pg_identifier(schema),
525        quote_pg_identifier(table)
526    )
527}
528
529fn format_grant_usage(schema: &str, user_name: &str) -> String {
530    format!(
531        "GRANT USAGE ON SCHEMA {} TO {};",
532        quote_pg_identifier(schema),
533        quote_pg_identifier(user_name)
534    )
535}
536
537fn format_grant_table_privilege(
538    schema: &str,
539    table: &str,
540    user_name: &str,
541    privilege: &str,
542) -> String {
543    format!(
544        "GRANT {} ON TABLE {} TO {};",
545        privilege,
546        format_pg_table_name(schema, table),
547        quote_pg_identifier(user_name)
548    )
549}
550
551fn format_required_table_grants(
552    schema: &str,
553    table: &str,
554    user_name: &str,
555    privilege: &str,
556) -> String {
557    format!(
558        "{} {}",
559        format_grant_usage(schema, user_name),
560        format_grant_table_privilege(schema, table, user_name, privilege)
561    )
562}
563
564fn quote_pg_identifier(identifier: &str) -> String {
565    format!("\"{}\"", identifier.replace('"', "\"\""))
566}
567
568impl fmt::Display for SslMode {
569    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
570        f.write_str(match self {
571            SslMode::Disabled => "disabled",
572            SslMode::Preferred => "preferred",
573            SslMode::Required => "required",
574            SslMode::VerifyCa => "verify-ca",
575            SslMode::VerifyFull => "verify-full",
576        })
577    }
578}
579
580impl std::str::FromStr for SslMode {
581    type Err = serde_json::Error;
582
583    fn from_str(s: &str) -> Result<Self, Self::Err> {
584        serde_json::from_value(serde_json::Value::String(s.to_owned()))
585    }
586}
587
588pub async fn create_pg_client(
589    config: &PgConnectionConfig,
590    tcp_keepalive: Option<TcpKeepaliveConfig>,
591) -> anyhow::Result<PgClient> {
592    let mut pg_config = tokio_postgres::Config::new();
593    pg_config
594        .user(&config.user)
595        .password(&config.password)
596        .host(&config.host)
597        .port(config.port)
598        .dbname(&config.database);
599
600    // Configure TCP keepalive if provided
601    if let Some(keepalive) = tcp_keepalive {
602        pg_config.keepalives(true);
603        pg_config.keepalives_idle(std::time::Duration::from_secs(
604            keepalive.tcp_keepalive_idle as u64,
605        ));
606        #[cfg(not(target_os = "windows"))]
607        {
608            pg_config.keepalives_interval(std::time::Duration::from_secs(
609                keepalive.tcp_keepalive_interval as u64,
610            ));
611            pg_config.keepalives_retries(keepalive.tcp_keepalive_count);
612        }
613        tracing::info!(
614            "TCP keepalive enabled: idle={}s, interval={}s, retries={}",
615            keepalive.tcp_keepalive_idle,
616            keepalive.tcp_keepalive_interval,
617            keepalive.tcp_keepalive_count
618        );
619    }
620
621    let verify_hostname = matches!(config.ssl_mode, SslMode::VerifyFull);
622
623    #[cfg(not(madsim))]
624    let connector = match config.ssl_mode {
625        SslMode::Disabled => {
626            pg_config.ssl_mode(tokio_postgres::config::SslMode::Disable);
627            MaybeMakeTlsConnector::NoTls(NoTls)
628        }
629        SslMode::Preferred => {
630            pg_config.ssl_mode(tokio_postgres::config::SslMode::Prefer);
631            match SslConnector::builder(SslMethod::tls()) {
632                Ok(mut builder) => {
633                    // disable certificate verification for `prefer`
634                    builder.set_verify(SslVerifyMode::NONE);
635                    MaybeMakeTlsConnector::Tls(MakeTlsConnector::new(builder.build()))
636                }
637                Err(e) => {
638                    tracing::warn!(error = %e.as_report(), "SSL connector error");
639                    MaybeMakeTlsConnector::NoTls(NoTls)
640                }
641            }
642        }
643        SslMode::Required => {
644            pg_config.ssl_mode(tokio_postgres::config::SslMode::Require);
645            let mut builder = SslConnector::builder(SslMethod::tls())?;
646            // disable certificate verification for `require`
647            builder.set_verify(SslVerifyMode::NONE);
648            MaybeMakeTlsConnector::Tls(MakeTlsConnector::new(builder.build()))
649        }
650
651        SslMode::VerifyCa | SslMode::VerifyFull => {
652            pg_config.ssl_mode(tokio_postgres::config::SslMode::Require);
653            let mut builder = SslConnector::builder(SslMethod::tls())?;
654            if let Some(ssl_root_cert) = &config.ssl_root_cert {
655                builder.set_ca_file(ssl_root_cert).map_err(|e| {
656                    anyhow!(format!("bad ssl root cert error: {}", e.to_report_string()))
657                })?;
658            }
659            let mut connector = MakeTlsConnector::new(builder.build());
660            if !verify_hostname {
661                connector.set_callback(|c, _| {
662                    c.set_verify_hostname(false);
663                    Ok(())
664                });
665            }
666            MaybeMakeTlsConnector::Tls(connector)
667        }
668    };
669    #[cfg(madsim)]
670    let connector = NoTls;
671
672    let (client, connection) = pg_config.connect(connector).await?;
673
674    tokio::spawn(async move {
675        if let Err(e) = connection.await {
676            tracing::error!(error = %e.as_report(), "postgres connection error");
677        }
678    });
679
680    Ok(client)
681}
682
683// Used for both source and sink connector
684pub fn sea_type_to_rw_type(col_type: &SeaType) -> ConnectorResult<DataType> {
685    let dtype = match col_type {
686        SeaType::SmallInt | SeaType::SmallSerial => DataType::Int16,
687        SeaType::Integer | SeaType::Serial => DataType::Int32,
688        SeaType::BigInt | SeaType::BigSerial => DataType::Int64,
689        SeaType::Money | SeaType::Decimal(_) | SeaType::Numeric(_) => DataType::Decimal,
690        SeaType::Real => DataType::Float32,
691        SeaType::DoublePrecision => DataType::Float64,
692        SeaType::Varchar(_) | SeaType::Char(_) | SeaType::Text => DataType::Varchar,
693        SeaType::Bytea => DataType::Bytea,
694        SeaType::Timestamp(_) => DataType::Timestamp,
695        SeaType::TimestampWithTimeZone(_) => DataType::Timestamptz,
696        SeaType::Date => DataType::Date,
697        SeaType::Time(_) | SeaType::TimeWithTimeZone(_) => DataType::Time,
698        SeaType::Interval(_) => DataType::Interval,
699        SeaType::Boolean => DataType::Boolean,
700        SeaType::Point => DataType::Struct(StructType::new(vec![
701            ("x", DataType::Float32),
702            ("y", DataType::Float32),
703        ])),
704        SeaType::Uuid => DataType::Varchar,
705        SeaType::Xml => DataType::Varchar,
706        SeaType::Json => DataType::Jsonb,
707        SeaType::JsonBinary => DataType::Jsonb,
708        SeaType::Array(def) => {
709            let item_type = match def.col_type.as_ref() {
710                Some(ty) => sea_type_to_rw_type(ty.as_ref())?,
711                None => {
712                    return Err(anyhow!("ARRAY type missing element type").into());
713                }
714            };
715
716            DataType::list(item_type)
717        }
718        SeaType::PgLsn => DataType::Int64,
719        SeaType::Cidr
720        | SeaType::Inet
721        | SeaType::MacAddr
722        | SeaType::MacAddr8
723        | SeaType::Int4Range
724        | SeaType::Int8Range
725        | SeaType::NumRange
726        | SeaType::TsRange
727        | SeaType::TsTzRange
728        | SeaType::DateRange
729        | SeaType::Enum(_) => DataType::Varchar,
730        SeaType::Line
731        | SeaType::Lseg
732        | SeaType::Box
733        | SeaType::Path
734        | SeaType::Polygon
735        | SeaType::Circle
736        | SeaType::Bit(_)
737        | SeaType::VarBit(_)
738        | SeaType::TsVector
739        | SeaType::TsQuery => {
740            bail!("{:?} type not supported", col_type);
741        }
742        SeaType::Unknown(name) => {
743            if let Some(dim) = parse_pgvector_dimension(name)? {
744                DataType::Vector(dim)
745            } else {
746                // NOTES: user-defined enum type is classified as `Unknown`
747                tracing::warn!("Unknown Postgres data type: {name}, map to varchar");
748                DataType::Varchar
749            }
750        }
751    };
752
753    Ok(dtype)
754}
755
756fn parse_pgvector_dimension(type_name: &str) -> ConnectorResult<Option<usize>> {
757    let normalized = type_name.trim().to_ascii_lowercase();
758    if normalized == "vector" {
759        bail!("pgvector type `vector` is missing dimension, expected `vector(n)`")
760    }
761    if !normalized.starts_with("vector(") || !normalized.ends_with(')') {
762        return Ok(None);
763    }
764
765    let dim_text = normalized
766        .trim_start_matches("vector(")
767        .trim_end_matches(')')
768        .trim();
769    let dim = dim_text
770        .parse::<usize>()
771        .map_err(|_| anyhow!("invalid pgvector dimension in type `{type_name}`"))?;
772
773    if !(1..=DataType::VEC_MAX_SIZE).contains(&dim) {
774        bail!(
775            "pgvector dimension out of range in type `{}`: expect 1..={}",
776            type_name,
777            DataType::VEC_MAX_SIZE
778        );
779    }
780
781    Ok(Some(dim))
782}
783
784// Used for sink connector
785// We use `sea-schema` for table schema discovery.
786// So we have to map `sea-schema` pg types
787// to `tokio-postgres` pg types (which we use for query binding).
788fn sea_type_to_pg_type(sea_type: &SeaType) -> ConnectorResult<tokio_postgres::types::Type> {
789    use tokio_postgres::types::Type as PgType;
790    match sea_type {
791        SeaType::SmallInt => Ok(PgType::INT2),
792        SeaType::Integer => Ok(PgType::INT4),
793        SeaType::BigInt => Ok(PgType::INT8),
794        SeaType::Decimal(_) => Ok(PgType::NUMERIC),
795        SeaType::Numeric(_) => Ok(PgType::NUMERIC),
796        SeaType::Real => Ok(PgType::FLOAT4),
797        SeaType::DoublePrecision => Ok(PgType::FLOAT8),
798        SeaType::Varchar(_) => Ok(PgType::VARCHAR),
799        SeaType::Char(_) => Ok(PgType::CHAR),
800        SeaType::Text => Ok(PgType::TEXT),
801        SeaType::Bytea => Ok(PgType::BYTEA),
802        SeaType::Timestamp(_) => Ok(PgType::TIMESTAMP),
803        SeaType::TimestampWithTimeZone(_) => Ok(PgType::TIMESTAMPTZ),
804        SeaType::Date => Ok(PgType::DATE),
805        SeaType::Time(_) => Ok(PgType::TIME),
806        SeaType::TimeWithTimeZone(_) => Ok(PgType::TIMETZ),
807        SeaType::Interval(_) => Ok(PgType::INTERVAL),
808        SeaType::Boolean => Ok(PgType::BOOL),
809        SeaType::Point => Ok(PgType::POINT),
810        SeaType::Uuid => Ok(PgType::UUID),
811        SeaType::Json => Ok(PgType::JSON),
812        SeaType::JsonBinary => Ok(PgType::JSONB),
813        SeaType::Array(t) => {
814            let Some(t) = t.col_type.as_ref() else {
815                bail!("missing array type")
816            };
817            match t.as_ref() {
818                // RW only supports 1 level of nesting.
819                SeaType::SmallInt => Ok(PgType::INT2_ARRAY),
820                SeaType::Integer => Ok(PgType::INT4_ARRAY),
821                SeaType::BigInt => Ok(PgType::INT8_ARRAY),
822                SeaType::Decimal(_) => Ok(PgType::NUMERIC_ARRAY),
823                SeaType::Numeric(_) => Ok(PgType::NUMERIC_ARRAY),
824                SeaType::Real => Ok(PgType::FLOAT4_ARRAY),
825                SeaType::DoublePrecision => Ok(PgType::FLOAT8_ARRAY),
826                SeaType::Varchar(_) => Ok(PgType::VARCHAR_ARRAY),
827                SeaType::Char(_) => Ok(PgType::CHAR_ARRAY),
828                SeaType::Text => Ok(PgType::TEXT_ARRAY),
829                SeaType::Bytea => Ok(PgType::BYTEA_ARRAY),
830                SeaType::Timestamp(_) => Ok(PgType::TIMESTAMP_ARRAY),
831                SeaType::TimestampWithTimeZone(_) => Ok(PgType::TIMESTAMPTZ_ARRAY),
832                SeaType::Date => Ok(PgType::DATE_ARRAY),
833                SeaType::Time(_) => Ok(PgType::TIME_ARRAY),
834                SeaType::TimeWithTimeZone(_) => Ok(PgType::TIMETZ_ARRAY),
835                SeaType::Interval(_) => Ok(PgType::INTERVAL_ARRAY),
836                SeaType::Boolean => Ok(PgType::BOOL_ARRAY),
837                SeaType::Point => Ok(PgType::POINT_ARRAY),
838                SeaType::Uuid => Ok(PgType::UUID_ARRAY),
839                SeaType::Json => Ok(PgType::JSON_ARRAY),
840                SeaType::JsonBinary => Ok(PgType::JSONB_ARRAY),
841                SeaType::Array(_) => bail!("nested array type is not supported"),
842                SeaType::Unknown(name) => {
843                    // Treat as enum type
844                    Ok(PgType::new(
845                        name.clone(),
846                        0,
847                        PgKind::Array(PgType::new(
848                            name.clone(),
849                            0,
850                            PgKind::Enum(vec![]),
851                            "".into(),
852                        )),
853                        "".into(),
854                    ))
855                }
856                _ => bail!("unsupported array type: {:?}", t),
857            }
858        }
859        SeaType::Unknown(name) => {
860            // Treat as enum type
861            Ok(PgType::new(
862                name.clone(),
863                0,
864                PgKind::Enum(vec![]),
865                "".into(),
866            ))
867        }
868        _ => bail!("unsupported type: {:?}", sea_type),
869    }
870}
871
872#[cfg(test)]
873mod tests {
874    use super::{
875        format_grant_table_privilege, format_grant_usage, format_pg_table_name,
876        format_required_table_grants, parse_pgvector_dimension,
877    };
878
879    #[test]
880    fn test_parse_pgvector_dimension() {
881        assert_eq!(parse_pgvector_dimension("vector(3)").unwrap(), Some(3));
882        assert_eq!(parse_pgvector_dimension("VECTOR(768)").unwrap(), Some(768));
883        assert_eq!(parse_pgvector_dimension("varchar").unwrap(), None);
884    }
885
886    #[test]
887    fn test_parse_pgvector_dimension_requires_size() {
888        let err = parse_pgvector_dimension("vector").unwrap_err();
889        assert!(err.to_string().contains("missing dimension"));
890    }
891
892    #[test]
893    fn test_format_postgres_privilege_grants_quote_identifiers() {
894        assert_eq!(
895            format_pg_table_name("public", "GlobalBrandContentAnalysis"),
896            r#""public"."GlobalBrandContentAnalysis""#
897        );
898        assert_eq!(
899            format_grant_usage("tenant schema", r#"cdc"user"#),
900            r#"GRANT USAGE ON SCHEMA "tenant schema" TO "cdc""user";"#
901        );
902        assert_eq!(
903            format_grant_table_privilege("tenant schema", "Orders", r#"cdc"user"#, "SELECT"),
904            r#"GRANT SELECT ON TABLE "tenant schema"."Orders" TO "cdc""user";"#
905        );
906        assert_eq!(
907            format_required_table_grants("tenant schema", "Orders", r#"cdc"user"#, "SELECT"),
908            r#"GRANT USAGE ON SCHEMA "tenant schema" TO "cdc""user"; GRANT SELECT ON TABLE "tenant schema"."Orders" TO "cdc""user";"#
909        );
910    }
911}