Skip to main content

jsonld_compaction/
node.rs

1use crate::{Error, IriConfusedWithPrefix, Options, add_value, compact_iri, compact_property, iri::keyword_alias};
2use contextual::WithContext;
3use jsonld_context_processing::{Options as ProcessingOptions, Process, ProcessingMode};
4use jsonld_core::{Container, ContainerKind, Context, ContextRef, Id, Loader, Node, Term, Type};
5use jsonld_syntax::Keyword;
6use rdfx::vocabulary::{Vocabulary, VocabularyMut};
7use std::hash::Hash;
8
9/// Turns an optional string into a JSON string, or JSON `null` when absent.
10///
11/// IRI compaction returns `None` for a null term, which the specification
12/// requires to be written out as `null` rather than omitted.
13fn optional_string(s: Option<&str>) -> jstrict::Value {
14    s.map_or(jstrict::Value::Null, |s| jstrict::Value::String(s.into()))
15}
16
17/// Compacts a node object, following the [compaction algorithm][1]'s node
18/// object case.
19///
20/// Emits `@id`, `@type`, `@reverse`, `@index`, `@graph`, `@included` and every
21/// ordinary property into a single JSON object, keyed by whatever each expanded
22/// property compacts to. `index` is the `@index` the node was reached through,
23/// and is dropped when the active property's container mapping already turned it
24/// into a map key.
25///
26/// A node object consisting of nothing but an `@id` is a node reference and
27/// is compacted by [`compact_node_reference`] once the active property's
28/// scoped context is in force.
29///
30/// [1]: https://www.w3.org/TR/json-ld-api/#compaction-algorithm
31pub async fn compact_indexed_node_with<N, L>(
32    vocabulary: &mut N,
33    node: &Node<N::Iri, N::BlankId>,
34    index: Option<&str>,
35    mut active_context: &Context<N::Iri, N::BlankId>,
36    type_scoped_context: &Context<N::Iri, N::BlankId>,
37    active_property: Option<&str>,
38    loader: &L,
39    options: Options,
40) -> Result<jstrict::Value, Error<L::Error>>
41where
42    N: VocabularyMut,
43    N::Iri: Clone + Hash + Eq,
44    N::BlankId: Clone + Hash + Eq,
45    L: Loader,
46{
47    // If active context has a previous context, the active context is not propagated.
48    // If element does not contain an @value entry, and element does not consist of
49    // a single @id entry, set active context to previous context from active context,
50    // as the scope of a term-scoped context does not apply when processing new node objects.
51    if !(node.is_empty() && node.id.is_some()) {
52        // does not consist of a single @id entry
53        if let Some(previous_context) = active_context.previous_context() {
54            active_context = previous_context;
55        }
56    }
57
58    // If the term definition for active property in active context has a local context:
59    //
60    // Known deviation from the specification text, which says to look the term
61    // definition up in the active context. This looks it up in
62    // `type_scoped_context` instead, which is what makes the W3C compaction test
63    // suite pass; the spec text is believed to be in error. See
64    // https://github.com/w3c/json-ld-api/issues/502.
65    let mut active_context = ContextRef::Borrowed(active_context);
66    if let Some(active_property) = active_property
67        && let Some(active_property_definition) = type_scoped_context.get(active_property)
68        && let Some(local_context) = active_property_definition.context()
69    {
70        active_context = ContextRef::owned(
71            local_context
72                .process_with(
73                    vocabulary,
74                    active_context.as_ref(),
75                    loader,
76                    active_property_definition.base_url().cloned(),
77                    ProcessingOptions::from(options).with_override(),
78                )
79                .await?
80                .into_processed(),
81        );
82    }
83
84    if node.is_empty()
85        && let Some(id) = &node.id
86    {
87        return Ok(compact_node_reference(
88            vocabulary,
89            id,
90            index,
91            active_context.as_ref(),
92            active_property,
93            options,
94        )?);
95    }
96
97    let mut result = jstrict::Object::default();
98
99    if !node.types().is_empty() {
100        // If element has an @type entry, create a new array compacted types initialized by
101        // transforming each expanded type of that entry into its compacted form by IRI
102        // compacting expanded type. Then, for each term in compacted types ordered
103        // lexicographically:
104        let mut compacted_types = Vec::new();
105        for ty in node.types() {
106            let compacted_ty = compact_iri(vocabulary, type_scoped_context, &ty.clone().into_term(), true, false, options)?;
107            compacted_types.push(compacted_ty);
108        }
109
110        compacted_types.sort_by(|a, b| a.as_deref().cmp(&b.as_deref()));
111
112        for term in compacted_types.iter().flatten() {
113            if let Some(term_definition) = type_scoped_context.get(&**term)
114                && let Some(local_context) = term_definition.context()
115            {
116                let processing_options = ProcessingOptions::from(options).without_propagation();
117                active_context = ContextRef::owned(
118                    local_context
119                        .process_with(
120                            vocabulary,
121                            active_context.as_ref(),
122                            loader,
123                            term_definition.base_url().cloned(),
124                            processing_options,
125                        )
126                        .await?
127                        .into_processed(),
128                );
129            }
130        }
131    }
132
133    // For each key expanded property and value expanded value in element, ordered
134    // lexicographically by expanded property if ordered is true:
135    let expanded_entries: Vec<_> = if options.ordered {
136        // Decorate-sort-undecorate: cache `as_str()` so the comparator does
137        // not call `with(vocabulary).as_str()` O(P log P) times. The cached
138        // `&str` borrows from `entry.0` (which lives in `node.properties()`)
139        // and `vocabulary`, both of which outlive `decorated`, so we avoid
140        // the per-entry `to_string()` allocation.
141        let vocabulary: &N = vocabulary;
142        let mut decorated: Vec<(&str, _)> = node.properties().iter().map(|entry| (entry.0.with(vocabulary).as_str(), entry)).collect();
143        decorated.sort_by(|a, b| a.0.cmp(b.0));
144        decorated.into_iter().map(|(_, entry)| entry).collect()
145    } else {
146        node.properties().iter().collect()
147    };
148
149    // If expanded property is @id:
150    if let Some(id_entry) = &node.id {
151        insert_id(vocabulary, &mut result, id_entry, active_context.as_ref(), options)?;
152    }
153
154    compact_types(
155        vocabulary,
156        &mut result,
157        node.types.as_deref(),
158        active_context.as_ref(),
159        type_scoped_context,
160        options,
161    )?;
162
163    // If expanded property is @reverse:
164    if let Some(reverse_properties) = node.reverse_properties_entry()
165        && !reverse_properties.is_empty()
166    {
167        // Initialize compacted value to the result of using this algorithm recursively,
168        // passing active context, @reverse for active property,
169        // expanded value for element, and the compactArrays and ordered flags.
170        let active_property = "@reverse";
171        if let Some(active_property_definition) = active_context.get(active_property)
172            && let Some(local_context) = active_property_definition.context()
173        {
174            active_context = ContextRef::owned(
175                local_context
176                    .process_with(
177                        vocabulary,
178                        active_context.as_ref(),
179                        loader,
180                        active_property_definition.base_url().cloned(),
181                        ProcessingOptions::from(options).with_override(),
182                    )
183                    .await?
184                    .into_processed(),
185            );
186        }
187
188        // Every property compacts into one shared object, deliberately. Giving
189        // each property its own object and merging afterwards — the obvious way
190        // to make these iterations independent of one another — does not
191        // reproduce the same output: `add_value` cannot merge the fragments
192        // that `compact_property` produces for `@nest` sub-objects, for
193        // `@index`/`@id`/`@type`/`@language` container maps, or for graph
194        // fragments, because those are objects that have to be merged key by
195        // key rather than appended. Doing it correctly would mean either
196        // restructuring `compact_property` to emit a flat log of insertions, or
197        // consulting the active context per key to decide between a recursive
198        // merge and `add_value`.
199        let mut reverse_result = jstrict::Object::default();
200        for (expanded_property, expanded_value) in reverse_properties {
201            compact_property(
202                vocabulary,
203                &mut reverse_result,
204                expanded_property.clone().into(),
205                expanded_value.iter(),
206                active_context.as_ref(),
207                loader,
208                true,
209                options,
210            )
211            .await?;
212        }
213
214        // For each property and value in compacted value:
215        let mut reverse_map = jstrict::Object::default();
216        for (property, mapped_value) in &mut reverse_result {
217            let mut value = jstrict::Value::Null;
218            std::mem::swap(&mut value, &mut *mapped_value);
219
220            // If the term definition for property in the active context indicates that
221            // property is a reverse property
222            if let Some(term_definition) = active_context.get(property.as_str())
223                && term_definition.reverse_property()
224            {
225                // Initialize as array to true if the container mapping for property in
226                // the active context includes @set, otherwise the negation of compactArrays.
227                let as_array = term_definition.container().contains(ContainerKind::Set) || !options.compact_arrays;
228
229                // Use add value to add value to the property entry in result using as array.
230                add_value(&mut result, property, value, as_array);
231                continue;
232            }
233
234            reverse_map.insert(property.clone(), value);
235        }
236
237        if !reverse_map.is_empty() {
238            // Initialize alias by IRI compacting @reverse.
239            let alias = keyword_alias(vocabulary, active_context.as_ref(), options, Keyword::Reverse);
240
241            // Set the value of the alias entry of result to compacted value.
242            result.insert(alias.into(), reverse_map.into());
243        }
244    }
245
246    insert_index(vocabulary, &mut result, index, active_context.as_ref(), active_property, options);
247
248    if let Some(graph_entry) = node.graph_entry() {
249        compact_property(
250            vocabulary,
251            &mut result,
252            Term::Keyword(Keyword::Graph),
253            graph_entry.iter(),
254            active_context.as_ref(),
255            loader,
256            false,
257            options,
258        )
259        .await?;
260    }
261
262    for (expanded_property, expanded_value) in expanded_entries {
263        compact_property(
264            vocabulary,
265            &mut result,
266            expanded_property.clone().into(),
267            expanded_value.iter(),
268            active_context.as_ref(),
269            loader,
270            false,
271            options,
272        )
273        .await?;
274    }
275
276    if let Some(included_entry) = node.included_entry() {
277        compact_property(
278            vocabulary,
279            &mut result,
280            Term::Keyword(Keyword::Included),
281            included_entry.iter(),
282            active_context.as_ref(),
283            loader,
284            false,
285            options,
286        )
287        .await?;
288    }
289
290    Ok(result.into())
291}
292
293/// Compacts a node reference, a node object whose only entry is `@id`,
294/// against an active context in which the active property's scoped context
295/// is already in force.
296///
297/// Follows step 7 of the [value compaction algorithm][1]: under a term whose
298/// type mapping is `@id` the reference becomes the `@id` compacted as an IRI
299/// reference (`vocab` set to `false`), under a term whose type mapping is
300/// `@vocab` it becomes the `@id` compacted with `vocab` set to `true`, and in
301/// either case `index` is dropped. Otherwise the result is an object holding
302/// the `@id`, compacted with `vocab` set to `false`, under the active
303/// context's alias of `@id`, plus `index` under the alias of `@index` unless
304/// the active property's container mapping includes `@index`.
305///
306/// `active_property` is the compacted term the reference is written under,
307/// or `None` at the top level. When that term defines a scoped `@context`,
308/// `active_context` must be the result of processing it on top of the
309/// context the term was selected in; a node reference does not revert a
310/// term-scoped context to the previous context, and type-scoped contexts do
311/// not apply because a reference has no `@type`. This is synchronous and
312/// needs no loader.
313///
314/// [1]: https://www.w3.org/TR/json-ld-api/#value-compaction
315///
316/// # Errors
317///
318/// Returns [`IriConfusedWithPrefix`] when the `@id` can only be written out
319/// in full and would then be read back as a compact IRI.
320pub fn compact_node_reference<N>(
321    vocabulary: &N,
322    id: &Id<N::Iri, N::BlankId>,
323    index: Option<&str>,
324    active_context: &Context<N::Iri, N::BlankId>,
325    active_property: Option<&str>,
326    options: Options,
327) -> Result<jstrict::Value, IriConfusedWithPrefix>
328where
329    N: Vocabulary,
330    N::Iri: Clone + Hash + Eq,
331    N::BlankId: Clone + Hash + Eq,
332{
333    // This covers step 7 of the compaction algorithm:
334    // If element has an @value or @id entry and the result of using the
335    // Value Compaction algorithm, passing active context, active property,
336    // and element as value is a scalar, or the term definition for active property
337    // has a type mapping of @json, return that result.
338    //
339    // together with step 7 of the value compaction algorithm:
340    // If value has an @id entry and has no other entries other than @index:
341    //
342    // If the type mapping of active property is set to @id,
343    // set result to the result of IRI compacting the value associated with the
344    // @id entry using false for vocab.
345    let type_mapping = match active_property {
346        Some(prop) => match active_context.get(prop) {
347            Some(def) => def.typ(),
348            None => None,
349        },
350        None => None,
351    };
352
353    if type_mapping == Some(&Type::Id) {
354        let compacted_value = compact_iri(vocabulary, active_context, &id.clone().into_term(), false, false, options)?;
355        return Ok(optional_string(compacted_value.as_deref()));
356    }
357
358    // Otherwise, if the type mapping of active property is set to @vocab,
359    // set result to the result of IRI compacting the value associated with the @id entry.
360    if type_mapping == Some(&Type::Vocab) {
361        let compacted_value = compact_iri(vocabulary, active_context, &id.clone().into_term(), true, false, options)?;
362        return Ok(optional_string(compacted_value.as_deref()));
363    }
364
365    let mut result = jstrict::Object::default();
366    insert_id(vocabulary, &mut result, id, active_context, options)?;
367    insert_index(vocabulary, &mut result, index, active_context, active_property, options);
368    Ok(result.into())
369}
370
371/// Inserts a node's `@id`, compacted as an IRI reference, into `result` under
372/// whatever `@id` compacts to.
373fn insert_id<N>(
374    vocabulary: &N,
375    result: &mut jstrict::Object,
376    id: &Id<N::Iri, N::BlankId>,
377    active_context: &Context<N::Iri, N::BlankId>,
378    options: Options,
379) -> Result<(), IriConfusedWithPrefix>
380where
381    N: Vocabulary,
382    N::Iri: Clone + Hash + Eq,
383    N::BlankId: Clone + Hash + Eq,
384{
385    // If expanded value is a string, then initialize compacted value by IRI
386    // compacting expanded value with vocab set to false.
387    let compacted_value = compact_iri(vocabulary, active_context, &id.clone().into_term(), false, false, options)?;
388
389    // Initialize alias by IRI compacting expanded property.
390    let alias = keyword_alias(vocabulary, active_context, options, Keyword::Id);
391    result.insert(alias.into(), optional_string(compacted_value.as_deref()));
392    Ok(())
393}
394
395/// Inserts the `@index` a node was reached through into `result`, unless the
396/// active property's container mapping includes `@index`.
397fn insert_index<N>(
398    vocabulary: &N,
399    result: &mut jstrict::Object,
400    index: Option<&str>,
401    active_context: &Context<N::Iri, N::BlankId>,
402    active_property: Option<&str>,
403    options: Options,
404) where
405    N: Vocabulary,
406    N::Iri: Clone + Hash + Eq,
407    N::BlankId: Clone + Hash + Eq,
408{
409    // If expanded property is @index and active property has a container mapping in
410    // active context that includes @index,
411    if let Some(index_entry) = index {
412        let mut index_container = false;
413        if let Some(active_property) = active_property
414            && let Some(active_property_definition) = active_context.get(active_property)
415            && active_property_definition.container().contains(ContainerKind::Index)
416        {
417            // then the compacted result will be inside of an @index container,
418            // drop the @index entry by continuing to the next expanded property.
419            index_container = true;
420        }
421
422        if !index_container {
423            // Initialize alias by IRI compacting expanded property.
424            let alias = keyword_alias(vocabulary, active_context, options, Keyword::Index);
425
426            // Add an entry alias to result whose value is set to expanded value and continue with the next expanded property.
427            result.insert(alias.into(), index_entry.into());
428        }
429    }
430}
431
432/// Checks whether the `@type` values of a node object are written as an array
433/// even when there is only one.
434///
435/// `type_alias` is what `@type` compacts to under `active_context`, as
436/// [`keyword_alias`][crate::keyword_alias] returns it. Follows the *as array*
437/// rule the [compaction algorithm][1] applies to `@type`: `true` when
438/// processing JSON-LD 1.1 and the
439/// alias's container mapping includes `@set`, or when `options.compact_arrays`
440/// is off.
441///
442/// [1]: https://www.w3.org/TR/json-ld-api/#compaction-algorithm
443#[must_use]
444pub fn types_as_array<I, B>(active_context: &Context<I, B>, type_alias: &str, options: Options) -> bool {
445    // Initialize as array to true if processing mode is json-ld-1.1 and the
446    // container mapping for alias in the active context includes @set,
447    // otherwise to the negation of compactArrays.
448    let container_mapping = match active_context.get(type_alias) {
449        Some(def) => def.container(),
450        None => Container::None,
451    };
452    (options.processing_mode == ProcessingMode::JsonLd1_1 && container_mapping.contains(ContainerKind::Set)) || !options.compact_arrays
453}
454
455/// Compacts a node object's `@type` values and inserts them into `result` under
456/// whatever `@type` itself compacts to.
457///
458/// Each type is compacted against `type_scoped_context`, as the specification
459/// requires, while the key for `@type` comes from `active_context`. A single type
460/// becomes a bare string unless the term for `@type` has an `@set` container (in
461/// JSON-LD 1.1) or `compact_arrays` is off.
462fn compact_types<N, E>(
463    vocabulary: &mut N,
464    result: &mut jstrict::Object,
465    types: Option<&[Id<N::Iri, N::BlankId>]>,
466    active_context: &Context<N::Iri, N::BlankId>,
467    type_scoped_context: &Context<N::Iri, N::BlankId>,
468    options: Options,
469) -> Result<(), Error<E>>
470where
471    N: VocabularyMut,
472    N::Iri: Clone + Hash + Eq,
473    N::BlankId: Clone + Hash + Eq,
474{
475    // If expanded property is @type:
476    if let Some(types) = types
477        && !types.is_empty()
478    {
479        // If expanded value is a string,
480        // then initialize compacted value by IRI compacting expanded value using
481        // type-scoped context for active context.
482        let compacted_value = if types.len() == 1 {
483            let arc = compact_iri(vocabulary, type_scoped_context, &types[0].clone().into_term(), true, false, options)?;
484            optional_string(arc.as_deref())
485        } else {
486            // Otherwise, expanded value must be a @type array:
487            // Initialize compacted value to an empty array.
488            let mut compacted_value = Vec::with_capacity(types.len());
489
490            // For each item expanded type in expanded value:
491            for ty in types {
492                let ty = ty.clone().into_term();
493
494                // Set term by IRI compacting expanded type using type-scoped context for active context.
495                let compacted_ty = compact_iri(vocabulary, type_scoped_context, &ty, true, false, options)?;
496
497                // Append term, to compacted value.
498                compacted_value.push(optional_string(compacted_ty.as_deref()));
499            }
500
501            jstrict::Value::Array(compacted_value.into_iter().collect())
502        };
503
504        // Initialize alias by IRI compacting expanded property.
505        let alias = keyword_alias(vocabulary, active_context, options, Keyword::Type);
506
507        let as_array = types_as_array(active_context, alias, options);
508
509        // Use add value to add compacted value to the alias entry in result using as array.
510        add_value(result, alias, compacted_value, as_array);
511    }
512
513    Ok(())
514}
515
516#[cfg(test)]
517mod tests {
518    #![allow(clippy::unwrap_used)]
519    use super::*;
520    use crate::{
521        CompactFragment,
522        test_fixtures::{VALUE_CONTEXT, compact_one_value_document, expanded_object, json, processed_context},
523    };
524    use jsonld_core::NoLoader;
525    use rdfx::vocabulary::{no_vocabulary, no_vocabulary_mut};
526
527    /// Compacting one node reference on its own gives what the full compaction
528    /// algorithm writes for it as the only value of a property, under `@id`-
529    /// and `@vocab`-typed terms as well as under an untyped term.
530    #[tokio::test]
531    async fn compact_node_reference_matches_the_compaction_of_a_one_value_document() {
532        let context = processed_context(VALUE_CONTEXT).await;
533
534        // (property, expanded node reference, expected key, expected compacted value)
535        let cases = [
536            (
537                "https://example.com/object",
538                r#"{ "@id": "https://example.com/Building" }"#,
539                "object",
540                r#""ex:Building""#,
541            ),
542            (
543                "https://example.com/objectVocab",
544                r#"{ "@id": "https://example.com/Building" }"#,
545                "objectVocab",
546                r#""Building""#,
547            ),
548            (
549                "https://example.com/objectVocab",
550                r#"{ "@id": "https://vocab.example/Thing" }"#,
551                "objectVocab",
552                r#""Thing""#,
553            ),
554            (
555                "https://example.com/text",
556                r#"{ "@id": "urn:ngsi-ld:Thing:1" }"#,
557                "text",
558                r#"{ "id": "urn:ngsi-ld:Thing:1" }"#,
559            ),
560            ("https://example.com/text", r#"{ "@id": "_:b0" }"#, "text", r#"{ "id": "_:b0" }"#),
561            ("https://example.com/object", r#"{ "@id": "urn:x", "@index": "i" }"#, "object", r#""urn:x""#),
562            (
563                "https://example.com/text",
564                r#"{ "@id": "urn:x", "@index": "i" }"#,
565                "text",
566                r#"{ "id": "urn:x", "@index": "i" }"#,
567            ),
568        ];
569
570        for (property, reference, expected_key, expected) in cases {
571            let (key, written) = compact_one_value_document(&context, property, reference).await;
572            assert_eq!(key, expected_key, "key of {reference}");
573
574            let object = expanded_object(reference);
575            let compacted = compact_node_reference(no_vocabulary(), object.id().unwrap(), object.index(), &context, Some(&key), Options::default()).unwrap();
576            assert_eq!(compacted, json(expected), "compaction of {reference} under {key}");
577            assert_eq!(compacted, written, "{reference} compacted alone and in a document");
578        }
579    }
580
581    /// A single `@type` is written bare unless the term `@type` compacts to has
582    /// an `@set` container.
583    #[tokio::test]
584    async fn types_as_array_follows_the_container_of_the_type_alias() {
585        let node = expanded_object(r#"{ "@id": "urn:x", "@type": ["https://example.com/Building"] }"#);
586        let cases = [
587            (
588                r#"{ "type": "@type", "ex": "https://example.com/" }"#,
589                false,
590                r#"{ "@id": "urn:x", "type": "ex:Building" }"#,
591            ),
592            (
593                r#"{ "type": { "@id": "@type", "@container": "@set" }, "ex": "https://example.com/" }"#,
594                true,
595                r#"{ "@id": "urn:x", "type": ["ex:Building"] }"#,
596            ),
597        ];
598
599        for (source, as_array, expected) in cases {
600            let context = processed_context(source).await;
601            let alias = keyword_alias(no_vocabulary(), &context, Options::default(), Keyword::Type);
602            assert_eq!(types_as_array(&context, alias, Options::default()), as_array, "{source}");
603
604            let compacted = node
605                .compact_fragment_full(no_vocabulary_mut(), &context, &context, None, &NoLoader, Options::default())
606                .await
607                .unwrap();
608            assert_eq!(compacted, json(expected), "{source}");
609        }
610    }
611}