defmodule JSON.LD.Expansion do @moduledoc """ Implementation of the JSON-LD 1.1 Expansion Algorithms. """ import JSON.LD.{IRIExpansion, Utils} alias JSON.LD.{Context, Options} alias JSON.LD.Context.TermDefinition defp init_options(options) do options |> Keyword.put_new(:frame_expansion, false) |> Keyword.put_new(:ordered, false) |> Keyword.put_new(:from_map, false) end def expand(active_context, active_property, element, options, processor_options) do options = init_options(options) # 2) If active property is @default, initialize the frameExpansion flag to false options = if active_property == "@default", do: Keyword.put(options, :frame_expansion, false), else: options # 3) If active property has a term definition in active context with a local context, initialize property-scoped context to that local context. property_scoped_context = if term_def = active_property && active_context.term_defs[active_property] do term_def.local_context end do_expand( active_context, active_property, element, Keyword.put(options, :property_scoped_context, property_scoped_context), processor_options ) end # 1) If element is null, return null. defp do_expand(_, _, nil, _, _), do: nil # 4) If element is a scalar, ... defp do_expand(active_context, active_property, element, options, processor_options) when is_binary(element) or is_number(element) or is_boolean(element) do if active_property in [nil, "@graph"] do nil else active_context = if property_scoped_context = options[:property_scoped_context] do Context.update(active_context, property_scoped_context, base: (term_def = active_context.term_defs[active_property]) && term_def.base_url, processor_options: processor_options ) else active_context end expand_value(active_context, active_property, element, processor_options) end end # 5) If element is an array, ... defp do_expand(active_context, active_property, element, options, processor_options) when is_list(element) do term_def = active_context.term_defs[active_property] container_mapping = term_def && term_def.container_mapping Enum.flat_map(element, fn item -> expanded_item = expand(active_context, active_property, item, options, processor_options) if container_mapping && "@list" in container_mapping && is_list(expanded_item) do %{"@list" => expanded_item} else expanded_item end |> List.wrap() end) end # 6) - 20) defp do_expand(active_context, active_property, element, options, processor_options) when is_map(element) do # 7) active_context = if active_context.previous_context do expanded_elements = Enum.map(element, fn {key, _} -> expand_iri(key, active_context, processor_options, false, true) end) if !options[:from_map] && "@value" not in expanded_elements && expanded_elements != ["@id"] do active_context.previous_context else active_context end else active_context end # 8) active_context = if property_scoped_context = options[:property_scoped_context] do Context.update( active_context, property_scoped_context, base: (term_def = active_context.term_defs[active_property]) && term_def.base_url, override_protected: true, processor_options: processor_options ) else active_context end # 9) active_context = if Map.has_key?(element, "@context") do Context.update(active_context, Map.get(element, "@context"), processor_options) else active_context end # 10) type_scoped_context = active_context # 11) sorted = Enum.sort_by(element, fn {key, _} -> key end) {active_context, first_type_key} = Enum.reduce(sorted, {active_context, nil}, fn {key, value}, {context, first_type_key} -> if expand_iri(key, context, processor_options, false, true) == "@type" do { value |> List.wrap() |> Enum.sort() |> Enum.reduce(context, fn term, context when is_binary(term) -> type_scoped_term_def = type_scoped_context.term_defs[term] if local_context = type_scoped_term_def && type_scoped_term_def.local_context do term_def = context.term_defs[term] Context.update(context, local_context, base: (term_def && term_def.base_url) || processor_options.base, propagate: false, processor_options: processor_options ) else context end _, context -> context end), first_type_key || key } else {context, first_type_key} end end) # 12) input_type = element[first_type_key] |> List.wrap() |> List.last() |> case do nil -> nil input_type -> expand_iri(input_type, active_context, processor_options, false, true) end # 13) result = if(processor_options.ordered, do: sorted, else: element) |> expand_object( active_property, active_context, type_scoped_context, input_type, processor_options, options ) result = case result do # 15) %{"@value" => value} -> # 15.1) keys = Map.keys(result) if keys -- ~w[@direction @index @language @type @value] != [] || (("@language" in keys or "@direction" in keys) and "@type" in keys) do raise JSON.LD.Error.invalid_value_object("value object with disallowed members") end cond do # 15.2) result["@type"] == "@json" -> result # 15.3) value == nil or value == [] -> nil # 15.4) !is_binary(value) and Map.has_key?(result, "@language") -> raise JSON.LD.Error.invalid_language_tagged_value( "@value '#{inspect(value)}' is tagged with a language" ) # 15.5) (type = result["@type"]) && !(is_binary(type) and valid_uri?(type)) -> raise JSON.LD.Error.invalid_typed_value(value, type) true -> result end # 16) %{"@type" => type} when not is_list(type) -> Map.put(result, "@type", [type]) # 17) %{"@set" => set} -> validate_set_or_list_object(result) set %{"@list" => _} -> validate_set_or_list_object(result) result _ -> result end # 18) If result contains only the key @language, set result to null. result = if is_map(result) and map_size(result) == 1 and Map.has_key?(result, "@language"), do: nil, else: result # 19) If active property is null or @graph, drop free-floating values # SPEC ISSUE: the "only" in "or contains only the entries @value or @list" in 19.1 had to be ignored if active_property in [nil, "@graph"] and is_map(result) and (Enum.empty?(result) or (Map.has_key?(result, "@value") or Map.has_key?(result, "@list") or (map_size(result) == 1 and Map.has_key?(result, "@id") and not processor_options.frame_expansion))), do: nil, else: result end defp expand_object( element, active_property, active_context, type_scoped_context, input_type, processor_options, options ) do processing_mode = processor_options.processing_mode frame_expansion = processor_options.frame_expansion # 13) {result, nests} = Enum.reduce(element, {%{}, []}, fn {key, value}, {result, nests} -> # 13.1) if key == "@context" do {result, nests} else # 13.2) expanded_property = expand_iri(key, active_context, processor_options, false, true) keyword? = JSON.LD.keyword?(expanded_property) # 13.3) if expanded_property && (String.contains?(expanded_property, ":") || keyword?) do # 13.4) if keyword? do # 13.4.1) if active_property == "@reverse" do raise JSON.LD.Error.invalid_reverse_property_map() end # 13.4.2) if Map.has_key?(result, expanded_property) and (processing_mode == "json-ld-1.0" or expanded_property not in ["@included", "@type"]) do raise JSON.LD.Error.colliding_keywords(expanded_property) end expanded_value = case expanded_property do # 13.4.3) "@id" -> cond do is_binary(value) -> expand_iri(value, active_context, processor_options, true, false) true -> raise JSON.LD.Error.invalid_id_value(value) end # 13.4.4) "@type" -> expanded_value = cond do is_binary(value) -> expand_iri(value, type_scoped_context, processor_options, true, true) is_list(value) and Enum.all?(value, &is_binary/1) -> Enum.map(value, fn item -> expand_iri(item, type_scoped_context, processor_options, true, true) end) true -> raise JSON.LD.Error.invalid_type_value(value) end if existing = result["@type"] do List.wrap(existing) ++ List.wrap(expanded_value) else expanded_value end # 13.4.5) "@graph" -> active_context |> expand("@graph", value, options, processor_options) |> List.wrap() # 13.4.6) "@included" when processing_mode != "json-ld-1.0" -> expanded_value = active_context |> expand(nil, value, options, processor_options) |> to_list() unless Enum.all?(expanded_value, &node?/1) do raise JSON.LD.Error.invalid_included_value(expanded_value) end expanded_value ++ List.wrap(result["@included"]) # 13.4.7) "@value" -> cond do input_type == "@json" and processing_mode != "json-ld-1.0" -> value frame_expansion and is_list(value) -> value frame_expansion and value == %{} -> [value] not (scalar?(value) or is_nil(value)) -> raise JSON.LD.Error.invalid_value_object_value(value) true -> if is_nil(value) do {:skip, Map.put(result, "@value", nil)} else value end end # 13.4.8) "@language" -> cond do frame_expansion and is_list(value) -> Enum.map(value, &validate_and_normalize_language(&1, processor_options)) frame_expansion and value == %{} -> [value] is_binary(value) -> expanded_value = validate_and_normalize_language(value, processor_options) if frame_expansion do [expanded_value] else expanded_value end true -> raise JSON.LD.Error.invalid_language_tagged_string(value) end # 13.4.9) "@direction" when processing_mode != "json-ld-1.0" -> cond do value in ~w[ltr rtl] -> if frame_expansion do [value] else value end frame_expansion and is_list(value) and Enum.all?(value, &(&1 in ~w[ltr rtl])) -> value frame_expansion and value == %{} -> [value] true -> raise JSON.LD.Error.invalid_base_direction(value) end # 13.4.10) "@index" -> if is_binary(value) do value else raise JSON.LD.Error.invalid_index_value(value) end # 13.4.11) "@list" -> if active_property in [nil, "@graph"] do {:skip, result} else active_context |> expand(active_property, value, options, processor_options) |> List.wrap() end # 13.4.12) "@set" -> expand(active_context, active_property, value, options, processor_options) # 13.4.13) "@reverse" -> # 13.4.13.1) unless is_map(value) do raise JSON.LD.Error.invalid_reverse_value(value) end # 13.4.13.2) expanded_value = expand(active_context, "@reverse", value, options, processor_options) # 13.4.13.3) new_result = if Map.has_key?(expanded_value, "@reverse") do Enum.reduce( expanded_value["@reverse"], result, fn {property, item}, new_result -> items = List.wrap(item) Map.update(new_result, property, items, &(&1 ++ items)) end ) else result end # 13.4.13.4) new_result = if Map.keys(expanded_value) != ["@reverse"] do expanded_value = Map.delete(expanded_value, "@reverse") reverse_map = Enum.reduce(expanded_value, Map.get(new_result, "@reverse", %{}), fn {property, items}, reverse_map -> Enum.each(items, fn item -> if value?(item) or list?(item) do raise JSON.LD.Error.invalid_reverse_property_value(item) end end) Map.update(reverse_map, property, items, &(&1 ++ items)) end) Map.put(new_result, "@reverse", reverse_map) else new_result end {:skip, new_result} # 13.4.14) "@nest" -> {:nests, [key | nests]} # 13.4.15) expanded_property when frame_expansion and expanded_property in ~w[@default @embed @explicit @omitDefault @requireAll] -> active_context |> expand(expanded_property, value, options, processor_options) |> List.wrap() _ -> nil end # 13.4.16) and 13.4.17) case {expanded_value, expanded_property, input_type} do {{:nests, nests}, _, _} -> {result, nests} {{:skip, new_result}, _, _} -> {new_result, nests} {nil, "@value", type} when type != "@json" -> {result, nests} _ -> {Map.put(result, expanded_property, expanded_value), nests} end else term_def = active_context.term_defs[key] # 13.5) container_mapping = List.wrap(term_def && term_def.container_mapping) expanded_value = cond do # 13.6) term_def && term_def.type_mapping == "@json" -> %{"@value" => value, "@type" => "@json"} # 13.7) is_map(value) and "@language" in container_mapping -> direction = TermDefinition.direction(term_def, active_context) value |> maybe_sort_by(processor_options.ordered, fn {language, _} -> language end) |> Enum.flat_map(fn {language, language_value} -> expanded_language = expand_iri(language, active_context, options, false, true) language_value |> List.wrap() |> Enum.flat_map(fn nil -> [] item when is_binary(item) -> v = %{"@value" => item} v = if language == "@none" or expanded_language == "@none" do v else Map.put( v, "@language", validate_and_normalize_language(language, processor_options) ) end if direction do Map.put(v, "@direction", to_string(direction)) else v end |> List.wrap() item -> raise JSON.LD.Error.invalid_language_map_value(item) end) end) # 13.8) is_map(value) and Enum.any?(~w[@index @type @id], &(&1 in container_mapping)) -> # 13.8.2) index_key = (term_def && term_def.index_mapping) || "@index" # 13.8.3.1) # Note: We're doing this outside of the loop container_context = if "@type" in container_mapping or "@id" in container_mapping do active_context.previous_context end || active_context # 13.8.3) value |> maybe_sort_by(processor_options.ordered, fn {index, _} -> index end) |> Enum.flat_map(fn {index, index_value} -> # 13.8.3.1) is done above outside of this loop # 13.8.3.2) index_term_def = container_context.term_defs[index] map_context = if "@type" in container_mapping && index_term_def && index_term_def.local_context do Context.update(container_context, index_term_def.local_context, base: term_def.base_url, processor_options: processor_options ) else container_context end # 13.8.3.4) # SPEC ISSUE: which context should be used here? expanded_index = expand_iri(index, map_context, options, false, true) # 13.8.3.6) map_context |> expand( key, # 13.8.3.5) List.wrap(index_value), Keyword.put(options, :from_map, true), processor_options ) # 13.8.3.7) |> Enum.map(fn item -> # 13.8.3.7.1) item = if "@graph" in container_mapping and not graph?(item) do %{"@graph" => List.wrap(item)} else item end cond do expanded_index == "@none" -> item # 13.8.3.7.2) "@index" in container_mapping and index_key != "@index" -> re_expanded_index = expand_value(active_context, index_key, index, processor_options) # SPEC ISSUE: vocab option had to be set to true expanded_index_key = expand_iri( index_key, container_context, processor_options, false, true ) item = Map.update( item, expanded_index_key, [re_expanded_index], &[re_expanded_index | List.wrap(&1)] ) if value?(item) and map_size(Map.delete(item, "@value")) > 0 do raise JSON.LD.Error.invalid_value_object( "Attempt to add illegal key to value object: #{inspect(item)}" ) else item end # 13.8.3.7.3) "@index" in container_mapping and not Map.has_key?(item, "@index") -> Map.put(item, "@index", index) # 13.8.3.7.4) "@id" in container_mapping and not Map.has_key?(item, "@id") -> Map.put( item, "@id", expand_iri(index, map_context, processor_options, true, false) ) # 13.8.3.7.5) "@type" in container_mapping -> Map.update( item, "@type", [expanded_index], &[expanded_index | List.wrap(&1)] ) true -> item end end) end) # 13.9) true -> expand(active_context, key, value, options, processor_options) end # 13.10) if is_nil(expanded_value) do {result, nests} else # 13.11) expanded_value = if "@list" in container_mapping and not list?(expanded_value) do %{"@list" => List.wrap(expanded_value)} else expanded_value end # 13.12) expanded_value = if "@graph" in container_mapping and "@id" not in container_mapping and "@index" not in container_mapping do expanded_value |> List.wrap() |> Enum.map(fn ev -> %{"@graph" => List.wrap(ev)} end) else expanded_value end # 13.13) if term_def && term_def.reverse_property do reverse_map = Map.get(result, "@reverse", %{}) reverse_map = expanded_value |> List.wrap() |> Enum.reduce(reverse_map, fn item, reverse_map -> if Map.has_key?(item, "@value") or Map.has_key?(item, "@list") do raise JSON.LD.Error.invalid_reverse_property_value(item) end Map.update(reverse_map, expanded_property, [item], fn members -> members ++ [item] end) end) {Map.put(result, "@reverse", reverse_map), nests} else # 13.14) expanded_value = List.wrap(expanded_value) { Map.update(result, expanded_property, expanded_value, fn values -> expanded_value ++ values end), nests } end end end else {result, nests} end end end) # 14) nests |> maybe_sort(processor_options.ordered) |> Enum.reduce(result, fn nesting_key, result -> # SPEC ISSUE: this nest_context creation is not specified, but required to pass #tc037 and #tc038 term_def = active_context.term_defs[nesting_key] nest_context = if nest_context = term_def && term_def.local_context do Context.update( active_context, nest_context, override_protected: true, processor_options: processor_options ) else active_context end nested_values = case element do element when is_map(element) -> element[nesting_key] element when is_list(element) -> Enum.find_value(element, fn {k, v} -> if k == nesting_key, do: v end) end |> List.wrap() Enum.reduce(nested_values, result, fn nested_value, result -> if not is_map(nested_value) or Enum.any?(nested_value, fn {k, _} -> expand_iri(k, nest_context, processor_options, false, true) == "@value" end) do raise JSON.LD.Error.invalid_nest_value(invalid: nested_value) end deep_merge( result, expand_object( nested_value, active_property, nest_context, type_scoped_context, input_type, processor_options, options ) ) end) end) end @spec validate_set_or_list_object(map) :: true defp validate_set_or_list_object(object) when map_size(object) == 1, do: true defp validate_set_or_list_object(%{"@index" => _} = object) when map_size(object) == 2, do: true defp validate_set_or_list_object(object) do raise JSON.LD.Error.invalid_set_or_list_object(object) end @doc """ Details at """ @spec expand_value(Context.t(), String.t(), any, Options.t()) :: map def expand_value(active_context, active_property, value, options \\ Options.new()) do term_def = Map.get(active_context.term_defs, active_property, %TermDefinition{}) cond do # 1) term_def.type_mapping == "@id" and is_binary(value) -> %{"@id" => expand_iri(value, active_context, options, true, false)} # 2) term_def.type_mapping == "@vocab" and is_binary(value) -> %{"@id" => expand_iri(value, active_context, options, true, true)} true -> result = %{"@value" => value} cond do (type_mapping = term_def.type_mapping) && type_mapping not in ~w[@id @vocab @none] -> Map.put(result, "@type", type_mapping) is_binary(value) -> language = TermDefinition.language(term_def, active_context) direction = TermDefinition.direction(term_def, active_context) result = if not is_nil(language) do Map.put(result, "@language", language) else result end if not is_nil(direction) do Map.put(result, "@direction", to_string(direction)) else result end true -> result end end end end