jsonld-ex/lib/json/ld/compaction.ex

764 lines
31 KiB
Elixir

defmodule JSON.LD.Compaction do
@moduledoc nil
import JSON.LD.Utils
alias JSON.LD.Context
def compact(input, context, options \\ %JSON.LD.Options{}) do
with options = JSON.LD.Options.new(options),
active_context = JSON.LD.context(context, options),
inverse_context = Context.inverse(active_context),
expanded = JSON.LD.expand(input, options) do
result =
case do_compact(expanded, active_context, inverse_context, nil, options.compact_arrays) do
[] ->
%{}
result when is_list(result) ->
# TODO: Spec fixme? We're setting vocab to true, as other implementations do it, but this is not mentioned in the spec
%{compact_iri("@graph", active_context, inverse_context, nil, true) => result}
result ->
result
end
if Context.empty?(active_context),
do: result,
else: Map.put(result, "@context", context["@context"] || context)
end
end
defp do_compact(
element,
active_context,
inverse_context,
active_property,
compact_arrays \\ true
)
# 1) If element is a scalar, it is already in its most compact form, so simply return element.
defp do_compact(element, _, _, _, _)
when is_binary(element) or is_number(element) or is_boolean(element),
do: element
# 2) If element is an array
defp do_compact(element, active_context, inverse_context, active_property, compact_arrays)
when is_list(element) do
result =
Enum.reduce(element, [], fn item, result ->
case do_compact(item, active_context, inverse_context, active_property, compact_arrays) do
nil -> result
compacted_item -> [compacted_item | result]
end
end)
|> Enum.reverse()
if compact_arrays and length(result) == 1 and
is_nil(
(term_def = active_context.term_defs[active_property]) && term_def.container_mapping
) do
List.first(result)
else
result
end
end
# 3) Otherwise element is a JSON object.
defp do_compact(element, active_context, inverse_context, active_property, compact_arrays)
when is_map(element) do
# 4)
if Map.has_key?(element, "@value") or Map.has_key?(element, "@id") do
result = compact_value(element, active_context, inverse_context, active_property)
if scalar?(result) do
result
else
do_compact_non_scalar(
element,
active_context,
inverse_context,
active_property,
compact_arrays
)
end
else
do_compact_non_scalar(
element,
active_context,
inverse_context,
active_property,
compact_arrays
)
end
end
defp do_compact_non_scalar(
element,
active_context,
inverse_context,
active_property,
compact_arrays
) do
# 5)
inside_reverse = active_property == "@reverse"
# 6) + 7)
element
|> Enum.sort_by(fn {expanded_property, _} -> expanded_property end)
|> Enum.reduce(%{}, fn {expanded_property, expanded_value}, result ->
cond do
# 7.1)
expanded_property in ~w[@id @type] ->
# 7.1.1)
compacted_value =
if is_binary(expanded_value) do
compact_iri(
expanded_value,
active_context,
inverse_context,
nil,
expanded_property == "@type"
)
# 7.1.2)
else
# 7.1.2.1)
# TODO: RDF.rb calls also Array#compact
if(is_list(expanded_value),
do: expanded_value,
else: [expanded_value]
)
# 7.1.2.2)
|> Enum.reduce([], fn expanded_type, compacted_value ->
compacted_value ++
[compact_iri(expanded_type, active_context, inverse_context, nil, true)]
end)
# 7.1.2.3)
|> case(
do:
(
[compacted_value] -> compacted_value
compacted_value -> compacted_value
)
)
end
# 7.1.3)
alias = compact_iri(expanded_property, active_context, inverse_context, nil, true)
# 7.1.4)
Map.put(result, alias, compacted_value)
# 7.2)
expanded_property == "@reverse" ->
# 7.2.1)
compacted_value =
do_compact(expanded_value, active_context, inverse_context, "@reverse")
# 7.2.2)
{compacted_value, result} =
Enum.reduce(compacted_value, {%{}, result}, fn {property, value},
{compacted_value, result} ->
term_def = active_context.term_defs[property]
# 7.2.2.1)
if term_def && term_def.reverse_property do
# 7.2.2.1.1)
value =
if (!compact_arrays or term_def.container_mapping == "@set") and
!is_list(value) do
[value]
else
value
end
# 7.2.2.1.2) + 7.2.2.1.3)
{compacted_value, merge_compacted_value(result, property, value)}
else
{Map.put(compacted_value, property, value), result}
end
end)
# 7.2.3)
unless Enum.empty?(compacted_value) do
# 7.2.3.1)
alias = compact_iri("@reverse", active_context, inverse_context, nil, true)
# 7.2.3.2)
Map.put(result, alias, compacted_value)
else
result
end
# 7.3)
expanded_property == "@index" &&
active_context.term_defs[active_property] &&
active_context.term_defs[active_property].container_mapping == "@index" ->
result
# 7.4)
expanded_property in ~w[@index @value @language] ->
# 7.4.1)
alias = compact_iri(expanded_property, active_context, inverse_context, nil, true)
# 7.4.2)
Map.put(result, alias, expanded_value)
true ->
# 7.5)
result =
if expanded_value == [] do
# 7.5.1)
item_active_property =
compact_iri(
expanded_property,
active_context,
inverse_context,
expanded_value,
true,
inside_reverse
)
# 7.5.2)
Map.update(result, item_active_property, [], fn
value when not is_list(value) -> [value]
value -> value
end)
else
result
end
# 7.6)
Enum.reduce(expanded_value, result, fn expanded_item, result ->
# 7.6.1)
item_active_property =
compact_iri(
expanded_property,
active_context,
inverse_context,
expanded_item,
true,
inside_reverse
)
# 7.6.2)
term_def = active_context.term_defs[item_active_property]
container = (term_def && term_def.container_mapping) || nil
# 7.6.3)
value = (is_map(expanded_item) && expanded_item["@list"]) || expanded_item
compacted_item =
do_compact(
value,
active_context,
inverse_context,
item_active_property,
compact_arrays
)
# 7.6.4)
compacted_item =
if list?(expanded_item) do
# 7.6.4.1)
compacted_item =
unless is_list(compacted_item), do: [compacted_item], else: compacted_item
# 7.6.4.2)
unless container == "@list" do
# 7.6.4.2.1)
compacted_item = %{
# TODO: Spec fixme? We're setting vocab to true, as other implementations do it, but this is not mentioned in the spec
compact_iri("@list", active_context, inverse_context, nil, true) =>
compacted_item
}
# 7.6.4.2.2)
if Map.has_key?(expanded_item, "@index") do
Map.put(
compacted_item,
# TODO: Spec fixme? We're setting vocab to true, as other implementations do it, but this is not mentioned in the spec
compact_iri("@index", active_context, inverse_context, nil, true),
expanded_item["@index"]
)
else
compacted_item
end
# 7.6.4.3)
else
if Map.has_key?(result, item_active_property) do
raise JSON.LD.CompactionToListOfListsError,
message:
"The compacted document contains a list of lists as multiple lists have been compacted to the same term."
else
compacted_item
end
end
else
compacted_item
end
# 7.6.5)
if container in ~w[@language @index] do
map_object = result[item_active_property] || %{}
compacted_item =
if container == "@language" and
is_map(compacted_item) and Map.has_key?(compacted_item, "@value"),
do: compacted_item["@value"],
else: compacted_item
map_key = expanded_item[container]
map_object = merge_compacted_value(map_object, map_key, compacted_item)
Map.put(result, item_active_property, map_object)
# 7.6.6)
else
compacted_item =
if !is_list(compacted_item) and
(!compact_arrays or
container in ~w[@set @list] or expanded_property in ~w[@list @graph]),
do: [compacted_item],
else: compacted_item
merge_compacted_value(result, item_active_property, compacted_item)
end
end)
end
end)
end
defp merge_compacted_value(map, key, value) do
Map.update(map, key, value, fn
old_value when is_list(old_value) and is_list(value) ->
old_value ++ value
old_value when is_list(old_value) ->
old_value ++ [value]
old_value when is_list(value) ->
[old_value | value]
old_value ->
[old_value, value]
end)
end
@doc """
IRI Compaction
Details at <https://www.w3.org/TR/json-ld-api/#iri-compaction>
"""
def compact_iri(
iri,
active_context,
inverse_context,
value \\ nil,
vocab \\ false,
reverse \\ false
)
# 1) If iri is null, return null.
def compact_iri(nil, _, _, _, _, _), do: nil
def compact_iri(iri, active_context, inverse_context, value, vocab, reverse) do
# 2) If vocab is true and iri is a key in inverse context:
term =
if vocab && Map.has_key?(inverse_context, iri) do
# 2.1) Initialize default language to active context's default language, if it has one, otherwise to @none.
# TODO: Spec fixme: This step is effectively useless; see Spec fixme on step 2.6.3
# default_language = active_context.default_language || "@none"
# 2.3) Initialize type/language to @language, and type/language value to @null. These two variables will keep track of the preferred type mapping or language mapping for a term, based on what is compatible with value.
type_language = "@language"
type_language_value = "@null"
# 2.2) Initialize containers to an empty array. This array will be used to keep track of an ordered list of preferred container mappings for a term, based on what is compatible with value.
# 2.4) If value is a JSON object that contains the key @index, then append the value @index to containers.
containers = if index?(value), do: ["@index"], else: []
{containers, type_language, type_language_value} =
cond do
# 2.5) If reverse is true, set type/language to @type, type/language value to @reverse, and append @set to containers.
reverse ->
containers = containers ++ ["@set"]
type_language = "@type"
type_language_value = "@reverse"
{containers, type_language, type_language_value}
# 2.6) Otherwise, if value is a list object, then set type/language and type/language value to the most specific values that work for all items in the list as follows:
list?(value) ->
# 2.6.1) If @index is a not key in value, then append @list to containers.
containers = if not index?(value), do: containers ++ ["@list"], else: containers
# 2.6.2) Initialize list to the array associated with the key @list in value.
list = value["@list"]
# 2.6.3) Initialize common type and common language to null. If list is empty, set common language to default language.
# TODO: Spec fixme: Setting common language to default language is effectively useless, since the only place it is used is the follow loop in 2.6.4, which is immediately left when the list is empty
{common_type, common_language} = {nil, nil}
{type_language, type_language_value} =
if Enum.empty?(list) do
{type_language, type_language_value}
else
# 2.6.4) For each item in list:
{common_type, common_language} =
Enum.reduce_while(list, {common_type, common_language}, fn item,
{common_type,
common_language} ->
# 2.6.4.1) Initialize item language to @none and item type to @none.
{item_type, item_language} = {"@none", "@none"}
# 2.6.4.2) If item contains the key @value:
{item_type, item_language} =
if Map.has_key?(item, "@value") do
cond do
# 2.6.4.2.1) If item contains the key @language, then set item language to its associated value.
Map.has_key?(item, "@language") ->
{item_type, item["@language"]}
# 2.6.4.2.2) Otherwise, if item contains the key @type, set item type to its associated value.
Map.has_key?(item, "@type") ->
{item["@type"], item_language}
# 2.6.4.2.3) Otherwise, set item language to @null.
true ->
{item_type, "@null"}
end
# 2.6.4.3) Otherwise, set item type to @id.
else
{"@id", item_language}
end
common_language =
cond do
# 2.6.4.4) If common language is null, set it to item language.
is_nil(common_language) ->
item_language
# 2.6.4.5) Otherwise, if item language does not equal common language and item contains the key @value, then set common language to @none because list items have conflicting languages.
item_language != common_language and Map.has_key?(item, "@value") ->
"@none"
true ->
common_language
end
common_type =
cond do
# 2.6.4.6) If common type is null, set it to item type.
is_nil(common_type) ->
item_type
# 2.6.4.7) Otherwise, if item type does not equal common type, then set common type to @none because list items have conflicting types.
item_type != common_type ->
"@none"
true ->
common_type
end
# 2.6.4.8) If common language is @none and common type is @none, then stop processing items in the list because it has been detected that there is no common language or type amongst the items.
if common_language == "@none" and common_type == "@none" do
{:halt, {common_type, common_language}}
else
{:cont, {common_type, common_language}}
end
end)
# 2.6.5) If common language is null, set it to @none.
common_language = if is_nil(common_language), do: "@none", else: common_language
# 2.6.6) If common type is null, set it to @none.
common_type = if is_nil(common_type), do: "@none", else: common_type
# 2.6.7) If common type is not @none then set type/language to @type and type/language value to common type.
if common_type != "@none" do
type_language = "@type"
type_language_value = common_type
{type_language, type_language_value}
# 2.6.8) Otherwise, set type/language value to common language.
else
type_language_value = common_language
{type_language, type_language_value}
end
end
{containers, type_language, type_language_value}
# 2.7) Otherwise
true ->
# 2.7.1) If value is a value object:
{containers, type_language, type_language_value} =
if is_map(value) and Map.has_key?(value, "@value") do
# 2.7.1.1) If value contains the key @language and does not contain the key @index, then set type/language value to its associated value and append @language to containers.
if Map.has_key?(value, "@language") and not Map.has_key?(value, "@index") do
type_language_value = value["@language"]
containers = containers ++ ["@language"]
{containers, type_language, type_language_value}
else
# 2.7.1.2) Otherwise, if value contains the key @type, then set type/language value to its associated value and set type/language to @type.
if Map.has_key?(value, "@type") do
type_language_value = value["@type"]
type_language = "@type"
{containers, type_language, type_language_value}
else
{containers, type_language, type_language_value}
end
end
# 2.7.2) Otherwise, set type/language to @type and set type/language value to @id.
else
type_language = "@type"
type_language_value = "@id"
{containers, type_language, type_language_value}
end
# 2.7.3) Append @set to containers.
containers = containers ++ ["@set"]
{containers, type_language, type_language_value}
end
# 2.8) Append @none to containers. This represents the non-existence of a container mapping, and it will be the last container mapping value to be checked as it is the most generic.
containers = containers ++ ["@none"]
# 2.9) If type/language value is null, set it to @null. This is the key under which null values are stored in the inverse context entry.
type_language_value =
if is_nil(type_language_value), do: "@null", else: type_language_value
# 2.10) Initialize preferred values to an empty array. This array will indicate, in order, the preferred values for a term's type mapping or language mapping.
preferred_values = []
# 2.11) If type/language value is @reverse, append @reverse to preferred values.
preferred_values =
if type_language_value == "@reverse",
do: preferred_values ++ ["@reverse"],
else: preferred_values
# 2.12) If type/language value is @id or @reverse and value has an @id member:
preferred_values =
if type_language_value in ~w[@id @reverse] and is_map(value) and
Map.has_key?(value, "@id") do
# 2.12.1) If the result of using the IRI compaction algorithm, passing active context, inverse context, the value associated with the @id key in value for iri, true for vocab, and true for document relative has a term definition in the active context with an IRI mapping that equals the value associated with the @id key in value, then append @vocab, @id, and @none, in that order, to preferred values.
# TODO: Spec fixme? document_relative is not a specified parameter of compact_iri
compact_id = compact_iri(value["@id"], active_context, inverse_context, nil, true)
if (term_def = active_context.term_defs[compact_id]) &&
term_def.iri_mapping == value["@id"] do
preferred_values ++ ~w[@vocab @id @none]
# 2.12.2) Otherwise, append @id, @vocab, and @none, in that order, to preferred values.
else
preferred_values ++ ~w[@id @vocab @none]
end
# 2.13) Otherwise, append type/language value and @none, in that order, to preferred values.
else
preferred_values ++ [type_language_value, "@none"]
end
# 2.14) Initialize term to the result of the Term Selection algorithm, passing inverse context, iri, containers, type/language, and preferred values.
select_term(inverse_context, iri, containers, type_language, preferred_values)
end
cond do
# 2.15) If term is not null, return term.
not is_nil(term) ->
term
# 3) At this point, there is no simple term that iri can be compacted to. If vocab is true and active context has a vocabulary mapping:
# 3.1) If iri begins with the vocabulary mapping's value but is longer, then initialize suffix to the substring of iri that does not match. If suffix does not have a term definition in active context, then return suffix.
vocab && active_context.vocab && String.starts_with?(iri, active_context.vocab) ->
suffix = String.replace_prefix(iri, active_context.vocab, "")
if suffix != "" && is_nil(active_context.term_defs[suffix]) do
String.replace_prefix(iri, active_context.vocab, "")
else
create_compact_iri(iri, active_context, value, vocab)
end
true ->
create_compact_iri(iri, active_context, value, vocab)
end
end
defp create_compact_iri(iri, active_context, value, vocab) do
# 4) The iri could not be compacted using the active context's vocabulary mapping. Try to create a compact IRI, starting by initializing compact IRI to null. This variable will be used to tore the created compact IRI, if any.
# 5) For each key term and value term definition in the active context:
compact_iri =
Enum.reduce(active_context.term_defs, nil, fn {term, term_def}, compact_iri ->
cond do
# 5.1) If the term contains a colon (:), then continue to the next term because terms with colons can't be used as prefixes.
String.contains?(term, ":") ->
compact_iri
# 5.2) If the term definition is null, its IRI mapping equals iri, or its IRI mapping is not a substring at the beginning of iri, the term cannot be used as a prefix because it is not a partial match with iri. Continue with the next term.
is_nil(term_def) || term_def.iri_mapping == iri ||
not String.starts_with?(iri, term_def.iri_mapping) ->
compact_iri
true ->
# 5.3) Initialize candidate by concatenating term, a colon (:), and the substring of iri that follows after the value of the term definition's IRI mapping.
candidate =
term <>
":" <> (String.split_at(iri, String.length(term_def.iri_mapping)) |> elem(1))
# 5.4) If either compact IRI is null or candidate is shorter or the same length but lexicographically less than compact IRI and candidate does not have a term definition in active context or if the term definition has an IRI mapping that equals iri and value is null, set compact IRI to candidate.
# TODO: Spec fixme: The specified expression is pretty ambiguous without brackets ...
# TODO: Spec fixme: "if the term definition has an IRI mapping that equals iri" is already catched in 5.2, so will never happen here ...
if (is_nil(compact_iri) or shortest_or_least?(candidate, compact_iri)) and
(is_nil(active_context.term_defs[candidate]) or
(active_context.term_defs[candidate].iri_mapping == iri and is_nil(value))) do
candidate
else
compact_iri
end
end
end)
cond do
# 6) If compact IRI is not null, return compact IRI.
not is_nil(compact_iri) ->
compact_iri
# 7) If vocab is false then transform iri to a relative IRI using the document's base IRI.
not vocab ->
remove_base(iri, Context.base(active_context))
# 8) Finally, return iri as is.
true ->
iri
end
end
defp shortest_or_least?(a, b) do
(a_len = String.length(a)) < (b_len = String.length(b)) or
(a_len == b_len and a < b)
end
defp remove_base(iri, nil), do: iri
defp remove_base(iri, base) do
base_len = String.length(base)
if String.starts_with?(iri, base) and String.at(iri, base_len) in ~w(? #) do
String.split_at(iri, base_len) |> elem(1)
else
case URI.parse(base) do
%URI{path: nil} ->
iri
base ->
do_remove_base(iri, %URI{base | path: parent_path(base.path)}, 0)
end
end
end
defp do_remove_base(iri, base, index) do
base_str = URI.to_string(base)
cond do
String.starts_with?(iri, base_str) ->
case String.duplicate("../", index) <>
(String.split_at(iri, String.length(base_str)) |> elem(1)) do
"" -> "./"
rel -> rel
end
base.path == "/" ->
iri
true ->
do_remove_base(iri, %URI{base | path: parent_path(base.path)}, index + 1)
end
end
defp parent_path("/"), do: "/"
defp parent_path(path) do
case Path.dirname(String.trim_trailing(path, "/")) do
"/" -> "/"
parent -> parent <> "/"
end
end
@doc """
Value Compaction
Details at <https://www.w3.org/TR/json-ld-api/#value-compaction>
"""
def compact_value(value, active_context, inverse_context, active_property) do
term_def = active_context.term_defs[active_property]
# 1) Initialize number members to the number of members value contains.
number_members = Enum.count(value)
# 2) If value has an @index member and the container mapping associated to active property is set to @index, decrease number members by 1.
number_members =
if term_def != nil and Map.has_key?(value, "@index") and
term_def.container_mapping == "@index",
do: number_members - 1,
else: number_members
# 3) If number members is greater than 2, return value as it cannot be compacted.
unless number_members > 2 do
{type_mapping, language_mapping} =
if term_def,
do: {term_def.type_mapping, term_def.language_mapping},
else: {nil, nil}
cond do
# 4) If value has an @id member
id = Map.get(value, "@id") ->
cond do
# 4.1) If number members is 1 and the type mapping of active property is set to @id, return the result of using the IRI compaction algorithm, passing active context, inverse context, and the value of the @id member for iri.
number_members == 1 and type_mapping == "@id" ->
compact_iri(id, active_context, inverse_context)
# 4.2) Otherwise, if number members is 1 and the type mapping of active property is set to @vocab, return the result of using the IRI compaction algorithm, passing active context, inverse context, the value of the @id member for iri, and true for vocab.
number_members == 1 and type_mapping == "@vocab" ->
compact_iri(id, active_context, inverse_context, nil, true)
# 4.3) Otherwise, return value as is.
true ->
value
end
# 5) Otherwise, if value has an @type member whose value matches the type mapping of active property, return the value associated with the @value member of value.
(type = Map.get(value, "@type")) && type == type_mapping ->
value["@value"]
# 6) Otherwise, if value has an @language member whose value matches the language mapping of active property, return the value associated with the @value member of value.
# TODO: Spec fixme: doesn't specify to check default language as well
(language = Map.get(value, "@language")) &&
language in [language_mapping, active_context.default_language] ->
value["@value"]
true ->
# 7) Otherwise, if number members equals 1 and either the value of the @value member is not a string, or the active context has no default language, or the language mapping of active property is set to null,, return the value associated with the @value member.
value_value = value["@value"]
# TODO: Spec fixme: doesn't specify to check default language as well
if number_members == 1 and
(not is_binary(value_value) or
!active_context.default_language or
Context.language(active_context, active_property) == nil) do
value_value
# 8) Otherwise, return value as is.
else
value
end
end
else
value
end
end
@doc """
Term Selection
Details at <https://www.w3.org/TR/json-ld-api/#term-selection>
"""
def select_term(inverse_context, iri, containers, type_language, preferred_values) do
container_map = inverse_context[iri]
Enum.find_value(containers, fn container ->
if type_language_map = container_map[container] do
value_map = type_language_map[type_language]
Enum.find_value(preferred_values, fn item -> value_map[item] end)
end
end)
end
end