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.
- checksums.yaml +4 -4
- data/README.md +274 -208
- data/lib/better_model/archivable.rb +203 -92
- data/lib/better_model/errors/archivable/already_archived_error.rb +11 -0
- data/lib/better_model/errors/archivable/archivable_error.rb +13 -0
- data/lib/better_model/errors/archivable/configuration_error.rb +10 -0
- data/lib/better_model/errors/archivable/not_archived_error.rb +11 -0
- data/lib/better_model/errors/archivable/not_enabled_error.rb +11 -0
- data/lib/better_model/errors/better_model_error.rb +9 -0
- data/lib/better_model/errors/permissible/configuration_error.rb +9 -0
- data/lib/better_model/errors/permissible/permissible_error.rb +13 -0
- data/lib/better_model/errors/predicable/configuration_error.rb +9 -0
- data/lib/better_model/errors/predicable/predicable_error.rb +13 -0
- data/lib/better_model/errors/searchable/configuration_error.rb +9 -0
- data/lib/better_model/errors/searchable/invalid_order_error.rb +11 -0
- data/lib/better_model/errors/searchable/invalid_pagination_error.rb +11 -0
- data/lib/better_model/errors/searchable/invalid_predicate_error.rb +11 -0
- data/lib/better_model/errors/searchable/invalid_security_error.rb +11 -0
- data/lib/better_model/errors/searchable/searchable_error.rb +13 -0
- data/lib/better_model/errors/sortable/configuration_error.rb +10 -0
- data/lib/better_model/errors/sortable/sortable_error.rb +13 -0
- data/lib/better_model/errors/stateable/check_failed_error.rb +14 -0
- data/lib/better_model/errors/stateable/configuration_error.rb +10 -0
- data/lib/better_model/errors/stateable/invalid_state_error.rb +11 -0
- data/lib/better_model/errors/stateable/invalid_transition_error.rb +11 -0
- data/lib/better_model/errors/stateable/not_enabled_error.rb +11 -0
- data/lib/better_model/errors/stateable/stateable_error.rb +13 -0
- data/lib/better_model/errors/stateable/validation_failed_error.rb +11 -0
- data/lib/better_model/errors/statusable/configuration_error.rb +9 -0
- data/lib/better_model/errors/statusable/statusable_error.rb +13 -0
- data/lib/better_model/errors/taggable/configuration_error.rb +10 -0
- data/lib/better_model/errors/taggable/taggable_error.rb +13 -0
- data/lib/better_model/errors/traceable/configuration_error.rb +10 -0
- data/lib/better_model/errors/traceable/not_enabled_error.rb +11 -0
- data/lib/better_model/errors/traceable/traceable_error.rb +13 -0
- data/lib/better_model/errors/validatable/configuration_error.rb +10 -0
- data/lib/better_model/errors/validatable/not_enabled_error.rb +11 -0
- data/lib/better_model/errors/validatable/validatable_error.rb +13 -0
- data/lib/better_model/models/state_transition.rb +122 -0
- data/lib/better_model/models/version.rb +68 -0
- data/lib/better_model/permissible.rb +103 -52
- data/lib/better_model/predicable.rb +142 -131
- data/lib/better_model/repositable/base_repository.rb +232 -0
- data/lib/better_model/repositable.rb +32 -0
- data/lib/better_model/searchable.rb +123 -96
- data/lib/better_model/sortable.rb +137 -41
- data/lib/better_model/stateable/configurator.rb +103 -85
- data/lib/better_model/stateable/guard.rb +41 -21
- data/lib/better_model/stateable/transition.rb +64 -35
- data/lib/better_model/stateable.rb +43 -25
- data/lib/better_model/statusable.rb +84 -52
- data/lib/better_model/taggable.rb +120 -75
- data/lib/better_model/traceable.rb +56 -48
- data/lib/better_model/validatable/configurator.rb +54 -177
- data/lib/better_model/validatable.rb +88 -113
- data/lib/better_model/version.rb +1 -1
- data/lib/better_model.rb +42 -9
- data/lib/generators/better_model/repository/repository_generator.rb +141 -0
- data/lib/generators/better_model/repository/templates/application_repository.rb.tt +21 -0
- data/lib/generators/better_model/repository/templates/repository.rb.tt +71 -0
- data/lib/generators/better_model/stateable/templates/README +1 -1
- metadata +45 -14
- data/lib/better_model/schedulable/occurrence_calculator.rb +0 -1034
- data/lib/better_model/schedulable/schedule_builder.rb +0 -269
- data/lib/better_model/schedulable.rb +0 -356
- data/lib/better_model/state_transition.rb +0 -106
- data/lib/better_model/stateable/errors.rb +0 -45
- data/lib/better_model/validatable/business_rule_validator.rb +0 -47
- data/lib/better_model/validatable/order_validator.rb +0 -77
- data/lib/better_model/version_record.rb +0 -66
- data/lib/generators/better_model/taggable/taggable_generator.rb +0 -129
- data/lib/generators/better_model/taggable/templates/README.tt +0 -62
- data/lib/generators/better_model/taggable/templates/migration.rb.tt +0 -21
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "errors/statusable/statusable_error"
|
|
4
|
+
require_relative "errors/statusable/configuration_error"
|
|
5
|
+
|
|
6
|
+
# Statusable - Declarative status system for Rails models.
|
|
4
7
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
8
|
+
# This concern enables defining statuses on models using a simple, declarative DSL
|
|
9
|
+
# similar to the Enrichable pattern but for statuses.
|
|
7
10
|
#
|
|
8
|
-
#
|
|
11
|
+
# @example Basic Usage
|
|
9
12
|
# class Communications::Consult < ApplicationRecord
|
|
10
13
|
# include BetterModel::Statusable
|
|
11
14
|
#
|
|
@@ -17,7 +20,7 @@
|
|
|
17
20
|
# is :ready_to_start, -> { scheduled? && scheduled_at <= Time.current }
|
|
18
21
|
# end
|
|
19
22
|
#
|
|
20
|
-
#
|
|
23
|
+
# @example Checking Statuses
|
|
21
24
|
# consult.is?(:pending) # => true/false
|
|
22
25
|
# consult.is_pending? # => true/false
|
|
23
26
|
# consult.is_active_session? # => true/false
|
|
@@ -29,58 +32,79 @@ module BetterModel
|
|
|
29
32
|
extend ActiveSupport::Concern
|
|
30
33
|
|
|
31
34
|
included do
|
|
32
|
-
# Registry
|
|
35
|
+
# Registry of statuses defined for this class
|
|
33
36
|
class_attribute :is_definitions
|
|
34
37
|
self.is_definitions = {}
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
class_methods do
|
|
38
|
-
# DSL
|
|
41
|
+
# DSL to define statuses.
|
|
42
|
+
#
|
|
43
|
+
# Defines a status check that can be evaluated against model instances.
|
|
44
|
+
# Automatically creates a convenience method is_<status_name>? for each status.
|
|
39
45
|
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
46
|
+
# @param status_name [Symbol, String] Status identifier (e.g., :pending, :active)
|
|
47
|
+
# @param condition_proc [Proc, nil] Lambda or proc that defines the condition
|
|
48
|
+
# @yield Alternative to condition_proc parameter
|
|
49
|
+
# @raise [BetterModel::Errors::Statusable::ConfigurationError] If parameters are invalid
|
|
44
50
|
#
|
|
45
|
-
#
|
|
51
|
+
# @example With lambda parameter
|
|
46
52
|
# is :pending, -> { status == 'initialized' }
|
|
47
|
-
#
|
|
53
|
+
#
|
|
54
|
+
# @example With block
|
|
55
|
+
# is :expired do
|
|
56
|
+
# expires_at.present? && expires_at <= Time.current
|
|
57
|
+
# end
|
|
58
|
+
#
|
|
59
|
+
# @example Complex condition
|
|
48
60
|
# is :ready do
|
|
49
61
|
# scheduled_at.present? && scheduled_at <= Time.current
|
|
50
62
|
# end
|
|
51
63
|
def is(status_name, condition_proc = nil, &block)
|
|
52
|
-
#
|
|
53
|
-
|
|
64
|
+
# Validate parameters before converting
|
|
65
|
+
if status_name.blank?
|
|
66
|
+
raise BetterModel::Errors::Statusable::ConfigurationError, "Status name cannot be blank"
|
|
67
|
+
end
|
|
54
68
|
|
|
55
69
|
status_name = status_name.to_sym
|
|
56
70
|
condition = condition_proc || block
|
|
57
|
-
raise ArgumentError, "Condition proc or block is required" unless condition
|
|
58
|
-
raise ArgumentError, "Condition must respond to call" unless condition.respond_to?(:call)
|
|
59
71
|
|
|
60
|
-
|
|
72
|
+
unless condition
|
|
73
|
+
raise BetterModel::Errors::Statusable::ConfigurationError, "Condition proc or block is required"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
unless condition.respond_to?(:call)
|
|
77
|
+
raise BetterModel::Errors::Statusable::ConfigurationError, "Condition must respond to call"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Register status in registry
|
|
61
81
|
self.is_definitions = is_definitions.merge(status_name => condition.freeze).freeze
|
|
62
82
|
|
|
63
|
-
#
|
|
83
|
+
# Generate dynamic method is_#{status_name}?
|
|
64
84
|
define_is_method(status_name)
|
|
65
85
|
end
|
|
66
86
|
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
87
|
+
# List all statuses defined for this class.
|
|
88
|
+
#
|
|
89
|
+
# @return [Array<Symbol>] Array of defined status names
|
|
90
|
+
def defined_statuses = is_definitions.keys
|
|
71
91
|
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
92
|
+
# Check if a status is defined.
|
|
93
|
+
#
|
|
94
|
+
# @param status_name [Symbol, String] Status name to check
|
|
95
|
+
# @return [Boolean] true if status is defined
|
|
96
|
+
def status_defined?(status_name) = is_definitions.key?(status_name.to_sym)
|
|
76
97
|
|
|
77
98
|
private
|
|
78
99
|
|
|
79
|
-
#
|
|
100
|
+
# Generate dynamic method is_#{status_name}? for each defined status.
|
|
101
|
+
#
|
|
102
|
+
# @param status_name [Symbol] Status name
|
|
103
|
+
# @api private
|
|
80
104
|
def define_is_method(status_name)
|
|
81
105
|
method_name = "is_#{status_name}?"
|
|
82
106
|
|
|
83
|
-
#
|
|
107
|
+
# Avoid redefining methods if they already exist
|
|
84
108
|
return if method_defined?(method_name)
|
|
85
109
|
|
|
86
110
|
define_method(method_name) do
|
|
@@ -89,35 +113,33 @@ module BetterModel
|
|
|
89
113
|
end
|
|
90
114
|
end
|
|
91
115
|
|
|
92
|
-
#
|
|
116
|
+
# Generic method to check if a status is active.
|
|
93
117
|
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
118
|
+
# Evaluates the status condition in the context of the model instance.
|
|
119
|
+
# Returns false if status is not defined (secure by default).
|
|
96
120
|
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
# - false se lo stato non è attivo o non è definito
|
|
121
|
+
# @param status_name [Symbol, String] Status name to check
|
|
122
|
+
# @return [Boolean] true if status is active, false otherwise
|
|
100
123
|
#
|
|
101
|
-
#
|
|
102
|
-
# consult.is?(:pending)
|
|
124
|
+
# @example
|
|
125
|
+
# consult.is?(:pending) # => true
|
|
103
126
|
def is?(status_name)
|
|
104
127
|
status_name = status_name.to_sym
|
|
105
128
|
condition = self.class.is_definitions[status_name]
|
|
106
129
|
|
|
107
|
-
#
|
|
130
|
+
# If status is not defined, return false (secure by default)
|
|
108
131
|
return false unless condition
|
|
109
132
|
|
|
110
|
-
#
|
|
111
|
-
#
|
|
133
|
+
# Evaluate condition in context of model instance
|
|
134
|
+
# Errors propagate naturally - fail fast
|
|
112
135
|
instance_exec(&condition)
|
|
113
136
|
end
|
|
114
137
|
|
|
115
|
-
#
|
|
138
|
+
# Returns all available statuses for this instance with their values.
|
|
116
139
|
#
|
|
117
|
-
#
|
|
118
|
-
# - Hash con chiavi simbolo (stati) e valori booleani (attivi/inattivi)
|
|
140
|
+
# @return [Hash{Symbol => Boolean}] Hash with status names and their active state
|
|
119
141
|
#
|
|
120
|
-
#
|
|
142
|
+
# @example
|
|
121
143
|
# consult.statuses
|
|
122
144
|
# # => { pending: true, active: false, expired: false, scheduled: true }
|
|
123
145
|
def statuses
|
|
@@ -126,26 +148,36 @@ module BetterModel
|
|
|
126
148
|
end
|
|
127
149
|
end
|
|
128
150
|
|
|
129
|
-
#
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
151
|
+
# Check if instance has at least one active status.
|
|
152
|
+
#
|
|
153
|
+
# @return [Boolean] true if any status is active
|
|
154
|
+
def has_any_status? = statuses.values.any?
|
|
133
155
|
|
|
134
|
-
#
|
|
156
|
+
# Check if instance has all specified statuses active.
|
|
157
|
+
#
|
|
158
|
+
# @param status_names [Array<Symbol>] Status names to check
|
|
159
|
+
# @return [Boolean] true if all statuses are active
|
|
135
160
|
def has_all_statuses?(status_names)
|
|
136
161
|
Array(status_names).all? { |status_name| is?(status_name) }
|
|
137
162
|
end
|
|
138
163
|
|
|
139
|
-
#
|
|
164
|
+
# Filter a list of statuses returning only active ones.
|
|
165
|
+
#
|
|
166
|
+
# @param status_names [Array<Symbol>] Status names to filter
|
|
167
|
+
# @return [Array<Symbol>] Active statuses
|
|
140
168
|
def active_statuses(status_names)
|
|
141
169
|
Array(status_names).select { |status_name| is?(status_name) }
|
|
142
170
|
end
|
|
143
171
|
|
|
144
|
-
# Override
|
|
172
|
+
# Override as_json to automatically include statuses if requested.
|
|
173
|
+
#
|
|
174
|
+
# @param options [Hash] Options for as_json
|
|
175
|
+
# @option options [Boolean] :include_statuses Include statuses in JSON output
|
|
176
|
+
# @return [Hash] JSON representation
|
|
145
177
|
def as_json(options = {})
|
|
146
178
|
result = super
|
|
147
179
|
|
|
148
|
-
# Include
|
|
180
|
+
# Include statuses if explicitly requested, converting symbol keys to strings
|
|
149
181
|
result["statuses"] = statuses.transform_keys(&:to_s) if options[:include_statuses]
|
|
150
182
|
|
|
151
183
|
result
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "errors/taggable/taggable_error"
|
|
4
|
+
require_relative "errors/taggable/configuration_error"
|
|
5
|
+
|
|
6
|
+
# Taggable - Declarative tag management system for Rails models.
|
|
4
7
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
8
|
+
# This concern enables managing multiple tags on models using PostgreSQL arrays
|
|
9
|
+
# with normalization, validation, and statistics. Search is delegated to Predicable.
|
|
7
10
|
#
|
|
8
|
-
#
|
|
11
|
+
# @example Basic Usage
|
|
9
12
|
# class Article < ApplicationRecord
|
|
10
13
|
# include BetterModel
|
|
11
14
|
#
|
|
@@ -16,18 +19,18 @@
|
|
|
16
19
|
# end
|
|
17
20
|
# end
|
|
18
21
|
#
|
|
19
|
-
#
|
|
20
|
-
# article.tag_with("ruby", "rails") #
|
|
21
|
-
# article.untag("rails") #
|
|
22
|
-
# article.tag_list = "ruby, rails, tutorial" #
|
|
22
|
+
# @example Managing Tags
|
|
23
|
+
# article.tag_with("ruby", "rails") # Add tags
|
|
24
|
+
# article.untag("rails") # Remove tags
|
|
25
|
+
# article.tag_list = "ruby, rails, tutorial" # From CSV string
|
|
23
26
|
# article.tagged_with?("ruby") # => true
|
|
24
27
|
#
|
|
25
|
-
#
|
|
28
|
+
# @example Searching (Delegated to Predicable)
|
|
26
29
|
# Article.tags_contains("ruby") # Predicable
|
|
27
30
|
# Article.tags_overlaps(["ruby", "python"]) # Predicable
|
|
28
31
|
# Article.search(tags_contains: "ruby") # Searchable + Predicable
|
|
29
32
|
#
|
|
30
|
-
#
|
|
33
|
+
# @example Statistics
|
|
31
34
|
# Article.tag_counts # => {"ruby" => 45, "rails" => 38}
|
|
32
35
|
# Article.popular_tags(limit: 10) # => [["ruby", 45], ["rails", 38], ...]
|
|
33
36
|
#
|
|
@@ -35,7 +38,11 @@ module BetterModel
|
|
|
35
38
|
module Taggable
|
|
36
39
|
extend ActiveSupport::Concern
|
|
37
40
|
|
|
38
|
-
#
|
|
41
|
+
# Taggable Configuration.
|
|
42
|
+
#
|
|
43
|
+
# Internal configuration class for the Taggable DSL.
|
|
44
|
+
#
|
|
45
|
+
# @api private
|
|
39
46
|
class Configuration
|
|
40
47
|
attr_reader :validates_minimum, :validates_maximum, :allowed_tags, :forbidden_tags
|
|
41
48
|
|
|
@@ -91,19 +98,22 @@ module BetterModel
|
|
|
91
98
|
end
|
|
92
99
|
|
|
93
100
|
included do
|
|
94
|
-
#
|
|
101
|
+
# Validate ActiveRecord inheritance
|
|
95
102
|
unless ancestors.include?(ActiveRecord::Base)
|
|
96
|
-
raise
|
|
103
|
+
raise BetterModel::Errors::Taggable::ConfigurationError, "Invalid configuration"
|
|
97
104
|
end
|
|
98
105
|
|
|
99
|
-
#
|
|
106
|
+
# Taggable configuration for this class
|
|
100
107
|
class_attribute :taggable_config, default: nil
|
|
101
108
|
end
|
|
102
109
|
|
|
103
110
|
class_methods do
|
|
104
|
-
# DSL
|
|
111
|
+
# DSL to configure Taggable.
|
|
105
112
|
#
|
|
106
|
-
#
|
|
113
|
+
# @yield [config] Configuration block
|
|
114
|
+
# @raise [BetterModel::Errors::Taggable::ConfigurationError] If already configured or field doesn't exist
|
|
115
|
+
#
|
|
116
|
+
# @example
|
|
107
117
|
# taggable do
|
|
108
118
|
# tag_field :tags
|
|
109
119
|
# normalize true
|
|
@@ -114,39 +124,41 @@ module BetterModel
|
|
|
114
124
|
# validates_tags minimum: 1, maximum: 10, allowed_tags: ["ruby", "rails"]
|
|
115
125
|
# end
|
|
116
126
|
def taggable(&block)
|
|
117
|
-
#
|
|
127
|
+
# Prevent multiple configuration
|
|
118
128
|
if taggable_config.present?
|
|
119
|
-
raise
|
|
129
|
+
raise BetterModel::Errors::Taggable::ConfigurationError, "Invalid configuration"
|
|
120
130
|
end
|
|
121
131
|
|
|
122
|
-
#
|
|
132
|
+
# Create configuration
|
|
123
133
|
config = Configuration.new
|
|
124
134
|
config.instance_eval(&block) if block_given?
|
|
125
135
|
|
|
126
|
-
#
|
|
136
|
+
# Validate that field exists
|
|
127
137
|
tag_field_name = config.tag_field.to_s
|
|
128
138
|
unless column_names.include?(tag_field_name)
|
|
129
|
-
raise
|
|
139
|
+
raise BetterModel::Errors::Taggable::ConfigurationError, "Invalid configuration"
|
|
130
140
|
end
|
|
131
141
|
|
|
132
|
-
#
|
|
142
|
+
# Save configuration (frozen for thread-safety)
|
|
133
143
|
self.taggable_config = config.freeze
|
|
134
144
|
|
|
135
|
-
# Auto-
|
|
145
|
+
# Auto-register predicates for search (delegated to Predicable)
|
|
136
146
|
predicates config.tag_field if respond_to?(:predicates)
|
|
137
147
|
|
|
138
|
-
#
|
|
148
|
+
# Register validations if configured
|
|
139
149
|
setup_validations(config) if config.validates_minimum || config.validates_maximum ||
|
|
140
150
|
config.allowed_tags || config.forbidden_tags
|
|
141
151
|
end
|
|
142
152
|
|
|
143
153
|
# ============================================================================
|
|
144
|
-
# CLASS METHODS -
|
|
154
|
+
# CLASS METHODS - Statistics
|
|
145
155
|
# ============================================================================
|
|
146
156
|
|
|
147
|
-
#
|
|
157
|
+
# Returns a hash with the count of each tag.
|
|
158
|
+
#
|
|
159
|
+
# @return [Hash{String => Integer}] Tag counts
|
|
148
160
|
#
|
|
149
|
-
#
|
|
161
|
+
# @example
|
|
150
162
|
# Article.tag_counts # => {"ruby" => 45, "rails" => 38, "tutorial" => 12}
|
|
151
163
|
def tag_counts
|
|
152
164
|
return {} unless taggable_config
|
|
@@ -163,9 +175,12 @@ module BetterModel
|
|
|
163
175
|
counts
|
|
164
176
|
end
|
|
165
177
|
|
|
166
|
-
#
|
|
178
|
+
# Returns the most popular tags with their counts.
|
|
167
179
|
#
|
|
168
|
-
#
|
|
180
|
+
# @param limit [Integer] Maximum number of tags to return
|
|
181
|
+
# @return [Array<Array(String, Integer)>] Tag-count pairs sorted by count
|
|
182
|
+
#
|
|
183
|
+
# @example
|
|
169
184
|
# Article.popular_tags(limit: 10)
|
|
170
185
|
# # => [["ruby", 45], ["rails", 38], ["tutorial", 12]]
|
|
171
186
|
def popular_tags(limit: 10)
|
|
@@ -176,9 +191,13 @@ module BetterModel
|
|
|
176
191
|
.first(limit)
|
|
177
192
|
end
|
|
178
193
|
|
|
179
|
-
#
|
|
194
|
+
# Returns tags that appear together with the specified tag.
|
|
195
|
+
#
|
|
196
|
+
# @param tag [String] Tag to find related tags for
|
|
197
|
+
# @param limit [Integer] Maximum number of related tags to return
|
|
198
|
+
# @return [Array<String>] Related tags sorted by frequency
|
|
180
199
|
#
|
|
181
|
-
#
|
|
200
|
+
# @example
|
|
182
201
|
# Article.related_tags("ruby", limit: 10)
|
|
183
202
|
# # => ["rails", "gem", "activerecord"]
|
|
184
203
|
def related_tags(tag, limit: 10)
|
|
@@ -187,25 +206,25 @@ module BetterModel
|
|
|
187
206
|
field = taggable_config.tag_field
|
|
188
207
|
related_counts = Hash.new(0)
|
|
189
208
|
|
|
190
|
-
#
|
|
209
|
+
# Normalize query tag
|
|
191
210
|
config = taggable_config
|
|
192
211
|
normalized_tag = tag.to_s
|
|
193
212
|
normalized_tag = normalized_tag.strip if config.strip
|
|
194
213
|
normalized_tag = normalized_tag.downcase if config.normalize
|
|
195
214
|
|
|
196
|
-
#
|
|
215
|
+
# Find records containing the tag
|
|
197
216
|
find_each do |record|
|
|
198
217
|
tags = record.public_send(field) || []
|
|
199
218
|
next unless tags.include?(normalized_tag)
|
|
200
219
|
|
|
201
|
-
#
|
|
220
|
+
# Count other tags that appear together
|
|
202
221
|
tags.each do |other_tag|
|
|
203
222
|
next if other_tag == normalized_tag
|
|
204
223
|
related_counts[other_tag] += 1
|
|
205
224
|
end
|
|
206
225
|
end
|
|
207
226
|
|
|
208
|
-
#
|
|
227
|
+
# Return sorted by frequency
|
|
209
228
|
related_counts
|
|
210
229
|
.sort_by { |_tag, count| -count }
|
|
211
230
|
.first(limit)
|
|
@@ -214,11 +233,14 @@ module BetterModel
|
|
|
214
233
|
|
|
215
234
|
private
|
|
216
235
|
|
|
217
|
-
# Setup
|
|
236
|
+
# Setup ActiveRecord validations.
|
|
237
|
+
#
|
|
238
|
+
# @param config [Configuration] Taggable configuration
|
|
239
|
+
# @api private
|
|
218
240
|
def setup_validations(config)
|
|
219
241
|
field = config.tag_field
|
|
220
242
|
|
|
221
|
-
#
|
|
243
|
+
# Minimum validation
|
|
222
244
|
if config.validates_minimum
|
|
223
245
|
min = config.validates_minimum
|
|
224
246
|
validate do
|
|
@@ -229,7 +251,7 @@ module BetterModel
|
|
|
229
251
|
end
|
|
230
252
|
end
|
|
231
253
|
|
|
232
|
-
#
|
|
254
|
+
# Maximum validation
|
|
233
255
|
if config.validates_maximum
|
|
234
256
|
max = config.validates_maximum
|
|
235
257
|
validate do
|
|
@@ -240,7 +262,7 @@ module BetterModel
|
|
|
240
262
|
end
|
|
241
263
|
end
|
|
242
264
|
|
|
243
|
-
#
|
|
265
|
+
# Whitelist validation
|
|
244
266
|
if config.allowed_tags
|
|
245
267
|
allowed = config.allowed_tags
|
|
246
268
|
validate do
|
|
@@ -252,7 +274,7 @@ module BetterModel
|
|
|
252
274
|
end
|
|
253
275
|
end
|
|
254
276
|
|
|
255
|
-
#
|
|
277
|
+
# Blacklist validation
|
|
256
278
|
if config.forbidden_tags
|
|
257
279
|
forbidden = config.forbidden_tags
|
|
258
280
|
validate do
|
|
@@ -267,12 +289,15 @@ module BetterModel
|
|
|
267
289
|
end
|
|
268
290
|
|
|
269
291
|
# ============================================================================
|
|
270
|
-
# INSTANCE METHODS -
|
|
292
|
+
# INSTANCE METHODS - Tag Management
|
|
271
293
|
# ============================================================================
|
|
272
294
|
|
|
273
|
-
#
|
|
295
|
+
# Add one or more tags to the record.
|
|
296
|
+
#
|
|
297
|
+
# @param new_tags [Array<String>] Tags to add
|
|
298
|
+
# @return [void]
|
|
274
299
|
#
|
|
275
|
-
#
|
|
300
|
+
# @example
|
|
276
301
|
# article.tag_with("ruby")
|
|
277
302
|
# article.tag_with("ruby", "rails", "tutorial")
|
|
278
303
|
def tag_with(*new_tags)
|
|
@@ -281,21 +306,24 @@ module BetterModel
|
|
|
281
306
|
config = self.class.taggable_config
|
|
282
307
|
field = config.tag_field
|
|
283
308
|
|
|
284
|
-
#
|
|
309
|
+
# Initialize array if nil
|
|
285
310
|
current_tags = public_send(field) || []
|
|
286
311
|
|
|
287
|
-
#
|
|
312
|
+
# Normalize and add tags (avoid duplicates with |)
|
|
288
313
|
normalized_tags = new_tags.flatten.map { |tag| normalize_tag(tag) }.compact
|
|
289
314
|
updated_tags = (current_tags | normalized_tags)
|
|
290
315
|
|
|
291
|
-
#
|
|
316
|
+
# Update field
|
|
292
317
|
public_send("#{field}=", updated_tags)
|
|
293
318
|
save if persisted?
|
|
294
319
|
end
|
|
295
320
|
|
|
296
|
-
#
|
|
321
|
+
# Remove one or more tags from the record.
|
|
297
322
|
#
|
|
298
|
-
#
|
|
323
|
+
# @param tags_to_remove [Array<String>] Tags to remove
|
|
324
|
+
# @return [void]
|
|
325
|
+
#
|
|
326
|
+
# @example
|
|
299
327
|
# article.untag("tutorial")
|
|
300
328
|
# article.untag("ruby", "rails")
|
|
301
329
|
def untag(*tags_to_remove)
|
|
@@ -304,23 +332,26 @@ module BetterModel
|
|
|
304
332
|
config = self.class.taggable_config
|
|
305
333
|
field = config.tag_field
|
|
306
334
|
|
|
307
|
-
#
|
|
335
|
+
# Get current tags
|
|
308
336
|
current_tags = public_send(field) || []
|
|
309
337
|
|
|
310
|
-
#
|
|
338
|
+
# Normalize tags to remove
|
|
311
339
|
normalized_tags = tags_to_remove.flatten.map { |tag| normalize_tag(tag) }.compact
|
|
312
340
|
|
|
313
|
-
#
|
|
341
|
+
# Remove tags
|
|
314
342
|
updated_tags = current_tags - normalized_tags
|
|
315
343
|
|
|
316
|
-
#
|
|
344
|
+
# Update field
|
|
317
345
|
public_send("#{field}=", updated_tags)
|
|
318
346
|
save if persisted?
|
|
319
347
|
end
|
|
320
348
|
|
|
321
|
-
#
|
|
349
|
+
# Replace all existing tags with new tags.
|
|
350
|
+
#
|
|
351
|
+
# @param new_tags [Array<String>] New tags to set
|
|
352
|
+
# @return [void]
|
|
322
353
|
#
|
|
323
|
-
#
|
|
354
|
+
# @example
|
|
324
355
|
# article.retag("python", "django")
|
|
325
356
|
def retag(*new_tags)
|
|
326
357
|
return unless taggable_enabled?
|
|
@@ -328,17 +359,20 @@ module BetterModel
|
|
|
328
359
|
config = self.class.taggable_config
|
|
329
360
|
field = config.tag_field
|
|
330
361
|
|
|
331
|
-
#
|
|
362
|
+
# Normalize new tags
|
|
332
363
|
normalized_tags = new_tags.flatten.map { |tag| normalize_tag(tag) }.compact.uniq
|
|
333
364
|
|
|
334
|
-
#
|
|
365
|
+
# Replace all tags
|
|
335
366
|
public_send("#{field}=", normalized_tags)
|
|
336
367
|
save if persisted?
|
|
337
368
|
end
|
|
338
369
|
|
|
339
|
-
#
|
|
370
|
+
# Check if record has a specific tag.
|
|
340
371
|
#
|
|
341
|
-
#
|
|
372
|
+
# @param tag [String] Tag to check
|
|
373
|
+
# @return [Boolean] true if record has the tag
|
|
374
|
+
#
|
|
375
|
+
# @example
|
|
342
376
|
# article.tagged_with?("ruby") # => true/false
|
|
343
377
|
def tagged_with?(tag)
|
|
344
378
|
return false unless taggable_enabled?
|
|
@@ -356,9 +390,11 @@ module BetterModel
|
|
|
356
390
|
# TAG LIST (CSV Interface)
|
|
357
391
|
# ============================================================================
|
|
358
392
|
|
|
359
|
-
#
|
|
393
|
+
# Returns tags as a delimited string.
|
|
394
|
+
#
|
|
395
|
+
# @return [String] Tags joined by delimiter
|
|
360
396
|
#
|
|
361
|
-
#
|
|
397
|
+
# @example
|
|
362
398
|
# article.tag_list # => "ruby, rails, tutorial"
|
|
363
399
|
def tag_list
|
|
364
400
|
return "" unless taggable_enabled?
|
|
@@ -369,14 +405,17 @@ module BetterModel
|
|
|
369
405
|
|
|
370
406
|
current_tags = public_send(field) || []
|
|
371
407
|
|
|
372
|
-
#
|
|
408
|
+
# Add space after comma for readability (only if delimiter is comma)
|
|
373
409
|
separator = delimiter == "," ? "#{delimiter} " : delimiter
|
|
374
410
|
current_tags.join(separator)
|
|
375
411
|
end
|
|
376
412
|
|
|
377
|
-
#
|
|
413
|
+
# Set tags from a delimited string.
|
|
378
414
|
#
|
|
379
|
-
#
|
|
415
|
+
# @param tag_string [String] Delimited tag string
|
|
416
|
+
# @return [void]
|
|
417
|
+
#
|
|
418
|
+
# @example
|
|
380
419
|
# article.tag_list = "ruby, rails, tutorial"
|
|
381
420
|
def tag_list=(tag_string)
|
|
382
421
|
return unless taggable_enabled?
|
|
@@ -401,25 +440,26 @@ module BetterModel
|
|
|
401
440
|
# JSON SERIALIZATION
|
|
402
441
|
# ============================================================================
|
|
403
442
|
|
|
404
|
-
# Override as_json
|
|
443
|
+
# Override as_json to include tag information.
|
|
405
444
|
#
|
|
406
|
-
#
|
|
407
|
-
#
|
|
408
|
-
#
|
|
445
|
+
# @param options [Hash] Options for as_json
|
|
446
|
+
# @option options [Boolean] :include_tag_list Include tag_list as string
|
|
447
|
+
# @option options [Boolean] :include_tag_stats Include tag statistics
|
|
448
|
+
# @return [Hash] JSON representation
|
|
409
449
|
#
|
|
410
|
-
#
|
|
450
|
+
# @example
|
|
411
451
|
# article.as_json(include_tag_list: true, include_tag_stats: true)
|
|
412
452
|
def as_json(options = {})
|
|
413
453
|
json = super(options)
|
|
414
454
|
|
|
415
455
|
return json unless taggable_enabled?
|
|
416
456
|
|
|
417
|
-
#
|
|
457
|
+
# Add tag_list if requested
|
|
418
458
|
if options[:include_tag_list]
|
|
419
459
|
json["tag_list"] = tag_list
|
|
420
460
|
end
|
|
421
461
|
|
|
422
|
-
#
|
|
462
|
+
# Add tag statistics if requested
|
|
423
463
|
if options[:include_tag_stats]
|
|
424
464
|
config = self.class.taggable_config
|
|
425
465
|
field = config.tag_field
|
|
@@ -436,12 +476,17 @@ module BetterModel
|
|
|
436
476
|
|
|
437
477
|
private
|
|
438
478
|
|
|
439
|
-
#
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
479
|
+
# Check if Taggable is enabled for this class.
|
|
480
|
+
#
|
|
481
|
+
# @return [Boolean] true if enabled
|
|
482
|
+
# @api private
|
|
483
|
+
def taggable_enabled? = self.class.taggable_config.present?
|
|
443
484
|
|
|
444
|
-
#
|
|
485
|
+
# Normalize a tag according to configuration.
|
|
486
|
+
#
|
|
487
|
+
# @param tag [String] Tag to normalize
|
|
488
|
+
# @return [String, nil] Normalized tag or nil if invalid
|
|
489
|
+
# @api private
|
|
445
490
|
def normalize_tag(tag)
|
|
446
491
|
return nil if tag.blank?
|
|
447
492
|
|