use crate::error::{ReplicationError, Result};
use crate::prelude::*;
use crate::protocol::{ColumnInfo, RelationInfo};
use crate::sql_builder::{quote_ident, quote_literal, PG15, PG18};
#[derive(Debug, Clone)]
pub struct SnapshotTable {
pub(super) relation: Arc<RelationInfo>,
pub(super) copy_sql: String,
}
impl SnapshotTable {
#[inline]
pub fn relation(&self) -> &RelationInfo {
&self.relation
}
pub fn qualified_name(&self) -> String {
format!(
"{}.{}",
self.relation.namespace, self.relation.relation_name
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct CatalogRow {
pub(super) relation_id: u32,
pub(super) namespace: String,
pub(super) relation_name: String,
pub(super) replica_identity: u8,
pub(super) relkind: u8,
pub(super) column_name: String,
pub(super) type_id: u32,
pub(super) type_modifier: i32,
pub(super) row_filter: Option<String>,
pub(super) generated: bool,
}
pub(super) fn build_catalog_sql(server_version: i32, publications: &[String]) -> Result<String> {
if publications.is_empty() {
return Err(ReplicationError::config(
"initial snapshot requires at least one publication",
));
}
let quoted = publications
.iter()
.map(|p| quote_literal(p))
.collect::<Result<Vec<_>>>()?
.join(", ");
let filter_select = if server_version >= PG15 {
"pt.rowfilter"
} else {
"NULL::text AS rowfilter"
};
let attname_predicate = if server_version >= PG18 {
"a.attname = ANY (pt.attnames)"
} else if server_version >= PG15 {
"a.attname = ANY (pt.attnames) AND a.attgenerated = ''"
} else {
"a.attgenerated = ''"
};
Ok(format!(
"SELECT c.oid::text,
pt.schemaname,
pt.tablename,
c.relreplident::text,
c.relkind::text,
a.attname,
a.atttypid::text,
a.atttypmod::text,
{filter_select},
(a.attgenerated <> '')::text
FROM pg_catalog.pg_publication_tables pt
JOIN pg_catalog.pg_namespace n
ON n.nspname = pt.schemaname
JOIN pg_catalog.pg_class c
ON c.relname = pt.tablename AND c.relnamespace = n.oid
JOIN pg_catalog.pg_attribute a
ON a.attrelid = c.oid
AND a.attnum > 0
AND NOT a.attisdropped
AND {attname_predicate}
WHERE pt.pubname IN ({quoted})
ORDER BY pt.schemaname, pt.tablename, a.attnum"
))
}
pub(super) fn split_publication_names(value: &str) -> Vec<String> {
value
.split(',')
.map(|part| part.trim().trim_matches('"').trim().to_string())
.filter(|part| !part.is_empty())
.collect()
}
fn build_copy_sql(
relation: &RelationInfo,
row_filter: Option<&str>,
partitioned: bool,
has_generated: bool,
) -> Result<String> {
let table = format!(
"{}.{}",
quote_ident(&relation.namespace)?,
quote_ident(&relation.relation_name)?
);
let columns = relation
.columns
.iter()
.map(|c| quote_ident(&c.name))
.collect::<Result<Vec<_>>>()?
.join(", ");
if row_filter.is_none() && !partitioned && !has_generated {
return Ok(format!("COPY {table} ({columns}) TO STDOUT"));
}
let only = if partitioned { "" } else { "ONLY " };
let predicate = match row_filter {
Some(filter) => format!(" WHERE ({filter})"),
None => String::new(),
};
Ok(format!(
"COPY (SELECT {columns} FROM {only}{table}{predicate}) TO STDOUT"
))
}
pub(super) fn tables_from_catalog_rows(rows: &[CatalogRow]) -> Result<Vec<SnapshotTable>> {
let mut tables: Vec<SnapshotTable> = Vec::new();
let mut index = 0usize;
while index < rows.len() {
let first = &rows[index];
let mut end = index;
while end < rows.len()
&& rows[end].namespace == first.namespace
&& rows[end].relation_name == first.relation_name
{
end += 1;
}
let group = &rows[index..end];
index = end;
let row_filter = unique_row_filter(group)?;
let partitioned = first.relkind == b'p';
let mut columns: Vec<ColumnInfo> = Vec::with_capacity(group.len());
let mut has_generated = false;
for row in group {
if columns.iter().any(|c| &*c.name == row.column_name.as_str()) {
continue;
}
has_generated |= row.generated;
columns.push(ColumnInfo::new(
0, row.column_name.clone(),
row.type_id,
row.type_modifier,
));
}
let relation = RelationInfo::new(
first.relation_id,
first.namespace.clone(),
first.relation_name.clone(),
first.replica_identity,
columns,
);
let copy_sql =
build_copy_sql(&relation, row_filter.as_deref(), partitioned, has_generated)?;
tables.push(SnapshotTable {
relation: Arc::new(relation),
copy_sql,
});
}
Ok(tables)
}
fn unique_row_filter(group: &[CatalogRow]) -> Result<Option<String>> {
if group.iter().any(|r| r.row_filter.is_none()) {
return Ok(None);
}
let first = &group[0].row_filter;
if group.iter().any(|r| &r.row_filter != first) {
return Err(ReplicationError::config(format!(
"table {}.{} is published with conflicting row filters; \
snapshot it separately or drop it with `retain_tables`",
group[0].namespace, group[0].relation_name
)));
}
Ok(first.clone())
}
pub(super) fn derive_reader_conninfo(conninfo: &str) -> String {
let trimmed = conninfo.trim_start();
let is_uri = trimmed.starts_with("postgresql://") || trimmed.starts_with("postgres://");
if is_uri {
match conninfo.find('?') {
Some(_) if conninfo.ends_with('?') => format!("{conninfo}replication=false"),
Some(_) => format!("{conninfo}&replication=false"),
None => format!("{conninfo}?replication=false"),
}
} else {
format!("{conninfo} replication=false")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(ns: &str, table: &str, col: &str) -> CatalogRow {
CatalogRow {
relation_id: 42,
namespace: ns.to_string(),
relation_name: table.to_string(),
replica_identity: b'd',
relkind: b'r',
column_name: col.to_string(),
type_id: 25,
type_modifier: -1,
row_filter: None,
generated: false,
}
}
fn relation(ns: &str, table: &str, cols: &[&str]) -> RelationInfo {
RelationInfo::new(
42,
ns.to_string(),
table.to_string(),
b'd',
cols.iter()
.map(|c| ColumnInfo::new(0, c.to_string(), 25, -1))
.collect(),
)
}
#[test]
fn catalog_sql_pg15_filters_on_attnames() {
let sql = build_catalog_sql(PG15, &["my_pub".to_string()]).unwrap();
assert!(sql.contains("a.attname = ANY (pt.attnames)"));
assert!(sql.contains("pt.rowfilter"));
assert!(sql.contains("'my_pub'"));
}
#[test]
fn catalog_sql_pg14_omits_attnames_and_rowfilter() {
let sql = build_catalog_sql(PG15 - 10000, &["my_pub".to_string()]).unwrap();
assert!(
!sql.contains("pt.attnames"),
"attnames does not exist before PG15"
);
assert!(sql.contains("NULL::text AS rowfilter"));
}
#[test]
fn catalog_sql_pg14_excludes_generated_columns() {
let sql = build_catalog_sql(PG15 - 10000, &["my_pub".to_string()]).unwrap();
assert!(
sql.contains("a.attgenerated = ''"),
"pre-PG15 must filter generated columns out: {sql}"
);
}
#[test]
fn catalog_sql_excludes_generated_columns_below_pg18() {
for version in [PG15, PG15 + 10000, PG18 - 1] {
let sql = build_catalog_sql(version, &["p".to_string()]).unwrap();
assert!(
sql.contains("a.attname = ANY (pt.attnames) AND a.attgenerated = ''"),
"version {version} must not trust attnames for generated columns: {sql}"
);
}
}
#[test]
fn catalog_sql_trusts_attnames_from_pg18() {
for version in [PG18, PG18 + 10000] {
let sql = build_catalog_sql(version, &["p".to_string()]).unwrap();
assert!(sql.contains("a.attname = ANY (pt.attnames)"), "{sql}");
assert!(
!sql.contains("ANY (pt.attnames) AND a.attgenerated"),
"PG18+ must not filter generated columns out — \
publish_generated_columns makes them real stream columns: {sql}"
);
}
}
#[test]
fn catalog_sql_selects_attgenerated_on_every_version() {
for version in [PG15 - 10000, PG15, PG15 + 30000] {
let sql = build_catalog_sql(version, &["p".to_string()]).unwrap();
assert!(
sql.contains("(a.attgenerated <> '')::text"),
"version {version} must report generated columns: {sql}"
);
}
}
#[test]
fn catalog_sql_orders_by_attnum_to_match_copy() {
let sql = build_catalog_sql(PG15 + 10000, &["p".to_string()]).unwrap();
assert!(
sql.contains("ORDER BY pt.schemaname, pt.tablename, a.attnum"),
"column order must match COPY's or every value shifts"
);
}
#[test]
fn catalog_sql_quotes_multiple_publications() {
let sql = build_catalog_sql(PG15 + 10000, &["a".to_string(), "b".to_string()]).unwrap();
assert!(sql.contains("IN ('a', 'b')"), "{sql}");
}
#[test]
fn catalog_sql_escapes_quotes_in_publication_names() {
let sql = build_catalog_sql(PG15 + 10000, &["we'ird".to_string()]).unwrap();
assert!(sql.contains("'we''ird'"), "{sql}");
}
#[test]
fn catalog_sql_rejects_empty_publication_list() {
let err = build_catalog_sql(PG15 + 10000, &[]).unwrap_err();
assert!(matches!(err, ReplicationError::Config(_)), "{err:?}");
}
#[test]
fn split_publication_names_handles_quoted_csv() {
assert_eq!(split_publication_names("a"), vec!["a"]);
assert_eq!(split_publication_names("\"a\""), vec!["a"]);
assert_eq!(split_publication_names("\"a\",\"b\""), vec!["a", "b"]);
assert_eq!(split_publication_names("a, b ,c"), vec!["a", "b", "c"]);
}
#[test]
fn split_publication_names_drops_empties() {
assert_eq!(split_publication_names(""), Vec::<String>::new());
assert_eq!(split_publication_names(" , "), Vec::<String>::new());
assert_eq!(split_publication_names("a,,b"), vec!["a", "b"]);
}
#[test]
fn copy_sql_always_names_the_columns() {
let sql = build_copy_sql(
&relation("public", "users", &["id", "name"]),
None,
false,
false,
)
.unwrap();
assert_eq!(sql, r#"COPY "public"."users" ("id", "name") TO STDOUT"#);
let subset = build_copy_sql(
&relation("public", "users", &["id", "c"]),
None,
false,
false,
)
.unwrap();
assert_eq!(subset, r#"COPY "public"."users" ("id", "c") TO STDOUT"#);
}
#[test]
fn copy_sql_reads_only_the_table_itself_for_a_row_filter() {
let sql = build_copy_sql(
&relation("public", "users", &["id"]),
Some("(id > 5)"),
false,
false,
)
.unwrap();
assert_eq!(
sql,
r#"COPY (SELECT "id" FROM ONLY "public"."users" WHERE ((id > 5))) TO STDOUT"#
);
}
#[test]
fn copy_sql_omits_only_for_a_partitioned_table_with_a_row_filter() {
let sql = build_copy_sql(
&relation("public", "orders", &["id"]),
Some("(id > 5)"),
true,
false,
)
.unwrap();
assert_eq!(
sql,
r#"COPY (SELECT "id" FROM "public"."orders" WHERE ((id > 5))) TO STDOUT"#
);
}
#[test]
fn copy_sql_uses_a_subselect_for_a_partitioned_table() {
let sql = build_copy_sql(
&relation("public", "orders", &["id", "name"]),
None,
true,
false,
)
.unwrap();
assert_eq!(
sql,
r#"COPY (SELECT "id", "name" FROM "public"."orders") TO STDOUT"#
);
assert!(
!sql.starts_with(r#"COPY "public"."orders" TO"#),
"the plain form is rejected by PostgreSQL for partitioned tables"
);
}
#[test]
fn copy_sql_uses_a_subselect_for_a_generated_column() {
let sql = build_copy_sql(
&relation("public", "gen", &["id", "a", "g"]),
None,
false,
true,
)
.unwrap();
assert_eq!(
sql,
r#"COPY (SELECT "id", "a", "g" FROM ONLY "public"."gen") TO STDOUT"#
);
}
#[test]
fn copy_sql_combines_generated_with_partitioned_and_filtered() {
let sql = build_copy_sql(
&relation("public", "gen", &["id", "g"]),
Some("(id > 5)"),
true,
true,
)
.unwrap();
assert_eq!(
sql,
r#"COPY (SELECT "id", "g" FROM "public"."gen" WHERE ((id > 5))) TO STDOUT"#
);
}
#[test]
fn a_generated_column_row_produces_a_subselect_copy() {
let mut plain = row("public", "gen", "id");
plain.generated = false;
let mut gen = row("public", "gen", "g");
gen.generated = true;
let tables = tables_from_catalog_rows(&[plain, gen]).unwrap();
assert_eq!(
tables[0].copy_sql,
r#"COPY (SELECT "id", "g" FROM ONLY "public"."gen") TO STDOUT"#,
);
assert_eq!(
tables[0].relation.columns.len(),
2,
"the generated column must stay in the relation, or the snapshot's \
row shape disagrees with pgoutput's"
);
}
#[test]
fn partitioned_rows_produce_a_subselect_copy() {
let mut r = row("public", "orders", "id");
r.relkind = b'p';
let tables = tables_from_catalog_rows(&[r]).unwrap();
assert!(
tables[0].copy_sql.contains("SELECT"),
"{}",
tables[0].copy_sql
);
}
#[test]
fn copy_sql_quotes_awkward_identifiers() {
let sql = build_copy_sql(
&relation("My Schema", "we\"ird", &["Mixed Case"]),
None,
false,
false,
)
.unwrap();
assert!(sql.contains(r#""My Schema"."we""ird""#), "{sql}");
}
#[test]
fn groups_rows_into_one_table_each() {
let rows = vec![
row("public", "a", "id"),
row("public", "a", "name"),
row("public", "b", "id"),
];
let tables = tables_from_catalog_rows(&rows).unwrap();
assert_eq!(tables.len(), 2);
assert_eq!(tables[0].qualified_name(), "public.a");
assert_eq!(tables[0].relation.columns.len(), 2);
assert_eq!(tables[1].qualified_name(), "public.b");
}
#[test]
fn preserves_column_order_from_the_query() {
let rows = vec![
row("public", "t", "z"),
row("public", "t", "a"),
row("public", "t", "m"),
];
let tables = tables_from_catalog_rows(&rows).unwrap();
let names: Vec<&str> = tables[0]
.relation
.columns
.iter()
.map(|c| &*c.name)
.collect();
assert_eq!(names, vec!["z", "a", "m"]);
}
#[test]
fn collapses_duplicate_columns_from_two_publications() {
let rows = vec![
row("public", "t", "id"),
row("public", "t", "id"),
row("public", "t", "name"),
];
let tables = tables_from_catalog_rows(&rows).unwrap();
assert_eq!(tables[0].relation.columns.len(), 2);
}
#[test]
fn rejects_conflicting_row_filters() {
let mut a = row("public", "t", "id");
a.row_filter = Some("(id > 1)".to_string());
let mut b = row("public", "t", "name");
b.row_filter = Some("(id > 2)".to_string());
let err = tables_from_catalog_rows(&[a, b]).unwrap_err();
assert!(
format!("{err}").contains("conflicting row filters"),
"{err}"
);
}
#[test]
fn an_unfiltered_publication_wins_over_a_filtered_one() {
let mut filtered = row("public", "t", "id");
filtered.row_filter = Some("(id > 5)".to_string());
let unfiltered = row("public", "t", "id");
let tables = tables_from_catalog_rows(&[filtered, unfiltered]).unwrap();
assert_eq!(
tables[0].copy_sql, r#"COPY "public"."t" ("id") TO STDOUT"#,
"an unfiltered publication makes the whole table unfiltered"
);
}
#[test]
fn a_shared_row_filter_is_applied() {
let mut a = row("public", "t", "id");
a.row_filter = Some("(id > 1)".to_string());
let tables = tables_from_catalog_rows(&[a]).unwrap();
assert!(
tables[0].copy_sql.contains("WHERE ((id > 1))"),
"{}",
tables[0].copy_sql
);
}
#[test]
fn a_column_list_is_reflected_in_the_copy_statement() {
let a = row("public", "t", "id");
let tables = tables_from_catalog_rows(&[a]).unwrap();
assert!(
tables[0].copy_sql.contains(r#"("id")"#),
"{}",
tables[0].copy_sql
);
}
#[test]
fn empty_input_yields_no_tables() {
assert!(tables_from_catalog_rows(&[]).unwrap().is_empty());
}
#[test]
fn carries_replica_identity_and_types_through() {
let mut r = row("public", "t", "id");
r.replica_identity = b'f';
r.type_id = 23;
r.type_modifier = 4;
let tables = tables_from_catalog_rows(&[r]).unwrap();
assert_eq!(tables[0].relation.replica_identity, b'f');
assert_eq!(tables[0].relation.columns[0].type_id, 23);
assert_eq!(tables[0].relation.columns[0].type_modifier, 4);
}
#[test]
fn a_shared_prefix_or_a_shared_name_does_not_merge_two_tables() {
let tables = tables_from_catalog_rows(&[
row("a", "t", "id"),
row("b", "t", "id"),
row("b", "users", "id"),
row("b", "users_archive", "id"),
row("b", "users_archive", "archived_at"),
])
.unwrap();
let names: Vec<String> = tables.iter().map(|t| t.qualified_name()).collect();
assert_eq!(names, vec!["a.t", "b.t", "b.users", "b.users_archive"]);
assert_eq!(
tables[2].relation.columns.len(),
1,
"users must not absorb users_archive's columns"
);
assert_eq!(tables[3].relation.columns.len(), 2);
}
#[test]
fn relation_reports_the_metadata_of_its_own_table() {
let mut second = row("public", "b", "id");
second.type_id = 23;
let tables = tables_from_catalog_rows(&[row("public", "a", "id"), second]).unwrap();
assert_eq!(&*tables[0].relation().relation_name, "a");
assert_eq!(tables[0].relation().columns[0].type_id, 25);
assert_eq!(&*tables[1].relation().relation_name, "b");
assert_eq!(&*tables[1].relation().namespace, "public");
assert_eq!(tables[1].relation().columns[0].type_id, 23);
}
#[test]
fn reader_conninfo_uri_without_query() {
assert_eq!(
derive_reader_conninfo("postgresql://u@h/db"),
"postgresql://u@h/db?replication=false"
);
}
#[test]
fn reader_conninfo_uri_with_query_appends() {
assert_eq!(
derive_reader_conninfo("postgres://u@h/db?replication=database&sslmode=require"),
"postgres://u@h/db?replication=database&sslmode=require&replication=false"
);
}
#[test]
fn reader_conninfo_uri_with_bare_question_mark() {
assert_eq!(
derive_reader_conninfo("postgresql://u@h/db?"),
"postgresql://u@h/db?replication=false"
);
}
#[test]
fn reader_conninfo_keyword_value_form() {
assert_eq!(
derive_reader_conninfo("host=h dbname=db replication=database"),
"host=h dbname=db replication=database replication=false"
);
}
#[test]
fn reader_conninfo_tolerates_leading_whitespace() {
assert_eq!(
derive_reader_conninfo(" postgresql://u@h/db"),
" postgresql://u@h/db?replication=false"
);
}
#[cfg(feature = "rustls-tls")]
#[test]
fn reader_conninfo_parses_to_replication_none() {
use crate::connection::native::conninfo::{ConnInfo, ReplicationMode};
for base in [
"postgresql://u:p@h:5432/db?replication=database",
"host=h port=5432 dbname=db replication=database",
] {
let derived = derive_reader_conninfo(base);
let parsed = ConnInfo::parse(&derived).unwrap();
assert_eq!(
parsed.replication,
ReplicationMode::None,
"last occurrence must win for {derived}"
);
}
}
}