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