From c0ffeeef26050d7b3fc541c0ead18c93a64813f2 Mon Sep 17 00:00:00 2001 From: Thierry Joyal Date: Mon, 6 Jan 2020 17:17:35 +0000 Subject: [PATCH] [Strainer] Separate factory from template --- History.md | 4 + lib/liquid.rb | 3 +- lib/liquid/context.rb | 2 +- lib/liquid/strainer_factory.rb | 36 ++++ .../{strainer.rb => strainer_template.rb} | 47 ++--- lib/liquid/template.rb | 2 +- test/test_helper.rb | 16 +- test/unit/strainer_factory_unit_test.rb | 96 ++++++++++ test/unit/strainer_template_unit_test.rb | 82 +++++++++ test/unit/strainer_unit_test.rb | 167 ------------------ 10 files changed, 247 insertions(+), 208 deletions(-) create mode 100644 lib/liquid/strainer_factory.rb rename lib/liquid/{strainer.rb => strainer_template.rb} (50%) create mode 100644 test/unit/strainer_factory_unit_test.rb create mode 100644 test/unit/strainer_template_unit_test.rb delete mode 100644 test/unit/strainer_unit_test.rb diff --git a/History.md b/History.md index 9a82faab..b3130f9c 100644 --- a/History.md +++ b/History.md @@ -1,5 +1,9 @@ # Liquid Change Log +### Unreleased + +* Split Strainer class as a factory and a template (#1208) [Thierry Joyal] + ## 4.0.3 / 2019-03-12 ### Fixed diff --git a/lib/liquid.rb b/lib/liquid.rb index 733fd829..84b11e50 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -57,7 +57,8 @@ require 'liquid/forloop_drop' require 'liquid/extensions' require 'liquid/errors' require 'liquid/interrupts' -require 'liquid/strainer' +require 'liquid/strainer_factory' +require 'liquid/strainer_template' require 'liquid/expression' require 'liquid/context' require 'liquid/parser_switching' diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 81bd43d5..0737f744 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -52,7 +52,7 @@ module Liquid end def strainer - @strainer ||= Strainer.create(self, @filters) + @strainer ||= StrainerFactory.create(self, @filters) end # Adds filters to this context. diff --git a/lib/liquid/strainer_factory.rb b/lib/liquid/strainer_factory.rb new file mode 100644 index 00000000..7a7bcb8a --- /dev/null +++ b/lib/liquid/strainer_factory.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Liquid + # StrainerFactory is the factory for the filters system. + module StrainerFactory + extend self + + def add_global_filter(filter) + strainer_class_cache.clear + global_filters << filter + end + + def create(context, filters = []) + strainer_from_cache(filters).new(context) + end + + private + + def global_filters + @global_filters ||= [] + end + + def strainer_from_cache(filters) + strainer_class_cache[filters] ||= begin + klass = Class.new(StrainerTemplate) + global_filters.each { |f| klass.add_filter(f) } + filters.each { |f| klass.add_filter(f) } + klass + end + end + + def strainer_class_cache + @strainer_class_cache ||= {} + end + end +end diff --git a/lib/liquid/strainer.rb b/lib/liquid/strainer_template.rb similarity index 50% rename from lib/liquid/strainer.rb rename to lib/liquid/strainer_template.rb index 3f3417e3..10227c58 100644 --- a/lib/liquid/strainer.rb +++ b/lib/liquid/strainer_template.rb @@ -3,54 +3,39 @@ require 'set' module Liquid - # Strainer is the parent class for the filters system. + # StrainerTemplate is the computed class for the filters system. # New filters are mixed into the strainer class which is then instantiated for each liquid template render run. # - # The Strainer only allows method calls defined in filters given to it via Strainer.global_filter, + # The Strainer only allows method calls defined in filters given to it via StrainerFactory.add_global_filter, # Context#add_filters or Template.register_filter - class Strainer #:nodoc: - @@global_strainer = Class.new(Strainer) do - @filter_methods = Set.new - end - @@strainer_class_cache = Hash.new do |hash, filters| - hash[filters] = Class.new(@@global_strainer) do - @filter_methods = @@global_strainer.filter_methods.dup - filters.each { |f| add_filter(f) } - end - end - + class StrainerTemplate def initialize(context) @context = context end class << self - attr_reader :filter_methods - end + def add_filter(filter) + return if include?(filter) - def self.add_filter(filter) - raise ArgumentError, "Expected module but got: #{filter.class}" unless filter.is_a?(Module) - unless include?(filter) invokable_non_public_methods = (filter.private_instance_methods + filter.protected_instance_methods).select { |m| invokable?(m) } if invokable_non_public_methods.any? raise MethodOverrideError, "Filter overrides registered public methods as non public: #{invokable_non_public_methods.join(', ')}" - else - send(:include, filter) - @filter_methods.merge(filter.public_instance_methods.map(&:to_s)) end + + include(filter) + + filter_methods.merge(filter.public_instance_methods.map(&:to_s)) end - end - def self.global_filter(filter) - @@strainer_class_cache.clear - @@global_strainer.add_filter(filter) - end + def invokable?(method) + filter_methods.include?(method.to_s) + end - def self.invokable?(method) - @filter_methods.include?(method.to_s) - end + private - def self.create(context, filters = []) - @@strainer_class_cache[filters].new(context) + def filter_methods + @filter_methods ||= Set.new + end end def invoke(method, *args) diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index 228870cf..75b3d2ed 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -111,7 +111,7 @@ module Liquid # Pass a module with filter methods which should be available # to all liquid views. Good for registering the standard library def register_filter(mod) - Strainer.global_filter(mod) + StrainerFactory.add_global_filter(mod) end def default_resource_limits diff --git a/test/test_helper.rb b/test/test_helper.rb index 7356a290..7910ec42 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -55,19 +55,21 @@ module Minitest end def with_global_filter(*globals) - original_global_strainer = Liquid::Strainer.class_variable_get(:@@global_strainer) - Liquid::Strainer.class_variable_set(:@@global_strainer, Class.new(Liquid::Strainer) do - @filter_methods = Set.new - end) - Liquid::Strainer.class_variable_get(:@@strainer_class_cache).clear + original_global_filters = Liquid::StrainerFactory.instance_variable_get(:@global_filters) + Liquid::StrainerFactory.instance_variable_set(:@global_filters, []) + globals.each do |global| + Liquid::StrainerFactory.add_global_filter(global) + end + + Liquid::StrainerFactory.send(:strainer_class_cache).clear globals.each do |global| Liquid::Template.register_filter(global) end yield ensure - Liquid::Strainer.class_variable_get(:@@strainer_class_cache).clear - Liquid::Strainer.class_variable_set(:@@global_strainer, original_global_strainer) + Liquid::StrainerFactory.send(:strainer_class_cache).clear + Liquid::StrainerFactory.instance_variable_set(:@global_filters, original_global_filters) end def with_taint_mode(mode) diff --git a/test/unit/strainer_factory_unit_test.rb b/test/unit/strainer_factory_unit_test.rb new file mode 100644 index 00000000..6d644752 --- /dev/null +++ b/test/unit/strainer_factory_unit_test.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'test_helper' + +class StrainerFactoryUnitTest < Minitest::Test + include Liquid + + module AccessScopeFilters + def public_filter + "public" + end + + def private_filter + "private" + end + private :private_filter + end + + StrainerFactory.add_global_filter(AccessScopeFilters) + + module LateAddedFilter + def late_added_filter(_input) + "filtered" + end + end + + def test_strainer + strainer = StrainerFactory.create(nil) + assert_equal(5, strainer.invoke('size', 'input')) + assert_equal("public", strainer.invoke("public_filter")) + end + + def test_stainer_raises_argument_error + strainer = StrainerFactory.create(nil) + assert_raises(Liquid::ArgumentError) do + strainer.invoke("public_filter", 1) + end + end + + def test_stainer_argument_error_contains_backtrace + strainer = StrainerFactory.create(nil) + + exception = assert_raises(Liquid::ArgumentError) do + strainer.invoke("public_filter", 1) + end + + assert_match( + /\ALiquid error: wrong number of arguments \((1 for 0|given 1, expected 0)\)\z/, + exception.message + ) + assert_equal(exception.backtrace[0].split(':')[0], __FILE__) + end + + def test_strainer_only_invokes_public_filter_methods + strainer = StrainerFactory.create(nil) + assert_equal(false, strainer.class.invokable?('__test__')) + assert_equal(false, strainer.class.invokable?('test')) + assert_equal(false, strainer.class.invokable?('instance_eval')) + assert_equal(false, strainer.class.invokable?('__send__')) + assert_equal(true, strainer.class.invokable?('size')) # from the standard lib + end + + def test_strainer_returns_nil_if_no_filter_method_found + strainer = StrainerFactory.create(nil) + assert_nil(strainer.invoke("private_filter")) + assert_nil(strainer.invoke("undef_the_filter")) + end + + def test_strainer_returns_first_argument_if_no_method_and_arguments_given + strainer = StrainerFactory.create(nil) + assert_equal("password", strainer.invoke("undef_the_method", "password")) + end + + def test_strainer_only_allows_methods_defined_in_filters + strainer = StrainerFactory.create(nil) + assert_equal("1 + 1", strainer.invoke("instance_eval", "1 + 1")) + assert_equal("puts", strainer.invoke("__send__", "puts", "Hi Mom")) + assert_equal("has_method?", strainer.invoke("invoke", "has_method?", "invoke")) + end + + def test_strainer_uses_a_class_cache_to_avoid_method_cache_invalidation + a = Module.new + b = Module.new + strainer = StrainerFactory.create(nil, [a, b]) + assert_kind_of(StrainerTemplate, strainer) + assert_kind_of(a, strainer) + assert_kind_of(b, strainer) + assert_kind_of(Liquid::StandardFilters, strainer) + end + + def test_add_global_filter_clears_cache + assert_equal('input', StrainerFactory.create(nil).invoke('late_added_filter', 'input')) + StrainerFactory.add_global_filter(LateAddedFilter) + assert_equal('filtered', StrainerFactory.create(nil).invoke('late_added_filter', 'input')) + end +end diff --git a/test/unit/strainer_template_unit_test.rb b/test/unit/strainer_template_unit_test.rb new file mode 100644 index 00000000..e72ed44d --- /dev/null +++ b/test/unit/strainer_template_unit_test.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'test_helper' + +class StrainerTemplateUnitTest < Minitest::Test + include Liquid + + def test_add_filter_when_wrong_filter_class + c = Context.new + s = c.strainer + wrong_filter = ->(v) { v.reverse } + + exception = assert_raises(TypeError) do + s.class.add_filter(wrong_filter) + end + assert_equal(exception.message, "wrong argument type Proc (expected Module)") + end + + module PrivateMethodOverrideFilter + private + + def public_filter + "overriden as private" + end + end + + def test_add_filter_raises_when_module_privately_overrides_registered_public_methods + strainer = Context.new.strainer + + error = assert_raises(Liquid::MethodOverrideError) do + strainer.class.add_filter(PrivateMethodOverrideFilter) + end + assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message) + end + + module ProtectedMethodOverrideFilter + protected + + def public_filter + "overriden as protected" + end + end + + def test_add_filter_raises_when_module_overrides_registered_public_method_as_protected + strainer = Context.new.strainer + + error = assert_raises(Liquid::MethodOverrideError) do + strainer.class.add_filter(ProtectedMethodOverrideFilter) + end + assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message) + end + + module PublicMethodOverrideFilter + def public_filter + "public" + end + end + + def test_add_filter_does_not_raise_when_module_overrides_previously_registered_method + strainer = Context.new.strainer + with_global_filter do + strainer.class.add_filter(PublicMethodOverrideFilter) + assert(strainer.class.send(:filter_methods).include?('public_filter')) + end + end + + def test_add_filter_does_not_include_already_included_module + mod = Module.new do + class << self + attr_accessor :include_count + def included(_mod) + self.include_count += 1 + end + end + self.include_count = 0 + end + strainer = Context.new.strainer + strainer.class.add_filter(mod) + strainer.class.add_filter(mod) + assert_equal(1, mod.include_count) + end +end diff --git a/test/unit/strainer_unit_test.rb b/test/unit/strainer_unit_test.rb deleted file mode 100644 index a05f9084..00000000 --- a/test/unit/strainer_unit_test.rb +++ /dev/null @@ -1,167 +0,0 @@ -# frozen_string_literal: true - -require 'test_helper' - -class StrainerUnitTest < Minitest::Test - include Liquid - - module AccessScopeFilters - def public_filter - "public" - end - - def private_filter - "private" - end - private :private_filter - end - - Strainer.global_filter(AccessScopeFilters) - - def test_strainer - strainer = Strainer.create(nil) - assert_equal(5, strainer.invoke('size', 'input')) - assert_equal("public", strainer.invoke("public_filter")) - end - - def test_stainer_raises_argument_error - strainer = Strainer.create(nil) - assert_raises(Liquid::ArgumentError) do - strainer.invoke("public_filter", 1) - end - end - - def test_stainer_argument_error_contains_backtrace - strainer = Strainer.create(nil) - begin - strainer.invoke("public_filter", 1) - rescue Liquid::ArgumentError => e - assert_match( - /\ALiquid error: wrong number of arguments \((1 for 0|given 1, expected 0)\)\z/, - e.message - ) - assert_equal(e.backtrace[0].split(':')[0], __FILE__) - end - end - - def test_strainer_only_invokes_public_filter_methods - strainer = Strainer.create(nil) - assert_equal(false, strainer.class.invokable?('__test__')) - assert_equal(false, strainer.class.invokable?('test')) - assert_equal(false, strainer.class.invokable?('instance_eval')) - assert_equal(false, strainer.class.invokable?('__send__')) - assert_equal(true, strainer.class.invokable?('size')) # from the standard lib - end - - def test_strainer_returns_nil_if_no_filter_method_found - strainer = Strainer.create(nil) - assert_nil(strainer.invoke("private_filter")) - assert_nil(strainer.invoke("undef_the_filter")) - end - - def test_strainer_returns_first_argument_if_no_method_and_arguments_given - strainer = Strainer.create(nil) - assert_equal("password", strainer.invoke("undef_the_method", "password")) - end - - def test_strainer_only_allows_methods_defined_in_filters - strainer = Strainer.create(nil) - assert_equal("1 + 1", strainer.invoke("instance_eval", "1 + 1")) - assert_equal("puts", strainer.invoke("__send__", "puts", "Hi Mom")) - assert_equal("has_method?", strainer.invoke("invoke", "has_method?", "invoke")) - end - - def test_strainer_uses_a_class_cache_to_avoid_method_cache_invalidation - a = Module.new - b = Module.new - strainer = Strainer.create(nil, [a, b]) - assert_kind_of(Strainer, strainer) - assert_kind_of(a, strainer) - assert_kind_of(b, strainer) - assert_kind_of(Liquid::StandardFilters, strainer) - end - - def test_add_filter_when_wrong_filter_class - c = Context.new - s = c.strainer - wrong_filter = ->(v) { v.reverse } - - assert_raises ArgumentError do - s.class.add_filter(wrong_filter) - end - end - - module PrivateMethodOverrideFilter - private - - def public_filter - "overriden as private" - end - end - - def test_add_filter_raises_when_module_privately_overrides_registered_public_methods - strainer = Context.new.strainer - - error = assert_raises(Liquid::MethodOverrideError) do - strainer.class.add_filter(PrivateMethodOverrideFilter) - end - assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message) - end - - module ProtectedMethodOverrideFilter - protected - - def public_filter - "overriden as protected" - end - end - - def test_add_filter_raises_when_module_overrides_registered_public_method_as_protected - strainer = Context.new.strainer - - error = assert_raises(Liquid::MethodOverrideError) do - strainer.class.add_filter(ProtectedMethodOverrideFilter) - end - assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message) - end - - module PublicMethodOverrideFilter - def public_filter - "public" - end - end - - def test_add_filter_does_not_raise_when_module_overrides_previously_registered_method - strainer = Context.new.strainer - strainer.class.add_filter(PublicMethodOverrideFilter) - assert(strainer.class.filter_methods.include?('public_filter')) - end - - module LateAddedFilter - def late_added_filter(_input) - "filtered" - end - end - - def test_global_filter_clears_cache - assert_equal('input', Strainer.create(nil).invoke('late_added_filter', 'input')) - Strainer.global_filter(LateAddedFilter) - assert_equal('filtered', Strainer.create(nil).invoke('late_added_filter', 'input')) - end - - def test_add_filter_does_not_include_already_included_module - mod = Module.new do - class << self - attr_accessor :include_count - def included(_mod) - self.include_count += 1 - end - end - self.include_count = 0 - end - strainer = Context.new.strainer - strainer.class.add_filter(mod) - strainer.class.add_filter(mod) - assert_equal(1, mod.include_count) - end -end # StrainerTest