app/controllers/hierarchy_controller.rb

Summary

Maintainability
C
7 hrs
Test Coverage
# encoding: UTF-8

# Copyright 2011-2013 innoQ Deutschland GmbH
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

class HierarchyController < ApplicationController
  resource_description do
    name 'Hierarchy'
  end

  api :GET, 'hierarchy', 'Retrieve the downwards concepts hierarchy starting '\
                         'at top concepts.'
  formats [:html, :ttl, :rdf]
  example <<-DOC
    GET /hierarchy.ttl
    200

    # omitted namespace definitions
    :achievement_hobbies a skos:Concept;
                         skos:topConceptOf :scheme;
                         skos:prefLabel "Achievement hobbies"@en;
                         skos:narrower :model_building;
                         skos:narrower :gardening.
    :model_building a skos:Concept;
                    skos:prefLabel "Model building"@en;
                    skos:narrower :model_rocketry;
    :model_rocketry a skos:Concept;
                    skos:prefLabel "Model rocketry"@en.
    :gardening a skos:Concept;
               skos:prefLabel "Gardening"@en.
  DOC

  def index
    authorize! :read, Iqvoc::Concept.base_class

    # special-casing to avoid confusion, based on user feedback
    if params[:root]
      msg = ['to use a specific concept as hierarchy root, please use',
          url_for(params.merge 'action' => 'show')].join("\n")
      render status: 400, text: msg
      return
    end

    depth = params[:depth] || (unbounded? ? -1 : nil)

    render_hierarchy 'scheme', depth, unbounded?
  end

  api :GET, 'hierarchy/:root', "Retrieve a concept's up- or downwards "\
                               'hierarchy with optional siblings.'
  formats [:html, :ttl, :rdf]
  param :dir, ['down', 'up'],
      desc: <<-DOC
      Direction of the hierarchy.

      *down* follow narrower from root to its leaf nodes.

      *up* follow broader from root to its top term(s).
      DOC
  param :depth, [1, 2, 3, 4],
      'Number of levels of hierarchy to be included in the response'
  param :siblings, ['1', 'true'],
      'Siblings of each node will be included even if they are not part of '\
      'the hierarchy.'
  example <<-DOC
    GET /hierarchy/model_building.ttl?dir=down&depth=1

    # omitted namespace definitions
    :model_rocketry a skos:Concept;
                    skos:prefLabel "Model rocketry"@en.
    :radio-controlled_modeling a skos:Concept;
                               skos:prefLabel "Radio-controlled modeling"@en.
    :scale_modeling a skos:Concept;
                    skos:prefLabel "Scale modeling"@en.
    :model_building a skos:Concept;
                    skos:prefLabel "Model building"@en;
                    skos:narrower :model_rocketry;
                    skos:narrower :radio-controlled_modeling;
                    skos:narrower :scale_modeling.
  DOC

  def show
    authorize! :read, Iqvoc::Concept.base_class

    render_hierarchy params[:root], params[:depth], unbounded?
  end

  private

  def unbounded?
    Iqvoc.config['performance.unbounded_hierarchy']
  end

  def render_hierarchy(root_origin, depth, unbounded = false)
    default_depth = 3
    max_depth = 4 # XXX: arbitrary

    direction = params[:dir] == 'up' ? 'up' : 'down'
    depth = depth.blank? ? default_depth : (Float(depth).to_i rescue nil)
    include_siblings = ['true', '1'].include?(params[:siblings])
    include_unpublished = params[:published] == '0'

    scope = Iqvoc::Concept.base_class
    scope = include_unpublished ? scope.editor_selectable : scope.published
    scope = scope.ordered_by_pref_label

    # validate depth parameter
    if not depth
      error = 'invalid depth parameter' # TODO: i18n
    elsif depth > max_depth and not unbounded
      error = [403, 'excessive depth'] # TODO: i18n
    end
    # validate root parameter
    error = 'missing root parameter' unless root_origin # TODO: i18n
    unless error
      root_concepts = root_origin == 'scheme' ? scope.tops.load : # XXX: special-casing
          scope.where(origin: root_origin).load
      unless root_concepts.length > 0
        error = [404, 'no concept matching root parameter'] # TODO: i18n
      end
    end
    # error handling
    if error
      status, error = error if error.is_a? Array
      flash.now[:error] = error
      render 'hierarchy/show', status: (status || 400)
      return
    end

    # caching -- NB: invalidated on any in-scope concept modifications
    latest = scope.maximum(:updated_at)
    response.cache_control[:public] = !include_unpublished # XXX: this should not be necessary!?
    return unless stale?(etag: [latest, hierarchy_params.to_h], last_modified: latest,
        public: !include_unpublished)

    # NB: order matters due to the `where` clause below
    if direction == 'up'
      scope = scope.includes(:narrower_relations, :broader_relations)
    else
      scope = scope.includes(:broader_relations, :narrower_relations)
    end

    @concepts = {}
    root_concepts.each do |root_concept|
      if include_siblings
        determine_siblings(root_concept).each { |sib| @concepts[sib] = {} }
      end
      @concepts[root_concept] = populate_hierarchy(root_concept, scope, depth,
          0, include_siblings)
    end

    @relation_class = Iqvoc::Concept.broader_relation_class
    @relation_class = @relation_class.narrower_class unless direction == 'up'

    respond_to do |format|
      format.any(:html, :rdf, :ttl, :nt) { render 'hierarchy/show' }
    end
  end

  # returns a hash of concept/relations pairs of arbitrary nesting depth
  # NB: recursive, triggering one database query per iteration
  def populate_hierarchy(root_concept, scope, max_depth, current_depth = 0,
        include_siblings = false)
    current_depth += 1
    return {} if max_depth != -1 and current_depth > max_depth

    rels = scope.where(Concept::Relation::Base.arel_table[:target_id].
        eq(root_concept.id)).references(:concept_relations)

    results = rels.inject({}) do |memo, concept|
      if include_siblings
        determine_siblings(concept).each { |sib| memo[sib] = {} }
      end
      memo[concept] = populate_hierarchy(concept, scope, max_depth,
          current_depth, include_siblings)
      memo
    end

    results
  end

  # NB: includes support for poly-hierarchies -- XXX: untested
  def determine_siblings(concept)
    concept.broader_relations.map do |rel|
      rel.target.narrower_relations.map { |rel| rel.target } # XXX: expensive
    end.flatten.uniq.sort { |a, b| a.pref_label <=> b.pref_label }
  end

  private

  def hierarchy_params
    params.permit(:lang, :format, :root, :dir, :depth, :siblings, :published)
  end
end