Skip to main content

powerplatform_dataverse_client/dataverse/
parse.rs

1use std::collections::HashMap;
2use std::str::FromStr;
3
4use chrono::{DateTime, Utc};
5use log::warn;
6use rust_decimal::Decimal;
7use serde_json::Value;
8
9use crate::dataverse::entity::Value::{
10    Boolean, DateTime as DateTimeValue, Decimal as DecimalValue,
11    EntityReference as EntityRefValue, Float, Guid as GuidValue, Int, Money as MoneyValue, Null,
12    OptionSetValue as OptionSetSingle, OptionSetValueCollection as OptionSetMany, String,
13};
14use crate::dataverse::entity::{
15    Attribute, Entity, EntityReference, Money, OptionSetValue, OptionSetValueCollection,
16    Value as RowValue,
17};
18use crate::dataverse::entityattribute::EntityAttribute;
19use uuid::Uuid;
20
21const FORMATTED_VALUE_SUFFIX: &str = "@OData.Community.Display.V1.FormattedValue";
22
23/// Determine if a Dataverse response indicates more records.
24pub(crate) fn parse_more_records(json: &Value) -> bool {
25    match json.get("@Microsoft.Dynamics.CRM.morerecords") {
26        Some(Value::Bool(value)) => *value,
27        Some(Value::String(value)) => value.eq_ignore_ascii_case("true"),
28        _ => false,
29    }
30}
31
32/// Extract the paging cookie from a Dataverse response.
33pub(crate) fn extract_paging_cookie(json: &Value) -> Option<std::string::String> {
34    let cookie_element = json
35        .get("@Microsoft.Dynamics.CRM.fetchxmlpagingcookie")
36        .and_then(|value| value.as_str())?;
37    let key = "pagingcookie=\"";
38    let start = cookie_element.find(key)? + key.len();
39    let end = cookie_element[start..].find('"')? + start;
40    let encoded = &cookie_element[start..end];
41    let decoded_once = urlencoding::decode(encoded).ok()?.into_owned();
42    let decoded_twice = urlencoding::decode(&decoded_once).ok()?.into_owned();
43    Some(decoded_twice)
44}
45
46/// Parse entities from a Dataverse list response.
47pub(crate) fn parse_entities_from_response(
48    json: &Value,
49    entity_set: &str,
50    primary_id_attribute: Option<&str>,
51    entity_attributes: Option<&HashMap<std::string::String, EntityAttribute>>,
52) -> Result<Vec<Entity>, std::string::String> {
53    let response_object = json
54        .as_object()
55        .ok_or_else(|| "Invalid response from Dataverse".to_string())?;
56
57    let response_array = response_object
58        .get("value")
59        .ok_or_else(|| "Invalid response from Dataverse".to_string())?
60        .as_array()
61        .ok_or_else(|| "Invalid response from Dataverse".to_string())?;
62
63    let mut entities: Vec<Entity> = vec![];
64    let logical_name = infer_logical_name(entity_set);
65    let primary_id_key = primary_id_attribute
66        .map(|value| value.to_string())
67        .unwrap_or_else(|| format!("{}id", logical_name));
68
69    for record_value in response_array {
70        let record = record_value
71            .as_object()
72            .ok_or_else(|| "Invalid response from Dataverse".to_string())?;
73
74        // Fast-path parsing assumes the usual `<logicalname>id` convention unless metadata already
75        // told us the real primary id attribute. Failing here is preferable to silently producing
76        // malformed entities with missing ids.
77        let id_value = record
78            .get(&primary_id_key)
79            .and_then(|value| value.as_str())
80            .ok_or_else(|| {
81                format!(
82                    "Primary id '{}' not found for entity set '{}'",
83                    primary_id_key, entity_set
84                )
85            })?;
86        let id =
87            Uuid::parse_str(id_value).map_err(|_| "Invalid response from Dataverse".to_string())?;
88
89        let name = record
90            .get("name")
91            .and_then(|value| value.as_str())
92            .map(|value| value.to_string());
93
94        let mut entity = Entity::new(id, &logical_name, name);
95
96        let mut lookup_keys: Vec<(std::string::String, std::string::String)> = Vec::new();
97
98        for (key, value) in record {
99            if key.contains('@') {
100                continue;
101            }
102
103            if let Some(base) = lookup_base_attribute(key) {
104                let id = value
105                    .as_str()
106                    .map(|value| value.to_string())
107                    .unwrap_or_default();
108                lookup_keys.push((key.to_string(), base.clone()));
109
110                if id.is_empty() {
111                    entity.attributes.insert(base, Null);
112                }
113                continue;
114            }
115
116            let implemented = add_attribute(
117                &mut entity.attributes,
118                key,
119                value,
120                entity_attributes.and_then(|attributes| {
121                    attributes.get(&normalize_attribute_name(key))
122                }),
123            )
124                .map_err(|_| "Invalid response from Dataverse".to_string())?;
125
126            if !implemented {
127                warn!("Unsupported Dataverse key: {}", key);
128            }
129        }
130
131        for (raw_key, base) in lookup_keys {
132            if entity.attributes.contains_key(&base) {
133                continue;
134            }
135
136            let Some(id_value) = record.get(&raw_key).and_then(|value| value.as_str()) else {
137                entity.attributes.insert(base, Null);
138                continue;
139            };
140
141            let logical_key = format!("{raw_key}@Microsoft.Dynamics.CRM.lookuplogicalname");
142            let formatted_key = format!("{raw_key}@OData.Community.Display.V1.FormattedValue");
143
144            let logical_name = record
145                .get(&logical_key)
146                .and_then(|value| value.as_str())
147                .map(|value| value.to_string());
148
149            let name = record
150                .get(&formatted_key)
151                .and_then(|value| value.as_str())
152                .map(|value| value.to_string());
153
154            if let Some(name) = name.as_ref() {
155                entity
156                    .attributes
157                    .insert(format!("{base}name"), String(name.clone()));
158            }
159
160            if let Some(logical_name) = logical_name {
161                let id = Uuid::parse_str(id_value)
162                    .map_err(|_| "Invalid response from Dataverse".to_string())?;
163                entity.attributes.insert(
164                    base,
165                    EntityRefValue(EntityReference {
166                        id,
167                        logical_name,
168                        name,
169                    }),
170                );
171            } else {
172                warn!("Lookup logical name missing for key: {}", raw_key);
173                entity.attributes.insert(base, String(id_value.to_string()));
174            }
175        }
176
177        apply_lookup_attribute_annotations(&mut entity.attributes, record);
178        apply_formatted_value_names(&mut entity.attributes, record);
179
180        entities.push(entity);
181    }
182
183    Ok(entities)
184}
185
186/// Count the number of records in a Dataverse list response.
187pub(crate) fn parse_record_count_from_response(json: &Value) -> Result<usize, std::string::String> {
188    let response_object = json
189        .as_object()
190        .ok_or_else(|| "Invalid response from Dataverse".to_string())?;
191
192    let response_array = response_object
193        .get("value")
194        .ok_or_else(|| "Invalid response from Dataverse".to_string())?
195        .as_array()
196        .ok_or_else(|| "Invalid response from Dataverse".to_string())?;
197
198    Ok(response_array.len())
199}
200
201/// Convert a JSON value into a Dataverse attribute value.
202fn add_attribute(
203    attributes: &mut HashMap<Attribute, RowValue>,
204    key: &str,
205    value: &Value,
206    attribute: Option<&EntityAttribute>,
207) -> Result<bool, std::string::String> {
208    if value.is_null() {
209        attributes.insert(key.to_string(), Null);
210        return Ok(true);
211    }
212
213    if let Some(attribute_type) = attribute_type_key(attribute) {
214        if let Some(parsed) = parse_typed_attribute_value(value, attribute_type)? {
215            attributes.insert(key.to_string(), parsed);
216            return Ok(true);
217        }
218    }
219
220    if value.is_i64() {
221        let i = value
222            .as_i64()
223            .ok_or(format!("Unable to parse dataverse value: {:?}", value))?;
224        attributes.insert(key.to_string(), Int(i));
225        return Ok(true);
226    }
227
228    if value.is_u64() {
229        let i = value
230            .as_u64()
231            .ok_or(format!("Unable to parse dataverse value: {:?}", value))?;
232        if let Ok(as_i64) = i64::try_from(i) {
233            attributes.insert(key.to_string(), Int(as_i64));
234        } else {
235            attributes.insert(key.to_string(), Float(i as f64));
236        }
237        return Ok(true);
238    }
239
240    if value.is_f64() {
241        let f = value
242            .as_f64()
243            .ok_or(format!("Unable to parse dataverse value: {:?}", value))?;
244        attributes.insert(key.to_string(), Float(f));
245        return Ok(true);
246    }
247
248    if value.is_string() {
249        let s = value
250            .as_str()
251            .ok_or(format!("Unable to parse dataverse value: {:?}", value))?;
252        attributes.insert(key.to_string(), String(s.to_string()));
253        return Ok(true);
254    }
255
256    if value.is_boolean() {
257        let b = value
258            .as_bool()
259            .ok_or(format!("Unable to parse dataverse value: {:?}", value))?;
260        attributes.insert(key.to_string(), Boolean(b));
261        return Ok(true);
262    }
263
264    Ok(true)
265}
266
267fn parse_typed_attribute_value(
268    value: &Value,
269    attribute_type: &str,
270) -> Result<Option<RowValue>, std::string::String> {
271    match attribute_type {
272        "BigInt" | "BigIntType" => Ok(parse_i64_value(value).map(Int)),
273        "Boolean" | "BooleanType" => Ok(parse_bool_value(value).map(Boolean)),
274        "DateTime" | "DateTimeType" => Ok(parse_datetime_value(value).map(DateTimeValue)),
275        "Decimal" | "DecimalType" => Ok(parse_decimal_value(value).map(DecimalValue)),
276        "Double" | "DoubleType" => Ok(parse_f64_value(value).map(Float)),
277        "Integer" | "IntegerType" => Ok(parse_i64_value(value).map(Int)),
278        "Guid" | "Uniqueidentifier" | "UniqueidentifierType" => {
279            Ok(parse_guid_value(value).map(GuidValue))
280        }
281        "Money" | "MoneyType" => Ok(parse_decimal_value(value).map(|value| {
282            MoneyValue(Money { value })
283        })),
284        "Picklist" | "PicklistType" | "State" | "StateType" | "Status" | "StatusType" => {
285            Ok(parse_i32_value(value).map(|value| {
286                OptionSetSingle(OptionSetValue { value, name: None })
287            }))
288        }
289        "MultiSelectPicklist" | "MultiSelectPicklistType" => {
290            Ok(parse_multi_select_value(value).map(|values| {
291                OptionSetMany(OptionSetValueCollection { values })
292            }))
293        }
294        "Customer"
295        | "CustomerType"
296        | "Lookup"
297        | "LookupType"
298        | "Owner"
299        | "OwnerType"
300        | "PartyList"
301        | "PartyListType" => Ok(None),
302        "String"
303        | "StringType"
304        | "Memo"
305        | "MemoType"
306        | "EntityName"
307        | "EntityNameType"
308        | "Image"
309        | "ImageType"
310        | "File"
311        | "FileType" => Ok(value.as_str().map(|value| String(value.to_string()))),
312        _ => Ok(None),
313    }
314}
315
316fn attribute_type_key(attribute: Option<&EntityAttribute>) -> Option<&str> {
317    attribute
318        .and_then(|attribute| {
319            attribute
320                .attribute_type_name
321                .as_ref()
322                .and_then(|value| value.value.as_deref())
323                .or(attribute.attribute_type.as_deref())
324        })
325}
326
327fn normalize_attribute_name(value: &str) -> std::string::String {
328    value.to_ascii_lowercase()
329}
330
331fn parse_i64_value(value: &Value) -> Option<i64> {
332    value
333        .as_i64()
334        .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok()))
335}
336
337fn parse_i32_value(value: &Value) -> Option<i32> {
338    parse_i64_value(value).and_then(|value| i32::try_from(value).ok())
339}
340
341fn parse_f64_value(value: &Value) -> Option<f64> {
342    value
343        .as_f64()
344        .or_else(|| value.as_i64().map(|value| value as f64))
345        .or_else(|| value.as_u64().map(|value| value as f64))
346}
347
348fn parse_bool_value(value: &Value) -> Option<bool> {
349    value.as_bool()
350}
351
352fn parse_decimal_value(value: &Value) -> Option<Decimal> {
353    match value {
354        Value::Number(number) => Decimal::from_str(&number.to_string()).ok(),
355        Value::String(value) => Decimal::from_str(value).ok(),
356        _ => None,
357    }
358}
359
360fn parse_datetime_value(value: &Value) -> Option<DateTime<Utc>> {
361    value
362        .as_str()
363        .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
364        .map(|value| value.with_timezone(&Utc))
365}
366
367fn parse_guid_value(value: &Value) -> Option<Uuid> {
368    value
369        .as_str()
370        .and_then(|value| Uuid::parse_str(value).ok())
371}
372
373fn parse_multi_select_value(value: &Value) -> Option<Vec<i32>> {
374    match value {
375        Value::String(value) => {
376            let parsed: Vec<i32> = value
377                .split(',')
378                .map(str::trim)
379                .filter(|value| !value.is_empty())
380                .filter_map(|value| value.parse::<i32>().ok())
381                .collect();
382            Some(parsed)
383        }
384        Value::Array(values) => Some(
385            values
386                .iter()
387                .filter_map(parse_i32_value)
388                .collect(),
389        ),
390        _ => None,
391    }
392}
393
394fn lookup_base_attribute(key: &str) -> Option<std::string::String> {
395    if !key.starts_with('_') || !key.ends_with("_value") {
396        return None;
397    }
398
399    let trimmed = &key[1..key.len() - "_value".len()];
400    if trimmed.is_empty() {
401        return None;
402    }
403
404    Some(trimmed.to_string())
405}
406
407fn apply_formatted_value_names(
408    attributes: &mut HashMap<Attribute, RowValue>,
409    record: &serde_json::Map<std::string::String, Value>,
410) {
411    // Dataverse emits display labels as annotation siblings instead of embedding them in the typed
412    // value payload, so option set names have to be stitched back onto the parsed attribute map.
413    if !record.keys().any(|key| key.ends_with(FORMATTED_VALUE_SUFFIX)) {
414        return;
415    }
416
417    for (key, value) in record {
418        let Some(base_key) = key.strip_suffix(FORMATTED_VALUE_SUFFIX) else {
419            continue;
420        };
421
422        let Some(formatted) = value.as_str() else {
423            continue;
424        };
425
426        let Some(attribute) = attributes.get_mut(base_key) else {
427            continue;
428        };
429
430        if let OptionSetSingle(option) = attribute {
431            option.name = Some(formatted.to_string());
432        }
433    }
434}
435
436fn apply_lookup_attribute_annotations(
437    attributes: &mut HashMap<Attribute, RowValue>,
438    record: &serde_json::Map<std::string::String, Value>,
439) {
440    // Non-underscore lookup columns can arrive as plain string ids plus annotation siblings.
441    // When that happens, upgrade the parsed string into a full `EntityReference` so downstream
442    // callers see the same shape they would get from the `_lookup_value` form.
443    for (key, value) in record {
444        let Some(base_key) = key.strip_suffix("@Microsoft.Dynamics.CRM.lookuplogicalname") else {
445            continue;
446        };
447
448        if base_key.starts_with('_') {
449            continue;
450        }
451
452        let Some(logical_name) = value.as_str() else {
453            continue;
454        };
455
456        let Some(attribute) = attributes.get_mut(base_key) else {
457            continue;
458        };
459
460        let RowValue::String(id_value) = attribute else {
461            continue;
462        };
463
464        let Ok(id) = Uuid::parse_str(id_value) else {
465            continue;
466        };
467
468        let formatted_key = format!("{base_key}{FORMATTED_VALUE_SUFFIX}");
469        let name = record
470            .get(&formatted_key)
471            .and_then(|value| value.as_str())
472            .map(|value| value.to_string());
473
474        *attribute = EntityRefValue(EntityReference {
475            id,
476            logical_name: logical_name.to_string(),
477            name,
478        });
479    }
480}
481
482fn infer_logical_name(entity_set: &str) -> std::string::String {
483    // Metadata calls are avoided in the hot path for plain list parsing, so we keep a conservative
484    // singularization fallback here for the common entity-set naming patterns Dataverse uses.
485    let normalized = entity_set.trim().to_ascii_lowercase();
486
487    if normalized.ends_with("ies") && normalized.len() > 3 {
488        return format!("{}y", &normalized[..normalized.len() - 3]);
489    }
490
491    if ends_with_any(&normalized, &["ses", "xes", "zes", "ches", "shes"]) && normalized.len() > 2 {
492        return normalized[..normalized.len() - 2].to_string();
493    }
494
495    if normalized.ends_with('s')
496        && !normalized.ends_with("ss")
497        && !normalized.ends_with("us")
498        && !normalized.ends_with("is")
499        && normalized.len() > 1
500    {
501        return normalized[..normalized.len() - 1].to_string();
502    }
503
504    normalized
505}
506
507fn ends_with_any(name: &str, suffixes: &[&str]) -> bool {
508    suffixes.iter().any(|suffix| name.ends_with(suffix))
509}
510
511#[cfg(test)]
512mod tests {
513    use std::collections::HashMap;
514
515    use serde_json::json;
516
517    use super::{
518        extract_paging_cookie, infer_logical_name, parse_entities_from_response,
519        parse_more_records, parse_record_count_from_response,
520    };
521    use crate::dataverse::entityattribute::{AttributeTypeName, EntityAttribute};
522
523    #[test]
524    fn parses_more_records_from_bool_and_string_annotations() {
525        assert!(parse_more_records(&json!({
526            "@Microsoft.Dynamics.CRM.morerecords": true
527        })));
528        assert!(parse_more_records(&json!({
529            "@Microsoft.Dynamics.CRM.morerecords": "true"
530        })));
531        assert!(!parse_more_records(&json!({})));
532    }
533
534    #[test]
535    fn extracts_double_encoded_paging_cookie() {
536        let json = json!({
537            "@Microsoft.Dynamics.CRM.fetchxmlpagingcookie":
538                "pagingcookie=\"%253ccookie%2520page%253d%25221%2522%2520%252f%253e\""
539        });
540
541        let cookie = extract_paging_cookie(&json).expect("should extract cookie");
542
543        assert_eq!(cookie, "<cookie page=\"1\" />");
544    }
545
546    #[test]
547    fn parses_entities_and_upgrades_lookup_annotations() {
548        let json = json!({
549            "value": [
550                {
551                    "contactid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
552                    "fullname": "Ada Lovelace",
553                    "statecode": 0,
554                    "statecode@OData.Community.Display.V1.FormattedValue": "Active",
555                    "primarycontactid": "11111111-2222-3333-4444-555555555555",
556                    "primarycontactid@Microsoft.Dynamics.CRM.lookuplogicalname": "contact",
557                    "primarycontactid@OData.Community.Display.V1.FormattedValue": "Ada Lovelace"
558                }
559            ]
560        });
561        let entity_attributes = HashMap::from([(
562            "statecode".to_string(),
563            EntityAttribute {
564                logical_name: "statecode".to_string(),
565                schema_name: "StateCode".to_string(),
566                attribute_type: Some("State".to_string()),
567                attribute_type_name: Some(AttributeTypeName {
568                    value: Some("StateType".to_string()),
569                }),
570                is_custom_attribute: Some(false),
571                is_valid_odata_attribute: Some(true),
572                is_valid_for_read: Some(true),
573                is_valid_for_update: Some(false),
574            },
575        )]);
576
577        let entities =
578            parse_entities_from_response(
579                &json,
580                "contacts",
581                Some("contactid"),
582                Some(&entity_attributes),
583            )
584            .expect("should parse entities");
585
586        assert_eq!(entities.len(), 1);
587        let entity = &entities[0];
588        assert_eq!(entity.logical_name, "contact");
589        assert!(matches!(
590            entity.attributes.get("statecode"),
591            Some(crate::dataverse::entity::Value::OptionSetValue(option))
592                if option.value == 0 && option.name.as_deref() == Some("Active")
593        ));
594        assert!(matches!(
595            entity.attributes.get("primarycontactid"),
596            Some(crate::dataverse::entity::Value::EntityReference(reference))
597                if reference.logical_name == "contact"
598                    && reference.name.as_deref() == Some("Ada Lovelace")
599        ));
600    }
601
602    #[test]
603    fn record_count_uses_value_array_length() {
604        let count = parse_record_count_from_response(&json!({
605            "value": [{}, {}, {}]
606        }))
607        .expect("should count records");
608
609        assert_eq!(count, 3);
610    }
611
612    #[test]
613    fn infer_logical_name_handles_common_entity_set_pluralization() {
614        assert_eq!(infer_logical_name("contacts"), "contact");
615        assert_eq!(infer_logical_name("categories"), "category");
616        assert_eq!(infer_logical_name("boxes"), "box");
617    }
618}