1use 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
39const 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
56const 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#[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 #[serde(alias = "verify-ca")]
210 VerifyCa,
211 #[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 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 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 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 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 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 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 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 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 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 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 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 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
659pub fn postgres_point_type() -> DataType {
660 DataType::Struct(StructType::new(vec![
661 ("x", DataType::Float64),
662 ("y", DataType::Float64),
663 ]))
664}
665
666pub fn sea_type_to_rw_type(col_type: &SeaType) -> ConnectorResult<DataType> {
668 let dtype = match col_type {
669 SeaType::SmallInt | SeaType::SmallSerial => DataType::Int16,
670 SeaType::Integer | SeaType::Serial => DataType::Int32,
671 SeaType::BigInt | SeaType::BigSerial => DataType::Int64,
672 SeaType::Money | SeaType::Decimal(_) | SeaType::Numeric(_) => DataType::Decimal,
673 SeaType::Real => DataType::Float32,
674 SeaType::DoublePrecision => DataType::Float64,
675 SeaType::Varchar(_) | SeaType::Char(_) | SeaType::Text => DataType::Varchar,
676 SeaType::Bytea => DataType::Bytea,
677 SeaType::Timestamp(_) => DataType::Timestamp,
678 SeaType::TimestampWithTimeZone(_) => DataType::Timestamptz,
679 SeaType::Date => DataType::Date,
680 SeaType::Time(_) | SeaType::TimeWithTimeZone(_) => DataType::Time,
681 SeaType::Interval(_) => DataType::Interval,
682 SeaType::Boolean => DataType::Boolean,
683 SeaType::Point => postgres_point_type(),
684 SeaType::Uuid => DataType::Varchar,
685 SeaType::Xml => DataType::Varchar,
686 SeaType::Json => DataType::Jsonb,
687 SeaType::JsonBinary => DataType::Jsonb,
688 SeaType::Array(def) => {
689 let item_type = match def.col_type.as_ref() {
690 Some(ty) => sea_type_to_rw_type(ty.as_ref())?,
691 None => {
692 return Err(anyhow!("ARRAY type missing element type").into());
693 }
694 };
695
696 DataType::list(item_type)
697 }
698 SeaType::PgLsn => DataType::Int64,
699 SeaType::Cidr
700 | SeaType::Inet
701 | SeaType::MacAddr
702 | SeaType::MacAddr8
703 | SeaType::Int4Range
704 | SeaType::Int8Range
705 | SeaType::NumRange
706 | SeaType::TsRange
707 | SeaType::TsTzRange
708 | SeaType::DateRange
709 | SeaType::Enum(_) => DataType::Varchar,
710 SeaType::Line
711 | SeaType::Lseg
712 | SeaType::Box
713 | SeaType::Path
714 | SeaType::Polygon
715 | SeaType::Circle
716 | SeaType::Bit(_)
717 | SeaType::VarBit(_)
718 | SeaType::TsVector
719 | SeaType::TsQuery => {
720 bail!("{:?} data type is not supported", col_type);
721 }
722 SeaType::Unknown(name) => {
723 if let Some(dim) = parse_pgvector_dimension(name)? {
724 DataType::Vector(dim)
725 } else if matches!(name.to_ascii_lowercase().as_str(), "geometry" | "geography") {
726 DataType::Bytea
727 } else {
728 tracing::warn!("unknown PostgreSQL data type `{name}`; mapping it to varchar");
730 DataType::Varchar
731 }
732 }
733 };
734
735 Ok(dtype)
736}
737
738fn parse_pgvector_dimension(type_name: &str) -> ConnectorResult<Option<usize>> {
739 let normalized = type_name.trim().to_ascii_lowercase();
740 if normalized == "vector" {
741 bail!("pgvector type `vector` is missing dimension, expected `vector(n)`")
742 }
743 if !normalized.starts_with("vector(") || !normalized.ends_with(')') {
744 return Ok(None);
745 }
746
747 let dim_text = normalized
748 .trim_start_matches("vector(")
749 .trim_end_matches(')')
750 .trim();
751 let dim = dim_text
752 .parse::<usize>()
753 .map_err(|_| anyhow!("invalid pgvector dimension in type `{type_name}`"))?;
754
755 if !(1..=DataType::VEC_MAX_SIZE).contains(&dim) {
756 bail!(
757 "pgvector dimension out of range in type `{}`: expect 1..={}",
758 type_name,
759 DataType::VEC_MAX_SIZE
760 );
761 }
762
763 Ok(Some(dim))
764}
765
766fn sea_type_to_pg_type(sea_type: &SeaType) -> ConnectorResult<tokio_postgres::types::Type> {
771 use tokio_postgres::types::Type as PgType;
772 match sea_type {
773 SeaType::SmallInt => Ok(PgType::INT2),
774 SeaType::Integer => Ok(PgType::INT4),
775 SeaType::BigInt => Ok(PgType::INT8),
776 SeaType::Decimal(_) => Ok(PgType::NUMERIC),
777 SeaType::Numeric(_) => Ok(PgType::NUMERIC),
778 SeaType::Real => Ok(PgType::FLOAT4),
779 SeaType::DoublePrecision => Ok(PgType::FLOAT8),
780 SeaType::Varchar(_) => Ok(PgType::VARCHAR),
781 SeaType::Char(_) => Ok(PgType::CHAR),
782 SeaType::Text => Ok(PgType::TEXT),
783 SeaType::Bytea => Ok(PgType::BYTEA),
784 SeaType::Timestamp(_) => Ok(PgType::TIMESTAMP),
785 SeaType::TimestampWithTimeZone(_) => Ok(PgType::TIMESTAMPTZ),
786 SeaType::Date => Ok(PgType::DATE),
787 SeaType::Time(_) => Ok(PgType::TIME),
788 SeaType::TimeWithTimeZone(_) => Ok(PgType::TIMETZ),
789 SeaType::Interval(_) => Ok(PgType::INTERVAL),
790 SeaType::Boolean => Ok(PgType::BOOL),
791 SeaType::Point => Ok(PgType::POINT),
792 SeaType::Uuid => Ok(PgType::UUID),
793 SeaType::Json => Ok(PgType::JSON),
794 SeaType::JsonBinary => Ok(PgType::JSONB),
795 SeaType::Array(t) => {
796 let Some(t) = t.col_type.as_ref() else {
797 bail!("missing array type")
798 };
799 match t.as_ref() {
800 SeaType::SmallInt => Ok(PgType::INT2_ARRAY),
802 SeaType::Integer => Ok(PgType::INT4_ARRAY),
803 SeaType::BigInt => Ok(PgType::INT8_ARRAY),
804 SeaType::Decimal(_) => Ok(PgType::NUMERIC_ARRAY),
805 SeaType::Numeric(_) => Ok(PgType::NUMERIC_ARRAY),
806 SeaType::Real => Ok(PgType::FLOAT4_ARRAY),
807 SeaType::DoublePrecision => Ok(PgType::FLOAT8_ARRAY),
808 SeaType::Varchar(_) => Ok(PgType::VARCHAR_ARRAY),
809 SeaType::Char(_) => Ok(PgType::CHAR_ARRAY),
810 SeaType::Text => Ok(PgType::TEXT_ARRAY),
811 SeaType::Bytea => Ok(PgType::BYTEA_ARRAY),
812 SeaType::Timestamp(_) => Ok(PgType::TIMESTAMP_ARRAY),
813 SeaType::TimestampWithTimeZone(_) => Ok(PgType::TIMESTAMPTZ_ARRAY),
814 SeaType::Date => Ok(PgType::DATE_ARRAY),
815 SeaType::Time(_) => Ok(PgType::TIME_ARRAY),
816 SeaType::TimeWithTimeZone(_) => Ok(PgType::TIMETZ_ARRAY),
817 SeaType::Interval(_) => Ok(PgType::INTERVAL_ARRAY),
818 SeaType::Boolean => Ok(PgType::BOOL_ARRAY),
819 SeaType::Point => Ok(PgType::POINT_ARRAY),
820 SeaType::Uuid => Ok(PgType::UUID_ARRAY),
821 SeaType::Json => Ok(PgType::JSON_ARRAY),
822 SeaType::JsonBinary => Ok(PgType::JSONB_ARRAY),
823 SeaType::Array(_) => bail!("nested array type is not supported"),
824 SeaType::Unknown(name) => {
825 Ok(PgType::new(
827 name.clone(),
828 0,
829 PgKind::Array(PgType::new(
830 name.clone(),
831 0,
832 PgKind::Enum(vec![]),
833 "".into(),
834 )),
835 "".into(),
836 ))
837 }
838 _ => bail!("unsupported array type: {:?}", t),
839 }
840 }
841 SeaType::Unknown(name) => {
842 Ok(PgType::new(
844 name.clone(),
845 0,
846 PgKind::Enum(vec![]),
847 "".into(),
848 ))
849 }
850 _ => bail!("unsupported type: {:?}", sea_type),
851 }
852}
853
854#[cfg(test)]
855mod tests {
856 use super::{
857 format_grant_table_privilege, format_grant_usage, format_pg_table_name,
858 format_required_table_grants, parse_pgvector_dimension,
859 };
860
861 #[test]
862 fn test_parse_pgvector_dimension() {
863 assert_eq!(parse_pgvector_dimension("vector(3)").unwrap(), Some(3));
864 assert_eq!(parse_pgvector_dimension("VECTOR(768)").unwrap(), Some(768));
865 assert_eq!(parse_pgvector_dimension("varchar").unwrap(), None);
866 }
867
868 #[test]
869 fn test_parse_pgvector_dimension_requires_size() {
870 let err = parse_pgvector_dimension("vector").unwrap_err();
871 assert!(err.to_string().contains("missing dimension"));
872 }
873
874 #[test]
875 fn test_format_postgres_privilege_grants_quote_identifiers() {
876 assert_eq!(
877 format_pg_table_name("public", "GlobalBrandContentAnalysis"),
878 r#""public"."GlobalBrandContentAnalysis""#
879 );
880 assert_eq!(
881 format_grant_usage("tenant schema", r#"cdc"user"#),
882 r#"GRANT USAGE ON SCHEMA "tenant schema" TO "cdc""user";"#
883 );
884 assert_eq!(
885 format_grant_table_privilege("tenant schema", "Orders", r#"cdc"user"#, "SELECT"),
886 r#"GRANT SELECT ON TABLE "tenant schema"."Orders" TO "cdc""user";"#
887 );
888 assert_eq!(
889 format_required_table_grants("tenant schema", "Orders", r#"cdc"user"#, "SELECT"),
890 r#"GRANT USAGE ON SCHEMA "tenant schema" TO "cdc""user"; GRANT SELECT ON TABLE "tenant schema"."Orders" TO "cdc""user";"#
891 );
892 }
893}