1use crate::features;
2use serde::{Deserialize, Serialize};
3use std::borrow::Cow;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct TableDefinition {
7 pub name: String,
8 pub columns: Vec<ColumnDefinition>,
9 #[serde(default, skip_serializing_if = "Vec::is_empty")]
10 pub indexes: Vec<IndexDefinition>,
11 #[serde(default)]
12 pub soft_delete: bool,
13 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub owner_field: Option<String>,
17 #[serde(default)]
20 pub auth_required: bool,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub permission_area: Option<String>,
26 #[serde(default)]
34 pub owner_self_service: bool,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub hooks: Option<TableHooks>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub scoped_by: Option<String>,
44 #[serde(
48 default = "crate::serde_util::yes",
49 skip_serializing_if = "crate::serde_util::is_yes"
50 )]
51 pub managed: bool,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub checks: Vec<CheckConstraint>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub search: Option<SearchSpec>,
57 #[serde(default, skip_serializing_if = "Vec::is_empty")]
59 pub audit_redact: Vec<String>,
60 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
62 pub sensitive: bool,
63 #[serde(default, skip_serializing_if = "Pagination::is_offset")]
64 pub pagination: Pagination,
65}
66
67impl Default for TableDefinition {
68 fn default() -> Self {
69 Self {
70 name: String::new(),
71 columns: Vec::new(),
72 indexes: Vec::new(),
73 soft_delete: false,
74 owner_field: None,
75 auth_required: false,
76 permission_area: None,
77 owner_self_service: false,
78 hooks: None,
79 scoped_by: None,
80 managed: true,
81 checks: Vec::new(),
82 search: None,
83 audit_redact: Vec::new(),
84 sensitive: false,
85 pagination: Pagination::default(),
86 }
87 }
88}
89
90#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum Pagination {
94 #[default]
96 Offset,
97 Keyset,
99}
100
101impl Pagination {
102 pub fn is_offset(&self) -> bool {
103 *self == Pagination::Offset
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct SearchSpec {
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub columns: Vec<String>,
112 #[serde(
114 default = "SearchSpec::default_config",
115 skip_serializing_if = "SearchSpec::is_default_config"
116 )]
117 pub config: String,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub vector_column: Option<String>,
122}
123
124impl SearchSpec {
125 pub const DEFAULT_CONFIG: &'static str = "simple";
126
127 pub fn default_config() -> String {
128 Self::DEFAULT_CONFIG.to_string()
129 }
130
131 pub fn is_default_config(config: &str) -> bool {
132 config == Self::DEFAULT_CONFIG
133 }
134}
135
136impl Default for SearchSpec {
137 fn default() -> Self {
138 Self {
139 columns: Vec::new(),
140 config: Self::default_config(),
141 vector_column: None,
142 }
143 }
144}
145
146#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
147pub struct CheckConstraint {
148 pub name: String,
149 pub expr: String,
151}
152
153#[derive(Debug, Clone, Default, Serialize, Deserialize)]
154pub struct ColumnDefinition {
155 pub name: String,
156 pub column_type: ColumnType,
157 #[serde(default)]
158 pub primary_key: bool,
159 #[serde(default)]
160 pub nullable: bool,
161 #[serde(default)]
162 pub auto_generate: bool,
163 pub default_value: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub references: Option<String>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub on_delete: Option<ForeignKeyAction>,
170 #[serde(default)]
172 pub unique: bool,
173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 pub validations: Vec<ValidationRule>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
179#[serde(tag = "rule", rename_all = "snake_case")]
180pub enum ValidationRule {
182 Regex { pattern: String },
184 Min { value: f64 },
186 Max { value: f64 },
188 MinLength { value: usize },
190 MaxLength { value: usize },
192 OneOf { values: Vec<String> },
194}
195
196#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
197pub struct IndexDefinition {
198 pub name: String,
199 pub columns: Vec<String>,
200 #[serde(default)]
201 pub unique: bool,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub predicate: Option<String>,
205 #[serde(default, skip_serializing_if = "IndexMethod::is_btree")]
206 pub method: IndexMethod,
207}
208
209impl IndexDefinition {
210 pub fn is_v2(&self) -> bool {
214 self.predicate.is_some() || !self.method.is_btree()
215 }
216}
217
218#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum IndexMethod {
221 #[default]
222 Btree,
223 Gin,
224 Gist,
225 Hash,
226 Brin,
227}
228
229impl IndexMethod {
230 pub fn is_btree(&self) -> bool {
231 *self == IndexMethod::Btree
232 }
233
234 pub fn as_sql(self) -> &'static str {
235 match self {
236 IndexMethod::Btree => "btree",
237 IndexMethod::Gin => "gin",
238 IndexMethod::Gist => "gist",
239 IndexMethod::Hash => "hash",
240 IndexMethod::Brin => "brin",
241 }
242 }
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
246#[serde(rename_all = "snake_case")]
247pub enum ForeignKeyAction {
248 Cascade,
249 SetNull,
250 Restrict,
251 NoAction,
252}
253
254#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
255#[serde(rename_all = "lowercase")]
256pub enum ColumnType {
257 Uuid,
258 #[default]
259 Text,
260 Integer,
261 BigInteger,
262 Float,
263 Double,
264 Boolean,
265 Timestamp,
266 Date,
267 Jsonb,
268 #[serde(rename = "numeric")]
269 Numeric {
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 precision: Option<u16>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 scale: Option<u16>,
274 },
275 #[serde(rename = "varchar")]
276 Varchar {
277 length: u32,
278 },
279 #[serde(rename = "timestamp_naive")]
280 TimestampNaive,
281 #[serde(rename = "time")]
282 Time,
283 #[serde(rename = "bytea")]
284 Bytea,
285 #[serde(rename = "text_array")]
286 TextArray,
287 #[serde(rename = "uuid_array")]
288 UuidArray,
289 #[serde(rename = "integer_array")]
290 IntegerArray,
291 #[serde(rename = "biginteger_array")]
292 BigIntegerArray,
293 #[serde(rename = "jsonb_array")]
294 JsonbArray,
295}
296
297impl ColumnType {
298 pub const MAX_NUMERIC_PRECISION: u16 = 1000;
299 pub const MAX_VARCHAR_LENGTH: u32 = 10_485_760;
300
301 pub fn to_sql(&self) -> Cow<'static, str> {
302 match self {
303 ColumnType::Uuid => "UUID".into(),
304 ColumnType::Text => "TEXT".into(),
305 ColumnType::Integer => "INTEGER".into(),
306 ColumnType::BigInteger => "BIGINT".into(),
307 ColumnType::Float => "REAL".into(),
308 ColumnType::Double => "DOUBLE PRECISION".into(),
309 ColumnType::Boolean => "BOOLEAN".into(),
310 ColumnType::Timestamp => "TIMESTAMPTZ".into(),
311 ColumnType::Date => "DATE".into(),
312 ColumnType::Jsonb => "JSONB".into(),
313 ColumnType::Numeric {
314 precision: Some(precision),
315 scale: Some(scale),
316 } => format!("NUMERIC({precision},{scale})").into(),
317 ColumnType::Numeric {
318 precision: Some(precision),
319 scale: None,
320 } => format!("NUMERIC({precision})").into(),
321 ColumnType::Numeric {
322 precision: None, ..
323 } => "NUMERIC".into(),
324 ColumnType::Varchar { length } => format!("VARCHAR({length})").into(),
325 ColumnType::TimestampNaive => "TIMESTAMP".into(),
326 ColumnType::Time => "TIME".into(),
327 ColumnType::Bytea => "BYTEA".into(),
328 ColumnType::TextArray => "TEXT[]".into(),
329 ColumnType::UuidArray => "UUID[]".into(),
330 ColumnType::IntegerArray => "INTEGER[]".into(),
331 ColumnType::BigIntegerArray => "BIGINT[]".into(),
332 ColumnType::JsonbArray => "JSONB[]".into(),
333 }
334 }
335
336 pub fn is_v2(&self) -> bool {
338 !matches!(
339 self,
340 ColumnType::Uuid
341 | ColumnType::Text
342 | ColumnType::Integer
343 | ColumnType::BigInteger
344 | ColumnType::Float
345 | ColumnType::Double
346 | ColumnType::Boolean
347 | ColumnType::Timestamp
348 | ColumnType::Date
349 | ColumnType::Jsonb
350 )
351 }
352
353 pub fn pg_type_names(&self) -> &'static [&'static str] {
357 match self {
358 ColumnType::Uuid => &["uuid"],
359 ColumnType::Text => &["text", "character varying", "character", "citext"],
360 ColumnType::Integer => &["integer"],
361 ColumnType::BigInteger => &["bigint"],
362 ColumnType::Float => &["real"],
363 ColumnType::Double => &["double precision"],
364 ColumnType::Boolean => &["boolean"],
365 ColumnType::Timestamp => &["timestamp with time zone"],
366 ColumnType::Date => &["date"],
367 ColumnType::Jsonb => &["jsonb"],
368 ColumnType::Numeric { .. } => &["numeric"],
369 ColumnType::Varchar { .. } => &["character varying"],
370 ColumnType::TimestampNaive => &["timestamp without time zone"],
371 ColumnType::Time => &["time without time zone"],
372 ColumnType::Bytea => &["bytea"],
373 ColumnType::TextArray => &["text[]", "character varying[]"],
374 ColumnType::UuidArray => &["uuid[]"],
375 ColumnType::IntegerArray => &["integer[]"],
376 ColumnType::BigIntegerArray => &["bigint[]"],
377 ColumnType::JsonbArray => &["jsonb[]"],
378 }
379 }
380
381 pub fn filter_type(&self) -> &str {
383 match self {
384 ColumnType::Uuid => "uuid",
385 ColumnType::Text | ColumnType::Varchar { .. } => "text",
386 ColumnType::Integer | ColumnType::BigInteger => "integer",
387 ColumnType::Float | ColumnType::Double => "double",
388 ColumnType::Boolean => "boolean",
389 ColumnType::Timestamp | ColumnType::TimestampNaive => "timestamp",
390 ColumnType::Date => "date",
391 ColumnType::Jsonb => "jsonb",
392 ColumnType::Numeric { .. } => "numeric",
393 ColumnType::Time => "time",
394 ColumnType::Bytea => "bytea",
395 ColumnType::TextArray
396 | ColumnType::UuidArray
397 | ColumnType::IntegerArray
398 | ColumnType::BigIntegerArray
399 | ColumnType::JsonbArray => "array",
400 }
401 }
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize, Default)]
406pub struct TableHooks {
407 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub before_create: Option<String>,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub after_create: Option<String>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub before_update: Option<String>,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub after_update: Option<String>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub before_delete: Option<String>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub after_delete: Option<String>,
419}
420
421impl TableDefinition {
422 pub fn primary_key(&self) -> Option<&ColumnDefinition> {
424 self.columns.iter().find(|c| c.primary_key)
425 }
426
427 pub fn writable_columns(&self) -> Vec<&ColumnDefinition> {
429 self.columns
430 .iter()
431 .filter(|c| !c.auto_generate || !c.primary_key)
432 .collect()
433 }
434
435 pub fn derived_features(&self) -> impl Iterator<Item = &'static str> {
437 let types_v2 = !self.checks.is_empty()
438 || self.search.is_some()
439 || self.indexes.iter().any(IndexDefinition::is_v2)
440 || self.columns.iter().any(|c| c.column_type.is_v2());
441 let audit_redaction = !self.audit_redact.is_empty() || self.sensitive;
442 [
443 self.scoped_by
444 .is_some()
445 .then_some(features::SCOPED_PERMISSIONS),
446 (!self.managed).then_some(features::SCHEMA_ADOPTED),
447 types_v2.then_some(features::COLUMN_TYPES_V2),
448 audit_redaction.then_some(features::AUDIT_REDACTION),
449 (!self.pagination.is_offset()).then_some(features::KEYSET_LISTS),
450 ]
451 .into_iter()
452 .flatten()
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use crate::manifest::fixtures::sample_manifest;
460
461 #[test]
462 fn test_primary_key() {
463 let manifest = sample_manifest();
464 let table = manifest.get_table("todos").unwrap();
465 let pk = table.primary_key().unwrap();
466 assert_eq!(pk.name, "id");
467 assert!(pk.auto_generate);
468 }
469
470 #[test]
471 fn test_column_type_to_sql() {
472 assert_eq!(ColumnType::Uuid.to_sql(), "UUID");
473 assert_eq!(ColumnType::Text.to_sql(), "TEXT");
474 assert_eq!(ColumnType::Integer.to_sql(), "INTEGER");
475 assert_eq!(ColumnType::BigInteger.to_sql(), "BIGINT");
476 assert_eq!(ColumnType::Boolean.to_sql(), "BOOLEAN");
477 assert_eq!(ColumnType::Timestamp.to_sql(), "TIMESTAMPTZ");
478 assert_eq!(ColumnType::Date.to_sql(), "DATE");
479 assert_eq!(ColumnType::Jsonb.to_sql(), "JSONB");
480 }
481
482 fn v2_types() -> Vec<(ColumnType, serde_json::Value, &'static str)> {
483 use serde_json::json;
484 vec![
485 (
486 ColumnType::Numeric {
487 precision: Some(12),
488 scale: Some(2),
489 },
490 json!({"numeric": {"precision": 12, "scale": 2}}),
491 "NUMERIC(12,2)",
492 ),
493 (
494 ColumnType::Numeric {
495 precision: Some(9),
496 scale: None,
497 },
498 json!({"numeric": {"precision": 9}}),
499 "NUMERIC(9)",
500 ),
501 (
502 ColumnType::Numeric {
503 precision: None,
504 scale: None,
505 },
506 json!({"numeric": {}}),
507 "NUMERIC",
508 ),
509 (
510 ColumnType::Varchar { length: 50 },
511 json!({"varchar": {"length": 50}}),
512 "VARCHAR(50)",
513 ),
514 (
515 ColumnType::TimestampNaive,
516 json!("timestamp_naive"),
517 "TIMESTAMP",
518 ),
519 (ColumnType::Time, json!("time"), "TIME"),
520 (ColumnType::Bytea, json!("bytea"), "BYTEA"),
521 (ColumnType::TextArray, json!("text_array"), "TEXT[]"),
522 (ColumnType::UuidArray, json!("uuid_array"), "UUID[]"),
523 (
524 ColumnType::IntegerArray,
525 json!("integer_array"),
526 "INTEGER[]",
527 ),
528 (
529 ColumnType::BigIntegerArray,
530 json!("biginteger_array"),
531 "BIGINT[]",
532 ),
533 (ColumnType::JsonbArray, json!("jsonb_array"), "JSONB[]"),
534 ]
535 }
536
537 #[test]
538 fn v2_column_types_have_pinned_json_and_sql() {
539 for (column_type, wire, sql) in v2_types() {
540 assert_eq!(serde_json::to_value(&column_type).unwrap(), wire);
541 assert_eq!(
542 serde_json::from_value::<ColumnType>(wire).unwrap(),
543 column_type
544 );
545 assert_eq!(column_type.to_sql(), sql);
546 assert!(column_type.is_v2(), "{column_type:?}");
547 assert!(!column_type.pg_type_names().is_empty());
548 }
549 }
550
551 #[test]
552 fn legacy_column_types_are_unchanged() {
553 for (column_type, wire) in [
554 (ColumnType::Uuid, "uuid"),
555 (ColumnType::Text, "text"),
556 (ColumnType::Integer, "integer"),
557 (ColumnType::BigInteger, "biginteger"),
558 (ColumnType::Float, "float"),
559 (ColumnType::Double, "double"),
560 (ColumnType::Boolean, "boolean"),
561 (ColumnType::Timestamp, "timestamp"),
562 (ColumnType::Date, "date"),
563 (ColumnType::Jsonb, "jsonb"),
564 ] {
565 assert_eq!(
566 serde_json::to_value(&column_type).unwrap(),
567 serde_json::json!(wire)
568 );
569 assert!(!column_type.is_v2(), "{column_type:?}");
570 }
571 assert!(serde_json::from_str::<ColumnType>(r#""numeric""#).is_err());
572 assert!(serde_json::from_str::<ColumnType>(r#""interval""#).is_err());
573 }
574
575 #[test]
576 fn a_numeric_scale_needs_a_precision_to_reach_sql() {
577 let scale_only = ColumnType::Numeric {
578 precision: None,
579 scale: Some(2),
580 };
581 assert_eq!(scale_only.to_sql(), "NUMERIC");
582 }
583
584 fn column(name: &str, column_type: ColumnType) -> ColumnDefinition {
585 ColumnDefinition {
586 name: name.into(),
587 column_type,
588 primary_key: false,
589 nullable: true,
590 auto_generate: false,
591 default_value: None,
592 references: None,
593 on_delete: None,
594 unique: false,
595 validations: vec![],
596 }
597 }
598
599 #[test]
600 fn r3_table_fields_are_omitted_at_their_defaults() {
601 let table = TableDefinition {
602 name: "notes".into(),
603 ..Default::default()
604 };
605 assert!(table.managed);
606 assert_eq!(
607 serde_json::to_value(&table).unwrap(),
608 serde_json::json!({
609 "name": "notes",
610 "columns": [],
611 "soft_delete": false,
612 "auth_required": false,
613 "owner_self_service": false
614 })
615 );
616 let parsed: TableDefinition =
617 serde_json::from_str(r#"{"name":"notes","columns":[]}"#).unwrap();
618 assert!(parsed.managed);
619 assert_eq!(parsed.pagination, Pagination::Offset);
620 assert_eq!(table.derived_features().count(), 0);
621 }
622
623 #[test]
624 fn r3_table_fields_round_trip() {
625 let table = TableDefinition {
626 name: "lead".into(),
627 indexes: vec![IndexDefinition {
628 name: "uq_org_email".into(),
629 columns: vec!["organisation_id".into(), "email".into()],
630 unique: true,
631 predicate: Some("deleted_at IS NULL".into()),
632 method: IndexMethod::Gin,
633 }],
634 managed: false,
635 checks: vec![CheckConstraint {
636 name: "price_nonneg".into(),
637 expr: "price_cents >= 0".into(),
638 }],
639 search: Some(SearchSpec {
640 columns: vec!["name".into(), "email".into()],
641 config: "english".into(),
642 vector_column: Some("search_vector".into()),
643 }),
644 audit_redact: vec!["dob".into(), "bank_*".into()],
645 sensitive: true,
646 pagination: Pagination::Keyset,
647 ..Default::default()
648 };
649 let wire = serde_json::to_value(&table).unwrap();
650 assert_eq!(
651 wire,
652 serde_json::json!({
653 "name": "lead",
654 "columns": [],
655 "indexes": [{
656 "name": "uq_org_email",
657 "columns": ["organisation_id", "email"],
658 "unique": true,
659 "predicate": "deleted_at IS NULL",
660 "method": "gin"
661 }],
662 "soft_delete": false,
663 "auth_required": false,
664 "owner_self_service": false,
665 "managed": false,
666 "checks": [{"name": "price_nonneg", "expr": "price_cents >= 0"}],
667 "search": {
668 "columns": ["name", "email"],
669 "config": "english",
670 "vector_column": "search_vector"
671 },
672 "audit_redact": ["dob", "bank_*"],
673 "sensitive": true,
674 "pagination": "keyset"
675 })
676 );
677 let parsed: TableDefinition = serde_json::from_value(wire).unwrap();
678 assert!(!parsed.managed);
679 assert_eq!(parsed.indexes, table.indexes);
680 assert_eq!(parsed.checks, table.checks);
681 assert_eq!(parsed.search, table.search);
682 assert_eq!(parsed.audit_redact, table.audit_redact);
683 assert!(parsed.sensitive);
684 assert_eq!(parsed.pagination, Pagination::Keyset);
685 }
686
687 #[test]
688 fn a_default_search_config_is_omitted() {
689 let search = SearchSpec {
690 columns: vec!["name".into()],
691 ..Default::default()
692 };
693 let wire = serde_json::json!({"columns": ["name"]});
694 assert_eq!(serde_json::to_value(&search).unwrap(), wire);
695 assert_eq!(serde_json::from_value::<SearchSpec>(wire).unwrap(), search);
696 }
697
698 #[test]
699 fn a_legacy_index_is_unchanged() {
700 let index = IndexDefinition {
701 name: "idx_items_sku".into(),
702 columns: vec!["sku".into()],
703 ..Default::default()
704 };
705 assert_eq!(
706 serde_json::to_value(&index).unwrap(),
707 serde_json::json!({"name": "idx_items_sku", "columns": ["sku"], "unique": false})
708 );
709 assert!(!index.is_v2());
710 }
711
712 #[test]
713 fn an_index_over_several_columns_is_as_legacy_as_one_over_one() {
714 for unique in [false, true] {
715 let index = IndexDefinition {
716 name: "idx_items_branch_sku".into(),
717 columns: vec!["branch_id".into(), "sku".into()],
718 unique,
719 ..Default::default()
720 };
721 assert!(!index.is_v2(), "unique={unique}");
722 let table = TableDefinition {
723 name: "items".into(),
724 indexes: vec![index],
725 ..Default::default()
726 };
727 assert_eq!(table.derived_features().count(), 0, "unique={unique}");
728 }
729 }
730
731 #[test]
732 fn each_r3_table_field_demands_its_feature() {
733 let plain = || TableDefinition {
734 name: "t".into(),
735 ..Default::default()
736 };
737 let index = |index: IndexDefinition| TableDefinition {
738 indexes: vec![index],
739 ..plain()
740 };
741 let cases = [
742 (
743 TableDefinition {
744 managed: false,
745 ..plain()
746 },
747 features::SCHEMA_ADOPTED,
748 ),
749 (
750 TableDefinition {
751 checks: vec![CheckConstraint {
752 name: "c".into(),
753 expr: "a > 0".into(),
754 }],
755 ..plain()
756 },
757 features::COLUMN_TYPES_V2,
758 ),
759 (
760 TableDefinition {
761 search: Some(SearchSpec::default()),
762 ..plain()
763 },
764 features::COLUMN_TYPES_V2,
765 ),
766 (
767 TableDefinition {
768 columns: vec![column("tags", ColumnType::TextArray)],
769 ..plain()
770 },
771 features::COLUMN_TYPES_V2,
772 ),
773 (
774 index(IndexDefinition {
775 name: "i".into(),
776 columns: vec!["a".into()],
777 predicate: Some("a IS NOT NULL".into()),
778 ..Default::default()
779 }),
780 features::COLUMN_TYPES_V2,
781 ),
782 (
783 index(IndexDefinition {
784 name: "i".into(),
785 columns: vec!["a".into()],
786 method: IndexMethod::Brin,
787 ..Default::default()
788 }),
789 features::COLUMN_TYPES_V2,
790 ),
791 (
792 TableDefinition {
793 audit_redact: vec!["dob".into()],
794 ..plain()
795 },
796 features::AUDIT_REDACTION,
797 ),
798 (
799 TableDefinition {
800 sensitive: true,
801 ..plain()
802 },
803 features::AUDIT_REDACTION,
804 ),
805 (
806 TableDefinition {
807 pagination: Pagination::Keyset,
808 ..plain()
809 },
810 features::KEYSET_LISTS,
811 ),
812 ];
813 for (table, feature) in cases {
814 assert_eq!(
815 table.derived_features().collect::<Vec<_>>(),
816 [feature],
817 "{table:?}"
818 );
819 }
820 }
821
822 #[test]
823 fn test_fk_not_serialized_when_none() {
824 let col = ColumnDefinition {
825 name: "title".into(),
826 column_type: ColumnType::Text,
827 primary_key: false,
828 nullable: false,
829 auto_generate: false,
830 default_value: None,
831 references: None,
832 on_delete: None,
833 unique: false,
834 validations: vec![],
835 };
836 let json = serde_json::to_string(&col).unwrap();
837 assert!(!json.contains("references"));
838 assert!(!json.contains("on_delete"));
839 }
840
841 #[test]
842 fn test_unique_column_serialization() {
843 let col = ColumnDefinition {
844 name: "email".into(),
845 column_type: ColumnType::Text,
846 primary_key: false,
847 nullable: false,
848 auto_generate: false,
849 default_value: None,
850 references: None,
851 on_delete: None,
852 unique: true,
853 validations: vec![],
854 };
855 let json = serde_json::to_string(&col).unwrap();
856 assert!(json.contains("\"unique\":true"));
857 let parsed: ColumnDefinition = serde_json::from_str(&json).unwrap();
858 assert!(parsed.unique);
859 }
860
861 #[test]
862 fn test_index_serialization() {
863 let table = TableDefinition {
864 name: "users".into(),
865 columns: vec![],
866 indexes: vec![IndexDefinition {
867 name: "idx_users_email".into(),
868 columns: vec!["email".into()],
869 unique: true,
870 ..Default::default()
871 }],
872 soft_delete: false,
873 owner_field: None,
874 auth_required: false,
875 permission_area: None,
876 owner_self_service: false,
877 hooks: None,
878 scoped_by: None,
879 ..Default::default()
880 };
881 let json = serde_json::to_string(&table).unwrap();
882 assert!(json.contains("idx_users_email"));
883 let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
884 assert_eq!(parsed.indexes.len(), 1);
885 assert!(parsed.indexes[0].unique);
886 }
887
888 #[test]
889 fn test_indexes_not_serialized_when_empty() {
890 let table = TableDefinition {
891 name: "users".into(),
892 columns: vec![],
893 indexes: vec![],
894 soft_delete: false,
895 owner_field: None,
896 auth_required: false,
897 permission_area: None,
898 owner_self_service: false,
899 hooks: None,
900 scoped_by: None,
901 ..Default::default()
902 };
903 let json = serde_json::to_string(&table).unwrap();
904 assert!(!json.contains("indexes"));
905 }
906
907 #[test]
908 fn test_owner_field_serialization() {
909 let table = TableDefinition {
910 name: "notes".into(),
911 columns: vec![],
912 indexes: vec![],
913 soft_delete: false,
914 owner_field: Some("user_id".into()),
915 auth_required: false,
916 permission_area: None,
917 owner_self_service: false,
918 hooks: None,
919 scoped_by: None,
920 ..Default::default()
921 };
922 let json = serde_json::to_string(&table).unwrap();
923 assert!(json.contains("\"owner_field\":\"user_id\""));
924 let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
925 assert_eq!(parsed.owner_field.as_deref(), Some("user_id"));
926 }
927
928 #[test]
929 fn test_owner_field_not_serialized_when_none() {
930 let table = TableDefinition {
931 name: "notes".into(),
932 columns: vec![],
933 indexes: vec![],
934 soft_delete: false,
935 owner_field: None,
936 auth_required: false,
937 permission_area: None,
938 owner_self_service: false,
939 hooks: None,
940 scoped_by: None,
941 ..Default::default()
942 };
943 let json = serde_json::to_string(&table).unwrap();
944 assert!(!json.contains("owner_field"));
945 }
946
947 #[test]
948 fn test_owner_field_defaults_to_none() {
949 let json = r#"{"name":"notes","columns":[]}"#;
950 let table: TableDefinition = serde_json::from_str(json).unwrap();
951 assert!(table.owner_field.is_none());
952 }
953
954 #[test]
955 fn test_auth_required_serialization() {
956 let table = TableDefinition {
957 name: "orders".into(),
958 columns: vec![],
959 indexes: vec![],
960 soft_delete: false,
961 owner_field: None,
962 auth_required: true,
963 permission_area: None,
964 owner_self_service: false,
965 hooks: None,
966 scoped_by: None,
967 ..Default::default()
968 };
969 let json = serde_json::to_string(&table).unwrap();
970 assert!(json.contains("\"auth_required\":true"));
971 let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
972 assert!(parsed.auth_required);
973 }
974
975 #[test]
976 fn test_auth_required_defaults_to_false() {
977 let json = r#"{"name":"orders","columns":[]}"#;
978 let table: TableDefinition = serde_json::from_str(json).unwrap();
979 assert!(!table.auth_required);
980 }
981
982 #[test]
983 fn scoped_by_is_omitted_when_absent_and_derives_its_feature() {
984 let table: TableDefinition =
985 serde_json::from_str(r#"{"name":"leads","columns":[],"owner_field":"user_id"}"#)
986 .unwrap();
987 assert!(table.scoped_by.is_none());
988 assert!(!serde_json::to_string(&table).unwrap().contains("scoped_by"));
989 assert_eq!(table.derived_features().count(), 0);
990
991 let scoped = TableDefinition {
992 scoped_by: Some("leads".into()),
993 ..table
994 };
995 let json = serde_json::to_string(&scoped).unwrap();
996 assert!(json.contains(r#""scoped_by":"leads""#), "{json}");
997 assert_eq!(
998 scoped.derived_features().collect::<Vec<_>>(),
999 [crate::features::SCOPED_PERMISSIONS]
1000 );
1001 }
1002}