1use parse_rust_core::{ParseError, ParseValue};
11use parse_rust_storage::FieldType;
12
13pub const DEFAULT_COLUMNS: [(&str, FieldType); 4] = [
18 ("objectId", FieldType::String),
19 ("createdAt", FieldType::Date),
20 ("updatedAt", FieldType::Date),
21 ("ACL", FieldType::Acl),
22];
23
24pub const USER_COLUMNS: [(&str, FieldType); 5] = [
29 ("username", FieldType::String),
30 ("password", FieldType::String),
31 ("email", FieldType::String),
32 ("emailVerified", FieldType::Boolean),
33 ("authData", FieldType::Object),
34];
35
36pub const SYSTEM_CLASSES: [&str; 8] = [
38 "_User",
39 "_Installation",
40 "_Role",
41 "_Session",
42 "_Product",
43 "_PushStatus",
44 "_JobStatus",
45 "_JobSchedule",
46];
47
48pub fn infer_type(value: &ParseValue) -> Option<FieldType> {
59 match value {
60 ParseValue::Null => None,
61 ParseValue::Bool(_) => Some(FieldType::Boolean),
62 ParseValue::String(_) => Some(FieldType::String),
63 ParseValue::Number(_) => Some(FieldType::Number),
64 ParseValue::Array(_) => Some(FieldType::Array),
65 ParseValue::Object(_) => Some(FieldType::Object),
66 ParseValue::Date(_) => Some(FieldType::Date),
67 ParseValue::Bytes(_) => Some(FieldType::Bytes),
68 ParseValue::GeoPoint { .. } => Some(FieldType::GeoPoint),
69 ParseValue::Polygon(_) => Some(FieldType::Polygon),
70 ParseValue::File { .. } => Some(FieldType::File),
71 ParseValue::Pointer { class_name, .. } => Some(FieldType::Pointer {
72 target_class: class_name.clone(),
73 }),
74 ParseValue::Relation { class_name } => Some(FieldType::Relation {
75 target_class: class_name.clone(),
76 }),
77 }
78}
79
80fn matches_class_and_field_regex(s: &str) -> bool {
82 let mut chars = s.chars();
83 match chars.next() {
84 Some(c) if c.is_ascii_alphabetic() => {}
85 _ => return false,
86 }
87 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
88}
89
90pub fn class_name_is_valid(class_name: &str) -> bool {
96 if SYSTEM_CLASSES.contains(&class_name) {
97 return true;
98 }
99 if let Some(rest) = class_name.strip_prefix("_Join:") {
100 let mut parts = rest.splitn(2, ':');
103 let (a, b) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""));
104 let ok =
105 |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
106 let b_prefix: String = b
108 .chars()
109 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
110 .collect();
111 return ok(a) && !b_prefix.is_empty();
112 }
113 matches_class_and_field_regex(class_name)
114}
115
116pub fn field_name_is_valid(field_name: &str, class_name: &str) -> bool {
121 if !class_name.is_empty() && class_name != "_Hooks" && field_name == "className" {
122 return false;
123 }
124 matches_class_and_field_regex(field_name)
125}
126
127pub fn field_name_is_valid_for_class(field_name: &str, class_name: &str) -> bool {
129 if !field_name_is_valid(field_name, class_name) {
130 return false;
131 }
132 !DEFAULT_COLUMNS.iter().any(|(name, _)| *name == field_name)
133}
134
135pub fn schema_mismatch(
140 class_name: &str,
141 field_name: &str,
142 expected: &FieldType,
143 got: &FieldType,
144) -> ParseError {
145 ParseError::incorrect_type(format!(
146 "schema mismatch for {class_name}.{field_name}; expected {} but got {}",
147 expected.to_wire_string(),
148 got.to_wire_string()
149 ))
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use parse_rust_core::{ParseDate, ParseMap};
156
157 #[test]
158 fn null_infers_no_type_at_all() {
159 assert_eq!(infer_type(&ParseValue::Null), None);
162 }
163
164 #[test]
165 fn scalars_infer_as_upstream_does() {
166 assert_eq!(
167 infer_type(&ParseValue::Bool(true)),
168 Some(FieldType::Boolean)
169 );
170 assert_eq!(
171 infer_type(&ParseValue::String("x".into())),
172 Some(FieldType::String)
173 );
174 assert_eq!(
175 infer_type(&ParseValue::Number(1.0)),
176 Some(FieldType::Number)
177 );
178 assert_eq!(
181 infer_type(&ParseValue::Number(1.5)),
182 Some(FieldType::Number)
183 );
184 }
185
186 #[test]
187 fn containers_and_tagged_values() {
188 assert_eq!(
189 infer_type(&ParseValue::Array(vec![])),
190 Some(FieldType::Array)
191 );
192 assert_eq!(
193 infer_type(&ParseValue::Object(ParseMap::new())),
194 Some(FieldType::Object)
195 );
196 assert_eq!(
197 infer_type(&ParseValue::Date(
198 ParseDate::parse_iso("2026-01-01T00:00:00.000Z").expect("date")
199 )),
200 Some(FieldType::Date)
201 );
202 assert_eq!(
203 infer_type(&ParseValue::GeoPoint {
204 latitude: 1.0,
205 longitude: 2.0
206 }),
207 Some(FieldType::GeoPoint)
208 );
209 }
210
211 #[test]
212 fn pointers_and_relations_carry_their_target() {
213 assert_eq!(
214 infer_type(&ParseValue::Pointer {
215 class_name: "_User".into(),
216 object_id: "x".into()
217 }),
218 Some(FieldType::Pointer {
219 target_class: "_User".into()
220 })
221 );
222 assert_eq!(
223 infer_type(&ParseValue::Relation {
224 class_name: "Post".into()
225 }),
226 Some(FieldType::Relation {
227 target_class: "Post".into()
228 })
229 );
230 }
231
232 #[test]
233 fn class_names_follow_the_regex() {
234 assert!(class_name_is_valid("Post"));
235 assert!(class_name_is_valid("A1_b"));
236 assert!(!class_name_is_valid(""));
237 assert!(!class_name_is_valid("1Post"), "must not start with a digit");
238 assert!(!class_name_is_valid("_Custom"), "must not start with _");
239 assert!(!class_name_is_valid("has-dash"));
240 assert!(!class_name_is_valid("has space"));
241 }
242
243 #[test]
244 fn system_and_join_classes_are_valid_despite_the_underscore() {
245 for c in SYSTEM_CLASSES {
246 assert!(class_name_is_valid(c), "{c} should be valid");
247 }
248 assert!(class_name_is_valid("_Join:likes:Post"));
249 assert!(!class_name_is_valid("_Join:likes"), "needs both segments");
250 }
251
252 #[test]
253 fn class_name_is_refused_as_a_field_except_on_hooks() {
254 assert!(!field_name_is_valid("className", "Post"));
255 assert!(field_name_is_valid("className", "_Hooks"));
257 }
258
259 #[test]
260 fn default_columns_cannot_be_redefined() {
261 for (name, _) in DEFAULT_COLUMNS {
262 assert!(
263 !field_name_is_valid_for_class(name, "Post"),
264 "{name} is a default column"
265 );
266 }
267 assert!(field_name_is_valid_for_class("title", "Post"));
268 }
269
270 #[test]
271 fn the_mismatch_message_is_byte_exact() {
272 let e = schema_mismatch(
273 "Post",
274 "author",
275 &FieldType::Pointer {
276 target_class: "_User".into(),
277 },
278 &FieldType::String,
279 );
280 assert_eq!(
281 e.message,
282 "schema mismatch for Post.author; expected Pointer<_User> but got String"
283 );
284 assert_eq!(e.code, parse_rust_core::ErrorCode::IncorrectType);
285 }
286}