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 access_method: None,
182 tablespace: None,
183 });
184 let rendered = render_catalog(&cat);
185 let schema_pos = rendered.find("CREATE SCHEMA").unwrap();
186 let table_pos = rendered.find("CREATE TABLE").unwrap();
187 assert!(schema_pos < table_pos, "schema must come before table");
188 }
189
190 #[test]
191 #[allow(clippy::too_many_lines)] fn fk_rendered_after_table() {
193 let mut cat = Catalog::empty();
194 cat.schemas.push(Schema::new(id("app")));
195 cat.tables.push(Table {
196 qname: qn("app", "orgs"),
197 columns: vec![Column {
198 name: id("id"),
199 ty: ColumnType::BigInt,
200 nullable: false,
201 default: None,
202 identity: None,
203 generated: None,
204 collation: None,
205 storage: None,
206 compression: None,
207 comment: None,
208 }],
209 constraints: vec![Constraint {
210 qname: qn("app", "orgs_pkey"),
211 kind: ConstraintKind::PrimaryKey {
212 columns: vec![id("id")],
213 include: vec![],
214 },
215 deferrable: Deferrable::NotDeferrable,
216 comment: None,
217 }],
218 partition_by: None,
219 partition_of: None,
220 comment: None,
221 owner: None,
222 grants: vec![],
223 rls_enabled: false,
224 rls_forced: false,
225 policies: vec![],
226 storage: crate::ir::reloptions::TableStorageOptions::default(),
227 access_method: None,
228 tablespace: None,
229 });
230 cat.tables.push(Table {
231 qname: qn("app", "users"),
232 columns: vec![
233 Column {
234 name: id("id"),
235 ty: ColumnType::BigInt,
236 nullable: false,
237 default: None,
238 identity: None,
239 generated: None,
240 collation: None,
241 storage: None,
242 compression: None,
243 comment: None,
244 },
245 Column {
246 name: id("org_id"),
247 ty: ColumnType::BigInt,
248 nullable: false,
249 default: None,
250 identity: None,
251 generated: None,
252 collation: None,
253 storage: None,
254 compression: None,
255 comment: None,
256 },
257 ],
258 constraints: vec![
259 Constraint {
260 qname: qn("app", "users_pkey"),
261 kind: ConstraintKind::PrimaryKey {
262 columns: vec![id("id")],
263 include: vec![],
264 },
265 deferrable: Deferrable::NotDeferrable,
266 comment: None,
267 },
268 Constraint {
269 qname: qn("app", "users_org_fkey"),
270 kind: ConstraintKind::ForeignKey(ForeignKey {
271 columns: vec![id("org_id")],
272 referenced_table: qn("app", "orgs"),
273 referenced_columns: vec![id("id")],
274 on_update: ReferentialAction::NoAction,
275 on_delete: ReferentialAction::NoAction,
276 match_type: FkMatchType::Simple,
277 }),
278 deferrable: Deferrable::NotDeferrable,
279 comment: None,
280 },
281 ],
282 partition_by: None,
283 partition_of: None,
284 comment: None,
285 owner: None,
286 grants: vec![],
287 rls_enabled: false,
288 rls_forced: false,
289 policies: vec![],
290 storage: crate::ir::reloptions::TableStorageOptions::default(),
291 access_method: None,
292 tablespace: None,
293 });
294
295 let rendered = render_catalog(&cat);
296
297 assert!(
299 rendered.contains("ALTER TABLE"),
300 "expected ALTER TABLE for FK"
301 );
302 let create_table_pos = rendered.rfind("CREATE TABLE").unwrap();
303 let alter_pos = rendered.find("ALTER TABLE").unwrap();
304 assert!(
305 create_table_pos < alter_pos,
306 "ALTER TABLE must come after last CREATE TABLE"
307 );
308
309 let create_table_end = rendered.find("ALTER TABLE").unwrap();
311 let create_table_section = &rendered[..create_table_end];
312 assert!(
313 !create_table_section.contains("FOREIGN KEY"),
314 "FK should not be inline in CREATE TABLE"
315 );
316 }
317
318 #[test]
319 fn indexes_rendered_after_tables() {
320 let mut cat = Catalog::empty();
321 cat.tables.push(Table {
322 qname: qn("app", "users"),
323 columns: vec![Column {
324 name: id("email"),
325 ty: ColumnType::Text,
326 nullable: false,
327 default: None,
328 identity: None,
329 generated: None,
330 collation: None,
331 storage: None,
332 compression: None,
333 comment: None,
334 }],
335 constraints: vec![],
336 partition_by: None,
337 partition_of: None,
338 comment: None,
339 owner: None,
340 grants: vec![],
341 rls_enabled: false,
342 rls_forced: false,
343 policies: vec![],
344 storage: crate::ir::reloptions::TableStorageOptions::default(),
345 access_method: None,
346 tablespace: None,
347 });
348 cat.indexes.push(Index {
349 qname: qn("app", "users_email_idx"),
350 on: IndexParent::Table(qn("app", "users")),
351 method: IndexMethod::BTree,
352 columns: vec![IndexColumn {
353 expr: IndexColumnExpr::Column(id("email")),
354 collation: None,
355 opclass: None,
356 sort_order: SortOrder::Asc,
357 nulls_order: NullsOrder::NullsLast,
358 }],
359 include: vec![],
360 unique: true,
361 nulls_not_distinct: false,
362 predicate: None,
363 tablespace: None,
364 comment: None,
365 storage: crate::ir::reloptions::IndexStorageOptions::default(),
366 });
367
368 let rendered = render_catalog(&cat);
369 let table_pos = rendered.find("CREATE TABLE").unwrap();
370 let index_pos = rendered.find("CREATE UNIQUE INDEX").unwrap();
371 assert!(table_pos < index_pos, "index must come after table");
372 }
373
374 #[test]
375 fn sequences_rendered_last() {
376 let mut cat = Catalog::empty();
377 cat.sequences.push(Sequence {
378 qname: qn("app", "users_id_seq"),
379 data_type: ColumnType::BigInt,
380 start: 1,
381 increment: 1,
382 min_value: None,
383 max_value: None,
384 cache: 1,
385 cycle: false,
386 owned_by: None,
387 comment: None,
388 owner: None,
389 grants: vec![],
390 });
391 cat.tables.push(Table {
392 qname: qn("app", "users"),
393 columns: vec![Column {
394 name: id("id"),
395 ty: ColumnType::BigInt,
396 nullable: false,
397 default: None,
398 identity: None,
399 generated: None,
400 collation: None,
401 storage: None,
402 compression: None,
403 comment: None,
404 }],
405 constraints: vec![],
406 partition_by: None,
407 partition_of: None,
408 comment: None,
409 owner: None,
410 grants: vec![],
411 rls_enabled: false,
412 rls_forced: false,
413 policies: vec![],
414 storage: crate::ir::reloptions::TableStorageOptions::default(),
415 access_method: None,
416 tablespace: None,
417 });
418
419 let rendered = render_catalog(&cat);
420 let table_pos = rendered.find("CREATE TABLE").unwrap();
421 let seq_pos = rendered.find("CREATE SEQUENCE").unwrap();
422 assert!(table_pos < seq_pos, "sequence must come after table");
423 }
424
425 #[test]
426 fn parseable_by_pg_query() {
427 let mut cat = Catalog::empty();
428 cat.schemas.push(Schema::new(id("app")));
429 cat.tables.push(Table {
430 qname: qn("app", "users"),
431 columns: vec![
432 Column {
433 name: id("id"),
434 ty: ColumnType::BigInt,
435 nullable: false,
436 default: None,
437 identity: None,
438 generated: None,
439 collation: None,
440 storage: None,
441 compression: None,
442 comment: None,
443 },
444 Column {
445 name: id("email"),
446 ty: ColumnType::Text,
447 nullable: false,
448 default: None,
449 identity: None,
450 generated: None,
451 collation: None,
452 storage: None,
453 compression: None,
454 comment: None,
455 },
456 ],
457 constraints: vec![Constraint {
458 qname: qn("app", "users_pkey"),
459 kind: ConstraintKind::PrimaryKey {
460 columns: vec![id("id")],
461 include: vec![],
462 },
463 deferrable: Deferrable::NotDeferrable,
464 comment: None,
465 }],
466 partition_by: None,
467 partition_of: None,
468 comment: Some("user accounts".into()),
469 owner: None,
470 grants: vec![],
471 rls_enabled: false,
472 rls_forced: false,
473 policies: vec![],
474 storage: crate::ir::reloptions::TableStorageOptions::default(),
475 access_method: None,
476 tablespace: None,
477 });
478 cat.indexes.push(Index {
479 qname: qn("app", "users_email_idx"),
480 on: IndexParent::Table(qn("app", "users")),
481 method: IndexMethod::BTree,
482 columns: vec![IndexColumn {
483 expr: IndexColumnExpr::Column(id("email")),
484 collation: None,
485 opclass: None,
486 sort_order: SortOrder::Asc,
487 nulls_order: NullsOrder::NullsLast,
488 }],
489 include: vec![],
490 unique: true,
491 nulls_not_distinct: false,
492 predicate: None,
493 tablespace: None,
494 comment: None,
495 storage: crate::ir::reloptions::IndexStorageOptions::default(),
496 });
497
498 let rendered = render_catalog(&cat);
499
500 let r = pg_query::parse(&rendered);
502 assert!(
503 r.is_ok(),
504 "pg_query rejected rendered catalog:\n{rendered}\nerr: {r:?}"
505 );
506 }
507
508 #[test]
509 fn views_rendered_after_sequences() {
510 use crate::ir::view::View;
511 use crate::parse::normalize_body::NormalizedBody;
512
513 let mut cat = Catalog::empty();
514 cat.sequences.push(Sequence {
515 qname: qn("app", "users_id_seq"),
516 data_type: ColumnType::BigInt,
517 start: 1,
518 increment: 1,
519 min_value: None,
520 max_value: None,
521 cache: 1,
522 cycle: false,
523 owned_by: None,
524 comment: None,
525 owner: None,
526 grants: vec![],
527 });
528 cat.views.push(View {
529 qname: qn("app", "active_users"),
530 columns: vec![],
531 body_canonical: NormalizedBody::from_sql("SELECT 1").unwrap(),
532 body_dependencies: vec![],
533 security_barrier: None,
534 security_invoker: None,
535 check_option: None,
536 comment: None,
537 raw_body: String::new(),
538 owner: None,
539 grants: vec![],
540 });
541
542 let rendered = render_catalog(&cat);
543 let seq_pos = rendered.find("CREATE SEQUENCE").unwrap();
544 let view_pos = rendered.find("CREATE VIEW").unwrap();
545 assert!(seq_pos < view_pos, "views must come after sequences");
546 }
547
548 #[test]
549 fn materialized_views_rendered_in_catalog() {
550 use crate::ir::view::MaterializedView;
551 use crate::parse::normalize_body::NormalizedBody;
552
553 let mut cat = Catalog::empty();
554 cat.materialized_views.push(MaterializedView {
555 qname: qn("app", "summary"),
556 columns: vec![],
557 body_canonical: NormalizedBody::from_sql("SELECT count(*) FROM app.users").unwrap(),
558 body_dependencies: vec![],
559 comment: None,
560 raw_body: String::new(),
561 owner: None,
562 grants: vec![],
563 storage: crate::ir::reloptions::MaterializedViewStorageOptions::default(),
564 });
565
566 let rendered = render_catalog(&cat);
567 assert!(
568 rendered.contains("CREATE MATERIALIZED VIEW"),
569 "expected CREATE MATERIALIZED VIEW: {rendered}"
570 );
571 }
572}