Last active
October 26, 2016 14:10
-
-
Save maxinspace/e7b4682d8deed98962b4143db2c101af to your computer and use it in GitHub Desktop.
Test Task for TrustYou
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
## | |
# 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