1use indexmap::IndexMap;
9use parse_rust_core::{FieldWrite, ParseError, ParseMap, ParseValue};
10use parse_rust_storage::{ClassSchema, FieldType};
11
12use crate::infer::{
13 class_name_is_valid, default_columns_for, field_name_is_valid_for_class, infer_op_type,
14 infer_type, invalid_class_name_message, required_write_columns, schema_mismatch,
15 DEFAULT_COLUMNS,
16};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct SchemaDelta {
21 pub added: Vec<(String, FieldType)>,
23}
24
25impl SchemaDelta {
26 pub fn is_empty(&self) -> bool {
27 self.added.is_empty()
28 }
29}
30
31pub fn default_schema(class_name: &str) -> ClassSchema {
41 let mut schema = ClassSchema::new(class_name);
42 for (name, ty) in DEFAULT_COLUMNS {
43 schema.fields.insert(name.to_string(), ty);
44 }
45 for (name, ty) in default_columns_for(class_name) {
46 schema.fields.insert(name.to_string(), ty);
47 }
48 schema
49}
50
51pub fn validate_write(schema: &ClassSchema, object: &ParseMap) -> Result<SchemaDelta, ParseError> {
62 if !class_name_is_valid(&schema.class_name) {
63 return Err(ParseError::new(
64 parse_rust_core::ErrorCode::InvalidClassName,
65 invalid_class_name_message(&schema.class_name),
66 ));
67 }
68
69 let mut added = Vec::new();
70
71 for (field_name, value) in object {
72 if field_name.starts_with('_') {
81 continue;
82 }
83
84 if field_name == "ACL" {
90 match value {
91 ParseValue::Object(_) | ParseValue::Null => continue,
92 other => {
93 return Err(schema_mismatch(
94 &schema.class_name,
95 field_name,
96 &FieldType::Acl,
97 &infer_type(other).unwrap_or(FieldType::Object),
98 ))
99 }
100 }
101 }
102
103 let Some(incoming) = infer_type(value) else {
105 continue;
106 };
107
108 reconcile(schema, field_name, incoming, &mut added)?;
109 }
110
111 Ok(SchemaDelta { added })
112}
113
114pub fn validate_write_fields(
127 schema: &ClassSchema,
128 fields: &IndexMap<String, FieldWrite>,
129) -> Result<SchemaDelta, ParseError> {
130 if !class_name_is_valid(&schema.class_name) {
131 return Err(ParseError::new(
132 parse_rust_core::ErrorCode::InvalidClassName,
133 invalid_class_name_message(&schema.class_name),
134 ));
135 }
136
137 let mut geo_count = 0;
149 for (field_name, write) in fields {
150 if let FieldWrite::Value(value) = write {
151 if matches!(infer_type(value), Some(FieldType::GeoPoint)) {
152 geo_count += 1;
153 }
154 }
155 if geo_count > 1 {
156 let _ = field_name;
157 return Err(ParseError::incorrect_type(
158 "there can only be one geopoint field in a class".to_string(),
159 ));
160 }
161 }
162
163 let mut added = Vec::new();
164
165 for (field_name, write) in fields {
166 if field_name.starts_with('_') {
167 continue;
168 }
169 if field_name == "ACL" {
172 continue;
173 }
174
175 let incoming = match write {
176 FieldWrite::Value(value) => infer_type(value),
177 FieldWrite::Op(op) => infer_op_type(op)?,
178 };
179 let Some(incoming) = incoming else {
180 continue;
181 };
182
183 reconcile(schema, field_name, incoming, &mut added)?;
184 }
185
186 Ok(SchemaDelta { added })
187}
188
189fn reconcile(
191 schema: &ClassSchema,
192 field_name: &str,
193 incoming: FieldType,
194 added: &mut Vec<(String, FieldType)>,
195) -> Result<(), ParseError> {
196 if let Some(existing) = schema.field(field_name) {
197 if existing != &incoming {
198 return Err(schema_mismatch(
199 &schema.class_name,
200 field_name,
201 existing,
202 &incoming,
203 ));
204 }
205 return Ok(());
206 }
207
208 if !field_name_is_valid_for_class(field_name, &schema.class_name) {
209 return Err(ParseError::invalid_key_name(format!(
210 "Invalid field name: {field_name}."
211 )));
212 }
213
214 added.push((field_name.to_string(), incoming));
215 Ok(())
216}
217
218pub fn validate_required_columns(
235 class_name: &str,
236 object: &ParseMap,
237 is_update: bool,
238) -> Result<(), ParseError> {
239 for column in required_write_columns(class_name) {
240 let missing = match object.get(*column) {
241 None => !is_update,
242 Some(value) => {
243 if is_update {
244 is_delete_op(value)
245 } else {
246 is_falsy(value)
247 }
248 }
249 };
250 if missing {
251 return Err(ParseError::incorrect_type(format!("{column} is required.")));
252 }
253 }
254 Ok(())
255}
256
257fn is_falsy(value: &ParseValue) -> bool {
263 match value {
264 ParseValue::Null => true,
265 ParseValue::Bool(b) => !*b,
266 ParseValue::Number(n) => *n == 0.0 || n.is_nan(),
267 ParseValue::String(s) => s.is_empty(),
268 _ => false,
269 }
270}
271
272fn is_delete_op(value: &ParseValue) -> bool {
279 match value {
280 ParseValue::Object(map) => {
281 matches!(map.get("__op"), Some(ParseValue::String(op)) if op == "Delete")
282 }
283 _ => false,
284 }
285}
286
287pub fn apply(schema: &mut ClassSchema, delta: &SchemaDelta) {
289 for (name, ty) in &delta.added {
290 schema.fields.insert(name.clone(), ty.clone());
291 }
292}
293
294pub fn value_matches(ty: &FieldType, value: &ParseValue) -> bool {
298 match infer_type(value) {
299 None => true,
300 Some(t) => &t == ty,
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use parse_rust_core::ParseDate;
308
309 fn m(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
310 let mut map = ParseMap::new();
311 for (k, v) in pairs {
312 map.insert(k.to_string(), v);
313 }
314 map
315 }
316
317 #[test]
318 fn a_new_class_starts_with_the_four_default_columns() {
319 let s = default_schema("Post");
320 assert_eq!(s.fields.len(), 4);
321 for (name, _) in DEFAULT_COLUMNS {
322 assert!(s.field(name).is_some(), "{name} missing");
323 }
324 }
325
326 #[test]
327 fn the_first_write_infers_and_adds() {
328 let s = default_schema("Post");
329 let delta = validate_write(
330 &s,
331 &m(vec![
332 ("title", ParseValue::String("x".into())),
333 ("views", ParseValue::Number(1.0)),
334 ]),
335 )
336 .expect("validate");
337 assert_eq!(
338 delta.added,
339 vec![
340 ("title".to_string(), FieldType::String),
341 ("views".to_string(), FieldType::Number),
342 ],
343 "order of addition follows the object's key order"
344 );
345 }
346
347 #[test]
349 fn the_second_write_is_enforced_against_the_first() {
350 let mut s = default_schema("Post");
351 let delta = validate_write(&s, &m(vec![("title", ParseValue::String("x".into()))]))
352 .expect("first write");
353 apply(&mut s, &delta);
354
355 let again = validate_write(&s, &m(vec![("title", ParseValue::String("y".into()))]))
356 .expect("second write");
357 assert!(again.is_empty());
358
359 let err = validate_write(&s, &m(vec![("title", ParseValue::Number(1.0))])).unwrap_err();
360 assert_eq!(
361 err.message,
362 "schema mismatch for Post.title; expected String but got Number"
363 );
364 }
365
366 #[test]
367 fn pointer_target_class_is_part_of_the_type() {
368 let mut s = default_schema("Post");
369 let delta = validate_write(
370 &s,
371 &m(vec![(
372 "author",
373 ParseValue::Pointer {
374 class_name: "_User".into(),
375 object_id: "a".into(),
376 },
377 )]),
378 )
379 .expect("first");
380 apply(&mut s, &delta);
381
382 let err = validate_write(
383 &s,
384 &m(vec![(
385 "author",
386 ParseValue::Pointer {
387 class_name: "Admin".into(),
388 object_id: "a".into(),
389 },
390 )]),
391 )
392 .unwrap_err();
393 assert_eq!(
394 err.message,
395 "schema mismatch for Post.author; expected Pointer<_User> but got Pointer<Admin>"
396 );
397 }
398
399 #[test]
402 fn user_columns_are_typed_rather_than_inferred() {
403 let s = default_schema("_User");
404 assert_eq!(s.field("email"), Some(&FieldType::String));
405 assert_eq!(s.field("emailVerified"), Some(&FieldType::Boolean));
406 let err = validate_write(&s, &m(vec![("email", ParseValue::Number(42.0))])).unwrap_err();
408 assert!(
409 err.message.contains("expected String but got Number"),
410 "{}",
411 err.message
412 );
413 }
414
415 #[test]
416 fn an_acl_object_is_accepted_and_adds_no_column() {
417 let s = default_schema("Post");
418 let mut acl = ParseMap::new();
419 let mut entry = ParseMap::new();
420 entry.insert("read".into(), ParseValue::Bool(true));
421 acl.insert("*".into(), ParseValue::Object(entry));
422
423 let delta =
424 validate_write(&s, &m(vec![("ACL", ParseValue::Object(acl))])).expect("validate");
425 assert!(delta.is_empty(), "ACL is a default column, not a new field");
426
427 assert!(validate_write(&s, &m(vec![("ACL", ParseValue::Null)])).is_ok());
429
430 let err =
432 validate_write(&s, &m(vec![("ACL", ParseValue::String("nope".into()))])).unwrap_err();
433 assert!(
434 err.message.contains("expected ACL but got String"),
435 "{}",
436 err.message
437 );
438 }
439
440 #[test]
441 fn writing_null_creates_nothing() {
442 let s = default_schema("Post");
443 let delta = validate_write(&s, &m(vec![("ghost", ParseValue::Null)])).expect("validate");
444 assert!(
445 delta.is_empty(),
446 "a null must not create a column, or every optional field becomes a schema entry"
447 );
448 }
449
450 #[test]
451 fn null_is_assignable_to_an_existing_field_of_any_type() {
452 let mut s = default_schema("Post");
453 apply(
454 &mut s,
455 &SchemaDelta {
456 added: vec![("title".into(), FieldType::String)],
457 },
458 );
459 let delta = validate_write(&s, &m(vec![("title", ParseValue::Null)])).expect("validate");
460 assert!(delta.is_empty());
461 assert!(value_matches(&FieldType::String, &ParseValue::Null));
462 }
463
464 #[test]
465 fn default_columns_are_writable_but_not_redefinable() {
466 let s = default_schema("Post");
467 let ok = validate_write(
468 &s,
469 &m(vec![(
470 "createdAt",
471 ParseValue::Date(ParseDate::parse_iso("2026-01-01T00:00:00.000Z").expect("d")),
472 )]),
473 )
474 .expect("validate");
475 assert!(ok.is_empty());
476
477 let err = validate_write(&s, &m(vec![("createdAt", ParseValue::Number(1.0))])).unwrap_err();
478 assert!(err.message.contains("expected Date but got Number"));
479 }
480
481 #[test]
482 fn reserved_and_malformed_field_names_are_refused() {
483 let s = default_schema("Post");
484 for bad in ["className", "1field", "has-dash"] {
489 let err =
490 validate_write(&s, &m(vec![(bad, ParseValue::String("x".into()))])).unwrap_err();
491 assert_eq!(
492 err.code,
493 parse_rust_core::ErrorCode::InvalidKeyName,
494 "{bad} should be refused"
495 );
496 }
497 }
498
499 #[test]
500 fn an_invalid_class_name_is_refused_before_any_field() {
501 let s = ClassSchema::new("1Bad");
502 let err = validate_write(&s, &m(vec![("a", ParseValue::String("x".into()))])).unwrap_err();
503 assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidClassName);
504 }
505
506 #[test]
507 fn server_internal_columns_are_not_schema_fields() {
508 let s = default_schema("_User");
512 let delta = validate_write(
513 &s,
514 &m(vec![
515 ("username", ParseValue::String("alice".into())),
517 ("_hashed_password", ParseValue::String("$2b$10$...".into())),
519 ("_rperm", ParseValue::Array(vec![])),
520 ("nickname", ParseValue::String("al".into())),
522 ]),
523 )
524 .expect("validate");
525 assert_eq!(
526 delta.added,
527 vec![("nickname".to_string(), FieldType::String)],
528 "internal columns must not become schema fields"
529 );
530 }
531
532 #[test]
533 fn role_and_session_carry_their_class_specific_columns() {
534 let role = default_schema("_Role");
535 assert_eq!(role.field("name"), Some(&FieldType::String));
536 assert_eq!(
537 role.field("users"),
538 Some(&FieldType::Relation {
539 target_class: "_User".into()
540 })
541 );
542 assert_eq!(
543 role.field("roles"),
544 Some(&FieldType::Relation {
545 target_class: "_Role".into()
546 })
547 );
548
549 let session = default_schema("_Session");
550 assert_eq!(
551 session.field("user"),
552 Some(&FieldType::Pointer {
553 target_class: "_User".into()
554 })
555 );
556 for (name, ty) in [
557 ("installationId", FieldType::String),
558 ("sessionToken", FieldType::String),
559 ("expiresAt", FieldType::Date),
560 ("createdWith", FieldType::Object),
561 ] {
562 assert_eq!(session.field(name), Some(&ty), "{name}");
563 }
564 }
565
566 #[test]
569 fn a_role_write_is_checked_against_the_typed_relation_columns() {
570 let s = default_schema("_Role");
571 let err = validate_write(&s, &m(vec![("users", ParseValue::Array(vec![]))])).unwrap_err();
572 assert_eq!(
573 err.message,
574 "schema mismatch for _Role.users; expected Relation<_User> but got Array"
575 );
576 }
577
578 #[test]
579 fn a_role_needs_a_name_and_an_acl_on_create() {
580 let err = validate_required_columns("_Role", &ParseMap::new(), false).unwrap_err();
582 assert_eq!(err.message, "name is required.");
583 assert_eq!(err.code, parse_rust_core::ErrorCode::IncorrectType);
584
585 let err = validate_required_columns(
587 "_Role",
588 &m(vec![("name", ParseValue::String("Admins".into()))]),
589 false,
590 )
591 .unwrap_err();
592 assert_eq!(err.message, "ACL is required.");
593
594 let ok = m(vec![
595 ("name", ParseValue::String("Admins".into())),
596 ("ACL", ParseValue::Object(ParseMap::new())),
597 ]);
598 assert!(validate_required_columns("_Role", &ok, false).is_ok());
599 }
600
601 #[test]
603 fn a_falsy_required_column_counts_as_missing_on_create() {
604 for value in [
605 ParseValue::String(String::new()),
606 ParseValue::Null,
607 ParseValue::Bool(false),
608 ParseValue::Number(0.0),
609 ] {
610 let body = m(vec![
611 ("name", value),
612 ("ACL", ParseValue::Object(ParseMap::new())),
613 ]);
614 assert_eq!(
615 validate_required_columns("_Role", &body, false)
616 .unwrap_err()
617 .message,
618 "name is required."
619 );
620 }
621 }
622
623 #[test]
626 fn an_update_only_objects_to_deleting_a_required_column() {
627 let rename = m(vec![("name", ParseValue::String("Ops".into()))]);
628 assert!(validate_required_columns("_Role", &rename, true).is_ok());
629
630 let mut delete = ParseMap::new();
631 delete.insert("__op".into(), ParseValue::String("Delete".into()));
632 let body = m(vec![("ACL", ParseValue::Object(delete))]);
633 assert_eq!(
634 validate_required_columns("_Role", &body, true)
635 .unwrap_err()
636 .message,
637 "ACL is required."
638 );
639 }
640
641 #[test]
642 fn a_class_with_no_required_columns_never_fails() {
643 assert!(validate_required_columns("Post", &ParseMap::new(), false).is_ok());
644 assert!(validate_required_columns("_User", &ParseMap::new(), false).is_ok());
645 }
646
647 #[test]
648 fn ops_infer_their_own_types() {
649 use parse_rust_core::Op;
650
651 let s = default_schema("Post");
652 let mut fields: IndexMap<String, FieldWrite> = IndexMap::new();
653 fields.insert("views".into(), FieldWrite::Op(Op::Increment(1.0)));
654 fields.insert(
655 "tags".into(),
656 FieldWrite::Op(Op::Add(vec![ParseValue::String("x".into())])),
657 );
658 fields.insert("gone".into(), FieldWrite::Op(Op::Delete));
659 let delta = validate_write_fields(&s, &fields).expect("validate");
660 assert_eq!(
661 delta.added,
662 vec![
663 ("views".to_string(), FieldType::Number),
664 ("tags".to_string(), FieldType::Array),
665 ],
666 "Delete has no type and must not create a column"
667 );
668 }
669
670 #[test]
673 fn relation_ops_infer_their_target_from_the_first_pointer() {
674 use parse_rust_core::Op;
675
676 let s = default_schema("Post");
677 let pointer = ParseValue::Pointer {
678 class_name: "_User".into(),
679 object_id: "abc".into(),
680 };
681 for op in [
682 Op::AddRelation(vec![pointer.clone()]),
683 Op::RemoveRelation(vec![pointer.clone()]),
684 Op::Batch(vec![Op::AddRelation(vec![pointer.clone()])]),
685 ] {
686 let mut fields: IndexMap<String, FieldWrite> = IndexMap::new();
687 fields.insert("members".into(), FieldWrite::Op(op));
688 let delta = validate_write_fields(&s, &fields).expect("validate");
689 assert_eq!(
690 delta.added,
691 vec![(
692 "members".to_string(),
693 FieldType::Relation {
694 target_class: "_User".into()
695 }
696 )]
697 );
698 }
699 }
700
701 #[test]
704 fn a_relation_op_with_no_pointers_creates_nothing() {
705 use parse_rust_core::Op;
706
707 let s = default_schema("Post");
708 let mut fields: IndexMap<String, FieldWrite> = IndexMap::new();
709 fields.insert("members".into(), FieldWrite::Op(Op::AddRelation(vec![])));
710 fields.insert("other".into(), FieldWrite::Op(Op::Batch(vec![])));
711 assert!(validate_write_fields(&s, &fields)
712 .expect("validate")
713 .is_empty());
714 }
715
716 #[test]
717 fn the_op_aware_path_enforces_the_same_types_as_the_value_path() {
718 use parse_rust_core::Op;
719
720 let mut s = default_schema("Post");
721 apply(
722 &mut s,
723 &SchemaDelta {
724 added: vec![("title".into(), FieldType::String)],
725 },
726 );
727 let mut fields: IndexMap<String, FieldWrite> = IndexMap::new();
728 fields.insert("title".into(), FieldWrite::Op(Op::Increment(1.0)));
729 let err = validate_write_fields(&s, &fields).unwrap_err();
730 assert_eq!(
731 err.message,
732 "schema mismatch for Post.title; expected String but got Number"
733 );
734 }
735
736 #[test]
738 fn the_invalid_class_name_message_is_byte_exact() {
739 let s = ClassSchema::new("1Bad");
740 let err = validate_write(&s, &m(vec![("a", ParseValue::String("x".into()))])).unwrap_err();
741 assert_eq!(
742 err.message,
743 "Invalid classname: 1Bad, classnames can only have alphanumeric characters and _, and \
744 must start with an alpha character "
745 );
746 }
747
748 #[test]
749 fn validate_does_not_mutate_so_a_rejected_write_leaves_no_column() {
750 let s = default_schema("Post");
751 let before = s.fields.len();
752 let _ = validate_write(&s, &m(vec![("title", ParseValue::String("x".into()))]));
753 assert_eq!(
754 s.fields.len(),
755 before,
756 "validation must be pure; the caller applies only on commit"
757 );
758 }
759}