# -*- encoding: utf-8 -*- # frozen_string_literal: true module JSON::LD module Frame include Utils ## # Frame input. Input is expected in expanded form, but frame is in compacted form. # # @param [Hash{Symbol => Object}] state # Current framing state # @param [Array] subjects # The subjects to filter # @param [Hash{String => Object}] frame # @param [Hash{Symbol => Object}] options ({}) # @option options [Hash{String => Object}] :parent (nil) # Parent subject or top-level array # @option options [String] :property (nil) # The parent property. # @raise [JSON::LD::InvalidFrame] def frame(state, subjects, frame, options = {}) parent, property = options[:parent], options[:property] # Validate the frame validate_frame(state, frame) frame = frame.first if frame.is_a?(Array) # Get values for embedOn and explicitOn flags = { embed: get_frame_flag(frame, options, :embed), explicit: get_frame_flag(frame, options, :explicit), requireAll: get_frame_flag(frame, options, :requireAll), } # Create a set of matched subjects by filtering subjects by checking the map of flattened subjects against frame # This gives us a hash of objects indexed by @id matches = filter_subjects(state, subjects, frame, flags) # For each id and node from the set of matched subjects ordered by id matches.keys.kw_sort.each do |id| subject = matches[id] if flags[:embed] == '@link' && state[:link].has_key?(id) # TODO: may want to also match an existing linked subject # against the current frame ... so different frames could # produce different subjects that are only shared in-memory # when the frames are the same # add existing linked subject add_frame_output(parent, property, state[:link][id]) next end # Note: In order to treat each top-level match as a compartmentalized result, clear the unique embedded subjects map when the property is None, which only occurs at the top-level. state = state.merge(uniqueEmbeds: {}) if property.nil? output = {'@id' => id} state[:link][id] = output # if embed is @never or if a circular reference would be created by an embed, the subject cannot be embedded, just add the reference; note that a circular reference won't occur when the embed flag is `@link` as the above check will short-circuit before reaching this point if flags[:embed] == '@never' || creates_circular_reference(subject, state[:subjectStack]) add_frame_output(parent, property, output) next end # if only the last match should be embedded if flags[:embed] == '@last' # remove any existing embed remove_embed(state, id) if state[:uniqueEmbeds].include?(id) state[:uniqueEmbeds][id] = { parent: parent, property: property } end # push matching subject onto stack to enable circular embed checks state[:subjectStack] << subject # iterate over subject properties in order subject.keys.kw_sort.each do |prop| objects = subject[prop] # copy keywords to output if prop.start_with?('@') output[prop] = objects.dup next end # explicit is on and property isn't in frame, skip processing next if flags[:explicit] && !frame.has_key?(prop) # add objects objects.each do |o| case when list?(o) # add empty list list = {'@list' => []} add_frame_output(output, prop, list) src = o['@list'] src.each do |oo| if node_reference?(oo) subframe = frame[prop].first['@list'] if frame[prop].is_a?(Array) && frame[prop].first.is_a?(Hash) subframe ||= create_implicit_frame(flags) frame(state, [oo['@id']], subframe, options.merge(parent: list, property: '@list')) else add_frame_output(list, '@list', oo.dup) end end when node_reference?(o) # recurse into subject reference subframe = frame[prop] || create_implicit_frame(flags) frame(state, [o['@id']], subframe, options.merge(parent: output, property: prop)) else # include other values automatically add_frame_output(output, prop, o.dup) end end end # handle defaults in order frame.keys.kw_sort.reject {|p| p.start_with?('@')}.each do |prop| # if omit default is off, then include default values for properties that appear in the next frame but are not in the matching subject n = frame[prop].first || {} omit_default_on = get_frame_flag(n, options, :omitDefault) if !omit_default_on && !output[prop] preserve = n.fetch('@default', '@null').dup preserve = [preserve] unless preserve.is_a?(Array) output[prop] = [{'@preserve' => preserve}] end end # If frame has @reverse, embed identified nodes having this subject as a value of the associated property. frame.fetch('@reverse', {}).each do |reverse_prop, subframe| state[:subjects].each do |r_id, node| if Array(node[reverse_prop]).any? {|v| v['@id'] == id} # Node has property referencing this subject # recurse into reference (output['@reverse'] ||= {})[reverse_prop] ||= [] frame(state, [r_id], subframe, options.merge(parent: output['@reverse'][reverse_prop])) end end end # add output to parent add_frame_output(parent, property, output) # pop matching subject from circular ref-checking stack state[:subjectStack].pop() end end ## # Replace @preserve keys with the values, also replace @null with null # # @param [Array, Hash] input # @return [Array, Hash] def cleanup_preserve(input) result = case input when Array # If, after replacement, an array contains only the value null remove the value, leaving an empty array. input.map {|o| cleanup_preserve(o)}.compact when Hash output = Hash.new input.each do |key, value| if key == '@preserve' # replace all key-value pairs where the key is @preserve with the value from the key-pair output = cleanup_preserve(value) else v = cleanup_preserve(value) # Because we may have added a null value to an array, we need to clean that up, if we possible v = v.first if v.is_a?(Array) && v.length == 1 && context.expand_iri(key) != "@graph" && context.container(key).nil? output[key] = v end end output when '@null' # If the value from the key-pair is @null, replace the value with nul nil else input end result end private ## # Returns a map of all of the subjects that match a parsed frame. # # @param [Hash{Symbol => Object}] state # Current framing state # @param [Hash{String => Hash}] subjects # The subjects to filter # @param [Hash{String => Object}] frame # @param [Hash{Symbol => String}] flags the frame flags. # # @return all of the matched subjects. def filter_subjects(state, subjects, frame, flags) subjects.inject({}) do |memo, id| subject = state[:subjects][id] memo[id] = subject if filter_subject(subject, frame, flags) memo end end ## # Returns true if the given node matches the given frame. # # Matches either based on explicit type inclusion where the node has any type listed in the frame. If the frame has empty types defined matches nodes not having a @type. If the frame has a type of {} defined matches nodes having any type defined. # # Otherwise, does duck typing, where the node must have all of the properties defined in the frame. # # @param [Hash{String => Object}] subject the subject to check. # @param [Hash{String => Object}] frame the frame to check. # @param [Hash{Symbol => Object}] flags the frame flags. # # @return [Boolean] true if the node matches, false if not. def filter_subject(subject, frame, flags) types = frame.fetch('@type', []) raise InvalidFrame::Syntax, "frame @type must be an array: #{types.inspect}" unless types.is_a?(Array) subject_types = subject.fetch('@type', []) raise InvalidFrame::Syntax, "node @type must be an array: #{node_types.inspect}" unless subject_types.is_a?(Array) # check @type (object value means 'any' type, fall through to ducktyping) if !types.empty? && !(types.length == 1 && types.first.is_a?(Hash)) # If frame has an @type, use it for selecting appropriate nodes. return types.any? {|t| subject_types.include?(t)} else # Duck typing, for nodes not having a type, but having @id wildcard, matches_some = true, false frame.each do |k, v| case k when '@id' return false if v.is_a?(String) && subject['@id'] != v wildcard, matches_some = false, true when '@type' wildcard, matches_some = false, false when /^@/ else wildcard = false # v == [] means do not match if property is present if subject.has_key?(k) return false if v == [] && !subject[k].nil? matches_some = true next end # all properties must match to be a duck unless a @default is specified has_default = v.is_a?(Array) && v.length == 1 && v.first.is_a?(Hash) && v.first.has_key?('@default') return false if flags[:requireAll] && !has_default end end # return true if wildcard or subject matches some properties wildcard || matches_some end end def validate_frame(state, frame) raise InvalidFrame::Syntax, "Invalid JSON-LD syntax; a JSON-LD frame must be an object: #{frame.inspect}" unless frame.is_a?(Hash) || (frame.is_a?(Array) && frame.first.is_a?(Hash) && frame.length == 1) end # Checks the current subject stack to see if embedding the given subject would cause a circular reference. # # @param subject_to_embed the subject to embed. # @param subject_stack the current stack of subjects. # # @return true if a circular reference would be created, false if not. def creates_circular_reference(subject_to_embed, subject_stack) subject_stack[0..-2].any? do |subject| subject['@id'] == subject_to_embed['@id'] end end ## # Gets the frame flag value for the given flag name. # # @param frame the frame. # @param options the framing options. # @param name the flag name. # # @return the flag value. def get_frame_flag(frame, options, name) rval = frame.fetch("@#{name}", [options[name]]).first rval = rval.values.first if value?(rval) if name == :embed rval = case rval when true then '@last' when false then '@never' when '@always', '@never', '@link' then rval else '@last' end end rval end ## # Removes an existing embed. # # @param state the current framing state. # @param id the @id of the embed to remove. def remove_embed(state, id) # get existing embed embeds = state[:uniqueEmbeds]; embed = embeds[id]; property = embed[:property]; # create reference to replace embed subject = {'@id' => id} if embed[:parent].is_a?(Array) # replace subject with reference embed[:parent].map! do |parent| compare_values(parent, subject) ? subject : parent end else parent = embed[:parent] # replace node with reference if parent[property].is_a?(Array) parent[property].reject! {|v| compare_values(v, subject)} parent[property] << subject elsif compare_values(parent[property], subject) parent[property] = subject end end # recursively remove dependent dangling embeds def remove_dependents(id, embeds) # get embed keys as a separate array to enable deleting keys in map embeds.each do |id_dep, e| p = e.fetch(:parent, {}) if e.is_a?(Hash) next unless p.is_a?(Hash) pid = p.fetch('@id', nil) if pid == id embeds.delete(id_dep) remove_dependents(id_dep, embeds) end end end remove_dependents(id, embeds) end ## # Adds framing output to the given parent. # # @param parent the parent to add to. # @param property the parent property, null for an array parent. # @param output the output to add. def add_frame_output(parent, property, output) if parent.is_a?(Hash) parent[property] ||= [] parent[property] << output else parent << output end end # Creates an implicit frame when recursing through subject matches. If a frame doesn't have an explicit frame for a particular property, then a wildcard child frame will be created that uses the same flags that the parent frame used. # # @param [Hash] flags the current framing flags. # @return [Array] the implicit frame. def create_implicit_frame(flags) [flags.keys.inject({}) {|memo, key| memo["@#{key}"] = [flags[key]]; memo}] end end end