1use parse_rust_core::{ParseError, ParseMap, ParseValue};
9use parse_rust_storage::{ClassSchema, FieldType};
10
11use crate::infer::{
12 class_name_is_valid, field_name_is_valid_for_class, infer_type, schema_mismatch,
13 DEFAULT_COLUMNS,
14};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct SchemaDelta {
19 pub added: Vec<(String, FieldType)>,
21}
22
23impl SchemaDelta {
24 pub fn is_empty(&self) -> bool {
25 self.added.is_empty()
26 }
27}
28
29pub fn default_schema(class_name: &str) -> ClassSchema {
31 let mut schema = ClassSchema::new(class_name);
32 for (name, ty) in DEFAULT_COLUMNS {
33 schema.fields.insert(name.to_string(), ty);
34 }
35 if class_name == "_User" {
37 for (name, ty) in crate::infer::USER_COLUMNS {
38 schema.fields.insert(name.to_string(), ty);
39 }
40 }
41 schema
42}
43
44pub fn validate_write(schema: &ClassSchema, object: &ParseMap) -> Result<SchemaDelta, ParseError> {
55 if !class_name_is_valid(&schema.class_name) {
56 return Err(ParseError::new(
57 parse_rust_core::ErrorCode::InvalidClassName,
58 format!(
59 "Invalid classname: {}, classnames can only have alphanumeric characters and _, \
60 and must start with an alpha character",
61 schema.class_name
62 ),
63 ));
64 }
65
66 let mut added = Vec::new();
67
68 for (field_name, value) in object {
69 if field_name.starts_with('_') {
78 continue;
79 }
80
81 if field_name == "ACL" {
87 match value {
88 ParseValue::Object(_) | ParseValue::Null => continue,
89 other => {
90 return Err(schema_mismatch(
91 &schema.class_name,
92 field_name,
93 &FieldType::Acl,
94 &infer_type(other).unwrap_or(FieldType::Object),
95 ))
96 }
97 }
98 }
99
100 let Some(incoming) = infer_type(value) else {
102 continue;
103 };
104
105 if let Some(existing) = schema.field(field_name) {
106 if existing != &incoming {
107 return Err(schema_mismatch(
108 &schema.class_name,
109 field_name,
110 existing,
111 &incoming,
112 ));
113 }
114 continue;
115 }
116
117 if !field_name_is_valid_for_class(field_name, &schema.class_name) {
118 return Err(ParseError::invalid_key_name(format!(
119 "Invalid field name: {field_name}."
120 )));
121 }
122
123 added.push((field_name.clone(), incoming));
124 }
125
126 Ok(SchemaDelta { added })
127}
128
129pub fn apply(schema: &mut ClassSchema, delta: &SchemaDelta) {
131 for (name, ty) in &delta.added {
132 schema.fields.insert(name.clone(), ty.clone());
133 }
134}
135
136pub fn value_matches(ty: &FieldType, value: &ParseValue) -> bool {
140 match infer_type(value) {
141 None => true,
142 Some(t) => &t == ty,
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use parse_rust_core::ParseDate;
150
151 fn m(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
152 let mut map = ParseMap::new();
153 for (k, v) in pairs {
154 map.insert(k.to_string(), v);
155 }
156 map
157 }
158
159 #[test]
160 fn a_new_class_starts_with_the_four_default_columns() {
161 let s = default_schema("Post");
162 assert_eq!(s.fields.len(), 4);
163 for (name, _) in DEFAULT_COLUMNS {
164 assert!(s.field(name).is_some(), "{name} missing");
165 }
166 }
167
168 #[test]
169 fn the_first_write_infers_and_adds() {
170 let s = default_schema("Post");
171 let delta = validate_write(
172 &s,
173 &m(vec![
174 ("title", ParseValue::String("x".into())),
175 ("views", ParseValue::Number(1.0)),
176 ]),
177 )
178 .expect("validate");
179 assert_eq!(
180 delta.added,
181 vec![
182 ("title".to_string(), FieldType::String),
183 ("views".to_string(), FieldType::Number),
184 ],
185 "order of addition follows the object's key order"
186 );
187 }
188
189 #[test]
191 fn the_second_write_is_enforced_against_the_first() {
192 let mut s = default_schema("Post");
193 let delta = validate_write(&s, &m(vec![("title", ParseValue::String("x".into()))]))
194 .expect("first write");
195 apply(&mut s, &delta);
196
197 let again = validate_write(&s, &m(vec![("title", ParseValue::String("y".into()))]))
198 .expect("second write");
199 assert!(again.is_empty());
200
201 let err = validate_write(&s, &m(vec![("title", ParseValue::Number(1.0))])).unwrap_err();
202 assert_eq!(
203 err.message,
204 "schema mismatch for Post.title; expected String but got Number"
205 );
206 }
207
208 #[test]
209 fn pointer_target_class_is_part_of_the_type() {
210 let mut s = default_schema("Post");
211 let delta = validate_write(
212 &s,
213 &m(vec![(
214 "author",
215 ParseValue::Pointer {
216 class_name: "_User".into(),
217 object_id: "a".into(),
218 },
219 )]),
220 )
221 .expect("first");
222 apply(&mut s, &delta);
223
224 let err = validate_write(
225 &s,
226 &m(vec![(
227 "author",
228 ParseValue::Pointer {
229 class_name: "Admin".into(),
230 object_id: "a".into(),
231 },
232 )]),
233 )
234 .unwrap_err();
235 assert_eq!(
236 err.message,
237 "schema mismatch for Post.author; expected Pointer<_User> but got Pointer<Admin>"
238 );
239 }
240
241 #[test]
244 fn user_columns_are_typed_rather_than_inferred() {
245 let s = default_schema("_User");
246 assert_eq!(s.field("email"), Some(&FieldType::String));
247 assert_eq!(s.field("emailVerified"), Some(&FieldType::Boolean));
248 let err = validate_write(&s, &m(vec![("email", ParseValue::Number(42.0))])).unwrap_err();
250 assert!(
251 err.message.contains("expected String but got Number"),
252 "{}",
253 err.message
254 );
255 }
256
257 #[test]
258 fn an_acl_object_is_accepted_and_adds_no_column() {
259 let s = default_schema("Post");
260 let mut acl = ParseMap::new();
261 let mut entry = ParseMap::new();
262 entry.insert("read".into(), ParseValue::Bool(true));
263 acl.insert("*".into(), ParseValue::Object(entry));
264
265 let delta =
266 validate_write(&s, &m(vec![("ACL", ParseValue::Object(acl))])).expect("validate");
267 assert!(delta.is_empty(), "ACL is a default column, not a new field");
268
269 assert!(validate_write(&s, &m(vec![("ACL", ParseValue::Null)])).is_ok());
271
272 let err =
274 validate_write(&s, &m(vec![("ACL", ParseValue::String("nope".into()))])).unwrap_err();
275 assert!(
276 err.message.contains("expected ACL but got String"),
277 "{}",
278 err.message
279 );
280 }
281
282 #[test]
283 fn writing_null_creates_nothing() {
284 let s = default_schema("Post");
285 let delta = validate_write(&s, &m(vec![("ghost", ParseValue::Null)])).expect("validate");
286 assert!(
287 delta.is_empty(),
288 "a null must not create a column, or every optional field becomes a schema entry"
289 );
290 }
291
292 #[test]
293 fn null_is_assignable_to_an_existing_field_of_any_type() {
294 let mut s = default_schema("Post");
295 apply(
296 &mut s,
297 &SchemaDelta {
298 added: vec![("title".into(), FieldType::String)],
299 },
300 );
301 let delta = validate_write(&s, &m(vec![("title", ParseValue::Null)])).expect("validate");
302 assert!(delta.is_empty());
303 assert!(value_matches(&FieldType::String, &ParseValue::Null));
304 }
305
306 #[test]
307 fn default_columns_are_writable_but_not_redefinable() {
308 let s = default_schema("Post");
309 let ok = validate_write(
310 &s,
311 &m(vec![(
312 "createdAt",
313 ParseValue::Date(ParseDate::parse_iso("2026-01-01T00:00:00.000Z").expect("d")),
314 )]),
315 )
316 .expect("validate");
317 assert!(ok.is_empty());
318
319 let err = validate_write(&s, &m(vec![("createdAt", ParseValue::Number(1.0))])).unwrap_err();
320 assert!(err.message.contains("expected Date but got Number"));
321 }
322
323 #[test]
324 fn reserved_and_malformed_field_names_are_refused() {
325 let s = default_schema("Post");
326 for bad in ["className", "1field", "has-dash"] {
331 let err =
332 validate_write(&s, &m(vec![(bad, ParseValue::String("x".into()))])).unwrap_err();
333 assert_eq!(
334 err.code,
335 parse_rust_core::ErrorCode::InvalidKeyName,
336 "{bad} should be refused"
337 );
338 }
339 }
340
341 #[test]
342 fn an_invalid_class_name_is_refused_before_any_field() {
343 let s = ClassSchema::new("1Bad");
344 let err = validate_write(&s, &m(vec![("a", ParseValue::String("x".into()))])).unwrap_err();
345 assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidClassName);
346 }
347
348 #[test]
349 fn server_internal_columns_are_not_schema_fields() {
350 let s = default_schema("_User");
354 let delta = validate_write(
355 &s,
356 &m(vec![
357 ("username", ParseValue::String("alice".into())),
359 ("_hashed_password", ParseValue::String("$2b$10$...".into())),
361 ("_rperm", ParseValue::Array(vec![])),
362 ("nickname", ParseValue::String("al".into())),
364 ]),
365 )
366 .expect("validate");
367 assert_eq!(
368 delta.added,
369 vec![("nickname".to_string(), FieldType::String)],
370 "internal columns must not become schema fields"
371 );
372 }
373
374 #[test]
375 fn validate_does_not_mutate_so_a_rejected_write_leaves_no_column() {
376 let s = default_schema("Post");
377 let before = s.fields.len();
378 let _ = validate_write(&s, &m(vec![("title", ParseValue::String("x".into()))]));
379 assert_eq!(
380 s.fields.len(),
381 before,
382 "validation must be pure; the caller applies only on commit"
383 );
384 }
385}