sync.rsannotatedsync.rssource291 lines · 10.6 KB · raw
1//! The plan that brings a sidecar's foreign tables in line with the
2//! target's catalog, one change at a time. Unchanged tables are never
3//! touched, and nothing is dropped and imported again, so the sidecar's
4//! views, grants and functions over a foreign table survive a sync.
5//!
6//! A column is matched by its remote name (postgres_fdw's `column_name`
7//! option, else the local name). A rename is recognised when exactly one
8//! column disappeared and one appeared at the same position with the
9//! same type and nullability; anything else is a drop and an add, which
10//! the dependency check refuses when a view uses the dropped column.
11
12use std::fmt;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Column {
16    /// The name on the target (the `column_name` option locally).
17    pub name: String,
18    /// `format_type` of the column, as the target reports it.
19    pub type_sql: String,
20    pub not_null: bool,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Table {
25    pub schema: String,
26    pub name: String,
27    /// In attnum order, dropped columns left out.
28    pub columns: Vec<Column>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Change {
33    CreateTable { table: Table },
34    DropTable { schema: String, name: String },
35    AddColumn { schema: String, table: String, column: Column },
36    DropColumn { schema: String, table: String, column: String },
37    RenameColumn { schema: String, table: String, from: String, to: String },
38    AlterType { schema: String, table: String, column: String, type_sql: String },
39    SetNotNull { schema: String, table: String, column: String, not_null: bool },
40}
41
42/// What a change can break: the local object whose dependents
43/// (views, grants on columns, functions) must be checked in pg_depend
44/// before it is applied. `None` for changes nothing can depend on.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Touches<'a> {
47    pub schema: &'a str,
48    pub table: &'a str,
49    /// `None` for the whole table.
50    pub column: Option<&'a str>,
51}
52
53impl Change {
54    pub fn touches(&self) -> Option<Touches<'_>> {
55        match self {
56            Change::DropTable { schema, name } => Some(Touches { schema, table: name, column: None }),
57            Change::DropColumn { schema, table, column }
58            | Change::AlterType { schema, table, column, .. } => {
59                Some(Touches { schema, table, column: Some(column) })
60            }
61            _ => None,
62        }
63    }
64
65    /// The statement that applies the change on the sidecar.
66    pub fn sql(&self, server: &str) -> String {
67        match self {
68            Change::CreateTable { table } => {
69                let cols: Vec<String> = table
70                    .columns
71                    .iter()
72                    .map(|c| {
73                        format!(
74                            "{} {} OPTIONS (column_name {}){}",
75                            ident(&c.name),
76                            c.type_sql,
77                            lit(&c.name),
78                            if c.not_null { " NOT NULL" } else { "" }
79                        )
80                    })
81                    .collect();
82                format!(
83                    "CREATE FOREIGN TABLE {}.{} ({}) SERVER {} OPTIONS (schema_name {}, table_name {})",
84                    ident(&table.schema),
85                    ident(&table.name),
86                    cols.join(", "),
87                    ident(server),
88                    lit(&table.schema),
89                    lit(&table.name)
90                )
91            }
92            Change::DropTable { schema, name } => {
93                format!("DROP FOREIGN TABLE {}.{}", ident(schema), ident(name))
94            }
95            Change::AddColumn { schema, table, column } => format!(
96                "ALTER FOREIGN TABLE {}.{} ADD COLUMN {} {} OPTIONS (column_name {}){}",
97                ident(schema),
98                ident(table),
99                ident(&column.name),
100                column.type_sql,
101                lit(&column.name),
102                if column.not_null { " NOT NULL" } else { "" }
103            ),
104            Change::DropColumn { schema, table, column } => format!(
105                "ALTER FOREIGN TABLE {}.{} DROP COLUMN {}",
106                ident(schema),
107                ident(table),
108                ident(column)
109            ),
110            Change::RenameColumn { schema, table, from, to } => format!(
111                "ALTER FOREIGN TABLE {s}.{t} RENAME COLUMN {f} TO {n}; \
112                 ALTER FOREIGN TABLE {s}.{t} ALTER COLUMN {n} OPTIONS (SET column_name {l})",
113                s = ident(schema),
114                t = ident(table),
115                f = ident(from),
116                n = ident(to),
117                l = lit(to)
118            ),
119            Change::AlterType { schema, table, column, type_sql } => format!(
120                "ALTER FOREIGN TABLE {}.{} ALTER COLUMN {} TYPE {}",
121                ident(schema),
122                ident(table),
123                ident(column),
124                type_sql
125            ),
126            Change::SetNotNull { schema, table, column, not_null } => format!(
127                "ALTER FOREIGN TABLE {}.{} ALTER COLUMN {} {} NOT NULL",
128                ident(schema),
129                ident(table),
130                ident(column),
131                if *not_null { "SET" } else { "DROP" }
132            ),
133        }
134    }
135}
136
137impl fmt::Display for Change {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        f.write_str(&self.sql("<server>"))
140    }
141}
142
143/// The changes that make `local` (the sidecar's foreign tables in the
144/// imported schemas) match `target` (the target's tables in the same
145/// schemas). Tables are compared by schema and name; order follows
146/// `target`, then drops in `local`'s order.
147pub fn plan(target: &[Table], local: &[Table]) -> Vec<Change> {
148    let find = |set: &'_ [Table], t: &Table| -> Option<usize> {
149        set.iter().position(|o| o.schema == t.schema && o.name == t.name)
150    };
151    let mut changes = Vec::new();
152    for t in target {
153        match find(local, t) {
154            None => changes.push(Change::CreateTable { table: t.clone() }),
155            Some(i) => columns(t, &local[i], &mut changes),
156        }
157    }
158    for l in local {
159        if find(target, l).is_none() {
160            changes.push(Change::DropTable { schema: l.schema.clone(), name: l.name.clone() });
161        }
162    }
163    changes
164}
165
166fn columns(target: &Table, local: &Table, out: &mut Vec<Change>) {
167    let (schema, table) = (target.schema.clone(), target.name.clone());
168    let has = |cols: &[Column], name: &str| cols.iter().any(|c| c.name == name);
169    let gone: Vec<(usize, &Column)> =
170        local.columns.iter().enumerate().filter(|(_, c)| !has(&target.columns, &c.name)).collect();
171    let new: Vec<(usize, &Column)> =
172        target.columns.iter().enumerate().filter(|(_, c)| !has(&local.columns, &c.name)).collect();
173
174    let renamed = match (gone.as_slice(), new.as_slice()) {
175        ([(i, g)], [(j, n)]) if i == j && g.type_sql == n.type_sql && g.not_null == n.not_null => {
176            out.push(Change::RenameColumn {
177                schema: schema.clone(),
178                table: table.clone(),
179                from: g.name.clone(),
180                to: n.name.clone(),
181            });
182            true
183        }
184        _ => false,
185    };
186    if !renamed {
187        for (_, g) in &gone {
188            out.push(Change::DropColumn {
189                schema: schema.clone(),
190                table: table.clone(),
191                column: g.name.clone(),
192            });
193        }
194    }
195    for t in &target.columns {
196        let Some(l) = local.columns.iter().find(|l| l.name == t.name) else {
197            if !renamed {
198                out.push(Change::AddColumn {
199                    schema: schema.clone(),
200                    table: table.clone(),
201                    column: t.clone(),
202                });
203            }
204            continue;
205        };
206        if l.type_sql != t.type_sql {
207            out.push(Change::AlterType {
208                schema: schema.clone(),
209                table: table.clone(),
210                column: t.name.clone(),
211                type_sql: t.type_sql.clone(),
212            });
213        }
214        if l.not_null != t.not_null {
215            out.push(Change::SetNotNull {
216                schema: schema.clone(),
217                table: table.clone(),
218                column: t.name.clone(),
219                not_null: t.not_null,
220            });
221        }
222    }
223}
224
225pub fn ident(s: &str) -> String {
226    format!("\"{}\"", s.replace('"', "\"\""))
227}
228
229pub fn lit(s: &str) -> String {
230    format!("'{}'", s.replace('\'', "''"))
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    fn col(name: &str, ty: &str) -> Column {
238        Column { name: name.into(), type_sql: ty.into(), not_null: false }
239    }
240    fn table(name: &str, cols: &[Column]) -> Table {
241        Table { schema: "public".into(), name: name.into(), columns: cols.to_vec() }
242    }
243
244    #[test]
245    fn unchanged_tables_plan_nothing() {
246        let t = [table("a", &[col("id", "integer")])];
247        assert!(plan(&t, &t).is_empty());
248    }
249
250    #[test]
251    fn new_table_new_column_dropped_column_and_dropped_table() {
252        let local = [
253            table("people", &[col("id", "integer"), col("old", "text"), col("name", "text")]),
254            table("gone", &[col("id", "integer")]),
255            table("same", &[col("id", "integer")]),
256        ];
257        let target = [
258            table("people", &[col("id", "integer"), col("name", "text"), col("email", "text")]),
259            table("same", &[col("id", "integer")]),
260            table("fresh", &[col("id", "bigint")]),
261        ];
262        let p = plan(&target, &local);
263        assert_eq!(
264            p,
265            vec![
266                Change::DropColumn { schema: "public".into(), table: "people".into(), column: "old".into() },
267                Change::AddColumn { schema: "public".into(), table: "people".into(), column: col("email", "text") },
268                Change::CreateTable { table: target[2].clone() },
269                Change::DropTable { schema: "public".into(), name: "gone".into() },
270            ]
271        );
272        assert!(p.iter().all(|c| !c.sql("s").contains("\"same\"")));
273    }
274
275    #[test]
276    fn rename_in_place_and_type_change() {
277        let local = [table("t", &[col("id", "integer"), col("nm", "text")])];
278        let target = [table("t", &[col("id", "bigint"), col("name", "text")])];
279        let p = plan(&target, &local);
280        assert!(matches!(&p[0], Change::RenameColumn { from, to, .. } if from == "nm" && to == "name"));
281        assert!(matches!(&p[1], Change::AlterType { column, .. } if column == "id"));
282        assert_eq!(p[0].touches(), None);
283        assert!(p[1].touches().is_some());
284    }
285
286    #[test]
287    fn quoting() {
288        assert_eq!(ident("a\"b"), "\"a\"\"b\"");
289        assert_eq!(lit("o'k"), "'o''k'");
290    }
291}