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