Skip to main content

mitosis/
mask.rs

1//! Masking files (`template --mask`): which columns to replace and which rows to keep, and the SQL
2//! that does it. Every masking strategy is idempotent, so a layered build can mask again in place.
3
4use crate::error::{ErrorCode, MitosisError, Result};
5use serde::Deserialize;
6use std::collections::BTreeMap;
7use std::path::Path;
8
9/// How one column is replaced.
10#[derive(Clone, Debug, PartialEq)]
11pub enum Strategy {
12    Email,
13    Name,
14    Hash,
15    Null,
16    Redact,
17    Const(String),
18    Keep,
19}
20
21/// Which rows of a table a subset keeps.
22#[derive(Clone, Debug, PartialEq)]
23pub enum SubsetRule {
24    /// A SQL condition; rows for which it is false are deleted.
25    Where(String),
26    /// A percent (0 < p <= 100) of the table's pages, by `TABLESAMPLE SYSTEM`.
27    Sample(f64),
28}
29
30/// A parsed masking file. Table keys are `table` (schema `public`) or `schema.table`.
31#[derive(Clone, Debug, Default, PartialEq)]
32pub struct MaskFile {
33    pub tables: BTreeMap<String, BTreeMap<String, Strategy>>,
34    pub subset: BTreeMap<String, SubsetRule>,
35}
36
37/// One column of a user table, from the catalog.
38#[derive(Clone, Debug, Deserialize)]
39pub struct ColumnInfo {
40    pub schema: String,
41    pub table: String,
42    pub column: String,
43    /// `format_type` output, like `character varying(40)`.
44    pub data_type: String,
45    pub not_null: bool,
46}
47
48/// One foreign key, from the catalog: tables as quoted, schema-qualified SQL (`"s"."t"`),
49/// columns as quoted identifiers, in key order.
50#[derive(Clone, Debug, Deserialize)]
51pub struct ForeignKey {
52    pub child: String,
53    pub child_cols: Vec<String>,
54    pub parent: String,
55    pub parent_cols: Vec<String>,
56}
57
58/// The temp table that holds one sampled table's row ids while its other rows are deleted.
59const SAMPLE_TABLE: &str = "mitosis_sample";
60
61/// Foreign-key cleanup passes after a subset before the build gives up.
62const MAX_PASSES: u32 = 100;
63
64fn invalid(msg: impl Into<String>) -> MitosisError {
65    MitosisError::new(ErrorCode::InvalidRequest, msg)
66}
67
68fn parse_strategy(s: &str) -> Option<Strategy> {
69    Some(match s {
70        "email" => Strategy::Email,
71        "name" => Strategy::Name,
72        "hash" => Strategy::Hash,
73        "null" => Strategy::Null,
74        "redact" => Strategy::Redact,
75        "keep" => Strategy::Keep,
76        _ => Strategy::Const(s.strip_prefix("const:")?.to_string()),
77    })
78}
79
80fn parse_subset(key: &str, s: &str) -> Result<SubsetRule> {
81    let t = s.trim();
82    if let Some(p) = t
83        .strip_suffix('%')
84        .and_then(|p| p.trim().parse::<f64>().ok())
85    {
86        if p.is_nan() || p <= 0.0 || p > 100.0 {
87            return Err(invalid(format!(
88                "subset.{key}: the sample percent must be above 0 and at most 100, not {p}"
89            )));
90        }
91        return Ok(SubsetRule::Sample(p));
92    }
93    if t.is_empty() {
94        return Err(invalid(format!(
95            "subset.{key} needs a filter (a SQL condition) or a percent like \"10%\""
96        )));
97    }
98    Ok(SubsetRule::Where(t.to_string()))
99}
100
101impl MaskFile {
102    /// Parses masking-file TOML. Errors name the line (syntax) or the key (meaning).
103    pub fn parse(text: &str) -> Result<MaskFile> {
104        let root: toml::Table = toml::from_str(text).map_err(|e| invalid(e.to_string()))?;
105        let mut m = MaskFile::default();
106        for (key, value) in root {
107            match key.as_str() {
108                "tables" => {
109                    let tables = value
110                        .as_table()
111                        .ok_or_else(|| invalid("tables must be a table, like [tables.users]"))?;
112                    for (t, cols) in tables {
113                        let cols = cols.as_table().ok_or_else(|| {
114                            invalid(format!(
115                                "tables.{t} must be a table of column = \"strategy\""
116                            ))
117                        })?;
118                        let mut out = BTreeMap::new();
119                        for (c, s) in cols {
120                            let s = s.as_str().ok_or_else(|| {
121                                if s.is_table() {
122                                    invalid(format!(
123                                        "tables.{t}.{c} must be a string; for a schema-qualified \
124                                         table, quote the key: [tables.\"{t}.{c}\"]"
125                                    ))
126                                } else {
127                                    invalid(format!("tables.{t}.{c} must be a string"))
128                                }
129                            })?;
130                            let strategy = parse_strategy(s).ok_or_else(|| {
131                                invalid(format!(
132                                    "tables.{t}.{c}: unknown strategy {s:?} (use email, name, hash, \
133                                     null, redact, const:<value> or keep)"
134                                ))
135                            })?;
136                            out.insert(c.clone(), strategy);
137                        }
138                        m.tables.insert(t.clone(), out);
139                    }
140                }
141                "subset" => {
142                    let rules = value.as_table().ok_or_else(|| {
143                        invalid("subset must be a table of table = \"filter or percent\"")
144                    })?;
145                    for (t, s) in rules {
146                        let s = s.as_str().ok_or_else(|| {
147                            if s.is_table() {
148                                invalid(format!(
149                                    "subset.{t} must be a string; for a schema-qualified table, \
150                                     quote the key: \"{t}.<table>\" = \"...\""
151                                ))
152                            } else {
153                                invalid(format!("subset.{t} must be a string"))
154                            }
155                        })?;
156                        m.subset.insert(t.clone(), parse_subset(t, s)?);
157                    }
158                }
159                other => {
160                    return Err(invalid(format!(
161                        "unknown key {other} (a masking file has [tables.<table>] and [subset])"
162                    )))
163                }
164            }
165        }
166        Ok(m)
167    }
168
169    /// Reads and parses the masking file at `path`; errors name the file.
170    pub fn load(path: &Path) -> Result<MaskFile> {
171        let named = |msg: String| invalid(format!("masking file {}: {msg}", path.display()));
172        let text = std::fs::read_to_string(path).map_err(|e| named(e.to_string()))?;
173        MaskFile::parse(&text).map_err(|e| named(e.message))
174    }
175
176    /// FNV-1a 64 of the file's bytes, as hex (like the migrations folder hash).
177    pub fn hash(path: &Path) -> Result<String> {
178        let data = std::fs::read(path)
179            .map_err(|e| invalid(format!("masking file {}: {e}", path.display())))?;
180        Ok(hash_bytes(&data))
181    }
182
183    /// `load` and `hash` from one read, so the parsed file is the one the hash names.
184    pub fn load_hashed(path: &Path) -> Result<(MaskFile, String)> {
185        let named = |msg: String| invalid(format!("masking file {}: {msg}", path.display()));
186        let data = std::fs::read(path).map_err(|e| named(e.to_string()))?;
187        let hash = hash_bytes(&data);
188        let text = String::from_utf8(data).map_err(|e| named(e.to_string()))?;
189        let file = MaskFile::parse(&text).map_err(|e| named(e.message))?;
190        Ok((file, hash))
191    }
192}
193
194fn hash_bytes(data: &[u8]) -> String {
195    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
196    crate::templates::fnv1a(&mut h, data);
197    format!("{h:016x}")
198}
199
200fn quote_ident(s: &str) -> String {
201    format!("\"{}\"", s.replace('"', "\"\""))
202}
203
204fn quote_literal(s: &str) -> String {
205    format!("'{}'", s.replace('\'', "''"))
206}
207
208/// `users` -> (`public`, `users`); `billing.cards` -> (`billing`, `cards`).
209fn split_table(name: &str) -> (&str, &str) {
210    name.split_once('.').unwrap_or(("public", name))
211}
212
213fn qualified(name: &str) -> String {
214    let (schema, table) = split_table(name);
215    format!("{}.{}", quote_ident(schema), quote_ident(table))
216}
217
218/// text, varchar, char and citext (also schema-qualified); not arrays or domains.
219fn is_text(data_type: &str) -> bool {
220    if data_type.ends_with(']') {
221        return false;
222    }
223    let base = data_type.split('(').next().unwrap_or("").trim();
224    let base = base.rsplit('.').next().unwrap_or(base);
225    matches!(
226        base,
227        "text" | "character varying" | "varchar" | "character" | "char" | "bpchar" | "citext"
228    )
229}
230
231fn strategy_name(s: &Strategy) -> &'static str {
232    match s {
233        Strategy::Email => "email",
234        Strategy::Name => "name",
235        Strategy::Hash => "hash",
236        Strategy::Null => "null",
237        Strategy::Redact => "redact",
238        Strategy::Const(_) => "const",
239        Strategy::Keep => "keep",
240    }
241}
242
243/// For one masked column: the condition under which a row still needs masking (NULL for a NULL
244/// value, so NULLs stay NULL) and the new value. `None` for `keep`. The checks compare `col::text`,
245/// which drops a `char(n)` column's padding, so a masked value is recognised there too.
246fn column_sql(
247    table: &str,
248    info: &ColumnInfo,
249    strategy: &Strategy,
250) -> Result<Option<(String, String)>> {
251    let c = quote_ident(&info.column);
252    let name = format!("{table}.{}", info.column);
253    let text_only = matches!(
254        strategy,
255        Strategy::Email | Strategy::Name | Strategy::Hash | Strategy::Redact
256    );
257    if text_only && !is_text(&info.data_type) {
258        return Err(invalid(format!(
259            "column {name} is {}, and {} works only on text, varchar, char and citext columns",
260            info.data_type,
261            strategy_name(strategy)
262        )));
263    }
264    // The salt comes from a setting, so it never appears in the statement text.
265    let h = format!(
266        "encode(sha256(decode(current_setting('{SALT_SETTING}'),'hex') || convert_to({c}::text,'UTF8')),'hex')"
267    );
268    Ok(Some(match strategy {
269        Strategy::Keep => return Ok(None),
270        Strategy::Email => (
271            format!("{c}::text NOT LIKE '%@masked.invalid'"),
272            format!("'u_' || left({h},12) || '@masked.invalid'"),
273        ),
274        Strategy::Name => (
275            format!("{c}::text NOT LIKE 'Masked %'"),
276            format!("'Masked ' || left({h},8)"),
277        ),
278        Strategy::Hash => (
279            format!("{c}::text NOT LIKE 'mh\\_%'"),
280            format!("'mh_' || {h}"),
281        ),
282        Strategy::Redact => (
283            format!("{c}::text <> repeat('x', length({c}))"),
284            format!("repeat('x', length({c}))"),
285        ),
286        Strategy::Null => {
287            if info.not_null {
288                return Err(invalid(format!(
289                    "column {name} is NOT NULL, so it cannot be masked with null"
290                )));
291            }
292            (format!("{c} IS NOT NULL"), "NULL".into())
293        }
294        Strategy::Const(v) => {
295            let lit = format!("{}::{}", quote_literal(v), info.data_type);
296            (format!("{c}::text <> ({lit})::text"), lit)
297        }
298    }))
299}
300
301/// The setting the masking statements read the salt from.
302const SALT_SETTING: &str = "mitosis.salt";
303
304/// `SET LOCAL mitosis.salt`, then one `UPDATE` per masked table, setting all its masked columns
305/// (empty when nothing is masked). Only the `SET` holds the salt; it must run in the same
306/// transaction as the updates. Rows already masked are left alone, so running the statements
307/// twice changes nothing. Unknown tables or columns, and strategies that do not fit the column,
308/// are errors naming the column.
309pub fn mask_statements(
310    m: &MaskFile,
311    salt_hex: &str,
312    columns: &[ColumnInfo],
313) -> Result<Vec<String>> {
314    if salt_hex.is_empty() || !salt_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
315        return Err(MitosisError::new(
316            ErrorCode::Internal,
317            "the masking salt is not hex",
318        ));
319    }
320    let mut out = Vec::new();
321    for (table, cols) in &m.tables {
322        let (schema, name) = split_table(table);
323        let known: Vec<&ColumnInfo> = columns
324            .iter()
325            .filter(|c| c.schema == schema && c.table == name)
326            .collect();
327        if known.is_empty() {
328            return Err(invalid(format!("masked table {table} does not exist")));
329        }
330        let (mut sets, mut needs) = (Vec::new(), Vec::new());
331        for (col, strategy) in cols {
332            let info = known
333                .iter()
334                .find(|c| c.column == *col)
335                .ok_or_else(|| invalid(format!("masked column {table}.{col} does not exist")))?;
336            let Some((need, value)) = column_sql(table, info, strategy)? else {
337                continue;
338            };
339            let c = quote_ident(col);
340            sets.push(format!("{c} = CASE WHEN {need} THEN {value} ELSE {c} END"));
341            needs.push(format!("({need})"));
342        }
343        if !sets.is_empty() {
344            out.push(format!(
345                "UPDATE {} SET {} WHERE {}",
346                qualified(table),
347                sets.join(", "),
348                needs.join(" OR ")
349            ));
350        }
351    }
352    if !out.is_empty() {
353        out.insert(0, format!("SET LOCAL {SALT_SETTING} = '{salt_hex}'"));
354    }
355    Ok(out)
356}
357
358/// The subset deletes, then a loop that deletes rows whose foreign-key parent is gone until a
359/// full pass deletes nothing (at most `MAX_PASSES`). Empty without a subset. Run with
360/// `session_replication_role = replica`, so foreign keys do not block the deletes meanwhile.
361pub fn subset_statements(m: &MaskFile, fks: &[ForeignKey]) -> Result<Vec<String>> {
362    let mut out = Vec::new();
363    if m.subset.is_empty() {
364        return Ok(Vec::new());
365    }
366    for (table, rule) in &m.subset {
367        let t = qualified(table);
368        out.push(match rule {
369            SubsetRule::Where(w) => vec![format!("DELETE FROM {t} WHERE ({w}) IS NOT TRUE")],
370            // The sampled row ids go into a temp table and the delete is an anti-join against
371            // it, which hashes or merges (and spills to disk) instead of rescanning per row.
372            // `tableoid` keeps partitions apart: their ctids repeat.
373            SubsetRule::Sample(p) => vec![
374                format!(
375                    "CREATE TEMP TABLE {SAMPLE_TABLE} AS \
376                     SELECT tableoid AS o, ctid AS k FROM {t} TABLESAMPLE SYSTEM ({p})"
377                ),
378                format!("ANALYZE {SAMPLE_TABLE}"),
379                format!(
380                    "DELETE FROM {t} AS x WHERE NOT EXISTS \
381                     (SELECT 1 FROM {SAMPLE_TABLE} s WHERE s.o = x.tableoid AND s.k = x.ctid)"
382                ),
383                format!("DROP TABLE {SAMPLE_TABLE}"),
384            ],
385        });
386    }
387    let mut out: Vec<String> = out.into_iter().flatten().collect();
388    if fks.is_empty() {
389        return Ok(out);
390    }
391    let mut body = String::new();
392    for fk in fks {
393        if fk.child_cols.is_empty() || fk.child_cols.len() != fk.parent_cols.len() {
394            return Err(MitosisError::new(
395                ErrorCode::Internal,
396                format!(
397                    "foreign key from {} to {} has unmatched columns",
398                    fk.child, fk.parent
399                ),
400            ));
401        }
402        let cols: Vec<String> = fk.child_cols.iter().map(|c| format!("c.{c}")).collect();
403        let on: Vec<String> = fk
404            .parent_cols
405            .iter()
406            .zip(&fk.child_cols)
407            .map(|(p, c)| format!("p.{p} = c.{c}"))
408            .collect();
409        body.push_str(&format!(
410            "    DELETE FROM {} AS c WHERE ({}) IS NOT NULL AND NOT EXISTS \
411             (SELECT 1 FROM {} AS p WHERE {});\n    GET DIAGNOSTICS n = ROW_COUNT;\n    total := total + n;\n",
412            fk.child,
413            cols.join(", "),
414            fk.parent,
415            on.join(" AND ")
416        ));
417    }
418    out.push(format!(
419        "DO $mitosis$\nDECLARE n bigint; total bigint; passes int := 0;\nBEGIN\n  LOOP\n    total := 0;\n{body}    \
420         EXIT WHEN total = 0;\n    passes := passes + 1;\n    IF passes >= {MAX_PASSES} THEN\n      \
421         RAISE EXCEPTION 'subset: rows referencing deleted rows were still being deleted after {MAX_PASSES} passes';\n    \
422         END IF;\n  END LOOP;\nEND\n$mitosis$"
423    ));
424    Ok(out)
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    const EXAMPLE: &str = r#"
432[tables.users]
433email = "email"        # deterministic fake email
434full_name = "name"     # deterministic fake name
435phone = "null"
436ssn = "hash"           # keyed hash, hex
437notes = "redact"       # same-length run of 'x'
438plan = "const:free"
439
440[subset]
441orders = "created_at > now() - interval '90 days'"
442events = "10%"          # TABLESAMPLE SYSTEM
443"#;
444
445    fn col(table: &str, column: &str, data_type: &str, not_null: bool) -> ColumnInfo {
446        let (schema, table) = split_table(table);
447        ColumnInfo {
448            schema: schema.into(),
449            table: table.into(),
450            column: column.into(),
451            data_type: data_type.into(),
452            not_null,
453        }
454    }
455
456    fn users() -> Vec<ColumnInfo> {
457        vec![
458            col("users", "id", "integer", true),
459            col("users", "email", "text", true),
460            col("users", "full_name", "character varying(80)", false),
461            col("users", "phone", "text", false),
462            col("users", "ssn", "citext", false),
463            col("users", "notes", "character(20)", false),
464            col("users", "plan", "text", false),
465        ]
466    }
467
468    #[test]
469    fn parse_full_example() {
470        let m = MaskFile::parse(EXAMPLE).unwrap();
471        let u = &m.tables["users"];
472        assert_eq!(u["email"], Strategy::Email);
473        assert_eq!(u["full_name"], Strategy::Name);
474        assert_eq!(u["phone"], Strategy::Null);
475        assert_eq!(u["ssn"], Strategy::Hash);
476        assert_eq!(u["notes"], Strategy::Redact);
477        assert_eq!(u["plan"], Strategy::Const("free".into()));
478        assert_eq!(
479            m.subset["orders"],
480            SubsetRule::Where("created_at > now() - interval '90 days'".into())
481        );
482        assert_eq!(m.subset["events"], SubsetRule::Sample(10.0));
483        let q = MaskFile::parse("[tables.\"billing.cards\"]\nnumber = \"keep\"").unwrap();
484        assert_eq!(q.tables["billing.cards"]["number"], Strategy::Keep);
485    }
486
487    #[test]
488    fn parse_errors_name_key() {
489        let msg = |text: &str| MaskFile::parse(text).unwrap_err().message;
490        assert!(msg("[tables.users]\nemail = \"fake\"").contains("tables.users.email"));
491        assert!(msg("[tables.users]\nemail = 3").contains("tables.users.email"));
492        assert!(msg("[tables]\nusers = \"email\"").contains("tables.users"));
493        assert!(msg("[subset]\norders = \"\"").contains("subset.orders"));
494        assert!(msg("[subset]\norders = 1").contains("subset.orders"));
495        assert!(msg("[mask]\nx = 1").contains("unknown key mask"));
496        // An unquoted dotted table key gets a hint to quote it.
497        assert!(
498            msg("[tables.billing.cards]\nnumber = \"keep\"").contains("[tables.\"billing.cards\"]")
499        );
500        assert!(msg("[subset]\nbilling.cards = \"1%\"").contains("quote the key"));
501        // A syntax error names the line.
502        assert!(
503            msg("[tables.users]\nemail = \n").contains("line 2"),
504            "{}",
505            msg("[tables.users]\nemail = \n")
506        );
507    }
508
509    #[test]
510    fn sample_percent_bounds() {
511        let rule = |s: &str| parse_subset("t", s);
512        assert_eq!(rule("100%").unwrap(), SubsetRule::Sample(100.0));
513        assert_eq!(rule("0.5 %").unwrap(), SubsetRule::Sample(0.5));
514        for bad in ["0%", "-1%", "100.1%", "NaN%", "inf%"] {
515            let e = rule(bad).unwrap_err();
516            assert!(e.message.contains("subset.t"), "{bad}: {}", e.message);
517        }
518        // Not a number before the % sign: a filter.
519        assert_eq!(
520            rule("name LIKE 'a%'").unwrap(),
521            SubsetRule::Where("name LIKE 'a%'".into())
522        );
523    }
524
525    #[test]
526    fn statements_skip_already_masked() {
527        let m = MaskFile::parse(EXAMPLE).unwrap();
528        let sql = mask_statements(&m, "00ff", &users()).unwrap();
529        assert_eq!(sql.len(), 2, "the salt, then one UPDATE per table");
530        assert_eq!(sql[0], "SET LOCAL mitosis.salt = '00ff'");
531        let s = &sql[1];
532        assert!(!s.contains("00ff"), "the salt stays out of the UPDATE: {s}");
533        assert!(s.starts_with("UPDATE \"public\".\"users\" SET "), "{s}");
534        assert!(
535            s.contains("\"email\"::text NOT LIKE '%@masked.invalid'"),
536            "{s}"
537        );
538        assert!(s.contains("'u_' || left(encode(sha256(decode(current_setting('mitosis.salt'),'hex') || convert_to(\"email\"::text,'UTF8')),'hex'),12) || '@masked.invalid'"), "{s}");
539        assert!(s.contains("\"full_name\"::text NOT LIKE 'Masked %'"), "{s}");
540        assert!(s.contains("'Masked ' || left(encode("), "{s}");
541        assert!(s.contains("\"ssn\"::text NOT LIKE 'mh\\_%'"), "{s}");
542        assert!(s.contains("'mh_' || encode("), "{s}");
543        assert!(
544            s.contains("\"notes\"::text <> repeat('x', length(\"notes\"))"),
545            "{s}"
546        );
547        assert!(
548            s.contains("\"phone\" = CASE WHEN \"phone\" IS NOT NULL THEN NULL"),
549            "{s}"
550        );
551        assert!(s.contains("'free'::text"), "{s}");
552        assert!(s.contains(" WHERE (\"email\"::text NOT LIKE"), "{s}");
553
554        let keep = MaskFile::parse("[tables.users]\nemail = \"keep\"").unwrap();
555        assert!(mask_statements(&keep, "00", &users()).unwrap().is_empty());
556    }
557
558    #[test]
559    fn email_rerun_on_char_column_matches_the_unpadded_value() {
560        // A char(40) value is padded with spaces; LIKE on the column itself would see them and
561        // mask an already-masked value again on every layered build.
562        let cols = vec![col("users", "alt", "character(40)", false)];
563        let m = MaskFile::parse("[tables.users]\nalt = \"email\"").unwrap();
564        let sql = mask_statements(&m, "00", &cols).unwrap();
565        assert!(
566            sql[1].contains("WHEN \"alt\"::text NOT LIKE '%@masked.invalid' THEN"),
567            "{}",
568            sql[1]
569        );
570        assert!(sql[1].contains("WHERE (\"alt\"::text NOT LIKE '%@masked.invalid')"));
571    }
572
573    #[test]
574    fn statements_check_columns_and_types() {
575        let err = |text: &str| {
576            mask_statements(&MaskFile::parse(text).unwrap(), "00", &users())
577                .unwrap_err()
578                .message
579        };
580        assert!(err("[tables.users]\nid = \"email\"").contains("users.id is integer"));
581        assert!(err("[tables.users]\nemail = \"null\"").contains("users.email is NOT NULL"));
582        assert!(err("[tables.users]\nnope = \"hash\"").contains("users.nope does not exist"));
583        assert!(err("[tables.people]\nemail = \"hash\"").contains("table people does not exist"));
584        assert!(!is_text("text[]") && !is_text("character varying(4)[]") && !is_text("\"char\""));
585        assert!(is_text("public.citext") && is_text("character(3)"));
586    }
587
588    #[test]
589    fn subset_statements_delete_orphans() {
590        let m = MaskFile::parse(EXAMPLE).unwrap();
591        let fks = vec![ForeignKey {
592            child: "\"public\".\"order_items\"".into(),
593            child_cols: vec!["\"order_id\"".into()],
594            parent: "\"public\".\"orders\"".into(),
595            parent_cols: vec!["\"id\"".into()],
596        }];
597        let sql = subset_statements(&m, &fks).unwrap();
598        assert_eq!(sql[0], "CREATE TEMP TABLE mitosis_sample AS SELECT tableoid AS o, ctid AS k FROM \"public\".\"events\" TABLESAMPLE SYSTEM (10)");
599        assert_eq!(sql[1], "ANALYZE mitosis_sample");
600        assert_eq!(sql[2], "DELETE FROM \"public\".\"events\" AS x WHERE NOT EXISTS (SELECT 1 FROM mitosis_sample s WHERE s.o = x.tableoid AND s.k = x.ctid)");
601        assert_eq!(sql[3], "DROP TABLE mitosis_sample");
602        assert_eq!(
603            sql[4],
604            "DELETE FROM \"public\".\"orders\" WHERE (created_at > now() - interval '90 days') IS NOT TRUE"
605        );
606        assert!(sql[5].contains("DELETE FROM \"public\".\"order_items\" AS c WHERE (c.\"order_id\") IS NOT NULL AND NOT EXISTS (SELECT 1 FROM \"public\".\"orders\" AS p WHERE p.\"id\" = c.\"order_id\")"), "{}", sql[5]);
607        assert!(sql[5].contains("passes >= 100"));
608        assert!(subset_statements(&MaskFile::default(), &fks)
609            .unwrap()
610            .is_empty());
611    }
612}