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