1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
use std::collections::HashMap;
use indexmap::IndexMap;
use serde_json::{Map, Value};
use crate::{
Db,
database::{FieldInfo, ReferenceColumn},
};
/// A small dictionary describing the inferred schema for a collection.
///
/// The `fields` map stores `FieldInfo` entries keyed by field name. This type
/// is used by the in-memory collection to track types and nullability across
/// multiple documents.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SchemaDict {
/// Map of field name -> field metadata
pub fields: IndexMap<String, FieldInfo>,
}
impl SchemaDict {
/// Return the `FieldInfo` for a field name if present.
///
/// # Example
///
/// ```
/// use fosk::{JsonPrimitive, SchemaDict};
/// use serde_json::json;
///
/// # fn main() -> Result<(), String> {
/// let value = json!({ "name": "Ada" });
/// let object = value
/// .as_object()
/// .ok_or_else(|| "expected a JSON object".to_string())?;
/// let schema = SchemaDict::infer_schema_from_object(object);
///
/// let name = schema
/// .get("name")
/// .ok_or_else(|| "missing name field".to_string())?;
/// assert_eq!(name.ty, JsonPrimitive::String);
/// assert!(schema.get("missing").is_none());
/// # Ok(())
/// # }
/// ```
pub fn get(&self, name: &str) -> Option<&FieldInfo> {
self.fields.get(name)
}
/// Build a `SchemaDict` from a single JSON object by inferring each field's
/// primitive type and nullability.
///
/// # Example
///
/// ```
/// use fosk::{JsonPrimitive, SchemaDict};
/// use serde_json::json;
///
/// # fn main() -> Result<(), String> {
/// let value = json!({ "id": 1, "name": "Ada" });
/// let object = value
/// .as_object()
/// .ok_or_else(|| "expected a JSON object".to_string())?;
///
/// let schema = SchemaDict::infer_schema_from_object(object);
///
/// assert_eq!(schema.fields["id"].ty, JsonPrimitive::Int);
/// assert_eq!(schema.fields["name"].ty, JsonPrimitive::String);
/// # Ok(())
/// # }
/// ```
pub fn infer_schema_from_object(obj: &Map<String, Value>) -> SchemaDict {
let mut fields = IndexMap::new();
for (k, v) in obj {
fields.insert(k.clone(), FieldInfo::infer_field_info(v));
}
SchemaDict { fields }
}
/// Merge a new JSON object into the schema, promoting types where
/// necessary and marking fields as nullable if they are absent or null in
/// the new object.
///
/// # Example
///
/// ```
/// use fosk::{JsonPrimitive, SchemaDict};
/// use serde_json::json;
///
/// # fn main() -> Result<(), String> {
/// let mut schema = SchemaDict::default();
///
/// let first_value = json!({ "id": 1, "age": 37 });
/// let first = first_value
/// .as_object()
/// .ok_or_else(|| "expected first row object".to_string())?;
/// schema.merge_schema(first);
///
/// let second_value = json!({ "id": 2 });
/// let second = second_value
/// .as_object()
/// .ok_or_else(|| "expected second row object".to_string())?;
/// schema.merge_schema(second);
///
/// assert_eq!(schema.fields["age"].ty, JsonPrimitive::Int);
/// assert!(schema.fields["age"].nullable);
/// # Ok(())
/// # }
/// ```
pub fn merge_schema(&mut self, obj: &Map<String, Value>) {
// First, mark missing keys as nullable (they weren't present on this row)
// (Optional) If you want "missing means nullable", uncomment:
for (key, field_info) in self.fields.iter_mut() {
if !obj.contains_key(key) {
field_info.nullable = true;
}
}
// Merge present keys
for (key, value) in obj {
let new_info = FieldInfo::infer_field_info(value);
match self.fields.get_mut(key) {
Some(old) => {
*old = old.merge_field_info(&new_info);
}
None => {
self.fields.insert(key.clone(), new_info);
}
}
}
}
}
/// A collection schema combined with registered inbound and outbound references.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SchemaWithRefs {
/// Collection name this schema describes.
pub name: String,
/// Map of field name -> field metadata
pub fields: IndexMap<String, FieldInfo>,
/// References from this collection to other collections, keyed by field name.
pub outbound_refs: HashMap<String, ReferenceColumn>,
/// References from other collections to this collection, keyed by field name.
pub inbound_refs: HashMap<String, ReferenceColumn>,
}
impl SchemaWithRefs {
/// Build schema metadata with references for `collection_name`.
///
/// Most callers use [`Db::schema_with_refs_of`](crate::Db::schema_with_refs_of)
/// instead of constructing this value directly.
///
/// # Example
///
/// ```
/// use fosk::{Db, DbConfig, SchemaWithRefs};
/// use serde_json::json;
///
/// # fn main() -> Result<(), String> {
/// let db = Db::new_with_config(DbConfig::none("id"));
/// let people = db.create("people");
/// let _inserted = people
/// .add(json!({ "id": 1, "name": "Ada" }))
/// .map_err(|error| error.to_string())?;
///
/// let schema = people
/// .schema()
/// .map_err(|error| error.to_string())?
/// .ok_or_else(|| "missing people schema".to_string())?;
/// let with_refs = SchemaWithRefs::new("people", &schema, &db);
///
/// assert_eq!(with_refs.name, "people");
/// # Ok(())
/// # }
/// ```
pub fn new(collection_name: &str, schema_dict: &SchemaDict, db: &Db) -> Self {
let fields = schema_dict.fields.clone();
let mut outbound_refs = HashMap::new();
let mut inbound_refs = HashMap::new();
if let Some(refs) = db.get_collection_refs(collection_name) {
for s_ref in refs.into_values() {
if s_ref.is_referrer {
outbound_refs.insert(s_ref.ref_column.clone(), s_ref.clone());
} else {
inbound_refs.insert(s_ref.column.clone(), s_ref.clone());
}
}
}
Self {
name: collection_name.to_string(),
fields,
outbound_refs,
inbound_refs,
}
}
}
#[cfg(test)]
mod tests {
use crate::{Db, DbConfig, JsonPrimitive};
use super::*;
use serde_json::json;
#[test]
fn test_nullability_on_missing_and_null_values() {
let mut s = SchemaDict::default();
// first row: present "age" as Int
let r1 = json!({"id": 1, "name": "Ana", "age": 30})
.as_object()
.unwrap()
.clone();
s.merge_schema(&r1);
assert_eq!(s.get("age").unwrap().ty, JsonPrimitive::Int);
assert!(!s.get("age").unwrap().nullable);
// second row: age is missing -> nullable should flip only for "age"
let r2 = json!({"id": 2, "name": "Bob"}).as_object().unwrap().clone();
s.merge_schema(&r2);
assert!(s.get("age").unwrap().nullable);
assert!(!s.get("name").unwrap().nullable);
// third row: age is explicitly null -> nullable remains true
let r3 = json!({"id": 3, "name": "Cara", "age": null})
.as_object()
.unwrap()
.clone();
s.merge_schema(&r3);
assert!(s.get("age").unwrap().nullable);
}
#[test]
fn test_new_field_added() {
let mut s = SchemaDict::default();
let r1 = json!({"id": 1, "name": "Ana"}).as_object().unwrap().clone();
s.merge_schema(&r1);
assert!(s.get("email").is_none());
let r2 = json!({"id": 2, "name": "Bob", "email": "b@x.com"})
.as_object()
.unwrap()
.clone();
s.merge_schema(&r2);
let email = s.get("email").unwrap();
assert_eq!(email.ty, JsonPrimitive::String);
assert!(!email.nullable);
}
#[test]
fn test_numeric_promotion_over_time() {
let mut s = SchemaDict::default();
let r1 = json!({"price": 10}).as_object().unwrap().clone(); // Int
s.merge_schema(&r1);
assert_eq!(s.get("price").unwrap().ty, JsonPrimitive::Int);
let r2 = json!({"price": 10.5}).as_object().unwrap().clone(); // Float
s.merge_schema(&r2);
assert_eq!(s.get("price").unwrap().ty, JsonPrimitive::Float); // promoted
}
#[test]
fn infer_schema_from_object_captures_all_fields() {
let row = json!({
"id": 1,
"name": "Ada",
"active": true,
"score": 4.5,
"meta": {"city": "Porto"},
"tags": ["a"],
"deleted_at": null
})
.as_object()
.unwrap()
.clone();
let schema = SchemaDict::infer_schema_from_object(&row);
assert_eq!(schema.get("id").unwrap().ty, JsonPrimitive::Int);
assert_eq!(schema.get("name").unwrap().ty, JsonPrimitive::String);
assert_eq!(schema.get("active").unwrap().ty, JsonPrimitive::Bool);
assert_eq!(schema.get("score").unwrap().ty, JsonPrimitive::Float);
assert_eq!(schema.get("meta").unwrap().ty, JsonPrimitive::Object);
assert_eq!(schema.get("tags").unwrap().ty, JsonPrimitive::Array);
assert_eq!(schema.get("deleted_at").unwrap().ty, JsonPrimitive::Null);
assert!(schema.get("missing").is_none());
}
#[test]
fn schema_with_refs_splits_outbound_and_inbound_references() {
let db = Db::new_with_config(DbConfig::none("id"));
let person = match db.create("people").add(json!({ "id": 1, "name": "Ada" })) {
Ok(person) => person,
Err(error) => panic!("people seed row should insert: {error}"),
};
assert_eq!(person["id"], 1);
let order = match db.create("orders").add(json!({ "id": 10, "person_id": 1 })) {
Ok(order) => order,
Err(error) => panic!("orders seed row should insert: {error}"),
};
assert_eq!(order["id"], 10);
assert!(db.create_reference("orders", "person_id", "people", "id"));
let orders_schema = db.get("orders").unwrap().schema().unwrap().unwrap();
let orders = SchemaWithRefs::new("orders", &orders_schema, &db);
assert_eq!(orders.name, "orders");
assert!(orders.fields.contains_key("person_id"));
assert!(orders.inbound_refs.contains_key("person_id"));
assert!(orders.outbound_refs.is_empty());
let people_schema = db.get("people").unwrap().schema().unwrap().unwrap();
let people = SchemaWithRefs::new("people", &people_schema, &db);
assert!(people.outbound_refs.contains_key("id"));
assert!(people.inbound_refs.is_empty());
}
}