1pub mod index;
29pub mod schema;
30pub mod sequence;
31pub mod table;
32pub mod view;
33
34use crate::ir::catalog::Catalog;
35use crate::ir::constraint::ConstraintKind;
36
37#[must_use]
44pub fn render_catalog(catalog: &Catalog) -> String {
45 let mut out = String::new();
46
47 for s in &catalog.schemas {
49 out.push_str(&schema::render_schema(s));
50 out.push('\n');
51 }
52 if !catalog.schemas.is_empty() {
53 out.push('\n');
54 }
55
56 for t in &catalog.tables {
58 out.push_str(&table::render_table(t));
59 out.push('\n');
60 }
61 if !catalog.tables.is_empty() {
62 out.push('\n');
63 }
64
65 let mut had_fk = false;
67 for t in &catalog.tables {
68 for c in &t.constraints {
69 if matches!(c.kind, ConstraintKind::ForeignKey(_)) {
70 out.push_str(&table::render_add_fk(&t.qname, c));
71 out.push('\n');
72 had_fk = true;
73 }
74 }
75 }
76 if had_fk {
77 out.push('\n');
78 }
79
80 for i in &catalog.indexes {
82 out.push_str(&index::render_index(i));
83 out.push('\n');
84 }
85 if !catalog.indexes.is_empty() {
86 out.push('\n');
87 }
88
89 for s in &catalog.sequences {
91 out.push_str(&sequence::render_sequence(s));
92 out.push('\n');
93 }
94 if !catalog.sequences.is_empty() {
95 out.push('\n');
96 }
97
98 for v in &catalog.views {
100 out.push_str(&view::render_view(v));
101 out.push('\n');
102 }
103 if !catalog.views.is_empty() {
104 out.push('\n');
105 }
106
107 for mv in &catalog.materialized_views {
109 out.push_str(&view::render_materialized_view(mv));
110 out.push('\n');
111 }
112
113 while out.ends_with("\n\n") {
115 out.pop();
116 }
117
118 out
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::identifier::{Identifier, QualifiedName};
125 use crate::ir::catalog::Catalog;
126 use crate::ir::column::Column;
127 use crate::ir::column_type::ColumnType;
128 use crate::ir::constraint::{
129 Constraint, ConstraintKind, Deferrable, FkMatchType, ForeignKey, ReferentialAction,
130 };
131 use crate::ir::index::{
132 Index, IndexColumn, IndexColumnExpr, IndexMethod, IndexParent, NullsOrder, SortOrder,
133 };
134 use crate::ir::schema::Schema;
135 use crate::ir::sequence::Sequence;
136 use crate::ir::table::Table;
137
138 fn id(s: &str) -> Identifier {
139 Identifier::from_unquoted(s).unwrap()
140 }
141
142 fn qn(schema: &str, name: &str) -> QualifiedName {
143 QualifiedName::new(id(schema), id(name))
144 }
145
146 #[test]
147 fn empty_catalog_renders_empty() {
148 let rendered = render_catalog(&Catalog::empty());
149 assert!(rendered.trim().is_empty());
151 }
152
153 #[test]
154 fn schema_renders_first() {
155 let mut cat = Catalog::empty();
156 cat.schemas.push(Schema::new(id("app")));
157 cat.tables.push(Table {
158 qname: qn("app", "users"),
159 columns: vec![Column {
160 name: id("id"),
161 ty: ColumnType::BigInt,
162 nullable: false,
163 default: None,
164 identity: None,
165 generated: None,
166 collation: None,
167 storage: None,
168 compression: None,
169 comment: None,
170 }],
171 constraints: vec![],
172 partition_by: None,
173 partition_of: None,
174 comment: None,
175 owner: None,
176 grants: vec![],
177 rls_enabled: false,
178 rls_forced: false,
179 policies: vec![],
180 storage: crate::ir::reloptions::TableStorageOptions::default(),
181 });
182 let rendered = render_catalog(&cat);
183 let schema_pos = rendered.find("CREATE SCHEMA").unwrap();
184 let table_pos = rendered.find("CREATE TABLE").unwrap();
185 assert!(schema_pos < table_pos, "schema must come before table");
186 }
187
188 #[test]
189 #[allow(clippy::too_many_lines)] fn fk_rendered_after_table() {
191 let mut cat = Catalog::empty();
192 cat.schemas.push(Schema::new(id("app")));
193 cat.tables.push(Table {
194 qname: qn("app", "orgs"),
195 columns: vec![Column {
196 name: id("id"),
197 ty: ColumnType::BigInt,
198 nullable: false,
199 default: None,
200 identity: None,
201 generated: None,
202 collation: None,
203 storage: None,
204 compression: None,
205 comment: None,
206 }],
207 constraints: vec![Constraint {
208 qname: qn("app", "orgs_pkey"),
209 kind: ConstraintKind::PrimaryKey {
210 columns: vec![id("id")],
211 include: vec![],
212 },
213 deferrable: Deferrable::NotDeferrable,
214 comment: None,
215 }],
216 partition_by: None,
217 partition_of: None,
218 comment: None,
219 owner: None,
220 grants: vec![],
221 rls_enabled: false,
222 rls_forced: false,
223 policies: vec![],
224 storage: crate::ir::reloptions::TableStorageOptions::default(),
225 });
226 cat.tables.push(Table {
227 qname: qn("app", "users"),
228 columns: vec![
229 Column {
230 name: id("id"),
231 ty: ColumnType::BigInt,
232 nullable: false,
233 default: None,
234 identity: None,
235 generated: None,
236 collation: None,
237 storage: None,
238 compression: None,
239 comment: None,
240 },
241 Column {
242 name: id("org_id"),
243 ty: ColumnType::BigInt,
244 nullable: false,
245 default: None,
246 identity: None,
247 generated: None,
248 collation: None,
249 storage: None,
250 compression: None,
251 comment: None,
252 },
253 ],
254 constraints: vec![
255 Constraint {
256 qname: qn("app", "users_pkey"),
257 kind: ConstraintKind::PrimaryKey {
258 columns: vec![id("id")],
259 include: vec![],
260 },
261 deferrable: Deferrable::NotDeferrable,
262 comment: None,
263 },
264 Constraint {
265 qname: qn("app", "users_org_fkey"),
266 kind: ConstraintKind::ForeignKey(ForeignKey {
267 columns: vec![id("org_id")],
268 referenced_table: qn("app", "orgs"),
269 referenced_columns: vec![id("id")],
270 on_update: ReferentialAction::NoAction,
271 on_delete: ReferentialAction::NoAction,
272 match_type: FkMatchType::Simple,
273 }),
274 deferrable: Deferrable::NotDeferrable,
275 comment: None,
276 },
277 ],
278 partition_by: None,
279 partition_of: None,
280 comment: None,
281 owner: None,
282 grants: vec![],
283 rls_enabled: false,
284 rls_forced: false,
285 policies: vec![],
286 storage: crate::ir::reloptions::TableStorageOptions::default(),
287 });
288
289 let rendered = render_catalog(&cat);
290
291 assert!(
293 rendered.contains("ALTER TABLE"),
294 "expected ALTER TABLE for FK"
295 );
296 let create_table_pos = rendered.rfind("CREATE TABLE").unwrap();
297 let alter_pos = rendered.find("ALTER TABLE").unwrap();
298 assert!(
299 create_table_pos < alter_pos,
300 "ALTER TABLE must come after last CREATE TABLE"
301 );
302
303 let create_table_end = rendered.find("ALTER TABLE").unwrap();
305 let create_table_section = &rendered[..create_table_end];
306 assert!(
307 !create_table_section.contains("FOREIGN KEY"),
308 "FK should not be inline in CREATE TABLE"
309 );
310 }
311
312 #[test]
313 fn indexes_rendered_after_tables() {
314 let mut cat = Catalog::empty();
315 cat.tables.push(Table {
316 qname: qn("app", "users"),
317 columns: vec![Column {
318 name: id("email"),
319 ty: ColumnType::Text,
320 nullable: false,
321 default: None,
322 identity: None,
323 generated: None,
324 collation: None,
325 storage: None,
326 compression: None,
327 comment: None,
328 }],
329 constraints: vec![],
330 partition_by: None,
331 partition_of: None,
332 comment: None,
333 owner: None,
334 grants: vec![],
335 rls_enabled: false,
336 rls_forced: false,
337 policies: vec![],
338 storage: crate::ir::reloptions::TableStorageOptions::default(),
339 });
340 cat.indexes.push(Index {
341 qname: qn("app", "users_email_idx"),
342 on: IndexParent::Table(qn("app", "users")),
343 method: IndexMethod::BTree,
344 columns: vec![IndexColumn {
345 expr: IndexColumnExpr::Column(id("email")),
346 collation: None,
347 opclass: None,
348 sort_order: SortOrder::Asc,
349 nulls_order: NullsOrder::NullsLast,
350 }],
351 include: vec![],
352 unique: true,
353 nulls_not_distinct: false,
354 predicate: None,
355 tablespace: None,
356 comment: None,
357 storage: crate::ir::reloptions::IndexStorageOptions::default(),
358 });
359
360 let rendered = render_catalog(&cat);
361 let table_pos = rendered.find("CREATE TABLE").unwrap();
362 let index_pos = rendered.find("CREATE UNIQUE INDEX").unwrap();
363 assert!(table_pos < index_pos, "index must come after table");
364 }
365
366 #[test]
367 fn sequences_rendered_last() {
368 let mut cat = Catalog::empty();
369 cat.sequences.push(Sequence {
370 qname: qn("app", "users_id_seq"),
371 data_type: ColumnType::BigInt,
372 start: 1,
373 increment: 1,
374 min_value: None,
375 max_value: None,
376 cache: 1,
377 cycle: false,
378 owned_by: None,
379 comment: None,
380 owner: None,
381 grants: vec![],
382 });
383 cat.tables.push(Table {
384 qname: qn("app", "users"),
385 columns: vec![Column {
386 name: id("id"),
387 ty: ColumnType::BigInt,
388 nullable: false,
389 default: None,
390 identity: None,
391 generated: None,
392 collation: None,
393 storage: None,
394 compression: None,
395 comment: None,
396 }],
397 constraints: vec![],
398 partition_by: None,
399 partition_of: None,
400 comment: None,
401 owner: None,
402 grants: vec![],
403 rls_enabled: false,
404 rls_forced: false,
405 policies: vec![],
406 storage: crate::ir::reloptions::TableStorageOptions::default(),
407 });
408
409 let rendered = render_catalog(&cat);
410 let table_pos = rendered.find("CREATE TABLE").unwrap();
411 let seq_pos = rendered.find("CREATE SEQUENCE").unwrap();
412 assert!(table_pos < seq_pos, "sequence must come after table");
413 }
414
415 #[test]
416 fn parseable_by_pg_query() {
417 let mut cat = Catalog::empty();
418 cat.schemas.push(Schema::new(id("app")));
419 cat.tables.push(Table {
420 qname: qn("app", "users"),
421 columns: vec![
422 Column {
423 name: id("id"),
424 ty: ColumnType::BigInt,
425 nullable: false,
426 default: None,
427 identity: None,
428 generated: None,
429 collation: None,
430 storage: None,
431 compression: None,
432 comment: None,
433 },
434 Column {
435 name: id("email"),
436 ty: ColumnType::Text,
437 nullable: false,
438 default: None,
439 identity: None,
440 generated: None,
441 collation: None,
442 storage: None,
443 compression: None,
444 comment: None,
445 },
446 ],
447 constraints: vec![Constraint {
448 qname: qn("app", "users_pkey"),
449 kind: ConstraintKind::PrimaryKey {
450 columns: vec![id("id")],
451 include: vec![],
452 },
453 deferrable: Deferrable::NotDeferrable,
454 comment: None,
455 }],
456 partition_by: None,
457 partition_of: None,
458 comment: Some("user accounts".into()),
459 owner: None,
460 grants: vec![],
461 rls_enabled: false,
462 rls_forced: false,
463 policies: vec![],
464 storage: crate::ir::reloptions::TableStorageOptions::default(),
465 });
466 cat.indexes.push(Index {
467 qname: qn("app", "users_email_idx"),
468 on: IndexParent::Table(qn("app", "users")),
469 method: IndexMethod::BTree,
470 columns: vec![IndexColumn {
471 expr: IndexColumnExpr::Column(id("email")),
472 collation: None,
473 opclass: None,
474 sort_order: SortOrder::Asc,
475 nulls_order: NullsOrder::NullsLast,
476 }],
477 include: vec![],
478 unique: true,
479 nulls_not_distinct: false,
480 predicate: None,
481 tablespace: None,
482 comment: None,
483 storage: crate::ir::reloptions::IndexStorageOptions::default(),
484 });
485
486 let rendered = render_catalog(&cat);
487
488 let r = pg_query::parse(&rendered);
490 assert!(
491 r.is_ok(),
492 "pg_query rejected rendered catalog:\n{rendered}\nerr: {r:?}"
493 );
494 }
495
496 #[test]
497 fn views_rendered_after_sequences() {
498 use crate::ir::view::View;
499 use crate::parse::normalize_body::NormalizedBody;
500
501 let mut cat = Catalog::empty();
502 cat.sequences.push(Sequence {
503 qname: qn("app", "users_id_seq"),
504 data_type: ColumnType::BigInt,
505 start: 1,
506 increment: 1,
507 min_value: None,
508 max_value: None,
509 cache: 1,
510 cycle: false,
511 owned_by: None,
512 comment: None,
513 owner: None,
514 grants: vec![],
515 });
516 cat.views.push(View {
517 qname: qn("app", "active_users"),
518 columns: vec![],
519 body_canonical: NormalizedBody::from_sql("SELECT 1").unwrap(),
520 body_dependencies: vec![],
521 security_barrier: None,
522 security_invoker: None,
523 comment: None,
524 raw_body: String::new(),
525 owner: None,
526 grants: vec![],
527 });
528
529 let rendered = render_catalog(&cat);
530 let seq_pos = rendered.find("CREATE SEQUENCE").unwrap();
531 let view_pos = rendered.find("CREATE VIEW").unwrap();
532 assert!(seq_pos < view_pos, "views must come after sequences");
533 }
534
535 #[test]
536 fn materialized_views_rendered_in_catalog() {
537 use crate::ir::view::MaterializedView;
538 use crate::parse::normalize_body::NormalizedBody;
539
540 let mut cat = Catalog::empty();
541 cat.materialized_views.push(MaterializedView {
542 qname: qn("app", "summary"),
543 columns: vec![],
544 body_canonical: NormalizedBody::from_sql("SELECT count(*) FROM app.users").unwrap(),
545 body_dependencies: vec![],
546 comment: None,
547 raw_body: String::new(),
548 owner: None,
549 grants: vec![],
550 storage: crate::ir::reloptions::MaterializedViewStorageOptions::default(),
551 });
552
553 let rendered = render_catalog(&cat);
554 assert!(
555 rendered.contains("CREATE MATERIALIZED VIEW"),
556 "expected CREATE MATERIALIZED VIEW: {rendered}"
557 );
558 }
559}