Add find filters to optimize where-first chains (#8171)
Merge pull request 8171
This commit is contained in:
		
							parent
							
								
									aecd937864
								
							
						
					
					
						commit
						0b2c4c9cec
					
				|  | @ -0,0 +1,49 @@ | |||
| #!/usr/bin/env ruby | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'benchmark/ips' | ||||
| require_relative '../lib/jekyll' | ||||
| 
 | ||||
| puts '' | ||||
| print 'Setting up... ' | ||||
| 
 | ||||
| SITE = Jekyll::Site.new( | ||||
|   Jekyll.configuration({ | ||||
|     "source"             => File.expand_path("../docs", __dir__), | ||||
|     "destination"        => File.expand_path("../docs/_site", __dir__), | ||||
|     "disable_disk_cache" => true, | ||||
|     "quiet"              => true, | ||||
|   }) | ||||
| ) | ||||
| 
 | ||||
| TEMPLATE_1 = Liquid::Template.parse(<<~HTML) | ||||
|   {%- assign doc = site.documents | where: 'url', '/docs/assets/' | first -%} | ||||
|   {{- doc.title -}} | ||||
| HTML | ||||
| 
 | ||||
| TEMPLATE_2 = Liquid::Template.parse(<<~HTML) | ||||
|   {%- assign doc = site.documents | find: 'url', '/docs/assets/' -%} | ||||
|   {{- doc.title -}} | ||||
| HTML | ||||
| 
 | ||||
| [:reset, :read, :generate].each { |phase| SITE.send(phase) } | ||||
| 
 | ||||
| puts 'done.' | ||||
| puts 'Testing... ' | ||||
| puts "  #{'where + first'.cyan} results in #{TEMPLATE_1.render(SITE.site_payload).inspect.green}" | ||||
| puts "  #{'find'.cyan} results in #{TEMPLATE_2.render(SITE.site_payload).inspect.green}" | ||||
| 
 | ||||
| if TEMPLATE_1.render(SITE.site_payload) == TEMPLATE_2.render(SITE.site_payload) | ||||
|   puts 'Success! Procceding to run benchmarks.'.green | ||||
|   puts '' | ||||
| else | ||||
|   puts 'Something went wrong. Aborting.'.magenta | ||||
|   puts '' | ||||
|   return | ||||
| end | ||||
| 
 | ||||
| Benchmark.ips do |x| | ||||
|   x.report('where + first') { TEMPLATE_1.render(SITE.site_payload) } | ||||
|   x.report('find') { TEMPLATE_2.render(SITE.site_payload) } | ||||
|   x.compare! | ||||
| end | ||||
|  | @ -111,6 +111,40 @@ | |||
| 
 | ||||
| # | ||||
| 
 | ||||
| - name: Find | ||||
|   description: >- | ||||
|     Return <strong>the first object</strong> in an array for which the queried | ||||
|     attribute has the given value or return <code>nil</code> if no item in | ||||
|     the array satisfies the given criteria. | ||||
|   version_badge: 4.1.0 | ||||
|   examples: | ||||
|     - input: '{{ site.members | find: "graduation_year", "2014" }}' | ||||
|       output: | ||||
| 
 | ||||
| # | ||||
| 
 | ||||
| - name: Find Expression | ||||
|   description: >- | ||||
|     Return <strong>the first object</strong> in an array for which the given | ||||
|     expression evaluates to true or return <code>nil</code> if no item in | ||||
|     the array satisfies the evaluated expression. | ||||
|   version_badge: 4.1.0 | ||||
|   examples: | ||||
|     - input: |- | ||||
|         {{ site.members | find_exp:"item", | ||||
|         "item.graduation_year == 2014" }} | ||||
|       output: | ||||
|     - input: |- | ||||
|         {{ site.members | find_exp:"item", | ||||
|         "item.graduation_year < 2014" }} | ||||
|       output: | ||||
|     - input: |- | ||||
|         {{ site.members | find_exp:"item", | ||||
|         "item.projects contains 'foo'" }} | ||||
|       output: | ||||
| 
 | ||||
| # | ||||
| 
 | ||||
| - name: Group By | ||||
|   description: Group an array's items by a given property. | ||||
|   examples: | ||||
|  |  | |||
|  | @ -210,6 +210,66 @@ module Jekyll | |||
|       end || [] | ||||
|     end | ||||
| 
 | ||||
|     # Search an array of objects and returns the first object that has the queried attribute | ||||
|     # with the given value or returns nil otherwise. | ||||
|     # | ||||
|     # input    - the object array. | ||||
|     # property - the property within each object to search by. | ||||
|     # value    - the desired value. | ||||
|     #            Cannot be an instance of Array nor Hash since calling #to_s on them returns | ||||
|     #            their `#inspect` string object. | ||||
|     # | ||||
|     # Returns the found object or nil | ||||
|     # | ||||
|     # rubocop:disable Metrics/CyclomaticComplexity | ||||
|     def find(input, property, value) | ||||
|       return input if !property || value.is_a?(Array) || value.is_a?(Hash) | ||||
|       return input unless input.respond_to?(:find) | ||||
| 
 | ||||
|       input    = input.values if input.is_a?(Hash) | ||||
|       input_id = input.hash | ||||
| 
 | ||||
|       # implement a hash based on method parameters to cache the end-result for given parameters. | ||||
|       @find_filter_cache ||= {} | ||||
|       @find_filter_cache[input_id] ||= {} | ||||
|       @find_filter_cache[input_id][property] ||= {} | ||||
| 
 | ||||
|       # stash or retrive results to return | ||||
|       # Since `enum.find` can return nil or false, we use a placeholder string "<__NO MATCH__>" | ||||
|       #   to validate caching. | ||||
|       result = @find_filter_cache[input_id][property][value] ||= begin | ||||
|         input.find do |object| | ||||
|           compare_property_vs_target(item_property(object, property), value) | ||||
|         end || "<__NO MATCH__>" | ||||
|       end | ||||
|       return nil if result == "<__NO MATCH__>" | ||||
| 
 | ||||
|       result | ||||
|     end | ||||
|     # rubocop:enable Metrics/CyclomaticComplexity | ||||
| 
 | ||||
|     # Searches an array of objects against an expression and returns the first object for which | ||||
|     # the expression evaluates to true, or returns nil otherwise. | ||||
|     # | ||||
|     # input - the object array | ||||
|     # variable - the variable to assign each item to in the expression | ||||
|     # expression - a Liquid comparison expression passed in as a string | ||||
|     # | ||||
|     # Returns the found object or nil | ||||
|     def find_exp(input, variable, expression) | ||||
|       return input unless input.respond_to?(:find) | ||||
| 
 | ||||
|       input = input.values if input.is_a?(Hash) | ||||
| 
 | ||||
|       condition = parse_condition(expression) | ||||
|       @context.stack do | ||||
|         input.find do |object| | ||||
|           @context[variable] = object | ||||
|           condition.evaluate(@context) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     # Convert the input into integer | ||||
|     # | ||||
|     # input - the object string | ||||
|  |  | |||
|  | @ -1079,6 +1079,207 @@ class TestFilters < JekyllUnitTest | |||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context "find filter" do | ||||
|       should "return any input that is not an array" do | ||||
|         assert_equal "some string", @filter.find("some string", "la", "le") | ||||
|       end | ||||
| 
 | ||||
|       should "filter objects in a hash appropriately" do | ||||
|         hash = { "a" => { "color" => "red" }, "b" => { "color" => "blue" } } | ||||
|         assert_equal({ "color" => "red" }, @filter.find(hash, "color", "red")) | ||||
|       end | ||||
| 
 | ||||
|       should "filter objects appropriately" do | ||||
|         assert_equal( | ||||
|           { "color" => "red", "size" => "large" }, | ||||
|           @filter.find(@array_of_objects, "color", "red") | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       should "filter objects with null properties appropriately" do | ||||
|         array = [{}, { "color" => nil }, { "color" => "" }, { "color" => "text" }] | ||||
|         assert_equal({}, @filter.find(array, "color", nil)) | ||||
|       end | ||||
| 
 | ||||
|       should "filter objects with numerical properties appropriately" do | ||||
|         array = [ | ||||
|           { "value" => "555" }, | ||||
|           { "value" => 555 }, | ||||
|           { "value" => 24.625 }, | ||||
|           { "value" => "24.625" }, | ||||
|         ] | ||||
|         assert_equal({ "value" => 24.625 }, @filter.find(array, "value", 24.625)) | ||||
|         assert_equal({ "value" => "555" }, @filter.find(array, "value", 555)) | ||||
|       end | ||||
| 
 | ||||
|       should "filter array properties appropriately" do | ||||
|         hash = { | ||||
|           "a" => { "tags" => %w(x y) }, | ||||
|           "b" => { "tags" => ["x"] }, | ||||
|           "c" => { "tags" => %w(y z) }, | ||||
|         } | ||||
|         assert_equal({ "tags" => %w(x y) }, @filter.find(hash, "tags", "x")) | ||||
|       end | ||||
| 
 | ||||
|       should "filter array properties alongside string properties" do | ||||
|         hash = { | ||||
|           "a" => { "tags" => %w(x y) }, | ||||
|           "b" => { "tags" => "x" }, | ||||
|           "c" => { "tags" => %w(y z) }, | ||||
|         } | ||||
|         assert_equal({ "tags" => %w(x y) }, @filter.find(hash, "tags", "x")) | ||||
|       end | ||||
| 
 | ||||
|       should "filter hash properties with null and empty values" do | ||||
|         hash = { | ||||
|           "a" => { "tags" => {} }, | ||||
|           "b" => { "tags" => "" }, | ||||
|           "c" => { "tags" => nil }, | ||||
|           "d" => { "tags" => ["x", nil] }, | ||||
|           "e" => { "tags" => [] }, | ||||
|           "f" => { "tags" => "xtra" }, | ||||
|         } | ||||
| 
 | ||||
|         assert_equal({ "tags" => nil }, @filter.find(hash, "tags", nil)) | ||||
|         assert_equal({ "tags" => "" }, @filter.find(hash, "tags", "")) | ||||
| 
 | ||||
|         # `{{ hash | find: 'tags', empty }}` | ||||
|         assert_equal( | ||||
|           { "tags" => {} }, | ||||
|           @filter.find(hash, "tags", Liquid::Expression::LITERALS["empty"]) | ||||
|         ) | ||||
| 
 | ||||
|         # `{{ `hash | find: 'tags', blank }}` | ||||
|         assert_equal( | ||||
|           { "tags" => {} }, | ||||
|           @filter.find(hash, "tags", Liquid::Expression::LITERALS["blank"]) | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       should "not match substrings" do | ||||
|         hash = { | ||||
|           "a" => { "category" => "bear" }, | ||||
|           "b" => { "category" => "wolf" }, | ||||
|           "c" => { "category" => %w(bear lion) }, | ||||
|         } | ||||
|         assert_nil @filter.find(hash, "category", "ear") | ||||
|       end | ||||
| 
 | ||||
|       should "stringify during comparison for compatibility with liquid parsing" do | ||||
|         hash = { | ||||
|           "The Words" => { "rating" => 1.2, "featured" => false }, | ||||
|           "Limitless" => { "rating" => 9.2, "featured" => true }, | ||||
|           "Hustle"    => { "rating" => 4.7, "featured" => true }, | ||||
|         } | ||||
| 
 | ||||
|         result = @filter.find(hash, "featured", "true") | ||||
|         assert_equal 9.2, result["rating"] | ||||
| 
 | ||||
|         result = @filter.find(hash, "rating", 4.7) | ||||
|         assert_equal 4.7, result["rating"] | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context "find_exp filter" do | ||||
|       should "return any input that is not an array" do | ||||
|         assert_equal "some string", @filter.find_exp("some string", "la", "le") | ||||
|       end | ||||
| 
 | ||||
|       should "filter objects in a hash appropriately" do | ||||
|         hash = { "a" => { "color"=>"red" }, "b" => { "color"=>"blue" } } | ||||
|         assert_equal( | ||||
|           { "color" => "red" }, | ||||
|           @filter.find_exp(hash, "item", "item.color == 'red'") | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       should "filter objects appropriately" do | ||||
|         assert_equal( | ||||
|           { "color" => "red", "size" => "large" }, | ||||
|           @filter.find_exp(@array_of_objects, "item", "item.color == 'red'") | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       should "filter objects appropriately with 'or', 'and' operators" do | ||||
|         assert_equal( | ||||
|           { "color" => "teal", "size" => "large" }, | ||||
|           @filter.find_exp( | ||||
|             @array_of_objects, "item", "item.color == 'red' or item.size == 'large'" | ||||
|           ) | ||||
|         ) | ||||
| 
 | ||||
|         assert_equal( | ||||
|           { "color" => "red", "size" => "large" }, | ||||
|           @filter.find_exp( | ||||
|             @array_of_objects, "item", "item.color == 'red' and item.size == 'large'" | ||||
|           ) | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       should "filter objects across multiple conditions" do | ||||
|         sample = [ | ||||
|           { "color" => "teal", "size" => "large", "type" => "variable" }, | ||||
|           { "color" => "red",  "size" => "large", "type" => "fixed" }, | ||||
|           { "color" => "red",  "size" => "medium", "type" => "variable" }, | ||||
|           { "color" => "blue", "size" => "medium", "type" => "fixed" }, | ||||
|         ] | ||||
|         assert_equal( | ||||
|           { "color" => "red", "size" => "large", "type" => "fixed" }, | ||||
|           @filter.find_exp( | ||||
|             sample, "item", "item.type == 'fixed' and item.color == 'red' or item.color == 'teal'" | ||||
|           ) | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       should "stringify during comparison for compatibility with liquid parsing" do | ||||
|         hash = { | ||||
|           "The Words" => { "rating" => 1.2, "featured" => false }, | ||||
|           "Limitless" => { "rating" => 9.2, "featured" => true }, | ||||
|           "Hustle"    => { "rating" => 4.7, "featured" => true }, | ||||
|         } | ||||
| 
 | ||||
|         result = @filter.find_exp(hash, "item", "item.featured == true") | ||||
|         assert_equal 9.2, result["rating"] | ||||
| 
 | ||||
|         result = @filter.find_exp(hash, "item", "item.rating == 4.7") | ||||
|         assert_equal 4.7, result["rating"] | ||||
|       end | ||||
| 
 | ||||
|       should "filter with other operators" do | ||||
|         assert_equal 3, @filter.find_exp([1, 2, 3, 4, 5], "n", "n >= 3") | ||||
|       end | ||||
| 
 | ||||
|       objects = [ | ||||
|         { "id" => "a", "groups" => [1, 2] }, | ||||
|         { "id" => "b", "groups" => [2, 3] }, | ||||
|         { "id" => "c" }, | ||||
|         { "id" => "d", "groups" => [1, 3] }, | ||||
|       ] | ||||
|       should "filter with the contains operator over arrays" do | ||||
|         result = @filter.find_exp(objects, "obj", "obj.groups contains 1") | ||||
|         assert_equal "a", result["id"] | ||||
|       end | ||||
| 
 | ||||
|       should "filter with the contains operator over hash keys" do | ||||
|         result = @filter.find_exp(objects, "obj", "obj contains 'groups'") | ||||
|         assert_equal "a", result["id"] | ||||
|       end | ||||
| 
 | ||||
|       should "filter posts" do | ||||
|         site = fixture_site.tap(&:read) | ||||
|         posts = site.site_payload["site"]["posts"] | ||||
|         result = @filter.find_exp(posts, "obj", "obj.title == 'Foo Bar'") | ||||
|         assert_equal(result, site.posts.find { |p| p.title == "Foo Bar" }) | ||||
|       end | ||||
| 
 | ||||
|       should "filter by variable values" do | ||||
|         @filter.site.tap(&:read) | ||||
|         posts  = @filter.site.site_payload["site"]["posts"] | ||||
|         result = @filter.find_exp(posts, "post", "post.date > site.dont_show_posts_before") | ||||
|         assert result.date > @sample_time | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context "group_by_exp filter" do | ||||
|       should "successfully group array of Jekyll::Page's" do | ||||
|         @filter.site.process | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue