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