Skip to content

Instantly share code, notes, and snippets.

@maxinspace
Last active October 26, 2016 14:10
Show Gist options
  • Save maxinspace/e7b4682d8deed98962b4143db2c101af to your computer and use it in GitHub Desktop.
Save maxinspace/e7b4682d8deed98962b4143db2c101af to your computer and use it in GitHub Desktop.
Test Task for TrustYou
##
# The IncludedResourceParams class is responsible for parsing a string containing
# a comma separated list of associated resources to include with a request. See
# http://jsonapi.org/format/#fetching-includes for additional details although
# this is not required knowledge for the task at hand.
#
# Our API requires specific inclusion of related resourses - that is we do NOT
# want to support wildcard inclusion (e.g. `foo.*`)
#
# The IncludedResourceParams class has three public methods making up its API.
#
# [included_resources]
# returns an array of non-wildcard included elements.
# [has_included_resources?]
# Returns true if our supplied param has included resources, false otherwise.
# [model_includes]
# returns an array suitable to supply to ActiveRecord's `includes` method
# (http://guides.rubyonrails.org/active_record_querying.html#eager-loading-multiple-associations)
# The included_resources should be transformed as specified in the unit tests
# included herein.
#
# All three public methods have unit tests written below that must pass. You are
# free to add additional classes/modules as necessary and/or private methods
# within the IncludedResourceParams class.
#
# Feel free to use the Ruby standard libraries available on codepad in your
# solution.
#
# Create your solution as a private fork, and send us the URL.
#
class IncludedResourceParams
attr_accessor :include_param
private :include_param
WILDCARD_END_COMMA = /((\w{1,}\.){1,}\*{1,},)/.freeze
WILDCARD_START_COMMA = /(,(\w{1,}\.){1,}\*{1,})/.freeze
WILDCARD_NO_COMMA = /((\w{1,}\.){1,}\*{1,})/.freeze
WILDCARD_CLEAN_REGEX = [WILDCARD_END_COMMA, WILDCARD_START_COMMA, WILDCARD_NO_COMMA].join("|").freeze
def initialize(include_param)
@include_param = include_param
end
##
# Does our IncludedResourceParams instance actually have any valid included
# resources after parsing?
#
# @return [Boolean] whether this instance has included resources
def has_included_resources?
# well, just check cached #included_resources
included_resources.any?
end
##
# Fetches the included resourcs as an Array containing only non-wildcard
# resource specifiers.
#
# @example nil
# IncludedResourceParams.new(nil).included_resources => []
#
# @example "foo,foo.bar,baz.*"
# IncludedResourceParams.new("foo,foo.bar,baz.*").included_resources => ["foo", "foo.bar"]
#
# @return [Array] an Array of Strings parsed from the include param with
# wildcard includes removed
def included_resources
return [] unless valid_parameters?
# because regex seems to be faster, than iterations, and data might be big.
# O(2n) is good enough speed.
@_included_resources ||= include_param.gsub(Regexp.new(WILDCARD_CLEAN_REGEX), "").split(",")
end
##
# Converts the resources to be included from their JSONAPI representation to
# a structure compatible with ActiveRecord's `includes` methods. This can/should
# be an Array in all cases. Does not do any verification that the resources
# specified for inclusion are actual ActiveRecord classes.
#
# @example nil
# IncludedResourceParams.new(nil).model_includes => []
#
# @example "foo"
# IncludedResourceParams.new("foo").model_includes => [:foo]
#
# @see Following unit tests
#
# @return [Array] an Array of Symbols and/or Hashes compatible with ActiveRecord
# `includes`
def model_includes
return [] unless included_resources.any?
# its cached, since the algorithm is quite heavy.
@_model_includes ||= generate_includes_array
end
private
def generate_includes_array
# so we generate an empty array for results
result = []
included_resources.each do |element|
# transform "foo.bar.baz" into [:foo, :bar, :baz]
element = element.split(".").map(&:to_sym)
# do magic. it mutates result variable.
recursive_merge!(result, element)
end
result
end
def transform_element(element)
# @example this turns [:foo, :bar, :baz] into {:foo => [{:bar => [:baz]}]}
element.reverse.reduce { |x, xs| { xs => [x] } }
end
def recursive_merge!(resources, element)
# we need to know, if the element can be matched with any resource
# within result array.
# Matched means that first key of resource is equal to first item in element array
# @example {:foo => [:bar]} matches with [:foo], or with [:foo, :baz, :bee]
# @example and doesn't match with [:baz], or [:baz, :foo, :bar]
matched = false
resources.map.with_index do |resource, index|
# if all element attributes matched -> return from the loop, and do nothing,
# we don't need to duplicate items
if element.empty?
matched = true
break
end
# this is recursive block.
# We iterate recursively through each nesting level of resource
# Cases with Symbol or Array marks the end of iteration to depth.
# For example, in the test you provided, there are exactly two cases of iteration end: Array and Symbol
# @example [{:foo => [{:bar => [:baz, :bat]}]}, :bar]
# Array and Symbol ends: [:baz, :bat], :bar
case resource
# when resource == symbol matches, it checks element. if resource == element.first, matched is triggered as true.
# Then merging stuff.
# If element has one entry - simply replace the contents.
# If element has more than one entry - it transforms element, and replace contents.
# if it doesn't match - nothing to do here, after loop it will be merged right into the place, where recursion
# is being now.
when Symbol
if resource == element.first
matched = true
if element.size == 1
resources[index] = element.first
else
resources[index] = transform_element(element)
end
end
# when resource == array matches, it iterates through this array.
# and then against each resource attribute, it checks element.
# same as symbol, if size = 1 -> replace, else -> transform.
# if doesn't match -> just go next. And if none match - do nothing
when Array
resources[index].map do |attribute|
if attribute == element.first
if element.size == 1
resources[index] = element
else
resources[index] = transform_element(element)
end
else
next
end
end
# here's recursion.
# so resource is Hash here.
# And if resource.keys include first item in element -> then mark matched as true,
# and write into value the same method call on resource.values, and element items without first.
# Simply -
# 1 iteration: resource = {:foo => [{:bar => [:baz]}]} , element = [:foo, :bar, :bee]
# 2 iteration: resource = {:bar => [:baz]} , element = [:bar, :bee]
# etc.
# so it recursively writes result of recursive_merge! into resource value.
# and if not matched, just call next.
else
if resource.keys.include? element.first
matched = true
resources[index][element.first] = recursive_merge!(resource.values.flatten, element.drop(1))
else
next
end
end
end
# that adds element to resources, if no match happened. On any level of nesting.
if matched == false
resources << transform_element(element)
end
# and finally return resources.
resources
end
def valid_parameters?
# I don't really know what param will come here, so I do some kind of
# strict typisation here. NOTHING BUT STRING SHALL PASS!
# and I can't use blank?, since you said to use only Ruby methods :(
return false unless include_param.is_a?(String)
include_param.to_s != ""
end
end
require 'test/unit'
class TestIncludedResourceParams < Test::Unit::TestCase
# Tests for #has_included_resources?
def test_has_included_resources_is_false_when_nil
r = IncludedResourceParams.new(nil)
assert r.has_included_resources? == false
end
def test_has_included_resources_is_false_when_only_wildcards
include_string = 'foo.**'
r = IncludedResourceParams.new(include_string)
assert r.has_included_resources? == false
end
def test_has_included_resources_is_true_with_non_wildcard_params
include_string = 'foo'
r = IncludedResourceParams.new(include_string)
assert r.has_included_resources?
end
def test_has_included_resources_is_true_with_both_wildcard_and_non_params
include_string = 'foo,bar.**'
r = IncludedResourceParams.new(include_string)
assert r.has_included_resources?
end
# Tests for #included_resources
def test_included_resources_always_returns_array
r = IncludedResourceParams.new(nil)
assert r.included_resources == []
end
def test_included_resources_returns_only_non_wildcards
r = IncludedResourceParams.new('foo,foo.bar,baz.*,bat.**')
assert r.included_resources == ['foo', 'foo.bar']
end
# Tests for #model_includes
def test_model_includes_when_params_nil
assert IncludedResourceParams.new(nil).model_includes == []
end
def test_model_includes_one_single_level_resource
assert IncludedResourceParams.new('foo').model_includes == [:foo]
end
def test_model_includes_multiple_single_level_resources
assert IncludedResourceParams.new('foo,bar').model_includes == [:foo, :bar]
end
def test_model_includes_single_two_level_resource
assert IncludedResourceParams.new('foo.bar').model_includes == [{:foo => [:bar]}]
end
def test_model_includes_multiple_two_level_resources
assert IncludedResourceParams.new('foo.bar,foo.bat').model_includes == [{:foo => [:bar, :bat]}]
assert IncludedResourceParams.new('foo.bar,baz.bat').model_includes == [{:foo => [:bar]}, {:baz => [:bat]}]
end
def test_model_includes_three_level_resources
assert IncludedResourceParams.new('foo.bar.baz').model_includes == [{:foo => [{:bar => [:baz]}]}]
end
def test_model_includes_multiple_three_level_resources
assert IncludedResourceParams.new('foo.bar.baz,foo,foo.bar.bat,bar').model_includes == [{:foo => [{:bar => [:baz, :bat]}]}, :bar]
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment