better_model 2.0.0 → 3.0.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +274 -208
  3. data/lib/better_model/archivable.rb +203 -92
  4. data/lib/better_model/errors/archivable/already_archived_error.rb +11 -0
  5. data/lib/better_model/errors/archivable/archivable_error.rb +13 -0
  6. data/lib/better_model/errors/archivable/configuration_error.rb +10 -0
  7. data/lib/better_model/errors/archivable/not_archived_error.rb +11 -0
  8. data/lib/better_model/errors/archivable/not_enabled_error.rb +11 -0
  9. data/lib/better_model/errors/better_model_error.rb +9 -0
  10. data/lib/better_model/errors/permissible/configuration_error.rb +9 -0
  11. data/lib/better_model/errors/permissible/permissible_error.rb +13 -0
  12. data/lib/better_model/errors/predicable/configuration_error.rb +9 -0
  13. data/lib/better_model/errors/predicable/predicable_error.rb +13 -0
  14. data/lib/better_model/errors/searchable/configuration_error.rb +9 -0
  15. data/lib/better_model/errors/searchable/invalid_order_error.rb +11 -0
  16. data/lib/better_model/errors/searchable/invalid_pagination_error.rb +11 -0
  17. data/lib/better_model/errors/searchable/invalid_predicate_error.rb +11 -0
  18. data/lib/better_model/errors/searchable/invalid_security_error.rb +11 -0
  19. data/lib/better_model/errors/searchable/searchable_error.rb +13 -0
  20. data/lib/better_model/errors/sortable/configuration_error.rb +10 -0
  21. data/lib/better_model/errors/sortable/sortable_error.rb +13 -0
  22. data/lib/better_model/errors/stateable/check_failed_error.rb +14 -0
  23. data/lib/better_model/errors/stateable/configuration_error.rb +10 -0
  24. data/lib/better_model/errors/stateable/invalid_state_error.rb +11 -0
  25. data/lib/better_model/errors/stateable/invalid_transition_error.rb +11 -0
  26. data/lib/better_model/errors/stateable/not_enabled_error.rb +11 -0
  27. data/lib/better_model/errors/stateable/stateable_error.rb +13 -0
  28. data/lib/better_model/errors/stateable/validation_failed_error.rb +11 -0
  29. data/lib/better_model/errors/statusable/configuration_error.rb +9 -0
  30. data/lib/better_model/errors/statusable/statusable_error.rb +13 -0
  31. data/lib/better_model/errors/taggable/configuration_error.rb +10 -0
  32. data/lib/better_model/errors/taggable/taggable_error.rb +13 -0
  33. data/lib/better_model/errors/traceable/configuration_error.rb +10 -0
  34. data/lib/better_model/errors/traceable/not_enabled_error.rb +11 -0
  35. data/lib/better_model/errors/traceable/traceable_error.rb +13 -0
  36. data/lib/better_model/errors/validatable/configuration_error.rb +10 -0
  37. data/lib/better_model/errors/validatable/not_enabled_error.rb +11 -0
  38. data/lib/better_model/errors/validatable/validatable_error.rb +13 -0
  39. data/lib/better_model/models/state_transition.rb +122 -0
  40. data/lib/better_model/models/version.rb +68 -0
  41. data/lib/better_model/permissible.rb +103 -52
  42. data/lib/better_model/predicable.rb +142 -131
  43. data/lib/better_model/repositable/base_repository.rb +232 -0
  44. data/lib/better_model/repositable.rb +32 -0
  45. data/lib/better_model/searchable.rb +123 -96
  46. data/lib/better_model/sortable.rb +137 -41
  47. data/lib/better_model/stateable/configurator.rb +103 -85
  48. data/lib/better_model/stateable/guard.rb +41 -21
  49. data/lib/better_model/stateable/transition.rb +64 -35
  50. data/lib/better_model/stateable.rb +43 -25
  51. data/lib/better_model/statusable.rb +84 -52
  52. data/lib/better_model/taggable.rb +120 -75
  53. data/lib/better_model/traceable.rb +56 -48
  54. data/lib/better_model/validatable/configurator.rb +54 -177
  55. data/lib/better_model/validatable.rb +88 -113
  56. data/lib/better_model/version.rb +1 -1
  57. data/lib/better_model.rb +42 -9
  58. data/lib/generators/better_model/repository/repository_generator.rb +141 -0
  59. data/lib/generators/better_model/repository/templates/application_repository.rb.tt +21 -0
  60. data/lib/generators/better_model/repository/templates/repository.rb.tt +71 -0
  61. data/lib/generators/better_model/stateable/templates/README +1 -1
  62. metadata +45 -14
  63. data/lib/better_model/schedulable/occurrence_calculator.rb +0 -1034
  64. data/lib/better_model/schedulable/schedule_builder.rb +0 -269
  65. data/lib/better_model/schedulable.rb +0 -356
  66. data/lib/better_model/state_transition.rb +0 -106
  67. data/lib/better_model/stateable/errors.rb +0 -45
  68. data/lib/better_model/validatable/business_rule_validator.rb +0 -47
  69. data/lib/better_model/validatable/order_validator.rb +0 -77
  70. data/lib/better_model/version_record.rb +0 -66
  71. data/lib/generators/better_model/taggable/taggable_generator.rb +0 -129
  72. data/lib/generators/better_model/taggable/templates/README.tt +0 -62
  73. data/lib/generators/better_model/taggable/templates/migration.rb.tt +0 -21
@@ -1,106 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module BetterModel
4
- # StateTransition - Base ActiveRecord model for state transition history
5
- #
6
- # Questo è un modello abstract. Le classi concrete vengono generate dinamicamente
7
- # per ogni tabella (state_transitions, order_transitions, etc.).
8
- #
9
- # Schema della tabella:
10
- # t.string :transitionable_type, null: false
11
- # t.integer :transitionable_id, null: false
12
- # t.string :event, null: false
13
- # t.string :from_state, null: false
14
- # t.string :to_state, null: false
15
- # t.json :metadata
16
- # t.datetime :created_at, null: false
17
- #
18
- # Utilizzo:
19
- # # Tutte le transizioni di un modello
20
- # order.state_transitions
21
- #
22
- # # Query globali (tramite classi dinamiche)
23
- # BetterModel::StateTransitions.for_model(Order)
24
- # BetterModel::OrderTransitions.by_event(:confirm)
25
- #
26
- class StateTransition < ActiveRecord::Base
27
- # Default table name (can be overridden by dynamic subclasses)
28
- self.table_name = "state_transitions"
29
-
30
- # Polymorphic association
31
- belongs_to :transitionable, polymorphic: true
32
-
33
- # Validations
34
- validates :event, :from_state, :to_state, presence: true
35
-
36
- # Scopes
37
-
38
- # Scope per modello specifico
39
- #
40
- # @param model_class [Class] Classe del modello
41
- # @return [ActiveRecord::Relation]
42
- #
43
- scope :for_model, ->(model_class) {
44
- where(transitionable_type: model_class.name)
45
- }
46
-
47
- # Scope per evento specifico
48
- #
49
- # @param event [Symbol, String] Nome dell'evento
50
- # @return [ActiveRecord::Relation]
51
- #
52
- scope :by_event, ->(event) {
53
- where(event: event.to_s)
54
- }
55
-
56
- # Scope per stato di partenza
57
- #
58
- # @param state [Symbol, String] Stato di partenza
59
- # @return [ActiveRecord::Relation]
60
- #
61
- scope :from_state, ->(state) {
62
- where(from_state: state.to_s)
63
- }
64
-
65
- # Scope per stato di arrivo
66
- #
67
- # @param state [Symbol, String] Stato di arrivo
68
- # @return [ActiveRecord::Relation]
69
- #
70
- scope :to_state, ->(state) {
71
- where(to_state: state.to_s)
72
- }
73
-
74
- # Scope per transizioni recenti
75
- #
76
- # @param duration [ActiveSupport::Duration] Durata (es. 7.days)
77
- # @return [ActiveRecord::Relation]
78
- #
79
- scope :recent, ->(duration = 7.days) {
80
- where("created_at >= ?", duration.ago)
81
- }
82
-
83
- # Scope per transizioni in un periodo
84
- #
85
- # @param start_time [Time, Date] Inizio periodo
86
- # @param end_time [Time, Date] Fine periodo
87
- # @return [ActiveRecord::Relation]
88
- #
89
- scope :between, ->(start_time, end_time) {
90
- where(created_at: start_time..end_time)
91
- }
92
-
93
- # Metodi di istanza
94
-
95
- # Formatted description della transizione
96
- #
97
- # @return [String]
98
- #
99
- def description
100
- "#{transitionable_type}##{transitionable_id}: #{from_state} -> #{to_state} (#{event})"
101
- end
102
-
103
- # Alias per retrocompatibilità
104
- alias_method :to_s, :description
105
- end
106
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module BetterModel
4
- module Stateable
5
- # Base error for all Stateable errors
6
- class StateableError < StandardError; end
7
-
8
- # Raised when Stateable is not enabled but methods are called
9
- class NotEnabledError < StateableError
10
- def initialize(msg = nil)
11
- super(msg || "Stateable is not enabled. Add 'stateable do...end' to your model.")
12
- end
13
- end
14
-
15
- # Raised when an invalid state is referenced
16
- class InvalidStateError < StateableError
17
- def initialize(state)
18
- super("Invalid state: #{state.inspect}")
19
- end
20
- end
21
-
22
- # Raised when trying to transition to an invalid state from current state
23
- class InvalidTransitionError < StateableError
24
- def initialize(event, from_state, to_state)
25
- super("Cannot transition from #{from_state.inspect} to #{to_state.inspect} via #{event.inspect}")
26
- end
27
- end
28
-
29
- # Raised when a guard condition fails
30
- class GuardFailedError < StateableError
31
- def initialize(event, guard_description = nil)
32
- msg = "Guard failed for transition #{event.inspect}"
33
- msg += ": #{guard_description}" if guard_description
34
- super(msg)
35
- end
36
- end
37
-
38
- # Raised when a transition validation fails
39
- class ValidationFailedError < StateableError
40
- def initialize(event, errors)
41
- super("Validation failed for transition #{event.inspect}: #{errors.full_messages.join(', ')}")
42
- end
43
- end
44
- end
45
- end
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module BetterModel
4
- module Validatable
5
- # Validator per business rules custom
6
- #
7
- # Permette di eseguire metodi custom come validatori, delegando la logica
8
- # di validazione complessa a metodi del modello.
9
- #
10
- # Il metodo della business rule deve aggiungere errori tramite `errors.add`
11
- # se la validazione fallisce.
12
- #
13
- # Esempio:
14
- # validates_with BusinessRuleValidator, rule_name: :valid_category
15
- #
16
- # # Nel modello:
17
- # def valid_category
18
- # unless Category.exists?(id: category_id)
19
- # errors.add(:category_id, "must be a valid category")
20
- # end
21
- # end
22
- #
23
- class BusinessRuleValidator < ActiveModel::Validator
24
- def initialize(options)
25
- super
26
-
27
- @rule_name = options[:rule_name]
28
-
29
- unless @rule_name
30
- raise ArgumentError, "BusinessRuleValidator requires :rule_name option"
31
- end
32
- end
33
-
34
- def validate(record)
35
- # Verifica che il metodo esista
36
- unless record.respond_to?(@rule_name, true)
37
- raise NoMethodError, "Business rule method '#{@rule_name}' not found in #{record.class.name}. " \
38
- "Define it in your model: def #{@rule_name}; ...; end"
39
- end
40
-
41
- # Esegui il metodo della business rule
42
- # Il metodo stesso è responsabile di aggiungere errori tramite errors.add
43
- record.send(@rule_name)
44
- end
45
- end
46
- end
47
- end
@@ -1,77 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module BetterModel
4
- module Validatable
5
- # Validator per validazioni di ordine tra campi (cross-field)
6
- #
7
- # Verifica che un campo sia in una relazione d'ordine rispetto ad un altro campo.
8
- # Supporta date/time (before/after) e numeri (lteq/gteq/lt/gt).
9
- #
10
- # Esempio:
11
- # validates_with OrderValidator,
12
- # attributes: [:starts_at],
13
- # second_field: :ends_at,
14
- # comparator: :before
15
- #
16
- class OrderValidator < ActiveModel::EachValidator
17
- COMPARATORS = {
18
- before: :<,
19
- after: :>,
20
- lteq: :<=,
21
- gteq: :>=,
22
- lt: :<,
23
- gt: :>
24
- }.freeze
25
-
26
- def initialize(options)
27
- super
28
-
29
- @second_field = options[:second_field]
30
- @comparator = options[:comparator]
31
-
32
- unless @second_field
33
- raise ArgumentError, "OrderValidator requires :second_field option"
34
- end
35
-
36
- unless COMPARATORS.key?(@comparator)
37
- raise ArgumentError, "Invalid comparator: #{@comparator}. Valid: #{COMPARATORS.keys.join(', ')}"
38
- end
39
- end
40
-
41
- def validate_each(record, attribute, value)
42
- second_value = record.send(@second_field)
43
-
44
- # Skip validation if either field is nil (use presence validation for that)
45
- return if value.nil? || second_value.nil?
46
-
47
- # Get the comparison operator
48
- operator = COMPARATORS[@comparator]
49
-
50
- # Perform comparison
51
- unless value.send(operator, second_value)
52
- record.errors.add(attribute, error_message(attribute, @comparator, @second_field))
53
- end
54
- end
55
-
56
- private
57
-
58
- def error_message(first_field, comparator, second_field)
59
- # Messaggi user-friendly basati sul comparatore
60
- case comparator
61
- when :before
62
- "must be before #{second_field.to_s.humanize.downcase}"
63
- when :after
64
- "must be after #{second_field.to_s.humanize.downcase}"
65
- when :lteq
66
- "must be less than or equal to #{second_field.to_s.humanize.downcase}"
67
- when :gteq
68
- "must be greater than or equal to #{second_field.to_s.humanize.downcase}"
69
- when :lt
70
- "must be less than #{second_field.to_s.humanize.downcase}"
71
- when :gt
72
- "must be greater than #{second_field.to_s.humanize.downcase}"
73
- end
74
- end
75
- end
76
- end
77
- end
@@ -1,66 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module BetterModel
4
- # Version model for tracking changes
5
- # This is the base AR model for version history
6
- # Actual table_name is set dynamically in subclasses
7
- class Version < ActiveRecord::Base
8
- self.abstract_class = true
9
-
10
- # Polymorphic association to the tracked model
11
- belongs_to :item, polymorphic: true, optional: true
12
-
13
- # Optional: belongs_to user who made the change
14
- # belongs_to :updated_by, class_name: "User", optional: true
15
-
16
- # Serialize object_changes as JSON
17
- # Rails handles this automatically for json/jsonb columns
18
-
19
- # Validations
20
- validates :item_type, :event, presence: true
21
- validates :event, inclusion: { in: %w[created updated destroyed] }
22
-
23
- # Scopes
24
- scope :for_item, ->(item) { where(item_type: item.class.name, item_id: item.id) }
25
- scope :created_events, -> { where(event: "created") }
26
- scope :updated_events, -> { where(event: "updated") }
27
- scope :destroyed_events, -> { where(event: "destroyed") }
28
- scope :by_user, ->(user_id) { where(updated_by_id: user_id) }
29
- scope :between, ->(start_time, end_time) { where(created_at: start_time..end_time) }
30
- scope :recent, ->(limit = 10) { order(created_at: :desc).limit(limit) }
31
-
32
- # Get the change for a specific field
33
- #
34
- # @param field_name [Symbol, String] Field name
35
- # @return [Hash, nil] Hash with :before and :after keys
36
- def change_for(field_name)
37
- return nil unless object_changes
38
-
39
- field = field_name.to_s
40
- return nil unless object_changes.key?(field)
41
-
42
- {
43
- before: object_changes[field][0],
44
- after: object_changes[field][1]
45
- }
46
- end
47
-
48
- # Check if a specific field changed in this version
49
- # This method overrides ActiveRecord's changed? to accept a field_name parameter
50
- #
51
- # @param field_name [Symbol, String, nil] Field name (if nil, calls ActiveRecord's changed?)
52
- # @return [Boolean]
53
- def changed?(field_name = nil)
54
- return super() if field_name.nil?
55
-
56
- object_changes&.key?(field_name.to_s) || false
57
- end
58
-
59
- # Get list of changed fields
60
- #
61
- # @return [Array<String>]
62
- def changed_fields
63
- object_changes&.keys || []
64
- end
65
- end
66
- end
@@ -1,129 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rails/generators"
4
- require "rails/generators/migration"
5
-
6
- module BetterModel
7
- module Generators
8
- class TaggableGenerator < Rails::Generators::NamedBase
9
- include Rails::Generators::Migration
10
-
11
- source_root File.expand_path("templates", __dir__)
12
-
13
- class_option :column_name, type: :string, default: "tags",
14
- desc: "Name of the tags column (default: tags)"
15
- class_option :skip_index, type: :boolean, default: false,
16
- desc: "Skip adding GIN index (PostgreSQL only)"
17
-
18
- def self.next_migration_number(dirname)
19
- next_migration_number = current_migration_number(dirname) + 1
20
- ActiveRecord::Migration.next_migration_number(next_migration_number)
21
- end
22
-
23
- def create_migration_file
24
- migration_template "migration.rb.tt",
25
- "db/migrate/add_#{column_name}_to_#{table_name}.rb"
26
- end
27
-
28
- def show_readme
29
- # Display README as template to interpolate variables
30
- if behavior == :invoke
31
- say "=" * 79, :green
32
- say ""
33
- say "Taggable column has been added to your model!"
34
- say ""
35
- say "Next steps:"
36
- say ""
37
- say "1. Run the migration:"
38
- say " $ bin/rails db:migrate"
39
- say ""
40
- say "2. Add Taggable configuration to your model:"
41
- say ""
42
- say " class #{model_name} < ApplicationRecord"
43
- say " include BetterModel"
44
- say ""
45
- if postgresql?
46
- say " # PostgreSQL: No additional configuration needed"
47
- else
48
- say " # SQLite/MySQL: Add serialization for the tags column"
49
- say " serialize :#{column_name}, coder: JSON, type: Array"
50
- say ""
51
- end
52
- say " taggable do"
53
- say " tag_field :#{column_name} # Column name (default: :tags)"
54
- say " normalize true # Convert to lowercase"
55
- say " strip true # Remove whitespace (default)"
56
- say " min_length 2 # Minimum tag length"
57
- say " max_length 30 # Maximum tag length"
58
- say " validates_tags minimum: 1, maximum: 10"
59
- say " end"
60
- say " end"
61
- say ""
62
- say "3. Usage examples:"
63
- say ""
64
- say " # Add tags"
65
- say " #{name.underscore}.tag_with(\"ruby\", \"rails\", \"web\")"
66
- say ""
67
- say " # Remove tags"
68
- say " #{name.underscore}.untag(\"web\")"
69
- say ""
70
- say " # Replace all tags"
71
- say " #{name.underscore}.retag(\"ruby\", \"api\")"
72
- say ""
73
- say " # Check for tag"
74
- say " #{name.underscore}.tagged_with?(\"ruby\") # => true"
75
- say ""
76
- say " # CSV interface"
77
- say " #{name.underscore}.tag_list = \"ruby, rails, tutorial\""
78
- say " #{name.underscore}.tag_list # => \"ruby, rails, tutorial\""
79
- say ""
80
- say " # Search with tags (Predicable integration)"
81
- say " #{model_name}.#{column_name}_contains(\"ruby\")"
82
- say " #{model_name}.#{column_name}_overlaps([\"ruby\", \"python\"])"
83
- say " #{model_name}.#{column_name}_contains_all([\"ruby\", \"rails\"])"
84
- say ""
85
- say " # Statistics"
86
- say " #{model_name}.tag_counts # => {\"ruby\" => 45, \"rails\" => 38}"
87
- say " #{model_name}.popular_tags(limit: 10) # => [[\"ruby\", 45], [\"rails\", 38]]"
88
- say " #{model_name}.related_tags(\"ruby\", limit: 5) # => [\"rails\", \"gem\", \"tutorial\"]"
89
- say ""
90
- say "For more information, see: docs/taggable.md"
91
- say ""
92
- say "=" * 79, :green
93
- end
94
- end
95
-
96
- private
97
-
98
- def table_name
99
- name.tableize
100
- end
101
-
102
- def model_name
103
- name.camelize
104
- end
105
-
106
- def migration_class_name
107
- "Add#{column_name.camelize}To#{name.camelize.pluralize}"
108
- end
109
-
110
- def column_name
111
- options[:column_name]
112
- end
113
-
114
- def add_index?
115
- !options[:skip_index]
116
- end
117
-
118
- def postgresql?
119
- # Check if PostgreSQL adapter is being used
120
- # Note: This checks the current adapter, which may not reflect production
121
- # The migration template includes runtime detection as well
122
- return false unless defined?(ActiveRecord::Base)
123
-
124
- adapter_name = ActiveRecord::Base.connection.adapter_name rescue nil
125
- adapter_name == "PostgreSQL"
126
- end
127
- end
128
- end
129
- end
@@ -1,62 +0,0 @@
1
- ===============================================================================
2
-
3
- Taggable column has been added to your model!
4
-
5
- Next steps:
6
-
7
- 1. Run the migration:
8
- $ bin/rails db:migrate
9
-
10
- 2. Add Taggable configuration to your model:
11
-
12
- class <%= model_name %> < ApplicationRecord
13
- include BetterModel
14
-
15
- <% if postgresql? -%>
16
- # PostgreSQL: No additional configuration needed
17
- <% else -%>
18
- # SQLite/MySQL: Add serialization for the tags column
19
- serialize :<%= column_name %>, coder: JSON, type: Array
20
-
21
- <% end -%>
22
- taggable do
23
- tag_field :<%= column_name %> # Column name (default: :tags)
24
- normalize true # Convert to lowercase
25
- strip true # Remove whitespace (default)
26
- min_length 2 # Minimum tag length
27
- max_length 30 # Maximum tag length
28
- validates_tags minimum: 1, maximum: 10
29
- end
30
- end
31
-
32
- 3. Usage examples:
33
-
34
- # Add tags
35
- product.tag_with("ruby", "rails", "web")
36
-
37
- # Remove tags
38
- product.untag("web")
39
-
40
- # Replace all tags
41
- product.retag("ruby", "api")
42
-
43
- # Check for tag
44
- product.tagged_with?("ruby") # => true
45
-
46
- # CSV interface
47
- product.tag_list = "ruby, rails, tutorial"
48
- product.tag_list # => "ruby, rails, tutorial"
49
-
50
- # Search with tags (Predicable integration)
51
- <%= model_name %>.<%= column_name %>_contains("ruby")
52
- <%= model_name %>.<%= column_name %>_overlaps(["ruby", "python"])
53
- <%= model_name %>.<%= column_name %>_contains_all(["ruby", "rails"])
54
-
55
- # Statistics
56
- <%= model_name %>.tag_counts # => {"ruby" => 45, "rails" => 38}
57
- <%= model_name %>.popular_tags(limit: 10) # => [["ruby", 45], ["rails", 38]]
58
- <%= model_name %>.related_tags("ruby", limit: 5) # => ["rails", "gem", "tutorial"]
59
-
60
- For more information, see: docs/taggable.md
61
-
62
- ===============================================================================
@@ -1,21 +0,0 @@
1
- class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
- def change
3
- change_table :<%= table_name %> do |t|
4
- # Tags column - database-specific implementation
5
- if ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
6
- # PostgreSQL: Use native array with default empty array
7
- t.string :<%= column_name %>, array: true, default: []
8
- else
9
- # SQLite/MySQL: Use text column (requires serialization in model)
10
- t.text :<%= column_name %>
11
- end
12
- end
13
- <% if add_index? -%>
14
-
15
- # Add index for PostgreSQL array searches (GIN index for better performance)
16
- if ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
17
- add_index :<%= table_name %>, :<%= column_name %>, using: 'gin'
18
- end
19
- <% end -%>
20
- end
21
- end