use crate::postgres::ddl::PostgresEntity;
use crate::postgres::grammar::{extract_nextval_sequence, is_serial_expression};
use crate::snapshot::{Snapshot, SnapshotEntity};
use crate::version::POSTGRES_SNAPSHOT_VERSION;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
impl SnapshotEntity for PostgresEntity {
const DIALECT: &'static str = "postgresql";
const SNAPSHOT_VERSION: &'static str = POSTGRES_SNAPSHOT_VERSION;
}
pub type PostgresSnapshot = Snapshot<PostgresEntity>;
impl Snapshot<PostgresEntity> {
#[must_use]
pub fn scoped_to_tables(&self, tables: &HashSet<(String, String)>) -> Self {
let schemas: HashSet<&str> = tables.iter().map(|(s, _)| s.as_str()).collect();
let mut scoped = Self::new();
for entity in &self.ddl {
match entity {
PostgresEntity::Schema(s) => {
if schemas.contains(s.name.as_ref()) {
scoped.ddl.push(entity.clone());
}
}
PostgresEntity::Table(t) => {
if tables.contains(&(t.schema.to_string(), t.name.to_string())) {
scoped.ddl.push(entity.clone());
}
}
PostgresEntity::Column(c) => {
if tables.contains(&(c.schema.to_string(), c.table.to_string())) {
scoped.ddl.push(entity.clone());
}
}
PostgresEntity::Index(i) => {
if tables.contains(&(i.schema.to_string(), i.table.to_string())) {
scoped.ddl.push(entity.clone());
}
}
PostgresEntity::ForeignKey(f) => {
if tables.contains(&(f.schema.to_string(), f.table.to_string())) {
scoped.ddl.push(entity.clone());
}
}
PostgresEntity::PrimaryKey(p) => {
if tables.contains(&(p.schema.to_string(), p.table.to_string())) {
scoped.ddl.push(entity.clone());
}
}
PostgresEntity::UniqueConstraint(u) => {
if tables.contains(&(u.schema.to_string(), u.table.to_string())) {
scoped.ddl.push(entity.clone());
}
}
PostgresEntity::CheckConstraint(c) => {
if tables.contains(&(c.schema.to_string(), c.table.to_string())) {
scoped.ddl.push(entity.clone());
}
}
PostgresEntity::Policy(p) => {
if tables.contains(&(p.schema.to_string(), p.table.to_string())) {
scoped.ddl.push(entity.clone());
}
}
PostgresEntity::Sequence(s) => {
if schemas.contains(s.schema.as_ref()) {
scoped.ddl.push(entity.clone());
}
}
PostgresEntity::Enum(e) => {
if schemas.contains(e.schema.as_ref()) {
scoped.ddl.push(entity.clone());
}
}
PostgresEntity::View(v) => {
if schemas.contains(v.schema.as_ref()) {
scoped.ddl.push(entity.clone());
}
}
_ => scoped.ddl.push(entity.clone()),
}
}
scoped
}
pub fn filter_serial_sequences(&mut self) {
self.filter_serial_sequences_except(&HashSet::new());
}
pub fn filter_serial_sequences_except(&mut self, keep: &HashSet<(String, String)>) {
let serial_seqs: HashSet<(String, String)> = self
.ddl
.iter()
.filter_map(|e| {
if let PostgresEntity::Column(c) = e {
let default = c.default.as_deref()?;
if is_serial_expression(default, &c.schema) {
let name = extract_nextval_sequence(default)?;
return Some((c.schema.to_string(), name));
}
}
None
})
.collect();
if !serial_seqs.is_empty() {
self.ddl.retain(|e| {
if let PostgresEntity::Sequence(s) = e {
let key = (s.schema.to_string(), s.name.to_string());
keep.contains(&key) || !serial_seqs.contains(&key)
} else {
true
}
});
}
}
pub fn normalize_columns_for_push(&mut self) {
let standalone_seqs = self.sequence_names();
for entity in &mut self.ddl {
if let PostgresEntity::Column(c) = entity {
c.ordinal_position = None;
if c.type_schema.as_deref() == Some("pg_catalog") {
c.type_schema = None;
}
if let Some(ref default) = c.default
&& is_serial_expression(default, &c.schema)
&& !extract_nextval_sequence(default)
.is_some_and(|seq| standalone_seqs.contains(&(c.schema.to_string(), seq)))
{
let serial_type = match c.sql_type.as_ref() {
"int4" | "integer" => Some("SERIAL"),
"int8" | "bigint" => Some("BIGSERIAL"),
"int2" | "smallint" => Some("SMALLSERIAL"),
_ => None,
};
if let Some(st) = serial_type {
c.sql_type = st.to_string().into();
c.default = None;
}
}
}
}
}
#[must_use]
pub fn sequence_names(&self) -> HashSet<(String, String)> {
self.ddl
.iter()
.filter_map(|e| {
if let PostgresEntity::Sequence(s) = e {
Some((s.schema.to_string(), s.name.to_string()))
} else {
None
}
})
.collect()
}
pub fn retain_sequences(&mut self, managed: &HashSet<(String, String)>) {
self.ddl.retain(|e| {
if let PostgresEntity::Sequence(s) = e {
managed.contains(&(s.schema.to_string(), s.name.to_string()))
} else {
true
}
});
}
#[must_use]
pub fn table_names(&self) -> HashSet<(String, String)> {
let mut tables = HashSet::new();
for entity in &self.ddl {
if let PostgresEntity::Table(t) = entity {
tables.insert((t.schema.to_string(), t.name.to_string()));
}
}
tables
}
#[must_use]
pub fn schema_names(&self) -> Vec<String> {
let mut names: Vec<String> = self
.table_names()
.into_iter()
.map(|(s, _)| s)
.collect::<HashSet<_>>()
.into_iter()
.collect();
names.sort();
names
}
#[must_use]
pub fn prepare_for_push(&self, desired: &Self) -> Self {
let tables = desired.table_names();
let managed = desired.sequence_names();
let mut scoped = self.scoped_to_tables(&tables);
scoped.filter_serial_sequences_except(&managed);
scoped.normalize_columns_for_push();
scoped.retain_sequences(&managed);
scoped
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct Meta {
#[serde(default)]
pub schemas: HashMap<String, String>,
#[serde(default)]
pub tables: HashMap<String, String>,
#[serde(default)]
pub columns: HashMap<String, String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct SnapshotV7 {
pub version: String,
pub dialect: String,
pub id: String,
pub prev_id: String,
pub tables: HashMap<String, serde_json::Value>,
pub enums: HashMap<String, serde_json::Value>,
pub schemas: HashMap<String, serde_json::Value>,
pub sequences: HashMap<String, serde_json::Value>,
#[serde(default)]
pub views: HashMap<String, serde_json::Value>,
#[serde(rename = "_meta")]
pub meta: Meta,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::postgres::ddl::{Column, Schema, Sequence, Table};
use crate::version::ORIGIN_UUID;
fn make_table(schema: &str, name: &str) -> PostgresEntity {
PostgresEntity::Table(Table {
schema: schema.to_string().into(),
name: name.to_string().into(),
is_unlogged: None,
is_temporary: None,
inherits: None,
tablespace: None,
is_rls_enabled: None,
comment: None,
})
}
fn make_column(schema: &str, table: &str, name: &str, sql_type: &str) -> Column {
Column::new(
schema.to_string(),
table.to_string(),
name.to_string(),
sql_type.to_string(),
)
}
fn make_sequence(schema: &str, name: &str) -> PostgresEntity {
PostgresEntity::Sequence(Sequence {
schema: schema.to_string().into(),
name: name.to_string().into(),
increment_by: None,
min_value: None,
max_value: None,
start_with: None,
cache_size: None,
cycle: None,
})
}
#[test]
fn test_new_snapshot() {
let snapshot = PostgresSnapshot::new();
assert_eq!(snapshot.version, "8");
assert_eq!(snapshot.dialect, "postgresql");
assert_eq!(snapshot.prev_ids, vec![ORIGIN_UUID]);
assert!(snapshot.ddl.is_empty());
assert!(snapshot.renames.is_empty());
}
#[test]
fn test_add_entity() {
let mut snapshot = PostgresSnapshot::new();
let schema = Schema::new("public");
snapshot.add_entity(PostgresEntity::Schema(schema));
let table = Table {
schema: "public".into(),
name: "users".into(),
is_unlogged: None,
is_temporary: None,
inherits: None,
tablespace: None,
is_rls_enabled: None,
comment: None,
};
snapshot.add_entity(PostgresEntity::Table(table));
assert_eq!(snapshot.ddl.len(), 2);
}
#[test]
fn test_schema_names() {
let mut snap = PostgresSnapshot::new();
snap.add_entity(make_table("public", "users"));
snap.add_entity(make_table("auth", "sessions"));
snap.add_entity(make_table("public", "posts"));
let names = snap.schema_names();
assert_eq!(names, vec!["auth", "public"]);
}
#[test]
fn test_schema_names_empty() {
let snap = PostgresSnapshot::new();
assert!(snap.schema_names().is_empty());
}
#[test]
fn test_filter_serial_sequences() {
let mut snap = PostgresSnapshot::new();
snap.add_entity(make_table("public", "users"));
let mut col = make_column("public", "users", "id", "int4");
col.default = Some("nextval('users_id_seq'::regclass)".into());
snap.add_entity(PostgresEntity::Column(col));
snap.add_entity(make_sequence("public", "users_id_seq"));
snap.add_entity(make_sequence("public", "custom_seq"));
snap.filter_serial_sequences();
let seq_names: Vec<&str> = snap
.ddl
.iter()
.filter_map(|e| {
if let PostgresEntity::Sequence(s) = e {
Some(s.name.as_ref())
} else {
None
}
})
.collect();
assert_eq!(seq_names, vec!["custom_seq"]);
}
#[test]
fn test_normalize_columns_for_push() {
let mut snap = PostgresSnapshot::new();
let mut col = make_column("public", "users", "id", "int4");
col.default = Some("nextval('users_id_seq'::regclass)".into());
col.ordinal_position = Some(1);
col.type_schema = Some("pg_catalog".into());
snap.add_entity(PostgresEntity::Column(col));
let mut col2 = make_column("public", "users", "big_id", "bigint");
col2.default = Some("nextval('users_big_id_seq'::regclass)".into());
snap.add_entity(PostgresEntity::Column(col2));
let mut col3 = make_column("public", "users", "name", "text");
col3.ordinal_position = Some(3);
snap.add_entity(PostgresEntity::Column(col3));
snap.normalize_columns_for_push();
let columns: Vec<&Column> = snap
.ddl
.iter()
.filter_map(|e| {
if let PostgresEntity::Column(c) = e {
Some(c)
} else {
None
}
})
.collect();
assert_eq!(columns[0].sql_type.as_ref(), "SERIAL");
assert!(columns[0].default.is_none());
assert!(columns[0].ordinal_position.is_none());
assert!(columns[0].type_schema.is_none());
assert_eq!(columns[1].sql_type.as_ref(), "BIGSERIAL");
assert!(columns[1].default.is_none());
assert_eq!(columns[2].sql_type.as_ref(), "text");
assert!(columns[2].ordinal_position.is_none());
}
#[test]
fn test_prepare_for_push() {
let mut live = PostgresSnapshot::new();
live.add_entity(PostgresEntity::Schema(Schema::new("public")));
live.add_entity(make_table("public", "users"));
live.add_entity(make_table("public", "unmanaged"));
let mut col = make_column("public", "users", "id", "int4");
col.default = Some("nextval('users_id_seq'::regclass)".into());
col.ordinal_position = Some(1);
col.type_schema = Some("pg_catalog".into());
live.add_entity(PostgresEntity::Column(col));
live.add_entity(make_sequence("public", "users_id_seq"));
let mut desired = PostgresSnapshot::new();
desired.add_entity(make_table("public", "users"));
let result = live.prepare_for_push(&desired);
let table_names: Vec<&str> = result
.ddl
.iter()
.filter_map(|e| {
if let PostgresEntity::Table(t) = e {
Some(t.name.as_ref())
} else {
None
}
})
.collect();
assert_eq!(table_names, vec!["users"]);
let seq_count = result
.ddl
.iter()
.filter(|e| matches!(e, PostgresEntity::Sequence(_)))
.count();
assert_eq!(seq_count, 0);
let col = result
.ddl
.iter()
.find_map(|e| {
if let PostgresEntity::Column(c) = e {
Some(c)
} else {
None
}
})
.unwrap();
assert_eq!(col.sql_type.as_ref(), "SERIAL");
assert!(col.default.is_none());
assert!(col.ordinal_position.is_none());
assert!(col.type_schema.is_none());
}
#[test]
fn test_prepare_for_push_keeps_declared_hand_managed_sequence() {
let mut live = PostgresSnapshot::new();
live.add_entity(PostgresEntity::Schema(Schema::new("public")));
live.add_entity(make_table("public", "invoices"));
let mut col = make_column("public", "invoices", "number", "int8");
col.default = Some("nextval('invoices_number_seq'::regclass)".into());
live.add_entity(PostgresEntity::Column(col));
live.add_entity(make_sequence("public", "invoices_number_seq"));
let mut desired = PostgresSnapshot::new();
desired.add_entity(make_table("public", "invoices"));
desired.add_entity(make_sequence("public", "invoices_number_seq"));
let result = live.prepare_for_push(&desired);
let seq_count = result
.ddl
.iter()
.filter(|e| matches!(e, PostgresEntity::Sequence(_)))
.count();
assert_eq!(seq_count, 1, "declared hand-managed sequence must survive");
let col = result
.ddl
.iter()
.find_map(|e| {
if let PostgresEntity::Column(c) = e {
Some(c)
} else {
None
}
})
.unwrap();
assert_eq!(
col.sql_type.as_ref(),
"int8",
"column on a hand-managed sequence must not become BIGSERIAL"
);
assert!(
col.default.is_some(),
"explicit nextval default must remain"
);
}
#[test]
fn test_prepare_for_push_drops_unmanaged_standalone_sequence() {
let mut live = PostgresSnapshot::new();
live.add_entity(PostgresEntity::Schema(Schema::new("public")));
live.add_entity(make_table("public", "users"));
live.add_entity(make_sequence("public", "audit_seq"));
let mut desired = PostgresSnapshot::new();
desired.add_entity(make_table("public", "users"));
let result = live.prepare_for_push(&desired);
let seq_count = result
.ddl
.iter()
.filter(|e| matches!(e, PostgresEntity::Sequence(_)))
.count();
assert_eq!(
seq_count, 0,
"unmanaged sequence must not enter the diff at all"
);
}
#[test]
fn test_scoped_to_tables_keeps_relevant_entities() {
let mut snap = PostgresSnapshot::new();
snap.add_entity(PostgresEntity::Schema(Schema::new("public")));
snap.add_entity(PostgresEntity::Schema(Schema::new("other")));
snap.add_entity(make_table("public", "users"));
snap.add_entity(make_table("other", "logs"));
snap.add_entity(make_sequence("public", "my_seq"));
snap.add_entity(make_sequence("other", "other_seq"));
let tables: HashSet<(String, String)> =
[("public".to_string(), "users".to_string())].into();
let scoped = snap.scoped_to_tables(&tables);
let schemas: Vec<&str> = scoped
.ddl
.iter()
.filter_map(|e| {
if let PostgresEntity::Schema(s) = e {
Some(s.name.as_ref())
} else {
None
}
})
.collect();
assert_eq!(schemas, vec!["public"]);
let tables: Vec<&str> = scoped
.ddl
.iter()
.filter_map(|e| {
if let PostgresEntity::Table(t) = e {
Some(t.name.as_ref())
} else {
None
}
})
.collect();
assert_eq!(tables, vec!["users"]);
let seqs: Vec<&str> = scoped
.ddl
.iter()
.filter_map(|e| {
if let PostgresEntity::Sequence(s) = e {
Some(s.name.as_ref())
} else {
None
}
})
.collect();
assert_eq!(seqs, vec!["my_seq"]);
}
}