Skip to main content

core_api/
ingest.rs

1use crate::db::GraphDb;
2use core_rules::{default_max_edges, Predicate, RuleDef};
3use core_storage::fs::Fs;
4use core_storage::{GraphError, Result, Value};
5use serde::Serialize;
6use std::collections::{BTreeMap, BTreeSet};
7
8/// Options for [`GraphDb::ingest`].
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct IngestOptions {
11    /// Property used as the node key. Also stored as a normal property.
12    pub key_field: String,
13    pub auto_fk: AutoFk,
14}
15
16impl Default for IngestOptions {
17    fn default() -> Self {
18        Self {
19            key_field: "id".into(),
20            auto_fk: AutoFk::default(),
21        }
22    }
23}
24
25/// Zero-config FK inference: declare a `KeyMatch` rule per `*_id` field, or skip.
26///
27/// Auto-declared rule names are `auto_fk_<src_label_lowercase>_<field>`
28/// (e.g. `auto_fk_person_org_id`, `auto_fk_device_org_id`) so two ingested
29/// labels sharing an FK field each get their own rule. A name collision is
30/// only the same `(label, field)` pair, where silent skip is correct.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum AutoFk {
33    Auto { suffix: String },
34    Off,
35}
36
37impl Default for AutoFk {
38    fn default() -> Self {
39        AutoFk::Auto {
40            suffix: "_id".into(),
41        }
42    }
43}
44
45/// One auto-FK field that was not turned into a rule.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
47pub struct FkSkip {
48    pub field: String,
49    pub reason: String,
50}
51
52/// Outcome of one [`GraphDb::ingest`] call. Row-level issues are collected here;
53/// a commit-level `Err` means nothing was applied.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct IngestReport {
56    pub inserted: usize,
57    pub row_errors: Vec<(usize, String)>,
58    pub rules_created: Vec<String>,
59    pub skipped_fk_fields: Vec<FkSkip>,
60    /// User edges from the same request that were newly inserted (duplicates
61    /// are no-ops and do not count).
62    pub edges_inserted: usize,
63}
64
65/// Convert a JSON value to a stored [`Value`].
66///
67/// JSON `null` returns `None` so the caller can skip the field (not an error).
68/// Integral numbers become [`Value::Int`]; other numbers become [`Value::Float`].
69/// Arrays become [`Value::List`] by recursing through this function. If any
70/// element is JSON `null` the entire array field returns `None` and the caller
71/// silently skips the field (same null-drop policy as top-level fields). JSON
72/// objects become [`Value::Map`] recursively; `null` values inside an object
73/// are silently omitted from the map.
74///
75/// **Behavior change from pre-Map:** JSON objects previously returned `None`
76/// (treated as a skipped/error field by `ingest_json`). They now produce
77/// `Value::Map` so nested objects are stored faithfully.
78pub fn json_to_value(v: serde_json::Value) -> Option<Value> {
79    match v {
80        serde_json::Value::Null => None,
81        serde_json::Value::Bool(b) => Some(Value::Bool(b)),
82        serde_json::Value::Number(n) => number_to_value(&n),
83        serde_json::Value::String(s) => Some(Value::Str(s)),
84        serde_json::Value::Array(items) => {
85            let mut out = Vec::with_capacity(items.len());
86            for item in items {
87                out.push(json_to_value(item)?);
88            }
89            Some(Value::List(out))
90        }
91        serde_json::Value::Object(obj) => {
92            let mut map = std::collections::BTreeMap::new();
93            for (k, v) in obj {
94                if let Some(val) = json_to_value(v) {
95                    map.insert(k, val);
96                }
97            }
98            Some(Value::Map(map))
99        }
100    }
101}
102
103fn number_to_value(n: &serde_json::Number) -> Option<Value> {
104    if let Some(i) = n.as_i64() {
105        return Some(Value::Int(i));
106    }
107    let f = n.as_f64()?;
108    if f.is_finite() && f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
109        Some(Value::Int(f as i64))
110    } else {
111        Some(Value::Float(f))
112    }
113}
114
115fn field_shape_error(_field: &str, _v: &serde_json::Value) -> Option<String> {
116    // All JSON shapes are now accepted: scalars, arrays (via List), and objects
117    // (via Map). `json_to_value` handles conversion recursively. `null` fields
118    // are silently dropped by the caller rather than reported as an error.
119    None
120}
121
122fn object_to_row(
123    obj: &serde_json::Map<String, serde_json::Value>,
124) -> std::result::Result<BTreeMap<String, Value>, String> {
125    let mut row = BTreeMap::new();
126    for (k, v) in obj {
127        if let Some(err) = field_shape_error(k, v) {
128            return Err(err);
129        }
130        if let Some(val) = json_to_value(v.clone()) {
131            row.insert(k.clone(), val);
132        }
133    }
134    Ok(row)
135}
136
137/// Parsed JSON rows ready for [`crate::GraphDb::ingest`], plus bookkeeping so
138/// per-row shape errors keep their original JSON-array indices.
139pub struct JsonRows {
140    /// Rows that passed shape checks, in original order.
141    pub rows: Vec<BTreeMap<String, Value>>,
142    kept_indices: Vec<usize>,
143    shape_errors: Vec<(usize, String)>,
144}
145
146impl JsonRows {
147    /// Remap ingest row-error indices onto the original JSON array and append
148    /// the shape errors collected by [`json_to_rows`].
149    pub fn into_report(self, mut report: IngestReport) -> IngestReport {
150        for (idx, _) in &mut report.row_errors {
151            *idx = self.kept_indices[*idx];
152        }
153        report.row_errors.extend(self.shape_errors);
154        report.row_errors.sort_by_key(|(i, _)| *i);
155        report
156    }
157}
158
159/// Convert a parsed JSON value (must be an array of objects) into ingest rows.
160///
161/// Same conversion as [`crate::GraphDb::ingest_json`]: [`json_to_value`] per
162/// field; nested objects / mixed arrays become per-row errors. A top-level
163/// value that is not an array of objects is [`GraphError::IngestError`].
164pub fn json_to_rows(value: &serde_json::Value) -> Result<JsonRows> {
165    let arr = value.as_array().ok_or_else(|| GraphError::IngestError {
166        detail: "top-level JSON must be an array of objects".into(),
167    })?;
168    if !arr.iter().all(|v| v.is_object()) {
169        return Err(GraphError::IngestError {
170            detail: "top-level JSON must be an array of objects".into(),
171        });
172    }
173
174    let mut rows = Vec::new();
175    let mut shape_errors = Vec::new();
176    let mut kept_indices = Vec::new();
177    for (i, item) in arr.iter().enumerate() {
178        let obj = item
179            .as_object()
180            .expect("top-level checked as array of objects");
181        match object_to_row(obj) {
182            Ok(row) => {
183                kept_indices.push(i);
184                rows.push(row);
185            }
186            Err(msg) => shape_errors.push((i, msg)),
187        }
188    }
189    Ok(JsonRows {
190        rows,
191        kept_indices,
192        shape_errors,
193    })
194}
195
196/// Parse JSON, convert rows, then delegate to [`run`].
197pub(crate) fn run_json<F: Fs>(
198    db: &mut GraphDb<F>,
199    label: &str,
200    json: &str,
201    opts: &IngestOptions,
202) -> Result<IngestReport> {
203    let parsed: serde_json::Value =
204        serde_json::from_str(json).map_err(|e| GraphError::IngestError {
205            detail: e.to_string(),
206        })?;
207    let mut converted = json_to_rows(&parsed)?;
208    let rows = std::mem::take(&mut converted.rows);
209    let report = run(db, label, rows, opts, &[])?;
210    Ok(converted.into_report(report))
211}
212
213type PropMap = BTreeMap<String, Value>;
214
215struct Classified {
216    accepted: Vec<(String, PropMap)>,
217    row_errors: Vec<(usize, String)>,
218}
219
220/// Classify rows, optionally infer auto-FK rules, and commit one atomic batch
221/// (rules first, then node inserts, then optional user edges).
222pub(crate) fn run<F: Fs>(
223    db: &mut GraphDb<F>,
224    label: &str,
225    rows: Vec<BTreeMap<String, Value>>,
226    opts: &IngestOptions,
227    edges: &[(String, String, String)],
228) -> Result<IngestReport> {
229    let Classified {
230        accepted,
231        row_errors,
232    } = classify_rows(db, rows, &opts.key_field);
233
234    let (new_rules, skipped_fk_fields) = match &opts.auto_fk {
235        AutoFk::Off => (Vec::new(), Vec::new()),
236        AutoFk::Auto { suffix } => infer_auto_fk(db, label, suffix, &opts.key_field, &accepted),
237    };
238
239    let rules_created: Vec<String> = new_rules.iter().map(|r| r.name.clone()).collect();
240
241    // One `WalRecord::Batch` (Batched fsync), not a loop of `insert_node`.
242    let mut batch = db.batch();
243    for def in new_rules {
244        batch.create_rule(def);
245    }
246    for (key, props) in &accepted {
247        let prop_vec: Vec<(String, Value)> =
248            props.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
249        batch.insert_node(label, key, prop_vec);
250    }
251    for (etype, src, dst) in edges {
252        batch.insert_edge(etype, src, dst);
253    }
254    let (_, edges_inserted) = batch.commit_ingest(label, accepted.len())?;
255
256    Ok(IngestReport {
257        inserted: accepted.len(),
258        row_errors,
259        rules_created,
260        skipped_fk_fields,
261        edges_inserted,
262    })
263}
264
265fn classify_rows<F: Fs>(db: &GraphDb<F>, rows: Vec<PropMap>, key_field: &str) -> Classified {
266    let mut accepted = Vec::new();
267    let mut row_errors = Vec::new();
268    let mut seen: BTreeSet<String> = BTreeSet::new();
269
270    for (i, row) in rows.into_iter().enumerate() {
271        match row.get(key_field) {
272            None => row_errors.push((i, format!("missing key field {key_field}"))),
273            Some(Value::Str(key)) => {
274                if db.has_node(key) || seen.contains(key) {
275                    row_errors.push((i, format!("duplicate key {key}")));
276                } else {
277                    seen.insert(key.clone());
278                    accepted.push((key.clone(), row));
279                }
280            }
281            Some(_) => row_errors.push((i, format!("key field {key_field} is not a string"))),
282        }
283    }
284    Classified {
285        accepted,
286        row_errors,
287    }
288}
289
290fn infer_auto_fk<F: Fs>(
291    db: &GraphDb<F>,
292    src_label: &str,
293    suffix: &str,
294    key_field: &str,
295    accepted: &[(String, PropMap)],
296) -> (Vec<RuleDef>, Vec<FkSkip>) {
297    let existing_rule_names: BTreeSet<String> = db.rules().into_iter().map(|r| r.name).collect();
298    let accepted_keys: BTreeSet<&str> = accepted.iter().map(|(k, _)| k.as_str()).collect();
299
300    let mut fields: BTreeSet<String> = BTreeSet::new();
301    for (_, row) in accepted {
302        for field in row.keys() {
303            if field != key_field && field.ends_with(suffix) && field.len() > suffix.len() {
304                fields.insert(field.clone());
305            }
306        }
307    }
308
309    let mut new_rules = Vec::new();
310    let mut skipped = Vec::new();
311
312    for field in fields {
313        let mut values: BTreeSet<&str> = BTreeSet::new();
314        for (_, row) in accepted {
315            if let Some(Value::Str(s)) = row.get(&field) {
316                values.insert(s.as_str());
317            }
318        }
319
320        let mut labels: BTreeSet<String> = BTreeSet::new();
321        for value in values {
322            if let Some(n) = db.node_ref(value) {
323                labels.insert(n.label().to_string());
324            }
325            if accepted_keys.contains(value) {
326                labels.insert(src_label.to_string());
327            }
328        }
329
330        match labels.len() {
331            0 => skipped.push(FkSkip {
332                field,
333                reason: "no matching target keys".into(),
334            }),
335            1 => {
336                let dst_label = labels.into_iter().next().expect("len == 1");
337                // `auto_fk_<src_label_lowercase>_<field>` — scoped by source
338                // label so Person.org_id and Device.org_id do not collide.
339                let name = format!("auto_fk_{}_{field}", src_label.to_lowercase());
340                if existing_rule_names.contains(&name) {
341                    continue;
342                }
343                let remainder = &field[..field.len() - suffix.len()];
344                let predicate = Predicate::KeyMatch {
345                    field: field.clone(),
346                };
347                let max_edges = Some(default_max_edges(&predicate));
348                new_rules.push(RuleDef {
349                    name,
350                    src_label: src_label.to_string(),
351                    dst_label,
352                    predicate,
353                    edge_type: remainder.to_uppercase(),
354                    weight_prop: None,
355                    max_edges,
356                    approximate: false,
357                    via_label: None,
358                    via_edge: None,
359                    via_dir: None,
360                });
361            }
362            _ => {
363                let listed = labels.into_iter().collect::<Vec<_>>().join(", ");
364                skipped.push(FkSkip {
365                    field,
366                    reason: format!("ambiguous target labels: {listed}"),
367                });
368            }
369        }
370    }
371
372    (new_rules, skipped)
373}