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 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 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
762fn 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 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 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 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}