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 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub access: Option<ColumnAccess>,
179}
180
181#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
185pub struct ColumnAccess {
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub read: Option<String>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub write: Option<String>,
190}
191
192impl ColumnAccess {
193 pub fn in_area(&self, area: &str) -> ColumnAccess {
196 let permission = |operation: &Option<String>| {
197 operation
198 .as_ref()
199 .map(|operation| format!("{area}:{operation}"))
200 };
201 ColumnAccess {
202 read: permission(&self.read),
203 write: permission(&self.write),
204 }
205 }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
209#[serde(tag = "rule", rename_all = "snake_case")]
210pub enum ValidationRule {
211 Regex { pattern: String },
213 Min { value: f64 },
215 Max { value: f64 },
217 MinLength { value: usize },
219 MaxLength { value: usize },
221 OneOf { values: Vec<String> },
223}
224
225#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
226pub struct IndexDefinition {
227 pub name: String,
228 pub columns: Vec<String>,
229 #[serde(default)]
230 pub unique: bool,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub predicate: Option<String>,
234 #[serde(default, skip_serializing_if = "IndexMethod::is_btree")]
235 pub method: IndexMethod,
236}
237
238impl IndexDefinition {
239 pub fn is_v2(&self) -> bool {
243 self.predicate.is_some() || !self.method.is_btree()
244 }
245}
246
247#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "snake_case")]
249pub enum IndexMethod {
250 #[default]
251 Btree,
252 Gin,
253 Gist,
254 Hash,
255 Brin,
256}
257
258impl IndexMethod {
259 pub fn is_btree(&self) -> bool {
260 *self == IndexMethod::Btree
261 }
262
263 pub fn as_sql(self) -> &'static str {
264 match self {
265 IndexMethod::Btree => "btree",
266 IndexMethod::Gin => "gin",
267 IndexMethod::Gist => "gist",
268 IndexMethod::Hash => "hash",
269 IndexMethod::Brin => "brin",
270 }
271 }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
275#[serde(rename_all = "snake_case")]
276pub enum ForeignKeyAction {
277 Cascade,
278 SetNull,
279 Restrict,
280 NoAction,
281}
282
283#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
284#[serde(rename_all = "lowercase")]
285pub enum ColumnType {
286 Uuid,
287 #[default]
288 Text,
289 Integer,
290 BigInteger,
291 Float,
292 Double,
293 Boolean,
294 Timestamp,
295 Date,
296 Jsonb,
297 #[serde(rename = "numeric")]
298 Numeric {
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 precision: Option<u16>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 scale: Option<u16>,
303 },
304 #[serde(rename = "varchar")]
305 Varchar {
306 length: u32,
307 },
308 #[serde(rename = "timestamp_naive")]
309 TimestampNaive,
310 #[serde(rename = "time")]
311 Time,
312 #[serde(rename = "bytea")]
313 Bytea,
314 #[serde(rename = "text_array")]
315 TextArray,
316 #[serde(rename = "uuid_array")]
317 UuidArray,
318 #[serde(rename = "integer_array")]
319 IntegerArray,
320 #[serde(rename = "biginteger_array")]
321 BigIntegerArray,
322 #[serde(rename = "jsonb_array")]
323 JsonbArray,
324}
325
326impl ColumnType {
327 pub const MAX_NUMERIC_PRECISION: u16 = 1000;
328 pub const MAX_VARCHAR_LENGTH: u32 = 10_485_760;
329
330 pub fn to_sql(&self) -> Cow<'static, str> {
331 match self {
332 ColumnType::Uuid => "UUID".into(),
333 ColumnType::Text => "TEXT".into(),
334 ColumnType::Integer => "INTEGER".into(),
335 ColumnType::BigInteger => "BIGINT".into(),
336 ColumnType::Float => "REAL".into(),
337 ColumnType::Double => "DOUBLE PRECISION".into(),
338 ColumnType::Boolean => "BOOLEAN".into(),
339 ColumnType::Timestamp => "TIMESTAMPTZ".into(),
340 ColumnType::Date => "DATE".into(),
341 ColumnType::Jsonb => "JSONB".into(),
342 ColumnType::Numeric {
343 precision: Some(precision),
344 scale: Some(scale),
345 } => format!("NUMERIC({precision},{scale})").into(),
346 ColumnType::Numeric {
347 precision: Some(precision),
348 scale: None,
349 } => format!("NUMERIC({precision})").into(),
350 ColumnType::Numeric {
351 precision: None, ..
352 } => "NUMERIC".into(),
353 ColumnType::Varchar { length } => format!("VARCHAR({length})").into(),
354 ColumnType::TimestampNaive => "TIMESTAMP".into(),
355 ColumnType::Time => "TIME".into(),
356 ColumnType::Bytea => "BYTEA".into(),
357 ColumnType::TextArray => "TEXT[]".into(),
358 ColumnType::UuidArray => "UUID[]".into(),
359 ColumnType::IntegerArray => "INTEGER[]".into(),
360 ColumnType::BigIntegerArray => "BIGINT[]".into(),
361 ColumnType::JsonbArray => "JSONB[]".into(),
362 }
363 }
364
365 pub fn is_v2(&self) -> bool {
367 !matches!(
368 self,
369 ColumnType::Uuid
370 | ColumnType::Text
371 | ColumnType::Integer
372 | ColumnType::BigInteger
373 | ColumnType::Float
374 | ColumnType::Double
375 | ColumnType::Boolean
376 | ColumnType::Timestamp
377 | ColumnType::Date
378 | ColumnType::Jsonb
379 )
380 }
381
382 pub fn pg_type_names(&self) -> &'static [&'static str] {
386 match self {
387 ColumnType::Uuid => &["uuid"],
388 ColumnType::Text => &["text", "character varying", "character", "citext"],
389 ColumnType::Integer => &["integer"],
390 ColumnType::BigInteger => &["bigint"],
391 ColumnType::Float => &["real"],
392 ColumnType::Double => &["double precision"],
393 ColumnType::Boolean => &["boolean"],
394 ColumnType::Timestamp => &["timestamp with time zone"],
395 ColumnType::Date => &["date"],
396 ColumnType::Jsonb => &["jsonb"],
397 ColumnType::Numeric { .. } => &["numeric"],
398 ColumnType::Varchar { .. } => &["character varying"],
399 ColumnType::TimestampNaive => &["timestamp without time zone"],
400 ColumnType::Time => &["time without time zone"],
401 ColumnType::Bytea => &["bytea"],
402 ColumnType::TextArray => &["text[]", "character varying[]"],
403 ColumnType::UuidArray => &["uuid[]"],
404 ColumnType::IntegerArray => &["integer[]"],
405 ColumnType::BigIntegerArray => &["bigint[]"],
406 ColumnType::JsonbArray => &["jsonb[]"],
407 }
408 }
409
410 pub fn filter_type(&self) -> &str {
412 match self {
413 ColumnType::Uuid => "uuid",
414 ColumnType::Text | ColumnType::Varchar { .. } => "text",
415 ColumnType::Integer | ColumnType::BigInteger => "integer",
416 ColumnType::Float | ColumnType::Double => "double",
417 ColumnType::Boolean => "boolean",
418 ColumnType::Timestamp | ColumnType::TimestampNaive => "timestamp",
419 ColumnType::Date => "date",
420 ColumnType::Jsonb => "jsonb",
421 ColumnType::Numeric { .. } => "numeric",
422 ColumnType::Time => "time",
423 ColumnType::Bytea => "bytea",
424 ColumnType::TextArray
425 | ColumnType::UuidArray
426 | ColumnType::IntegerArray
427 | ColumnType::BigIntegerArray
428 | ColumnType::JsonbArray => "array",
429 }
430 }
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize, Default)]
435pub struct TableHooks {
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub before_create: Option<String>,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub after_create: Option<String>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub before_update: Option<String>,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub after_update: Option<String>,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub before_delete: Option<String>,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub after_delete: Option<String>,
448}
449
450impl TableDefinition {
451 pub fn primary_key(&self) -> Option<&ColumnDefinition> {
453 self.columns.iter().find(|c| c.primary_key)
454 }
455
456 pub fn writable_columns(&self) -> Vec<&ColumnDefinition> {
458 self.columns
459 .iter()
460 .filter(|c| !c.auto_generate || !c.primary_key)
461 .collect()
462 }
463
464 pub fn access_area(&self) -> Option<&str> {
467 self.scoped_by
468 .as_deref()
469 .or(self.permission_area.as_deref())
470 }
471
472 pub fn restricted_columns(&self) -> impl Iterator<Item = (&ColumnDefinition, &ColumnAccess)> {
474 self.columns
475 .iter()
476 .filter_map(|column| column.access.as_ref().map(|access| (column, access)))
477 }
478
479 pub fn permissions_of(&self, column: &ColumnDefinition) -> Option<ColumnAccess> {
481 column
482 .access
483 .as_ref()
484 .zip(self.access_area())
485 .map(|(access, area)| access.in_area(area))
486 }
487
488 pub fn withholds(&self, column: &ColumnDefinition) -> bool {
490 self.permissions_of(column)
491 .is_some_and(|permissions| permissions.read.is_some())
492 }
493
494 pub fn derived_features(&self) -> impl Iterator<Item = &'static str> {
496 let types_v2 = !self.checks.is_empty()
497 || self.search.is_some()
498 || self.indexes.iter().any(IndexDefinition::is_v2)
499 || self.columns.iter().any(|c| c.column_type.is_v2());
500 let audit_redaction = !self.audit_redact.is_empty() || self.sensitive;
501 [
502 self.scoped_by
503 .is_some()
504 .then_some(features::SCOPED_PERMISSIONS),
505 (!self.managed).then_some(features::SCHEMA_ADOPTED),
506 types_v2.then_some(features::COLUMN_TYPES_V2),
507 audit_redaction.then_some(features::AUDIT_REDACTION),
508 (!self.pagination.is_offset()).then_some(features::KEYSET_LISTS),
509 self.restricted_columns()
510 .next()
511 .map(|_| features::COLUMN_PERMISSIONS),
512 ]
513 .into_iter()
514 .flatten()
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use crate::manifest::fixtures::sample_manifest;
522
523 #[test]
524 fn test_primary_key() {
525 let manifest = sample_manifest();
526 let table = manifest.get_table("todos").unwrap();
527 let pk = table.primary_key().unwrap();
528 assert_eq!(pk.name, "id");
529 assert!(pk.auto_generate);
530 }
531
532 #[test]
533 fn test_column_type_to_sql() {
534 assert_eq!(ColumnType::Uuid.to_sql(), "UUID");
535 assert_eq!(ColumnType::Text.to_sql(), "TEXT");
536 assert_eq!(ColumnType::Integer.to_sql(), "INTEGER");
537 assert_eq!(ColumnType::BigInteger.to_sql(), "BIGINT");
538 assert_eq!(ColumnType::Boolean.to_sql(), "BOOLEAN");
539 assert_eq!(ColumnType::Timestamp.to_sql(), "TIMESTAMPTZ");
540 assert_eq!(ColumnType::Date.to_sql(), "DATE");
541 assert_eq!(ColumnType::Jsonb.to_sql(), "JSONB");
542 }
543
544 fn v2_types() -> Vec<(ColumnType, serde_json::Value, &'static str)> {
545 use serde_json::json;
546 vec![
547 (
548 ColumnType::Numeric {
549 precision: Some(12),
550 scale: Some(2),
551 },
552 json!({"numeric": {"precision": 12, "scale": 2}}),
553 "NUMERIC(12,2)",
554 ),
555 (
556 ColumnType::Numeric {
557 precision: Some(9),
558 scale: None,
559 },
560 json!({"numeric": {"precision": 9}}),
561 "NUMERIC(9)",
562 ),
563 (
564 ColumnType::Numeric {
565 precision: None,
566 scale: None,
567 },
568 json!({"numeric": {}}),
569 "NUMERIC",
570 ),
571 (
572 ColumnType::Varchar { length: 50 },
573 json!({"varchar": {"length": 50}}),
574 "VARCHAR(50)",
575 ),
576 (
577 ColumnType::TimestampNaive,
578 json!("timestamp_naive"),
579 "TIMESTAMP",
580 ),
581 (ColumnType::Time, json!("time"), "TIME"),
582 (ColumnType::Bytea, json!("bytea"), "BYTEA"),
583 (ColumnType::TextArray, json!("text_array"), "TEXT[]"),
584 (ColumnType::UuidArray, json!("uuid_array"), "UUID[]"),
585 (
586 ColumnType::IntegerArray,
587 json!("integer_array"),
588 "INTEGER[]",
589 ),
590 (
591 ColumnType::BigIntegerArray,
592 json!("biginteger_array"),
593 "BIGINT[]",
594 ),
595 (ColumnType::JsonbArray, json!("jsonb_array"), "JSONB[]"),
596 ]
597 }
598
599 #[test]
600 fn v2_column_types_have_pinned_json_and_sql() {
601 for (column_type, wire, sql) in v2_types() {
602 assert_eq!(serde_json::to_value(&column_type).unwrap(), wire);
603 assert_eq!(
604 serde_json::from_value::<ColumnType>(wire).unwrap(),
605 column_type
606 );
607 assert_eq!(column_type.to_sql(), sql);
608 assert!(column_type.is_v2(), "{column_type:?}");
609 assert!(!column_type.pg_type_names().is_empty());
610 }
611 }
612
613 #[test]
614 fn legacy_column_types_are_unchanged() {
615 for (column_type, wire) in [
616 (ColumnType::Uuid, "uuid"),
617 (ColumnType::Text, "text"),
618 (ColumnType::Integer, "integer"),
619 (ColumnType::BigInteger, "biginteger"),
620 (ColumnType::Float, "float"),
621 (ColumnType::Double, "double"),
622 (ColumnType::Boolean, "boolean"),
623 (ColumnType::Timestamp, "timestamp"),
624 (ColumnType::Date, "date"),
625 (ColumnType::Jsonb, "jsonb"),
626 ] {
627 assert_eq!(
628 serde_json::to_value(&column_type).unwrap(),
629 serde_json::json!(wire)
630 );
631 assert!(!column_type.is_v2(), "{column_type:?}");
632 }
633 assert!(serde_json::from_str::<ColumnType>(r#""numeric""#).is_err());
634 assert!(serde_json::from_str::<ColumnType>(r#""interval""#).is_err());
635 }
636
637 #[test]
638 fn a_numeric_scale_needs_a_precision_to_reach_sql() {
639 let scale_only = ColumnType::Numeric {
640 precision: None,
641 scale: Some(2),
642 };
643 assert_eq!(scale_only.to_sql(), "NUMERIC");
644 }
645
646 fn column(name: &str, column_type: ColumnType) -> ColumnDefinition {
647 ColumnDefinition {
648 name: name.into(),
649 column_type,
650 primary_key: false,
651 nullable: true,
652 auto_generate: false,
653 default_value: None,
654 references: None,
655 on_delete: None,
656 unique: false,
657 validations: vec![],
658 ..Default::default()
659 }
660 }
661
662 #[test]
663 fn r3_table_fields_are_omitted_at_their_defaults() {
664 let table = TableDefinition {
665 name: "notes".into(),
666 ..Default::default()
667 };
668 assert!(table.managed);
669 assert_eq!(
670 serde_json::to_value(&table).unwrap(),
671 serde_json::json!({
672 "name": "notes",
673 "columns": [],
674 "soft_delete": false,
675 "auth_required": false,
676 "owner_self_service": false
677 })
678 );
679 let parsed: TableDefinition =
680 serde_json::from_str(r#"{"name":"notes","columns":[]}"#).unwrap();
681 assert!(parsed.managed);
682 assert_eq!(parsed.pagination, Pagination::Offset);
683 assert_eq!(table.derived_features().count(), 0);
684 }
685
686 #[test]
687 fn r3_table_fields_round_trip() {
688 let table = TableDefinition {
689 name: "book".into(),
690 indexes: vec![IndexDefinition {
691 name: "uq_org_email".into(),
692 columns: vec!["branch_id".into(), "email".into()],
693 unique: true,
694 predicate: Some("deleted_at IS NULL".into()),
695 method: IndexMethod::Gin,
696 }],
697 managed: false,
698 checks: vec![CheckConstraint {
699 name: "price_nonneg".into(),
700 expr: "price_cents >= 0".into(),
701 }],
702 search: Some(SearchSpec {
703 columns: vec!["name".into(), "email".into()],
704 config: "english".into(),
705 vector_column: Some("search_vector".into()),
706 }),
707 audit_redact: vec!["dob".into(), "bank_*".into()],
708 sensitive: true,
709 pagination: Pagination::Keyset,
710 ..Default::default()
711 };
712 let wire = serde_json::to_value(&table).unwrap();
713 assert_eq!(
714 wire,
715 serde_json::json!({
716 "name": "book",
717 "columns": [],
718 "indexes": [{
719 "name": "uq_org_email",
720 "columns": ["branch_id", "email"],
721 "unique": true,
722 "predicate": "deleted_at IS NULL",
723 "method": "gin"
724 }],
725 "soft_delete": false,
726 "auth_required": false,
727 "owner_self_service": false,
728 "managed": false,
729 "checks": [{"name": "price_nonneg", "expr": "price_cents >= 0"}],
730 "search": {
731 "columns": ["name", "email"],
732 "config": "english",
733 "vector_column": "search_vector"
734 },
735 "audit_redact": ["dob", "bank_*"],
736 "sensitive": true,
737 "pagination": "keyset"
738 })
739 );
740 let parsed: TableDefinition = serde_json::from_value(wire).unwrap();
741 assert!(!parsed.managed);
742 assert_eq!(parsed.indexes, table.indexes);
743 assert_eq!(parsed.checks, table.checks);
744 assert_eq!(parsed.search, table.search);
745 assert_eq!(parsed.audit_redact, table.audit_redact);
746 assert!(parsed.sensitive);
747 assert_eq!(parsed.pagination, Pagination::Keyset);
748 }
749
750 #[test]
751 fn a_default_search_config_is_omitted() {
752 let search = SearchSpec {
753 columns: vec!["name".into()],
754 ..Default::default()
755 };
756 let wire = serde_json::json!({"columns": ["name"]});
757 assert_eq!(serde_json::to_value(&search).unwrap(), wire);
758 assert_eq!(serde_json::from_value::<SearchSpec>(wire).unwrap(), search);
759 }
760
761 #[test]
762 fn a_legacy_index_is_unchanged() {
763 let index = IndexDefinition {
764 name: "idx_items_sku".into(),
765 columns: vec!["sku".into()],
766 ..Default::default()
767 };
768 assert_eq!(
769 serde_json::to_value(&index).unwrap(),
770 serde_json::json!({"name": "idx_items_sku", "columns": ["sku"], "unique": false})
771 );
772 assert!(!index.is_v2());
773 }
774
775 #[test]
776 fn an_index_over_several_columns_is_as_legacy_as_one_over_one() {
777 for unique in [false, true] {
778 let index = IndexDefinition {
779 name: "idx_items_branch_sku".into(),
780 columns: vec!["branch_id".into(), "sku".into()],
781 unique,
782 ..Default::default()
783 };
784 assert!(!index.is_v2(), "unique={unique}");
785 let table = TableDefinition {
786 name: "items".into(),
787 indexes: vec![index],
788 ..Default::default()
789 };
790 assert_eq!(table.derived_features().count(), 0, "unique={unique}");
791 }
792 }
793
794 #[test]
795 fn each_r3_table_field_demands_its_feature() {
796 let plain = || TableDefinition {
797 name: "t".into(),
798 ..Default::default()
799 };
800 let index = |index: IndexDefinition| TableDefinition {
801 indexes: vec![index],
802 ..plain()
803 };
804 let cases = [
805 (
806 TableDefinition {
807 managed: false,
808 ..plain()
809 },
810 features::SCHEMA_ADOPTED,
811 ),
812 (
813 TableDefinition {
814 checks: vec![CheckConstraint {
815 name: "c".into(),
816 expr: "a > 0".into(),
817 }],
818 ..plain()
819 },
820 features::COLUMN_TYPES_V2,
821 ),
822 (
823 TableDefinition {
824 search: Some(SearchSpec::default()),
825 ..plain()
826 },
827 features::COLUMN_TYPES_V2,
828 ),
829 (
830 TableDefinition {
831 columns: vec![column("tags", ColumnType::TextArray)],
832 ..plain()
833 },
834 features::COLUMN_TYPES_V2,
835 ),
836 (
837 index(IndexDefinition {
838 name: "i".into(),
839 columns: vec!["a".into()],
840 predicate: Some("a IS NOT NULL".into()),
841 ..Default::default()
842 }),
843 features::COLUMN_TYPES_V2,
844 ),
845 (
846 index(IndexDefinition {
847 name: "i".into(),
848 columns: vec!["a".into()],
849 method: IndexMethod::Brin,
850 ..Default::default()
851 }),
852 features::COLUMN_TYPES_V2,
853 ),
854 (
855 TableDefinition {
856 audit_redact: vec!["dob".into()],
857 ..plain()
858 },
859 features::AUDIT_REDACTION,
860 ),
861 (
862 TableDefinition {
863 sensitive: true,
864 ..plain()
865 },
866 features::AUDIT_REDACTION,
867 ),
868 (
869 TableDefinition {
870 pagination: Pagination::Keyset,
871 ..plain()
872 },
873 features::KEYSET_LISTS,
874 ),
875 ];
876 for (table, feature) in cases {
877 assert_eq!(
878 table.derived_features().collect::<Vec<_>>(),
879 [feature],
880 "{table:?}"
881 );
882 }
883 }
884
885 #[test]
886 fn test_fk_not_serialized_when_none() {
887 let col = ColumnDefinition {
888 name: "title".into(),
889 column_type: ColumnType::Text,
890 primary_key: false,
891 nullable: false,
892 auto_generate: false,
893 default_value: None,
894 references: None,
895 on_delete: None,
896 unique: false,
897 validations: vec![],
898 ..Default::default()
899 };
900 let json = serde_json::to_string(&col).unwrap();
901 assert!(!json.contains("references"));
902 assert!(!json.contains("on_delete"));
903 }
904
905 #[test]
906 fn test_unique_column_serialization() {
907 let col = ColumnDefinition {
908 name: "email".into(),
909 column_type: ColumnType::Text,
910 primary_key: false,
911 nullable: false,
912 auto_generate: false,
913 default_value: None,
914 references: None,
915 on_delete: None,
916 unique: true,
917 validations: vec![],
918 ..Default::default()
919 };
920 let json = serde_json::to_string(&col).unwrap();
921 assert!(json.contains("\"unique\":true"));
922 let parsed: ColumnDefinition = serde_json::from_str(&json).unwrap();
923 assert!(parsed.unique);
924 }
925
926 #[test]
927 fn test_index_serialization() {
928 let table = TableDefinition {
929 name: "users".into(),
930 columns: vec![],
931 indexes: vec![IndexDefinition {
932 name: "idx_users_email".into(),
933 columns: vec!["email".into()],
934 unique: true,
935 ..Default::default()
936 }],
937 soft_delete: false,
938 owner_field: None,
939 auth_required: false,
940 permission_area: None,
941 owner_self_service: false,
942 hooks: None,
943 scoped_by: None,
944 ..Default::default()
945 };
946 let json = serde_json::to_string(&table).unwrap();
947 assert!(json.contains("idx_users_email"));
948 let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
949 assert_eq!(parsed.indexes.len(), 1);
950 assert!(parsed.indexes[0].unique);
951 }
952
953 #[test]
954 fn test_indexes_not_serialized_when_empty() {
955 let table = TableDefinition {
956 name: "users".into(),
957 columns: vec![],
958 indexes: vec![],
959 soft_delete: false,
960 owner_field: None,
961 auth_required: false,
962 permission_area: None,
963 owner_self_service: false,
964 hooks: None,
965 scoped_by: None,
966 ..Default::default()
967 };
968 let json = serde_json::to_string(&table).unwrap();
969 assert!(!json.contains("indexes"));
970 }
971
972 #[test]
973 fn test_owner_field_serialization() {
974 let table = TableDefinition {
975 name: "notes".into(),
976 columns: vec![],
977 indexes: vec![],
978 soft_delete: false,
979 owner_field: Some("user_id".into()),
980 auth_required: false,
981 permission_area: None,
982 owner_self_service: false,
983 hooks: None,
984 scoped_by: None,
985 ..Default::default()
986 };
987 let json = serde_json::to_string(&table).unwrap();
988 assert!(json.contains("\"owner_field\":\"user_id\""));
989 let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
990 assert_eq!(parsed.owner_field.as_deref(), Some("user_id"));
991 }
992
993 #[test]
994 fn test_owner_field_not_serialized_when_none() {
995 let table = TableDefinition {
996 name: "notes".into(),
997 columns: vec![],
998 indexes: vec![],
999 soft_delete: false,
1000 owner_field: None,
1001 auth_required: false,
1002 permission_area: None,
1003 owner_self_service: false,
1004 hooks: None,
1005 scoped_by: None,
1006 ..Default::default()
1007 };
1008 let json = serde_json::to_string(&table).unwrap();
1009 assert!(!json.contains("owner_field"));
1010 }
1011
1012 #[test]
1013 fn test_owner_field_defaults_to_none() {
1014 let json = r#"{"name":"notes","columns":[]}"#;
1015 let table: TableDefinition = serde_json::from_str(json).unwrap();
1016 assert!(table.owner_field.is_none());
1017 }
1018
1019 #[test]
1020 fn test_auth_required_serialization() {
1021 let table = TableDefinition {
1022 name: "orders".into(),
1023 columns: vec![],
1024 indexes: vec![],
1025 soft_delete: false,
1026 owner_field: None,
1027 auth_required: true,
1028 permission_area: None,
1029 owner_self_service: false,
1030 hooks: None,
1031 scoped_by: None,
1032 ..Default::default()
1033 };
1034 let json = serde_json::to_string(&table).unwrap();
1035 assert!(json.contains("\"auth_required\":true"));
1036 let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
1037 assert!(parsed.auth_required);
1038 }
1039
1040 #[test]
1041 fn test_auth_required_defaults_to_false() {
1042 let json = r#"{"name":"orders","columns":[]}"#;
1043 let table: TableDefinition = serde_json::from_str(json).unwrap();
1044 assert!(!table.auth_required);
1045 }
1046
1047 #[test]
1048 fn scoped_by_is_omitted_when_absent_and_derives_its_feature() {
1049 let table: TableDefinition =
1050 serde_json::from_str(r#"{"name":"books","columns":[],"owner_field":"user_id"}"#)
1051 .unwrap();
1052 assert!(table.scoped_by.is_none());
1053 assert!(!serde_json::to_string(&table).unwrap().contains("scoped_by"));
1054 assert_eq!(table.derived_features().count(), 0);
1055
1056 let scoped = TableDefinition {
1057 scoped_by: Some("books".into()),
1058 ..table
1059 };
1060 let json = serde_json::to_string(&scoped).unwrap();
1061 assert!(json.contains(r#""scoped_by":"books""#), "{json}");
1062 assert_eq!(
1063 scoped.derived_features().collect::<Vec<_>>(),
1064 [crate::features::SCOPED_PERMISSIONS]
1065 );
1066 }
1067}