database_replicator/migration/
dump.rs1use crate::filters::ReplicationFilter;
5use anyhow::{Context, Result};
6use std::collections::BTreeSet;
7use std::fs;
8use std::process::{Command, Stdio};
9use std::time::Duration;
10
11pub async fn dump_globals(source_url: &str, output_path: &str) -> Result<()> {
13 tracing::info!("Dumping global objects to {}", output_path);
14
15 let parts = crate::utils::parse_postgres_url(source_url)
17 .with_context(|| format!("Failed to parse source URL: {}", source_url))?;
18 let pgpass = crate::utils::PgPassFile::new(&parts)
19 .context("Failed to create .pgpass file for authentication")?;
20
21 let env_vars = parts.to_pg_env_vars();
22 let output_path_owned = output_path.to_string();
23
24 crate::utils::retry_subprocess_with_backoff(
26 || {
27 let mut cmd = Command::new("pg_dumpall");
28 cmd.arg("--globals-only")
29 .arg("--no-role-passwords") .arg("--verbose") .arg("--host")
32 .arg(&parts.host)
33 .arg("--port")
34 .arg(parts.port.to_string())
35 .arg("--database")
36 .arg(&parts.database)
37 .arg(format!("--file={}", output_path_owned))
38 .env("PGPASSFILE", pgpass.path())
39 .stdout(Stdio::inherit())
40 .stderr(Stdio::inherit());
41
42 if let Some(user) = &parts.user {
44 cmd.arg("--username").arg(user);
45 }
46
47 for (env_var, value) in &env_vars {
49 cmd.env(env_var, value);
50 }
51
52 for (env_var, value) in crate::utils::get_keepalive_env_vars() {
54 cmd.env(env_var, value);
55 }
56
57 cmd.status().context(
58 "Failed to execute pg_dumpall. Is PostgreSQL client installed?\n\
59 Install with:\n\
60 - Ubuntu/Debian: sudo apt-get install postgresql-client\n\
61 - macOS: brew install postgresql\n\
62 - RHEL/CentOS: sudo yum install postgresql",
63 )
64 },
65 3, Duration::from_secs(1), "pg_dumpall (dump globals)",
68 )
69 .await
70 .context(
71 "pg_dumpall failed to dump global objects.\n\
72 \n\
73 Common causes:\n\
74 - Connection authentication failed\n\
75 - User lacks sufficient privileges (need SUPERUSER or pg_read_all_settings role)\n\
76 - Network connectivity issues\n\
77 - Invalid connection string\n\
78 - Connection timeout or network issues",
79 )?;
80
81 tracing::info!("✓ Global objects dumped successfully");
82 Ok(())
83}
84
85pub fn sanitize_globals_dump(path: &str) -> Result<()> {
96 let content = fs::read_to_string(path)
97 .with_context(|| format!("Failed to read globals dump at {}", path))?;
98
99 if let Some(updated) = rewrite_create_role_statements(&content) {
100 fs::write(path, updated)
101 .with_context(|| format!("Failed to update globals dump at {}", path))?;
102 }
103
104 Ok(())
105}
106
107pub fn remove_superuser_from_globals(path: &str) -> Result<()> {
113 let content = fs::read_to_string(path)
114 .with_context(|| format!("Failed to read globals dump at {}", path))?;
115
116 let mut updated = String::with_capacity(content.len());
117 let mut modified = false;
118 for line in content.lines() {
119 if line.contains("ALTER ROLE") && line.contains("SUPERUSER") {
120 updated.push_str("-- ");
121 updated.push_str(line);
122 updated.push('\n');
123 modified = true;
124 } else {
125 updated.push_str(line);
126 updated.push('\n');
127 }
128 }
129
130 if modified {
131 fs::write(path, updated)
132 .with_context(|| format!("Failed to write sanitized globals dump to {}", path))?;
133 }
134
135 Ok(())
136}
137
138pub fn remove_restricted_guc_settings(path: &str) -> Result<()> {
144 let content = fs::read_to_string(path)
145 .with_context(|| format!("Failed to read globals dump at {}", path))?;
146
147 let mut updated = String::with_capacity(content.len());
148 let mut modified = false;
149
150 for line in content.lines() {
151 let lower_line = line.to_ascii_lowercase();
152 if lower_line.contains("alter role") && lower_line.contains("set") {
153 updated.push_str("-- ");
154 updated.push_str(line);
155 updated.push('\n');
156 modified = true;
157 } else {
158 updated.push_str(line);
159 updated.push('\n');
160 }
161 }
162
163 if modified {
164 fs::write(path, updated)
165 .with_context(|| format!("Failed to write sanitized globals dump to {}", path))?;
166 }
167
168 Ok(())
169}
170
171pub fn remove_restricted_role_grants(path: &str) -> Result<()> {
177 const RESTRICTED_ROLES: &[&str] = &[
179 "pg_checkpoint",
180 "pg_read_all_data",
181 "pg_write_all_data",
182 "pg_read_all_settings",
183 "pg_read_all_stats",
184 "pg_stat_scan_tables",
185 "pg_monitor",
186 "pg_signal_backend",
187 "pg_read_server_files",
188 "pg_write_server_files",
189 "pg_execute_server_program",
190 "pg_create_subscription",
191 "pg_maintain",
192 "pg_use_reserved_connections",
193 ];
194
195 const RESTRICTED_GRANTORS: &[&str] = &[
197 "rdsadmin",
198 "rds_superuser",
199 "rdsrepladmin",
200 "rds_replication",
201 ];
202
203 let content = fs::read_to_string(path)
204 .with_context(|| format!("Failed to read globals dump at {}", path))?;
205
206 let mut updated = String::with_capacity(content.len());
207 let mut modified = false;
208
209 for line in content.lines() {
210 let lower_trimmed = line.trim().to_ascii_lowercase();
211 if lower_trimmed.starts_with("grant ") {
212 let is_restricted_role = RESTRICTED_ROLES.iter().any(|role| {
214 lower_trimmed
217 .split_whitespace()
218 .nth(1)
219 .map(|r| r.trim_matches('"') == *role)
220 .unwrap_or(false)
221 });
222
223 let has_restricted_grantor = RESTRICTED_GRANTORS.iter().any(|grantor| {
225 lower_trimmed.contains(&format!("granted by {}", grantor))
227 || lower_trimmed.contains(&format!("granted by \"{}\"", grantor))
228 });
229
230 if is_restricted_role || has_restricted_grantor {
231 updated.push_str("-- ");
232 updated.push_str(line);
233 updated.push('\n');
234 modified = true;
235 continue;
236 }
237 }
238
239 updated.push_str(line);
240 updated.push('\n');
241 }
242
243 if modified {
244 fs::write(path, updated)
245 .with_context(|| format!("Failed to write sanitized globals dump to {}", path))?;
246 }
247
248 Ok(())
249}
250
251fn rewrite_create_role_statements(sql: &str) -> Option<String> {
252 if sql.is_empty() {
253 return None;
254 }
255
256 let mut output = String::with_capacity(sql.len() + 1024);
257 let mut modified = false;
258 let mut cursor = 0;
259
260 while cursor < sql.len() {
261 if let Some(rel_pos) = sql[cursor..].find('\n') {
262 let end = cursor + rel_pos + 1;
263 let chunk = &sql[cursor..end];
264 if let Some(transformed) = wrap_create_role_line(chunk) {
265 output.push_str(&transformed);
266 modified = true;
267 } else {
268 output.push_str(chunk);
269 }
270 cursor = end;
271 } else {
272 let chunk = &sql[cursor..];
273 if let Some(transformed) = wrap_create_role_line(chunk) {
274 output.push_str(&transformed);
275 modified = true;
276 } else {
277 output.push_str(chunk);
278 }
279 break;
280 }
281 }
282
283 if modified {
284 Some(output)
285 } else {
286 None
287 }
288}
289
290fn wrap_create_role_line(chunk: &str) -> Option<String> {
291 let trimmed = chunk.trim_start();
292 if !trimmed.starts_with("CREATE ROLE ") {
293 return None;
294 }
295
296 let statement = trimmed.trim_end();
297 let statement_body = statement.trim_end_matches(';').trim_end();
298 let leading_ws_len = chunk.len() - trimmed.len();
299 let leading_ws = &chunk[..leading_ws_len];
300 let newline = if chunk.ends_with("\r\n") {
301 "\r\n"
302 } else if chunk.ends_with('\n') {
303 "\n"
304 } else {
305 ""
306 };
307
308 let role_token = extract_role_token(statement_body)?;
309
310 let notice_name = escape_single_quotes(&unquote_role_name(&role_token));
311
312 let mut block = String::with_capacity(chunk.len() + 128);
313 block.push_str(leading_ws);
314 block.push_str("DO $$\n");
315 block.push_str(leading_ws);
316 block.push_str("BEGIN\n");
317 block.push_str(leading_ws);
318 block.push_str(" ");
319 block.push_str(statement_body);
320 block.push_str(";\n");
321 block.push_str(leading_ws);
322 block.push_str("EXCEPTION\n");
323 block.push_str(leading_ws);
324 block.push_str(" WHEN duplicate_object THEN\n");
325 block.push_str(leading_ws);
326 block.push_str(" RAISE NOTICE 'Role ");
327 block.push_str(¬ice_name);
328 block.push_str(" already exists on target, skipping CREATE ROLE';\n");
329 block.push_str(leading_ws);
330 block.push_str("END $$;");
331
332 if !newline.is_empty() {
333 block.push_str(newline);
334 }
335
336 Some(block)
337}
338
339fn extract_role_token(statement: &str) -> Option<String> {
340 let remainder = statement.strip_prefix("CREATE ROLE")?.trim_start();
341
342 if remainder.starts_with('"') {
343 let mut idx = 1;
344 let bytes = remainder.as_bytes();
345 while idx < bytes.len() {
346 if bytes[idx] == b'"' {
347 if idx + 1 < bytes.len() && bytes[idx + 1] == b'"' {
348 idx += 2;
349 continue;
350 } else {
351 idx += 1;
352 break;
353 }
354 }
355 idx += 1;
356 }
357 if idx <= remainder.len() {
358 return Some(remainder[..idx].to_string());
359 }
360 None
361 } else {
362 let mut end = remainder.len();
363 for (i, ch) in remainder.char_indices() {
364 if ch.is_whitespace() || ch == ';' {
365 end = i;
366 break;
367 }
368 }
369 if end == 0 {
370 None
371 } else {
372 Some(remainder[..end].to_string())
373 }
374 }
375}
376
377fn unquote_role_name(token: &str) -> String {
378 if token.starts_with('"') && token.ends_with('"') && token.len() >= 2 {
379 let inner = &token[1..token.len() - 1];
380 inner.replace("\"\"", "\"")
381 } else {
382 token.to_string()
383 }
384}
385
386fn escape_single_quotes(value: &str) -> String {
387 value.replace('\'', "''")
388}
389
390pub async fn dump_schema(
392 source_url: &str,
393 database: &str,
394 output_path: &str,
395 filter: &ReplicationFilter,
396) -> Result<()> {
397 tracing::info!(
398 "Dumping schema for database '{}' to {}",
399 database,
400 output_path
401 );
402
403 let parts = crate::utils::parse_postgres_url(source_url)
405 .with_context(|| format!("Failed to parse source URL: {}", source_url))?;
406 let pgpass = crate::utils::PgPassFile::new(&parts)
407 .context("Failed to create .pgpass file for authentication")?;
408
409 let env_vars = parts.to_pg_env_vars();
410 let output_path_owned = output_path.to_string();
411
412 let exclude_tables = get_schema_excluded_tables_for_db(filter, database);
414 let include_tables = get_included_tables_for_db(filter, database);
415
416 crate::utils::retry_subprocess_with_backoff(
418 || {
419 let mut cmd = Command::new("pg_dump");
420 cmd.arg("--schema-only")
421 .arg("--no-owner") .arg("--no-privileges") .arg("--verbose"); if let Some(ref exclude) = exclude_tables {
428 if !exclude.is_empty() {
429 for table in exclude {
430 cmd.arg("--exclude-table").arg(table);
431 }
432 }
433 }
434
435 if let Some(ref include) = include_tables {
437 if !include.is_empty() {
438 for table in include {
439 cmd.arg("--table").arg(table);
440 }
441 }
442 }
443
444 cmd.arg("--host")
445 .arg(&parts.host)
446 .arg("--port")
447 .arg(parts.port.to_string())
448 .arg("--dbname")
449 .arg(&parts.database)
450 .arg(format!("--file={}", output_path_owned))
451 .env("PGPASSFILE", pgpass.path())
452 .stdout(Stdio::inherit())
453 .stderr(Stdio::inherit());
454
455 if let Some(user) = &parts.user {
457 cmd.arg("--username").arg(user);
458 }
459
460 for (env_var, value) in &env_vars {
462 cmd.env(env_var, value);
463 }
464
465 for (env_var, value) in crate::utils::get_keepalive_env_vars() {
467 cmd.env(env_var, value);
468 }
469
470 cmd.status().context(
471 "Failed to execute pg_dump. Is PostgreSQL client installed?\n\
472 Install with:\n\
473 - Ubuntu/Debian: sudo apt-get install postgresql-client\n\
474 - macOS: brew install postgresql\n\
475 - RHEL/CentOS: sudo yum install postgresql",
476 )
477 },
478 3, Duration::from_secs(1), "pg_dump (dump schema)",
481 )
482 .await
483 .with_context(|| {
484 format!(
485 "pg_dump failed to dump schema for database '{}'.\n\
486 \n\
487 Common causes:\n\
488 - Database does not exist\n\
489 - Connection authentication failed\n\
490 - User lacks privileges to read database schema\n\
491 - Network connectivity issues\n\
492 - Connection timeout or network issues",
493 database
494 )
495 })?;
496
497 tracing::info!("✓ Schema dumped successfully");
498 Ok(())
499}
500
501pub async fn dump_data(
511 source_url: &str,
512 database: &str,
513 output_path: &str,
514 filter: &ReplicationFilter,
515) -> Result<()> {
516 let num_cpus = std::thread::available_parallelism()
518 .map(|n| n.get().min(8))
519 .unwrap_or(4);
520
521 tracing::info!(
522 "Dumping data for database '{}' to {} (parallel={}, compression=9, format=directory)",
523 database,
524 output_path,
525 num_cpus
526 );
527
528 let parts = crate::utils::parse_postgres_url(source_url)
530 .with_context(|| format!("Failed to parse source URL: {}", source_url))?;
531 let pgpass = crate::utils::PgPassFile::new(&parts)
532 .context("Failed to create .pgpass file for authentication")?;
533
534 let env_vars = parts.to_pg_env_vars();
535 let output_path_owned = output_path.to_string();
536
537 let exclude_tables = get_data_excluded_tables_for_db(filter, database);
539 let include_tables = get_included_tables_for_db(filter, database);
540
541 crate::utils::retry_subprocess_with_backoff(
543 || {
544 let mut cmd = Command::new("pg_dump");
545 cmd.arg("--data-only")
546 .arg("--no-owner")
547 .arg("--format=directory") .arg("--blobs") .arg("--compress=9") .arg(format!("--jobs={}", num_cpus)) .arg("--verbose"); if let Some(ref exclude) = exclude_tables {
556 if !exclude.is_empty() {
557 for table in exclude {
558 cmd.arg("--exclude-table-data").arg(table);
559 }
560 }
561 }
562
563 if let Some(ref include) = include_tables {
565 if !include.is_empty() {
566 for table in include {
567 cmd.arg("--table").arg(table);
568 }
569 }
570 }
571
572 cmd.arg("--host")
573 .arg(&parts.host)
574 .arg("--port")
575 .arg(parts.port.to_string())
576 .arg("--dbname")
577 .arg(&parts.database)
578 .arg(format!("--file={}", output_path_owned))
579 .env("PGPASSFILE", pgpass.path())
580 .stdout(Stdio::inherit())
581 .stderr(Stdio::inherit());
582
583 if let Some(user) = &parts.user {
585 cmd.arg("--username").arg(user);
586 }
587
588 for (env_var, value) in &env_vars {
590 cmd.env(env_var, value);
591 }
592
593 for (env_var, value) in crate::utils::get_keepalive_env_vars() {
595 cmd.env(env_var, value);
596 }
597
598 cmd.status().context(
599 "Failed to execute pg_dump. Is PostgreSQL client installed?\n\
600 Install with:\n\
601 - Ubuntu/Debian: sudo apt-get install postgresql-client\n\
602 - macOS: brew install postgresql\n\
603 - RHEL/CentOS: sudo yum install postgresql",
604 )
605 },
606 3, Duration::from_secs(1), "pg_dump (dump data)",
609 )
610 .await
611 .with_context(|| {
612 format!(
613 "pg_dump failed to dump data for database '{}'.\n\
614 \n\
615 Common causes:\n\
616 - Database does not exist\n\
617 - Connection authentication failed\n\
618 - User lacks privileges to read table data\n\
619 - Network connectivity issues\n\
620 - Insufficient disk space for dump directory\n\
621 - Output directory already exists (pg_dump requires non-existent path)\n\
622 - Connection timeout or network issues",
623 database
624 )
625 })?;
626
627 tracing::info!(
628 "✓ Data dumped successfully using {} parallel jobs",
629 num_cpus
630 );
631 Ok(())
632}
633
634fn get_schema_excluded_tables_for_db(
639 filter: &ReplicationFilter,
640 db_name: &str,
641) -> Option<Vec<String>> {
642 let mut tables = BTreeSet::new();
643
644 if let Some(explicit) = filter.exclude_tables() {
647 for full_name in explicit {
648 let parts: Vec<&str> = full_name.split('.').collect();
649 if parts.len() == 2 && parts[0] == db_name {
650 tables.insert(format!("\"public\".\"{}\"", parts[1]));
652 }
653 }
654 }
655
656 if tables.is_empty() {
657 None
658 } else {
659 Some(tables.into_iter().collect())
660 }
661}
662
663fn get_data_excluded_tables_for_db(
668 filter: &ReplicationFilter,
669 db_name: &str,
670) -> Option<Vec<String>> {
671 let mut tables = BTreeSet::new();
672
673 if let Some(explicit) = filter.exclude_tables() {
676 for full_name in explicit {
677 let parts: Vec<&str> = full_name.split('.').collect();
678 if parts.len() == 2 && parts[0] == db_name {
679 tables.insert(format!("\"public\".\"{}\"", parts[1]));
681 }
682 }
683 }
684
685 for table in filter.schema_only_tables(db_name) {
687 tables.insert(table);
688 }
689
690 for (table, _) in filter.predicate_tables(db_name) {
691 tables.insert(table);
692 }
693
694 if tables.is_empty() {
695 None
696 } else {
697 Some(tables.into_iter().collect())
698 }
699}
700
701fn get_included_tables_for_db(filter: &ReplicationFilter, db_name: &str) -> Option<Vec<String>> {
704 filter.include_tables().map(|tables| {
705 tables
706 .iter()
707 .filter_map(|full_name| {
708 let parts: Vec<&str> = full_name.split('.').collect();
709 if parts.len() == 2 && parts[0] == db_name {
710 Some(format!("\"public\".\"{}\"", parts[1]))
712 } else {
713 None
714 }
715 })
716 .collect()
717 })
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723 use tempfile::tempdir;
724
725 #[tokio::test]
726 #[ignore]
727 async fn test_dump_globals() {
728 let url = std::env::var("TEST_SOURCE_URL").unwrap();
729 let dir = tempdir().unwrap();
730 let output = dir.path().join("globals.sql");
731
732 let result = dump_globals(&url, output.to_str().unwrap()).await;
733
734 assert!(result.is_ok());
735 assert!(output.exists());
736
737 let content = std::fs::read_to_string(&output).unwrap();
739 assert!(content.contains("CREATE ROLE") || !content.is_empty());
740 }
741
742 #[tokio::test]
743 #[ignore]
744 async fn test_dump_schema() {
745 let url = std::env::var("TEST_SOURCE_URL").unwrap();
746 let dir = tempdir().unwrap();
747 let output = dir.path().join("schema.sql");
748
749 let db = url.split('/').next_back().unwrap_or("postgres");
751
752 let filter = crate::filters::ReplicationFilter::empty();
753 let result = dump_schema(&url, db, output.to_str().unwrap(), &filter).await;
754
755 assert!(result.is_ok());
756 assert!(output.exists());
757 }
758
759 #[test]
760 fn test_get_schema_excluded_tables_for_db() {
761 let filter = crate::filters::ReplicationFilter::new(
762 None,
763 None,
764 None,
765 Some(vec![
766 "db1.table1".to_string(),
767 "db1.table2".to_string(),
768 "db2.table3".to_string(),
769 ]),
770 )
771 .unwrap();
772
773 let tables = get_schema_excluded_tables_for_db(&filter, "db1").unwrap();
775 assert_eq!(
777 tables,
778 vec!["\"public\".\"table1\"", "\"public\".\"table2\""]
779 );
780
781 let tables = get_schema_excluded_tables_for_db(&filter, "db2").unwrap();
782 assert_eq!(tables, vec!["\"public\".\"table3\""]);
783
784 let tables = get_schema_excluded_tables_for_db(&filter, "db3");
785 assert!(tables.is_none() || tables.unwrap().is_empty());
786 }
787
788 #[test]
789 fn test_get_data_excluded_tables_for_db() {
790 let filter = crate::filters::ReplicationFilter::new(
791 None,
792 None,
793 None,
794 Some(vec![
795 "db1.table1".to_string(),
796 "db1.table2".to_string(),
797 "db2.table3".to_string(),
798 ]),
799 )
800 .unwrap();
801
802 let tables = get_data_excluded_tables_for_db(&filter, "db1").unwrap();
804 assert_eq!(
806 tables,
807 vec!["\"public\".\"table1\"", "\"public\".\"table2\""]
808 );
809
810 let tables = get_data_excluded_tables_for_db(&filter, "db2").unwrap();
811 assert_eq!(tables, vec!["\"public\".\"table3\""]);
812
813 let tables = get_data_excluded_tables_for_db(&filter, "db3");
814 assert!(tables.is_none() || tables.unwrap().is_empty());
815 }
816
817 #[test]
818 fn test_get_included_tables_for_db() {
819 let filter = crate::filters::ReplicationFilter::new(
820 None,
821 None,
822 Some(vec![
823 "db1.users".to_string(),
824 "db1.orders".to_string(),
825 "db2.products".to_string(),
826 ]),
827 None,
828 )
829 .unwrap();
830
831 let tables = get_included_tables_for_db(&filter, "db1").unwrap();
832 assert_eq!(
834 tables,
835 vec!["\"public\".\"users\"", "\"public\".\"orders\""]
836 );
837
838 let tables = get_included_tables_for_db(&filter, "db2").unwrap();
839 assert_eq!(tables, vec!["\"public\".\"products\""]);
840
841 let tables = get_included_tables_for_db(&filter, "db3");
842 assert!(tables.is_none() || tables.unwrap().is_empty());
843 }
844
845 #[test]
846 fn test_get_schema_excluded_tables_for_db_with_empty_filter() {
847 let filter = crate::filters::ReplicationFilter::empty();
848 let tables = get_schema_excluded_tables_for_db(&filter, "db1");
849 assert!(tables.is_none());
850 }
851
852 #[test]
853 fn test_get_data_excluded_tables_for_db_with_empty_filter() {
854 let filter = crate::filters::ReplicationFilter::empty();
855 let tables = get_data_excluded_tables_for_db(&filter, "db1");
856 assert!(tables.is_none());
857 }
858
859 #[test]
860 fn test_get_included_tables_for_db_with_empty_filter() {
861 let filter = crate::filters::ReplicationFilter::empty();
862 let tables = get_included_tables_for_db(&filter, "db1");
863 assert!(tables.is_none());
864 }
865
866 #[test]
867 fn test_rewrite_create_role_statements_wraps_unquoted_role() {
868 let sql = "CREATE ROLE replicator WITH LOGIN;\nALTER ROLE replicator WITH LOGIN;\n";
869 let rewritten = rewrite_create_role_statements(sql).expect("rewrite expected");
870
871 assert!(rewritten.contains("DO $$"));
872 assert!(rewritten.contains("Role replicator already exists"));
873 assert!(rewritten.contains("CREATE ROLE replicator WITH LOGIN;"));
874 assert!(rewritten.contains("ALTER ROLE replicator WITH LOGIN;"));
875 }
876
877 #[test]
878 fn test_rewrite_create_role_statements_wraps_quoted_role() {
879 let sql = " CREATE ROLE \"Andre Admin\";\n";
880 let rewritten = rewrite_create_role_statements(sql).expect("rewrite expected");
881
882 assert!(rewritten.contains("DO $$"));
883 assert!(rewritten.contains("Andre Admin already exists"));
884 assert!(rewritten.contains("CREATE ROLE \"Andre Admin\""));
885 assert!(rewritten.starts_with(" DO $$"));
886 }
887
888 #[test]
889 fn test_rewrite_create_role_statements_noop_when_absent() {
890 let sql = "ALTER ROLE existing WITH LOGIN;\n";
891 assert!(rewrite_create_role_statements(sql).is_none());
892 }
893
894 #[test]
895 fn test_remove_restricted_role_grants() {
896 let dir = tempdir().unwrap();
897 let globals_file = dir.path().join("globals.sql");
898
899 let content = r#"CREATE ROLE myuser;
901ALTER ROLE myuser WITH LOGIN;
902GRANT pg_checkpoint TO myuser;
903GRANT "pg_read_all_stats" TO myuser;
904GRANT pg_monitor TO myuser;
905GRANT myrole TO myuser;
906GRANT SELECT ON TABLE users TO myuser;
907GRANT rds_superuser TO myuser GRANTED BY rdsadmin;
908GRANT ALL ON SCHEMA public TO myuser GRANTED BY "rdsadmin";
909GRANT SELECT ON TABLE orders TO myuser GRANTED BY postgres;
910"#;
911 std::fs::write(&globals_file, content).unwrap();
912
913 remove_restricted_role_grants(globals_file.to_str().unwrap()).unwrap();
915
916 let result = std::fs::read_to_string(&globals_file).unwrap();
918
919 assert!(result.contains("-- GRANT pg_checkpoint TO myuser;"));
921 assert!(result.contains("-- GRANT \"pg_read_all_stats\" TO myuser;"));
922 assert!(result.contains("-- GRANT pg_monitor TO myuser;"));
923
924 assert!(result.contains("-- GRANT rds_superuser TO myuser GRANTED BY rdsadmin;"));
926 assert!(result.contains("-- GRANT ALL ON SCHEMA public TO myuser GRANTED BY \"rdsadmin\";"));
927
928 assert!(result.contains("\nGRANT myrole TO myuser;\n"));
930 assert!(result.contains("\nGRANT SELECT ON TABLE users TO myuser;\n"));
931 assert!(result.contains("\nGRANT SELECT ON TABLE orders TO myuser GRANTED BY postgres;\n"));
932
933 assert!(result.contains("CREATE ROLE myuser;"));
935 assert!(result.contains("ALTER ROLE myuser WITH LOGIN;"));
936 }
937}