with_filters 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. data/.gitignore +8 -0
  2. data/.yardopts +1 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE +22 -0
  5. data/README.md +63 -0
  6. data/Rakefile +1 -0
  7. data/app/views/with_filters/_filter_form.html.erb +14 -0
  8. data/app/views/with_filters/filter/_check_box.html.erb +2 -0
  9. data/app/views/with_filters/filter/_check_boxes.html.erb +5 -0
  10. data/app/views/with_filters/filter/_radio.html.erb +5 -0
  11. data/app/views/with_filters/filter/_select.html.erb +2 -0
  12. data/app/views/with_filters/filter/_select_range.html.erb +4 -0
  13. data/app/views/with_filters/filter/_text.html.erb +2 -0
  14. data/app/views/with_filters/filter/_text_range.html.erb +4 -0
  15. data/changelog.md +2 -0
  16. data/lib/generators/with_filters/theme/theme_generator.rb +43 -0
  17. data/lib/with_filters/action_view_extension.rb +110 -0
  18. data/lib/with_filters/active_record_extension.rb +26 -0
  19. data/lib/with_filters/active_record_model_extension.rb +163 -0
  20. data/lib/with_filters/engine.rb +5 -0
  21. data/lib/with_filters/hash_extraction.rb +31 -0
  22. data/lib/with_filters/models/action.rb +14 -0
  23. data/lib/with_filters/models/filter/base.rb +36 -0
  24. data/lib/with_filters/models/filter/base_range.rb +42 -0
  25. data/lib/with_filters/models/filter/check_box.rb +20 -0
  26. data/lib/with_filters/models/filter/choice.rb +23 -0
  27. data/lib/with_filters/models/filter/collection.rb +28 -0
  28. data/lib/with_filters/models/filter/filter.rb +59 -0
  29. data/lib/with_filters/models/filter/radio.rb +7 -0
  30. data/lib/with_filters/models/filter/select.rb +22 -0
  31. data/lib/with_filters/models/filter/select_range.rb +15 -0
  32. data/lib/with_filters/models/filter/text.rb +30 -0
  33. data/lib/with_filters/models/filter/text_range.rb +15 -0
  34. data/lib/with_filters/models/filter_form.rb +93 -0
  35. data/lib/with_filters/value_prep/boolean_prep.rb +10 -0
  36. data/lib/with_filters/value_prep/date_prep.rb +10 -0
  37. data/lib/with_filters/value_prep/date_time_prep.rb +51 -0
  38. data/lib/with_filters/value_prep/default_prep.rb +88 -0
  39. data/lib/with_filters/value_prep/value_prep.rb +28 -0
  40. data/lib/with_filters/version.rb +3 -0
  41. data/lib/with_filters.rb +32 -0
  42. data/spec/active_record_model_extension_spec.rb +435 -0
  43. data/spec/dummy/README.rdoc +261 -0
  44. data/spec/dummy/Rakefile +7 -0
  45. data/spec/dummy/app/assets/javascripts/application.js +15 -0
  46. data/spec/dummy/app/assets/stylesheets/application.css +13 -0
  47. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  48. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  49. data/spec/dummy/app/mailers/.gitkeep +0 -0
  50. data/spec/dummy/app/models/.gitkeep +0 -0
  51. data/spec/dummy/app/models/date_time_tester.rb +2 -0
  52. data/spec/dummy/app/models/field_format_tester.rb +2 -0
  53. data/spec/dummy/app/models/nobel_prize.rb +3 -0
  54. data/spec/dummy/app/models/nobel_prize_winner.rb +3 -0
  55. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  56. data/spec/dummy/config/application.rb +56 -0
  57. data/spec/dummy/config/boot.rb +10 -0
  58. data/spec/dummy/config/database.yml +25 -0
  59. data/spec/dummy/config/environment.rb +5 -0
  60. data/spec/dummy/config/environments/development.rb +37 -0
  61. data/spec/dummy/config/environments/production.rb +67 -0
  62. data/spec/dummy/config/environments/test.rb +37 -0
  63. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  64. data/spec/dummy/config/initializers/inflections.rb +15 -0
  65. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  66. data/spec/dummy/config/initializers/secret_token.rb +7 -0
  67. data/spec/dummy/config/initializers/session_store.rb +8 -0
  68. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  69. data/spec/dummy/config/locales/en.yml +5 -0
  70. data/spec/dummy/config/routes.rb +58 -0
  71. data/spec/dummy/config.ru +4 -0
  72. data/spec/dummy/db/migrate/20111227224959_additional_columns.rb +73 -0
  73. data/spec/dummy/db/migrate/20120127203225_create_nobel_prize_winners.rb +30 -0
  74. data/spec/dummy/db/migrate/20120203212237_create_nobel_prizes.rb +34 -0
  75. data/spec/dummy/db/migrate/20120209051208_modify_updated_at.rb +13 -0
  76. data/spec/dummy/db/migrate/20120210163052_change_created_at.rb +17 -0
  77. data/spec/dummy/db/migrate/20120214172946_fix_einstein.rb +9 -0
  78. data/spec/dummy/db/migrate/20120227200013_create_date_time_testers.rb +25 -0
  79. data/spec/dummy/db/migrate/20120309202722_create_field_format_testers.rb +22 -0
  80. data/spec/dummy/db/migrate/20120310195447_update_field_format_testers.rb +15 -0
  81. data/spec/dummy/db/schema.rb +51 -0
  82. data/spec/dummy/db/test.sqlite3 +0 -0
  83. data/spec/dummy/lib/assets/.gitkeep +0 -0
  84. data/spec/dummy/log/.gitkeep +0 -0
  85. data/spec/dummy/public/404.html +26 -0
  86. data/spec/dummy/public/422.html +26 -0
  87. data/spec/dummy/public/500.html +25 -0
  88. data/spec/dummy/public/favicon.ico +0 -0
  89. data/spec/dummy/script/rails +6 -0
  90. data/spec/generators/with_filters_theme_spec.rb +33 -0
  91. data/spec/hash_extraction_spec.rb +51 -0
  92. data/spec/helpers/action_view_extension_spec.rb +345 -0
  93. data/spec/models/action.rb +20 -0
  94. data/spec/models/filter/base_range_spec.rb +32 -0
  95. data/spec/models/filter/base_spec.rb +76 -0
  96. data/spec/models/filter/check_box_spec.rb +36 -0
  97. data/spec/models/filter/choice_spec.rb +24 -0
  98. data/spec/models/filter/collection_spec.rb +72 -0
  99. data/spec/models/filter/filter_spec.rb +35 -0
  100. data/spec/models/filter/select_spec.rb +12 -0
  101. data/spec/models/filter/text_spec.rb +16 -0
  102. data/spec/models/filter_form_spec.rb +212 -0
  103. data/spec/spec_helper.rb +12 -0
  104. data/spec/value_prep/boolean_prep_spec.rb +13 -0
  105. data/spec/value_prep/date_prep_spec.rb +28 -0
  106. data/spec/value_prep/date_time_prep_spec.rb +106 -0
  107. data/spec/value_prep/default_prep_spec.rb +43 -0
  108. data/with_filters.gemspec +27 -0
  109. metadata +280 -0
@@ -0,0 +1,23 @@
1
+ module WithFilters
2
+ module Filter
3
+ # @private
4
+ class Choice
5
+ attr_reader :field_name, :label, :value, :attrs
6
+
7
+ def initialize(field_name, label, value, options = {})
8
+ @field_name = "#{field_name}[]"
9
+ @label = label
10
+ @value = value
11
+
12
+ options[:id] ||= "#{field_name}_#{value}".gsub(']', '').gsub(/[^-a-zA-Z0-9:.]/, '_')
13
+
14
+ @selected = !!options.delete(:selected)
15
+ @attrs = options
16
+ end
17
+
18
+ def selected?
19
+ @selected
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,28 @@
1
+ module WithFilters
2
+ module Filter
3
+ # @private
4
+ class Collection < Array
5
+ def initialize(field_name, choices, options = {})
6
+ choices = choices.to_a if choices.is_a?(Range)
7
+
8
+ selected = Array.wrap(options[:selected]).map(&:to_s)
9
+
10
+ choices.map do |element|
11
+ text, value, choice_options = if element.is_a?(Array)
12
+ html_attrs = element.detect {|e| Hash === e} || {}
13
+ element = element.reject {|e| Hash === e}
14
+ [element.first.to_s, element.last, html_attrs]
15
+ elsif !element.is_a?(String) && element.respond_to?(:first) && element.respond_to?(:last)
16
+ [element.first.to_s, element.last, {}]
17
+ else
18
+ [element.to_s, element, {}]
19
+ end
20
+
21
+ choice_options[:selected] = 'selected' if selected.include?(value.to_s)
22
+
23
+ self.push(Choice.new(field_name, text, value, choice_options))
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,59 @@
1
+ module WithFilters
2
+ # @private
3
+ module Filter
4
+ TYPES = {
5
+ :'datetime-local' => Text,
6
+ text: Text,
7
+ email: Text,
8
+ url: Text,
9
+ tel: Text,
10
+ number: Text,
11
+ range: Text,
12
+ date: Text,
13
+ month: Text,
14
+ week: Text,
15
+ time: Text,
16
+ datetime: Text,
17
+ search: Text,
18
+ color: Text,
19
+ hidden: Text,
20
+ checkbox: CheckBox,
21
+ radio: Radio,
22
+ select: Select
23
+ }
24
+
25
+ RANGED_TYPES = {
26
+ :'datetime-local' => TextRange,
27
+ text: TextRange,
28
+ email: TextRange,
29
+ url: TextRange,
30
+ tel: TextRange,
31
+ number: TextRange,
32
+ range: TextRange,
33
+ date: TextRange,
34
+ month: TextRange,
35
+ week: TextRange,
36
+ time: TextRange,
37
+ datetime: TextRange,
38
+ search: TextRange,
39
+ color: TextRange,
40
+ select: SelectRange
41
+ }
42
+
43
+ def self.create(name, namespace, value, options = {})
44
+ as = options.delete(:as)
45
+
46
+ options[:type] = as.to_s
47
+
48
+ TYPES[as].new(name, namespace, value, options)
49
+ end
50
+
51
+ def self.create_range(name, namespace, value, options = {})
52
+ as = options.delete(:as)
53
+
54
+ options[:type] = as.to_s
55
+
56
+ RANGED_TYPES[as].new(name, namespace, value, options)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,7 @@
1
+ module WithFilters
2
+ module Filter
3
+ # @private
4
+ class Radio < Base
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,22 @@
1
+ module WithFilters
2
+ module Filter
3
+ # @private
4
+ class Select < Base
5
+ def initialize(name, namespace, value, options = {})
6
+ collection = options.delete(:collection) if options[:collection].is_a?(String)
7
+ if collection
8
+ Array.wrap(value).each do |v|
9
+ matched = collection.sub!(/(<option[^>]*value\s*=\s*['"]?#{v}[^>]*)/, '\1 selected="selected"')
10
+ unless matched
11
+ collection.sub!(/>#{v}</, " selected=\"selected\">#{v}<")
12
+ end
13
+ end
14
+ end
15
+
16
+ super
17
+
18
+ @collection = collection if collection
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ module WithFilters
2
+ module Filter
3
+ # @private
4
+ class SelectStart < BaseStart
5
+ end
6
+
7
+ # @private
8
+ class SelectStop < BaseStop
9
+ end
10
+
11
+ # @private
12
+ class SelectRange < BaseRange
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,30 @@
1
+ module WithFilters
2
+ module Filter
3
+ # @private
4
+ class Text < Base
5
+ def initialize(name, namespace, value, options = {})
6
+ super
7
+
8
+ if @attrs[:type] != :text
9
+ new_partial = @to_partial_path.split(File::SEPARATOR)
10
+ new_partial[-1] = "_#{new_partial[-1]}_as_#{@attrs[:type]}.*"
11
+
12
+ @to_partial_path << "_as_#{@attrs[:type]}" if Dir.glob(File.join(Rails.root, 'app', 'views', *new_partial)).any?
13
+ end
14
+ end
15
+
16
+ def create_partial_path
17
+ partial_path = nil
18
+ if @theme and @attrs[:type] != :text
19
+ themed_partial_path = self.class.name.underscore.split(File::SEPARATOR).insert(1, @theme)
20
+ themed_partial_path[themed_partial_path.length - 1] << "_as_#{@attrs[:type]}"
21
+ if Dir.glob(File.join(Rails.root, 'app', 'views', *themed_partial_path).sub(/([^#{File::SEPARATOR}]+?)$/, '_\1.*')).any?
22
+ partial_path = themed_partial_path.join(File::SEPARATOR)
23
+ end
24
+ end
25
+
26
+ partial_path || super
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,15 @@
1
+ module WithFilters
2
+ module Filter
3
+ # @private
4
+ class TextStart < BaseStart
5
+ end
6
+
7
+ # @private
8
+ class TextStop < BaseStop
9
+ end
10
+
11
+ # @private
12
+ class TextRange < BaseRange
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,93 @@
1
+ module WithFilters
2
+ class FilterForm
3
+ attr_reader :attrs, :to_partial_path, :filters, :param_namespace, :hidden_filters, :actions
4
+
5
+ # @see ActionViewExtension#filter_form_for
6
+ #
7
+ # @since 0.1.0
8
+ def initialize(records, values = {}, options = {})
9
+ @records = records
10
+ @values = values
11
+
12
+ @theme = options.delete(:theme)
13
+ @attrs = options.reverse_merge(novalidate: 'novalidate', method: 'get')
14
+
15
+ @to_partial_path = self.class.name.underscore
16
+ @param_namespace = @records.with_filters_data[:param_namespace]
17
+ @hidden_filters = []
18
+ @filters = []
19
+ @actions = []
20
+ end
21
+
22
+ # @see input
23
+ #
24
+ # @since 0.1.0
25
+ def hidden(name, options = {})
26
+ options.merge!(as: :hidden)
27
+
28
+ input(name, options)
29
+ end
30
+
31
+ # @param [Symbol] name
32
+ # @param [Hash] options
33
+ #
34
+ # @since 0.1.0
35
+ def input(name, options = {})
36
+ options[:as] = find_as(name, options[:collection]) unless options[:as]
37
+ options.merge!(theme: @theme)
38
+ as = options[:as]
39
+
40
+ filter = WithFilters::Filter.create(name, self.param_namespace, @values[name], options)
41
+
42
+ (as == :hidden ? @hidden_filters : @filters).push(filter)
43
+ end
44
+
45
+ # @param [Symbol] name
46
+ # @param [Hash] options
47
+ #
48
+ # @since 0.1.0
49
+ def input_range(name, options = {})
50
+ options[:as] = find_as(name, options[:collection]) unless options[:as]
51
+ options.merge!(theme: @theme)
52
+
53
+ @filters.push(WithFilters::Filter.create_range(name, self.param_namespace, @values[name] || {}, options))
54
+ end
55
+
56
+ # @param [Symbol] type
57
+ # @param [Hash] options
58
+ #
59
+ # @since 0.1.0
60
+ def action(type, options = {})
61
+ @actions.push(WithFilters::Action.new(type, options))
62
+ end
63
+
64
+ private
65
+
66
+ # Converts a database column type to an input type.
67
+ #
68
+ # @param [Symbol] name
69
+ # @param [Boolean] has_collection Indicates whether or not there is a collection
70
+ # associated with the input data.
71
+ #
72
+ # @since 0.1.0
73
+ def find_as(name, has_collection)
74
+ return :select if has_collection
75
+
76
+ case @records.with_filters_data[:column_types][name]
77
+ when :integer, :float, :decimal then :number
78
+ when :date then :date
79
+ when :time then :time
80
+ when :datetime, :timestamp then :datetime
81
+ when :boolean then :checkbox
82
+ when :string
83
+ case name
84
+ when /email/ then :email
85
+ when /url/ then :url
86
+ when /phone/ then :tel
87
+ else :text
88
+ end
89
+ else :text
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,10 @@
1
+ module WithFilters
2
+ module ValuePrep
3
+ # @private
4
+ class BooleanPrep < DefaultPrep
5
+ def prepare_value(value)
6
+ (super == 'on')
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ module WithFilters
2
+ module ValuePrep
3
+ # @private
4
+ class DatePrep < DefaultPrep
5
+ def prepare_value(value)
6
+ super.to_date
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,51 @@
1
+ module WithFilters
2
+ module ValuePrep
3
+ # @private
4
+ class DateTimePrep < DefaultPrep
5
+ def prepare_value(value)
6
+ value = super
7
+ date_info = Date._parse(value)
8
+
9
+ if date_info[:sec_fraction]
10
+ to_parsed_s(value, {}, date_info[:sec_fraction].to_f)
11
+ else
12
+ {start: prepare_start_value(value), stop: prepare_stop_value(value)}
13
+ end
14
+ end
15
+
16
+ def prepare_start_value(value)
17
+ date_info = Date._parse(value)
18
+
19
+ if date_info[:sec_fraction]
20
+ to_parsed_s(value, {}, date_info[:sec_fraction])
21
+ else
22
+ to_parsed_s(value)
23
+ end
24
+ end
25
+
26
+ def prepare_stop_value(value)
27
+ date_info = Date._parse(value)
28
+
29
+ if date_info[:sec_fraction]
30
+ to_parsed_s(value, {}, date_info[:sec_fraction])
31
+ elsif date_info[:sec]
32
+ to_parsed_s(value, seconds: 1)
33
+ elsif date_info[:min]
34
+ to_parsed_s(value, minutes: 1)
35
+ elsif date_info[:hour]
36
+ to_parsed_s(value, hours: 1)
37
+ elsif date_info[:mday]
38
+ to_parsed_s(value, days: 1)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def to_parsed_s(value, advance_options = {}, sec_decimal = nil)
45
+ parsed_s = Time.zone.parse(value).advance(advance_options).to_s(:db)
46
+ parsed_s << ('%.6f' % sec_decimal)[1..-1] if sec_decimal
47
+ parsed_s
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,88 @@
1
+ module WithFilters
2
+ module ValuePrep
3
+ # @private
4
+ class DefaultPrep
5
+ # @param [Array, Hash, String] value The value to filter on.
6
+ # @param [Hash] options
7
+ # @option options [Symbol] :match Determines the way the filter is matched.
8
+ # Accepts `:exact`, `:contains`, `:begins_with` and `:ends_with`.
9
+ #
10
+ # @since 0.1.0
11
+ def initialize(value, options = {})
12
+ @value = value
13
+ @options = options
14
+ end
15
+
16
+ # Returns the value so that it is ready to be filtered with.
17
+ #
18
+ # @return [Array, Hash, String]
19
+ #
20
+ # @since 0.1.0
21
+ def value
22
+ @prepared_value ||= if @value.is_a?(Hash)
23
+ {start: prepare_start_value(@value[:start]), stop: prepare_stop_value(@value[:stop])}
24
+ else
25
+ temp = Array.wrap(@value).map do |value|
26
+ add_match(prepare_value(value))
27
+ end
28
+ temp.length == 1 ? temp.first : temp
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ # Prepares a value for use in the filter.
35
+ #
36
+ # @param [String] value The value to filter on.
37
+ #
38
+ # @return [String]
39
+ #
40
+ # @since 0.1.0
41
+ def prepare_value(value)
42
+ value.respond_to?(:strip) ? value.strip : value
43
+ end
44
+
45
+ # Prepares the start value for a ranged filter.
46
+ #
47
+ # @param [String] value The value to filter on.
48
+ #
49
+ # @return [String]
50
+ #
51
+ # @since 0.1.0
52
+ def prepare_start_value(value)
53
+ prepare_value(value)
54
+ end
55
+
56
+ # Prepares the stop value for a ranged filter.
57
+ #
58
+ # @param [String] value The value to filter on.
59
+ #
60
+ # @return [String]
61
+ #
62
+ # @since 0.1.0
63
+ def prepare_stop_value(value)
64
+ prepare_value(value)
65
+ end
66
+
67
+ # Add string matchers based on the `:match` option passed to #initialize.
68
+ #
69
+ # @param [String] value The value to filter on.
70
+ #
71
+ # @return [String]
72
+ #
73
+ # @since 0.1.0
74
+ def add_match(value)
75
+ case @options[:match]
76
+ when :contains
77
+ "%#{value}%"
78
+ when :begins_with
79
+ "#{value}%"
80
+ when :ends_with
81
+ "%#{value}"
82
+ else
83
+ value
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,28 @@
1
+ module WithFilters
2
+ # @private
3
+ module ValuePrep
4
+ # A mapping of Rails column types to value preparation class.
5
+ #
6
+ # since 0.1.0
7
+ TYPE_MAP = {
8
+ boolean: BooleanPrep,
9
+ date: DatePrep,
10
+ datetime: DateTimePrep,
11
+ timestamp: DateTimePrep
12
+ }
13
+
14
+ # A factory returning a class that prepares filter values based on a Rails
15
+ # column type.
16
+ #
17
+ # @param [Symbol] column_type A Rails column type.
18
+ # @param [String] value Value to be passed to the value preparation class.
19
+ # @param [Hash] options Options to be passed to the value preparation class.
20
+ #
21
+ # @return [WithFilters::DefaultPrep, Inherited from WithFilters::DefaultPrep]
22
+ #
23
+ # since 0.1.0
24
+ def self.prepare(column_type, value, options = {})
25
+ (TYPE_MAP[column_type].try(:new, value, options) || DefaultPrep.new(value, options)).value
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,3 @@
1
+ module WithFilters
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,32 @@
1
+ require 'with_filters/engine'
2
+ require 'with_filters/hash_extraction'
3
+ require 'with_filters/active_record_extension'
4
+ require 'with_filters/active_record_model_extension'
5
+ require 'with_filters/value_prep/default_prep'
6
+ require 'with_filters/value_prep/boolean_prep'
7
+ require 'with_filters/value_prep/date_prep'
8
+ require 'with_filters/value_prep/date_time_prep'
9
+ require 'with_filters/value_prep/value_prep'
10
+ require 'with_filters/action_view_extension'
11
+ require 'with_filters/models/action'
12
+ require 'with_filters/models/filter/choice'
13
+ require 'with_filters/models/filter/collection'
14
+ require 'with_filters/models/filter/base'
15
+ require 'with_filters/models/filter/base_range'
16
+ require 'with_filters/models/filter/text'
17
+ require 'with_filters/models/filter/text_range'
18
+ require 'with_filters/models/filter/radio'
19
+ require 'with_filters/models/filter/select'
20
+ require 'with_filters/models/filter/select_range'
21
+ require 'with_filters/models/filter/check_box'
22
+ require 'with_filters/models/filter/filter'
23
+ require 'with_filters/models/filter_form'
24
+ require 'with_filters/version'
25
+
26
+ ActiveSupport.on_load(:active_record) do
27
+ include WithFilters::ActiveRecordExtension
28
+ end
29
+
30
+ ActiveSupport.on_load(:action_view) do
31
+ include WithFilters::ActionViewExtension
32
+ end