Skip to main content

drasi_source_dataverse/
types.rs

1// Copyright 2026 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! OData response types for Dataverse change tracking.
16//!
17//! These types represent the OData Web API response format, which is the
18//! REST equivalent of the platform's `RetrieveEntityChangesResponse`.
19//!
20//! # Mapping to Platform SDK Types
21//!
22//! | Platform SDK                  | OData Web API                   |
23//! |-------------------------------|---------------------------------|
24//! | `NewOrUpdatedItem`            | Record in `value[]` without     |
25//! |                               | `$deletedEntity` in context     |
26//! | `RemovedOrDeletedItem`        | Record with `@odata.context`    |
27//! |                               | containing `$deletedEntity`     |
28//! | `DataToken` (string)          | `@odata.deltaLink` URL with     |
29//! |                               | embedded delta token            |
30//! | `MoreRecords` + `PagingCookie`| `@odata.nextLink` URL           |
31
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34
35/// OData delta response from the Dataverse Web API.
36///
37/// Equivalent to `RetrieveEntityChangesResponse` from the platform SDK.
38/// Contains changed records (new/updated and deleted) plus a delta link
39/// for the next poll.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ODataDeltaResponse {
42    /// OData context URL (contains entity set name and metadata path).
43    #[serde(rename = "@odata.context", default)]
44    pub context: Option<String>,
45
46    /// List of changed records. Each entry is either:
47    /// - A new/updated record (regular entity object)
48    /// - A deleted record (object with `@odata.context` containing `$deletedEntity`)
49    #[serde(default)]
50    pub value: Vec<Value>,
51
52    /// Delta link URL for the next change tracking poll.
53    /// Contains an embedded delta token equivalent to the platform's `DataToken`.
54    #[serde(rename = "@odata.deltaLink", default)]
55    pub delta_link: Option<String>,
56
57    /// Next link URL for pagination within the current response.
58    /// Equivalent to the platform's `MoreRecords` + `PagingCookie`.
59    #[serde(rename = "@odata.nextLink", default)]
60    pub next_link: Option<String>,
61}
62
63/// Classified change from a Dataverse delta response.
64///
65/// Maps to the platform's `IChangedItem` interface with
66/// `ChangeType.NewOrUpdated` and `ChangeType.RemoveOrDeleted` variants.
67#[derive(Debug, Clone)]
68pub enum DataverseChange {
69    /// A new or updated entity record.
70    /// Equivalent to `NewOrUpdatedItem` in the platform SDK.
71    NewOrUpdated {
72        /// Entity ID (GUID string).
73        id: String,
74        /// Entity logical name (used as the node label).
75        entity_name: String,
76        /// All attribute key-value pairs.
77        attributes: serde_json::Map<String, Value>,
78    },
79    /// A deleted entity record.
80    /// Equivalent to `RemovedOrDeletedItem` in the platform SDK.
81    Deleted {
82        /// Entity ID (GUID string).
83        id: String,
84        /// Entity logical name (used as the node label).
85        entity_name: String,
86    },
87}
88
89/// Extract the entity ID from an OData record.
90///
91/// Dataverse entity IDs follow the pattern `{entitylogicalname}id`.
92/// For example, `account` → `accountid`, `contact` → `contactid`.
93///
94/// # Arguments
95///
96/// * `record` - The JSON object representing the entity
97/// * `entity_name` - The entity logical name
98pub fn extract_entity_id(
99    record: &serde_json::Map<String, Value>,
100    entity_name: &str,
101) -> Option<String> {
102    let id_field = format!("{entity_name}id");
103    record
104        .get(&id_field)
105        .and_then(|v| v.as_str())
106        .map(|s| s.to_string())
107}
108
109/// Check if a delta response record represents a deleted entity.
110///
111/// In OData change tracking, deleted entities are identified by having an
112/// `@odata.context` field containing `$deletedEntity`.
113///
114/// This matches the platform's `ChangeType.RemoveOrDeleted` detection.
115pub fn is_deleted_entity(record: &Value) -> bool {
116    record
117        .get("@odata.context")
118        .and_then(|v| v.as_str())
119        .is_some_and(|ctx| ctx.contains("$deletedEntity"))
120}
121
122/// Parse a delta response into classified changes.
123///
124/// Processes each record in the delta response `value` array and classifies
125/// it as either `NewOrUpdated` or `Deleted`, mirroring the platform's
126/// `EntityChanges.Changes` collection.
127///
128/// # Arguments
129///
130/// * `response` - The OData delta response
131/// * `entity_name` - The entity logical name (used for ID extraction and labeling)
132pub fn parse_delta_changes(
133    response: &ODataDeltaResponse,
134    entity_name: &str,
135) -> Vec<DataverseChange> {
136    let mut changes = Vec::new();
137
138    for record in &response.value {
139        if is_deleted_entity(record) {
140            // Deleted entity: extract ID from the "id" field
141            if let Some(id) = record.get("id").and_then(|v| v.as_str()) {
142                changes.push(DataverseChange::Deleted {
143                    id: id.to_string(),
144                    entity_name: entity_name.to_string(),
145                });
146            }
147        } else if let Some(obj) = record.as_object() {
148            // New or updated entity: extract ID and attributes
149            if let Some(id) = extract_entity_id(obj, entity_name) {
150                // Filter out OData metadata and annotation fields from attributes.
151                // Dataverse annotations either start with "@" (e.g. "@odata.etag")
152                // or are attached to a base field name via "@" (e.g.
153                // "createdon@OData.Community.Display.V1.FormattedValue").
154                let attributes: serde_json::Map<String, Value> = obj
155                    .iter()
156                    .filter(|(key, _)| !key.starts_with('@') && !key.contains('@'))
157                    .map(|(k, v)| (k.clone(), v.clone()))
158                    .collect();
159
160                changes.push(DataverseChange::NewOrUpdated {
161                    id,
162                    entity_name: entity_name.to_string(),
163                    attributes,
164                });
165            }
166        }
167    }
168
169    changes
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_parse_delta_response() {
178        let json = r#"{
179            "@odata.context": "https://myorg.crm.dynamics.com/api/data/v9.2/$metadata#accounts",
180            "@odata.deltaLink": "https://myorg.crm.dynamics.com/api/data/v9.2/accounts?$deltatoken=12345",
181            "value": [
182                {
183                    "@odata.etag": "W/\"12345\"",
184                    "accountid": "60c4e274-0d87-e711-80e5-00155db19e6d",
185                    "name": "Contoso Ltd",
186                    "revenue": 1000000.0
187                }
188            ]
189        }"#;
190
191        let response: ODataDeltaResponse = serde_json::from_str(json).expect("should parse");
192        assert_eq!(response.value.len(), 1);
193        assert!(response.delta_link.is_some());
194        assert!(response.next_link.is_none());
195
196        let changes = parse_delta_changes(&response, "account");
197        assert_eq!(changes.len(), 1);
198        match &changes[0] {
199            DataverseChange::NewOrUpdated {
200                id,
201                entity_name,
202                attributes,
203            } => {
204                assert_eq!(id, "60c4e274-0d87-e711-80e5-00155db19e6d");
205                assert_eq!(entity_name, "account");
206                assert_eq!(
207                    attributes.get("name").and_then(|v| v.as_str()),
208                    Some("Contoso Ltd")
209                );
210                // OData metadata should be filtered out
211                assert!(!attributes.contains_key("@odata.etag"));
212            }
213            _ => panic!("Expected NewOrUpdated"),
214        }
215    }
216
217    #[test]
218    fn test_parse_delta_with_deletes() {
219        let json = r#"{
220            "@odata.context": "https://myorg.crm.dynamics.com/api/data/v9.2/$metadata#accounts",
221            "@odata.deltaLink": "https://myorg.crm.dynamics.com/api/data/v9.2/accounts?$deltatoken=12346",
222            "value": [
223                {
224                    "@odata.etag": "W/\"99999\"",
225                    "accountid": "aaaa-bbbb",
226                    "name": "Updated Corp",
227                    "revenue": 2000000.0
228                },
229                {
230                    "@odata.context": "https://myorg.crm.dynamics.com/api/data/v9.2/$metadata#accounts/$deletedEntity",
231                    "id": "cccc-dddd",
232                    "reason": "deleted"
233                }
234            ]
235        }"#;
236
237        let response: ODataDeltaResponse = serde_json::from_str(json).expect("should parse");
238        let changes = parse_delta_changes(&response, "account");
239        assert_eq!(changes.len(), 2);
240
241        match &changes[0] {
242            DataverseChange::NewOrUpdated { id, .. } => assert_eq!(id, "aaaa-bbbb"),
243            _ => panic!("Expected NewOrUpdated"),
244        }
245        match &changes[1] {
246            DataverseChange::Deleted { id, entity_name } => {
247                assert_eq!(id, "cccc-dddd");
248                assert_eq!(entity_name, "account");
249            }
250            _ => panic!("Expected Deleted"),
251        }
252    }
253
254    #[test]
255    fn test_is_deleted_entity() {
256        let deleted = serde_json::json!({
257            "@odata.context": "https://test.crm.dynamics.com/api/data/v9.2/$metadata#accounts/$deletedEntity",
258            "id": "123",
259            "reason": "deleted"
260        });
261        assert!(is_deleted_entity(&deleted));
262
263        let normal = serde_json::json!({
264            "@odata.etag": "W/\"123\"",
265            "accountid": "456",
266            "name": "Test"
267        });
268        assert!(!is_deleted_entity(&normal));
269    }
270
271    #[test]
272    fn test_extract_entity_id() {
273        let record = serde_json::json!({
274            "accountid": "60c4e274-0d87-e711-80e5-00155db19e6d",
275            "name": "Test"
276        });
277        let obj = record.as_object().expect("should be object");
278        assert_eq!(
279            extract_entity_id(obj, "account"),
280            Some("60c4e274-0d87-e711-80e5-00155db19e6d".to_string())
281        );
282    }
283
284    #[test]
285    fn test_extract_entity_id_missing() {
286        let record = serde_json::json!({"name": "Test"});
287        let obj = record.as_object().expect("should be object");
288        assert_eq!(extract_entity_id(obj, "account"), None);
289    }
290
291    #[test]
292    fn test_empty_delta_response() {
293        let json = r#"{
294            "@odata.context": "https://test.crm.dynamics.com/api/data/v9.2/$metadata#accounts",
295            "@odata.deltaLink": "https://test.crm.dynamics.com/api/data/v9.2/accounts?$deltatoken=999",
296            "value": []
297        }"#;
298
299        let response: ODataDeltaResponse = serde_json::from_str(json).expect("should parse");
300        let changes = parse_delta_changes(&response, "account");
301        assert!(changes.is_empty());
302    }
303
304    #[test]
305    fn test_pagination_response() {
306        let json = r#"{
307            "@odata.context": "https://test.crm.dynamics.com/api/data/v9.2/$metadata#accounts",
308            "@odata.nextLink": "https://test.crm.dynamics.com/api/data/v9.2/accounts?$skiptoken=abc",
309            "value": [
310                {"accountid": "111", "name": "Page1Record"}
311            ]
312        }"#;
313
314        let response: ODataDeltaResponse = serde_json::from_str(json).expect("should parse");
315        assert!(response.delta_link.is_none());
316        assert!(response.next_link.is_some());
317    }
318}