better_model 2.1.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 +96 -13
- data/lib/better_model/archivable.rb +203 -91
- 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 +114 -63
- data/lib/better_model/repositable/base_repository.rb +232 -0
- data/lib/better_model/repositable.rb +32 -0
- data/lib/better_model/searchable.rb +92 -92
- data/lib/better_model/sortable.rb +137 -41
- data/lib/better_model/stateable/configurator.rb +71 -53
- data/lib/better_model/stateable/guard.rb +35 -15
- data/lib/better_model/stateable/transition.rb +59 -30
- data/lib/better_model/stateable.rb +33 -15
- 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 +49 -172
- data/lib/better_model/validatable.rb +88 -113
- data/lib/better_model/version.rb +1 -1
- data/lib/better_model.rb +42 -5
- 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 +44 -7
- data/lib/better_model/state_transition.rb +0 -106
- data/lib/better_model/stateable/errors.rb +0 -48
- 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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterModel
|
|
4
|
+
module Repositable
|
|
5
|
+
# Base repository class for implementing the Repository Pattern with BetterModel.
|
|
6
|
+
#
|
|
7
|
+
# Repositories encapsulate data access logic and provide a clean interface for querying models.
|
|
8
|
+
# This base class integrates seamlessly with BetterModel's Searchable, Predicable, and Sortable concerns.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# class ArticleRepository < BetterModel::Repositable::BaseRepository
|
|
12
|
+
# def model_class = Article
|
|
13
|
+
#
|
|
14
|
+
# def published
|
|
15
|
+
# search({ status_eq: "published" })
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# def recent(days: 7)
|
|
19
|
+
# search({ created_at_gteq: days.days.ago })
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# repo = ArticleRepository.new
|
|
24
|
+
# articles = repo.search({ status_eq: "published" }, page: 1, per_page: 20)
|
|
25
|
+
# article = repo.search({ id_eq: 1 }, limit: 1)
|
|
26
|
+
#
|
|
27
|
+
# @example With BetterModel Searchable
|
|
28
|
+
# # If your model uses BetterModel::Searchable:
|
|
29
|
+
# class Article < ApplicationRecord
|
|
30
|
+
# include BetterModel
|
|
31
|
+
#
|
|
32
|
+
# predicates :title, :status, :view_count, :published_at
|
|
33
|
+
# sort :title, :view_count, :published_at
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# # The repository automatically uses the model's search() method
|
|
37
|
+
# repo = ArticleRepository.new
|
|
38
|
+
# repo.search({ title_cont: "Rails", view_count_gt: 100 })
|
|
39
|
+
#
|
|
40
|
+
class BaseRepository
|
|
41
|
+
attr_reader :model
|
|
42
|
+
|
|
43
|
+
# Initialize a new repository instance.
|
|
44
|
+
#
|
|
45
|
+
# @param model_class [Class] The ActiveRecord model class (optional if model_class method is defined)
|
|
46
|
+
def initialize(model_class = nil)
|
|
47
|
+
@model = model_class || self.class.instance_method(:model_class).bind(self).call
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Main search method with support for predicates, pagination, ordering, and limits.
|
|
51
|
+
#
|
|
52
|
+
# This method integrates with BetterModel's Searchable concern if available,
|
|
53
|
+
# otherwise falls back to standard ActiveRecord queries.
|
|
54
|
+
#
|
|
55
|
+
# @param predicates [Hash] Filter conditions using BetterModel predicates
|
|
56
|
+
# @param page [Integer] Page number for pagination (default: 1)
|
|
57
|
+
# @param per_page [Integer] Records per page (default: 20)
|
|
58
|
+
# @param includes [Array] Associations to eager load
|
|
59
|
+
# @param joins [Array] Associations to join
|
|
60
|
+
# @param order [String, Hash] SQL order clause
|
|
61
|
+
# @param order_scope [Hash] BetterModel sort scope (e.g., { field: :published_at, direction: :desc })
|
|
62
|
+
# @param limit [Integer, Symbol, nil] Result limit:
|
|
63
|
+
# - Integer (1): Returns single record (first)
|
|
64
|
+
# - Integer (2+): Returns limited relation
|
|
65
|
+
# - nil: Returns all results (no limit)
|
|
66
|
+
# - :default: Uses pagination (default behavior)
|
|
67
|
+
#
|
|
68
|
+
# @return [ActiveRecord::Relation, ActiveRecord::Base, nil] Query results
|
|
69
|
+
#
|
|
70
|
+
# @example Basic search with predicates
|
|
71
|
+
# search({ status_eq: "published", view_count_gt: 100 })
|
|
72
|
+
#
|
|
73
|
+
# @example With pagination
|
|
74
|
+
# search({ status_eq: "published" }, page: 2, per_page: 50)
|
|
75
|
+
#
|
|
76
|
+
# @example Single result
|
|
77
|
+
# search({ id_eq: 1 }, limit: 1)
|
|
78
|
+
#
|
|
79
|
+
# @example All results (no pagination)
|
|
80
|
+
# search({ status_eq: "published" }, limit: nil)
|
|
81
|
+
#
|
|
82
|
+
# @example With eager loading
|
|
83
|
+
# search({ status_eq: "published" }, includes: [:author, :comments])
|
|
84
|
+
#
|
|
85
|
+
# @example With ordering
|
|
86
|
+
# search({ status_eq: "published" }, order_scope: { field: :published_at, direction: :desc })
|
|
87
|
+
#
|
|
88
|
+
def search(predicates = {}, page: 1, per_page: 20, includes: [], joins: [], order: nil, order_scope: nil,
|
|
89
|
+
limit: :default)
|
|
90
|
+
# Remove nil values (keep false for boolean predicates)
|
|
91
|
+
cleaned_predicates = (predicates || {}).compact
|
|
92
|
+
|
|
93
|
+
# Validate predicates if model supports it
|
|
94
|
+
validate_predicates!(cleaned_predicates) if cleaned_predicates.present?
|
|
95
|
+
|
|
96
|
+
# Use BetterModel's search() method if available, otherwise use all
|
|
97
|
+
result = if @model.respond_to?(:search) && cleaned_predicates.present?
|
|
98
|
+
@model.search(cleaned_predicates)
|
|
99
|
+
else
|
|
100
|
+
@model.all
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Apply joins BEFORE includes (necessary for ORDER BY on joined tables)
|
|
104
|
+
result = result.joins(*joins) if joins.present?
|
|
105
|
+
|
|
106
|
+
# Apply includes if specified
|
|
107
|
+
result = result.includes(*includes) if includes.present?
|
|
108
|
+
|
|
109
|
+
# Apply ordering: order_scope has priority over order
|
|
110
|
+
if order_scope.present?
|
|
111
|
+
scope_name = build_scope_name(order_scope)
|
|
112
|
+
result = result.send(scope_name) if result.respond_to?(scope_name)
|
|
113
|
+
elsif order.present?
|
|
114
|
+
result = result.order(order)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Apply limit if specified (has priority over pagination)
|
|
118
|
+
case limit
|
|
119
|
+
when 1
|
|
120
|
+
result.first
|
|
121
|
+
when (2..)
|
|
122
|
+
result.limit(limit)
|
|
123
|
+
when nil
|
|
124
|
+
# limit: nil explicitly means unlimited - return all results
|
|
125
|
+
result
|
|
126
|
+
when :default
|
|
127
|
+
# :default means use pagination (this is the default when no limit is specified)
|
|
128
|
+
paginate(result, page: page, per_page: per_page)
|
|
129
|
+
else
|
|
130
|
+
# Fallback to pagination for any other case
|
|
131
|
+
paginate(result, page: page, per_page: per_page)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Standard CRUD methods delegated to the model
|
|
136
|
+
delegate :find, to: :@model
|
|
137
|
+
delegate :find_by, to: :@model
|
|
138
|
+
delegate :create, to: :@model
|
|
139
|
+
delegate :create!, to: :@model
|
|
140
|
+
|
|
141
|
+
# Build a new model instance with the given attributes.
|
|
142
|
+
#
|
|
143
|
+
# @param attributes [Hash] Model attributes
|
|
144
|
+
# @return [ActiveRecord::Base] New model instance (not persisted)
|
|
145
|
+
def build(attributes = {})
|
|
146
|
+
@model.new(attributes)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Update a record by ID.
|
|
150
|
+
#
|
|
151
|
+
# @param id [Integer] Record ID
|
|
152
|
+
# @param attributes [Hash] Attributes to update
|
|
153
|
+
# @return [ActiveRecord::Base] Updated record
|
|
154
|
+
# @raise [ActiveRecord::RecordNotFound] If record not found
|
|
155
|
+
# @raise [ActiveRecord::RecordInvalid] If validation fails
|
|
156
|
+
def update(id, attributes)
|
|
157
|
+
record = find(id)
|
|
158
|
+
record.update!(attributes)
|
|
159
|
+
record
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Delete a record by ID.
|
|
163
|
+
#
|
|
164
|
+
# @param id [Integer] Record ID
|
|
165
|
+
# @return [ActiveRecord::Base] Deleted record
|
|
166
|
+
# @raise [ActiveRecord::RecordNotFound] If record not found
|
|
167
|
+
def delete(id)
|
|
168
|
+
@model.destroy(id)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Base ActiveRecord methods delegated to the model
|
|
172
|
+
delegate :where, to: :@model
|
|
173
|
+
delegate :all, to: :@model
|
|
174
|
+
delegate :count, to: :@model
|
|
175
|
+
delegate :exists?, to: :@model
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
# Validate that predicates are supported by the model.
|
|
180
|
+
#
|
|
181
|
+
# If the model includes BetterModel::Predicable, validates against predicable_scope?.
|
|
182
|
+
# Otherwise, checks if the model responds to the predicate method.
|
|
183
|
+
#
|
|
184
|
+
# @param predicates [Hash] Predicates to validate
|
|
185
|
+
def validate_predicates!(predicates)
|
|
186
|
+
predicates.each_key do |predicate|
|
|
187
|
+
# Check if model supports predicable_scope? validation
|
|
188
|
+
if @model.respond_to?(:predicable_scope?)
|
|
189
|
+
next if @model.predicable_scope?(predicate)
|
|
190
|
+
|
|
191
|
+
available_list = @model.predicable_scopes.to_a.sort.join(", ")
|
|
192
|
+
Rails.logger.error "Invalid predicate '#{predicate}' for model #{@model.name}. " \
|
|
193
|
+
"Available: #{available_list}"
|
|
194
|
+
else
|
|
195
|
+
# Fallback to checking if method exists
|
|
196
|
+
next if @model.respond_to?(predicate)
|
|
197
|
+
|
|
198
|
+
available_list = available_predicates.join(", ")
|
|
199
|
+
Rails.logger.error "Invalid predicate '#{predicate}' for model #{@model.name}. " \
|
|
200
|
+
"Available: #{available_list}"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Get available predicates for models without BetterModel::Predicable.
|
|
206
|
+
#
|
|
207
|
+
# @return [Array<Symbol>] List of available predicate methods
|
|
208
|
+
def available_predicates
|
|
209
|
+
@model.methods.grep(/_eq$|_cont$|_gteq$|_lteq$|_in$|_not_null$|_null$/).sort
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Centralized pagination using ActiveRecord offset/limit.
|
|
213
|
+
#
|
|
214
|
+
# @param relation [ActiveRecord::Relation] Relation to paginate
|
|
215
|
+
# @param page [Integer] Page number (1-indexed)
|
|
216
|
+
# @param per_page [Integer] Records per page
|
|
217
|
+
# @return [ActiveRecord::Relation] Paginated relation
|
|
218
|
+
def paginate(relation, page:, per_page:)
|
|
219
|
+
offset_value = (page.to_i - 1) * per_page.to_i
|
|
220
|
+
relation.offset(offset_value).limit(per_page)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Build scope name for ordering (e.g., "sort_published_at_desc").
|
|
224
|
+
#
|
|
225
|
+
# @param order_scope [Hash] Hash with :field and :direction keys
|
|
226
|
+
# @return [String] Scope name
|
|
227
|
+
def build_scope_name(order_scope)
|
|
228
|
+
"sort_#{order_scope[:field]}_#{order_scope[:direction]}"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "repositable/base_repository"
|
|
4
|
+
|
|
5
|
+
module BetterModel
|
|
6
|
+
# Repositable provides the Repository Pattern infrastructure for BetterModel.
|
|
7
|
+
#
|
|
8
|
+
# Unlike other BetterModel concerns, Repositable is not included directly in models.
|
|
9
|
+
# Instead, it provides a BaseRepository class that you can inherit from to create
|
|
10
|
+
# repository classes for your models.
|
|
11
|
+
#
|
|
12
|
+
# @example Creating a repository
|
|
13
|
+
# class ArticleRepository < BetterModel::Repositable::BaseRepository
|
|
14
|
+
# def model_class = Article
|
|
15
|
+
#
|
|
16
|
+
# def published
|
|
17
|
+
# search({ status_eq: "published" })
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @example Using a repository
|
|
22
|
+
# repo = ArticleRepository.new
|
|
23
|
+
# articles = repo.published
|
|
24
|
+
# article = repo.find(1)
|
|
25
|
+
#
|
|
26
|
+
# @see BetterModel::Repositable::BaseRepository
|
|
27
|
+
#
|
|
28
|
+
module Repositable
|
|
29
|
+
# Version of the Repositable module
|
|
30
|
+
VERSION = "1.0.0"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "errors/searchable/searchable_error"
|
|
4
|
+
require_relative "errors/searchable/invalid_predicate_error"
|
|
5
|
+
require_relative "errors/searchable/invalid_order_error"
|
|
6
|
+
require_relative "errors/searchable/invalid_pagination_error"
|
|
7
|
+
require_relative "errors/searchable/invalid_security_error"
|
|
8
|
+
require_relative "errors/searchable/configuration_error"
|
|
9
|
+
|
|
10
|
+
# Searchable - Unified search system for Rails models.
|
|
4
11
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
12
|
+
# This concern orchestrates Predicable and Sortable to provide a complete
|
|
13
|
+
# search interface with filters, sorting, and pagination.
|
|
7
14
|
#
|
|
8
|
-
#
|
|
15
|
+
# @example Basic Usage
|
|
9
16
|
# class Article < ApplicationRecord
|
|
10
17
|
# include BetterModel
|
|
11
18
|
#
|
|
@@ -18,7 +25,7 @@
|
|
|
18
25
|
# end
|
|
19
26
|
# end
|
|
20
27
|
#
|
|
21
|
-
#
|
|
28
|
+
# @example Search with Filters, Pagination, and Sorting
|
|
22
29
|
# Article.search(
|
|
23
30
|
# { title_cont: "Rails", status_eq: "published" },
|
|
24
31
|
# pagination: { page: 1, per_page: 25 },
|
|
@@ -29,17 +36,11 @@ module BetterModel
|
|
|
29
36
|
module Searchable
|
|
30
37
|
extend ActiveSupport::Concern
|
|
31
38
|
|
|
32
|
-
# Error classes
|
|
33
|
-
class Error < StandardError; end
|
|
34
|
-
class InvalidPredicateError < Error; end
|
|
35
|
-
class InvalidOrderError < Error; end
|
|
36
|
-
class InvalidPaginationError < Error; end
|
|
37
|
-
class InvalidSecurityError < Error; end
|
|
38
39
|
|
|
39
40
|
included do
|
|
40
|
-
#
|
|
41
|
+
# Validate ActiveRecord inheritance
|
|
41
42
|
unless ancestors.include?(ActiveRecord::Base)
|
|
42
|
-
raise
|
|
43
|
+
raise BetterModel::Errors::Searchable::ConfigurationError, "Invalid configuration"
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
# Configuration registry
|
|
@@ -52,28 +53,35 @@ module BetterModel
|
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
class_methods do
|
|
55
|
-
#
|
|
56
|
+
# Main search interface.
|
|
56
57
|
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
# @param pagination [Hash] Parametri di paginazione (opzionale)
|
|
60
|
-
# @option pagination [Integer] :page Numero pagina
|
|
61
|
-
# @option pagination [Integer] :per_page Risultati per pagina
|
|
62
|
-
# @param orders [Array<Symbol>] Array di symbol scope sortable (opzionale)
|
|
58
|
+
# Provides a unified search interface combining predicates (filters),
|
|
59
|
+
# sorting, and pagination with support for OR conditions and security policies.
|
|
63
60
|
#
|
|
64
|
-
# @
|
|
61
|
+
# @param predicates [Hash] Filter conditions (uses Predicable scopes)
|
|
62
|
+
# @option predicates [Array<Hash>] :or OR conditions
|
|
63
|
+
# @param pagination [Hash] Pagination parameters (optional)
|
|
64
|
+
# @option pagination [Integer] :page Page number
|
|
65
|
+
# @option pagination [Integer] :per_page Results per page
|
|
66
|
+
# @param orders [Array<Symbol>] Array of sortable scope symbols (optional)
|
|
67
|
+
# @param security [Symbol] Security policy name (optional)
|
|
68
|
+
# @param includes [Array, Symbol] Associations to eager load with LEFT OUTER JOIN
|
|
69
|
+
# @param preload [Array, Symbol] Associations to preload with separate queries
|
|
70
|
+
# @param eager_load [Array, Symbol] Associations to eager load (forces LEFT OUTER JOIN)
|
|
65
71
|
#
|
|
66
|
-
# @
|
|
72
|
+
# @return [ActiveRecord::Relation] Chainable relation
|
|
73
|
+
#
|
|
74
|
+
# @example Basic search
|
|
67
75
|
# Article.search({ title_cont: "Rails" })
|
|
68
76
|
#
|
|
69
|
-
# @example
|
|
77
|
+
# @example With pagination and sorting
|
|
70
78
|
# Article.search(
|
|
71
79
|
# { title_cont: "Rails", status_eq: "published" },
|
|
72
80
|
# pagination: { page: 1, per_page: 25 },
|
|
73
81
|
# orders: [:sort_published_at_desc]
|
|
74
82
|
# )
|
|
75
83
|
#
|
|
76
|
-
# @example
|
|
84
|
+
# @example With OR conditions
|
|
77
85
|
# Article.search(
|
|
78
86
|
# {
|
|
79
87
|
# or: [
|
|
@@ -96,8 +104,7 @@ module BetterModel
|
|
|
96
104
|
|
|
97
105
|
# If there are remaining unknown options, they might be misplaced predicates
|
|
98
106
|
if options.any?
|
|
99
|
-
raise
|
|
100
|
-
"Did you mean to pass predicates as a hash? Use: search({#{options.keys.first}: ...})"
|
|
107
|
+
raise BetterModel::Errors::Searchable::ConfigurationError, "Invalid search options provided"
|
|
101
108
|
end
|
|
102
109
|
|
|
103
110
|
# Sanitize predicates
|
|
@@ -143,7 +150,9 @@ module BetterModel
|
|
|
143
150
|
scope
|
|
144
151
|
end
|
|
145
152
|
|
|
146
|
-
# DSL
|
|
153
|
+
# DSL to configure searchable.
|
|
154
|
+
#
|
|
155
|
+
# @yield [configurator] Configuration block
|
|
147
156
|
#
|
|
148
157
|
# @example
|
|
149
158
|
# searchable do
|
|
@@ -156,20 +165,21 @@ module BetterModel
|
|
|
156
165
|
self.searchable_config = configurator.to_h.freeze
|
|
157
166
|
end
|
|
158
167
|
|
|
159
|
-
#
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
168
|
+
# Check if a field is searchable (predicable).
|
|
169
|
+
#
|
|
170
|
+
# @param field_name [Symbol] Field name to check
|
|
171
|
+
# @return [Boolean] true if field is searchable
|
|
172
|
+
def searchable_field?(field_name) = respond_to?(:predicable_field?) && predicable_field?(field_name)
|
|
163
173
|
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
174
|
+
# Returns all searchable (predicable) fields.
|
|
175
|
+
#
|
|
176
|
+
# @return [Set<Symbol>] Set of searchable field names
|
|
177
|
+
def searchable_fields = respond_to?(:predicable_fields) ? predicable_fields : Set.new
|
|
168
178
|
|
|
169
|
-
#
|
|
179
|
+
# Returns available predicates for a field.
|
|
170
180
|
#
|
|
171
|
-
# @param field_name [Symbol]
|
|
172
|
-
# @return [Array<Symbol>] Array
|
|
181
|
+
# @param field_name [Symbol] Field name
|
|
182
|
+
# @return [Array<Symbol>] Array of available predicates
|
|
173
183
|
#
|
|
174
184
|
# @example
|
|
175
185
|
# Article.searchable_predicates_for(:title)
|
|
@@ -183,10 +193,10 @@ module BetterModel
|
|
|
183
193
|
.map { |scope| scope.to_s.delete_prefix("#{field_name}_").to_sym }
|
|
184
194
|
end
|
|
185
195
|
|
|
186
|
-
#
|
|
196
|
+
# Returns available sorts for a field.
|
|
187
197
|
#
|
|
188
|
-
# @param field_name [Symbol]
|
|
189
|
-
# @return [Array<Symbol>] Array
|
|
198
|
+
# @param field_name [Symbol] Field name
|
|
199
|
+
# @return [Array<Symbol>] Array of available sortable scopes
|
|
190
200
|
#
|
|
191
201
|
# @example
|
|
192
202
|
# Article.searchable_sorts_for(:title)
|
|
@@ -201,9 +211,14 @@ module BetterModel
|
|
|
201
211
|
|
|
202
212
|
private
|
|
203
213
|
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
#
|
|
214
|
+
# Sanitize and normalize predicate parameters.
|
|
215
|
+
#
|
|
216
|
+
# @param predicates [Hash, ActionController::Parameters] Predicates to sanitize
|
|
217
|
+
# @return [Hash] Sanitized predicates hash
|
|
218
|
+
# @api private
|
|
219
|
+
#
|
|
220
|
+
# @note Does not use to_unsafe_h for security. If passing ActionController::Parameters,
|
|
221
|
+
# you must call .permit! or .to_h explicitly in your controller before passing here.
|
|
207
222
|
def sanitize_predicates(predicates)
|
|
208
223
|
return {} if predicates.nil?
|
|
209
224
|
|
|
@@ -211,9 +226,7 @@ module BetterModel
|
|
|
211
226
|
# Ma richiedi che sia già stato permesso (non bypassa strong parameters)
|
|
212
227
|
if defined?(ActionController::Parameters) && predicates.is_a?(ActionController::Parameters)
|
|
213
228
|
unless predicates.permitted?
|
|
214
|
-
raise
|
|
215
|
-
"ActionController::Parameters must be explicitly permitted before passing to search. " \
|
|
216
|
-
"Use .permit! or .to_h in your controller."
|
|
229
|
+
raise BetterModel::Errors::Searchable::ConfigurationError, "Invalid configuration"
|
|
217
230
|
end
|
|
218
231
|
predicates = predicates.to_h
|
|
219
232
|
end
|
|
@@ -226,9 +239,7 @@ module BetterModel
|
|
|
226
239
|
predicates.each do |predicate_scope, value|
|
|
227
240
|
# Validate scope exists
|
|
228
241
|
unless respond_to?(:predicable_scope?) && predicable_scope?(predicate_scope)
|
|
229
|
-
raise InvalidPredicateError,
|
|
230
|
-
"Invalid predicate scope: #{predicate_scope}. " \
|
|
231
|
-
"Available predicable scopes: #{predicable_scopes.to_a.join(', ')}"
|
|
242
|
+
raise BetterModel::Errors::Searchable::InvalidPredicateError, "Invalid predicate"
|
|
232
243
|
end
|
|
233
244
|
|
|
234
245
|
# Skip nil/blank values (but not false)
|
|
@@ -257,13 +268,11 @@ module BetterModel
|
|
|
257
268
|
temp_scope = all
|
|
258
269
|
condition_hash.symbolize_keys.each do |predicate_scope, value|
|
|
259
270
|
unless respond_to?(:predicable_scope?) && predicable_scope?(predicate_scope)
|
|
260
|
-
raise InvalidPredicateError,
|
|
261
|
-
"Invalid predicate in OR condition: #{predicate_scope}. " \
|
|
262
|
-
"Available predicable scopes: #{predicable_scopes.to_a.join(', ')}"
|
|
271
|
+
raise BetterModel::Errors::Searchable::InvalidPredicateError, "Invalid predicate"
|
|
263
272
|
end
|
|
264
273
|
|
|
265
274
|
temp_scope = if value == true || value == "true"
|
|
266
|
-
temp_scope.public_send(predicate_scope)
|
|
275
|
+
temp_scope.public_send(predicate_scope, true)
|
|
267
276
|
elsif value.is_a?(Array)
|
|
268
277
|
# Splat array values for predicates like _between that expect multiple args
|
|
269
278
|
temp_scope.public_send(predicate_scope, *value)
|
|
@@ -284,9 +293,7 @@ module BetterModel
|
|
|
284
293
|
|
|
285
294
|
# Validate scope exists
|
|
286
295
|
unless respond_to?(:sortable_scope?) && sortable_scope?(order_scope)
|
|
287
|
-
raise InvalidOrderError,
|
|
288
|
-
"Invalid order scope: #{order_scope}. " \
|
|
289
|
-
"Available sortable scopes: #{sortable_scopes.to_a.join(', ')}"
|
|
296
|
+
raise BetterModel::Errors::Searchable::InvalidOrderError, "Invalid order scope"
|
|
290
297
|
end
|
|
291
298
|
|
|
292
299
|
# Apply scope
|
|
@@ -302,26 +309,28 @@ module BetterModel
|
|
|
302
309
|
per_page = pagination_params[:per_page]&.to_i
|
|
303
310
|
|
|
304
311
|
# Validate page >= 1 (always, even without per_page)
|
|
305
|
-
|
|
312
|
+
if page < 1
|
|
313
|
+
raise BetterModel::Errors::Searchable::InvalidPaginationError, "Invalid pagination parameters"
|
|
314
|
+
end
|
|
306
315
|
|
|
307
316
|
# DoS protection: limita il numero massimo di pagina per evitare offset enormi
|
|
308
317
|
# Default 10000, configurabile tramite searchable config
|
|
309
318
|
max_page = searchable_config[:max_page] || 10_000
|
|
310
319
|
if page > max_page
|
|
311
|
-
raise InvalidPaginationError,
|
|
312
|
-
"page must be <= #{max_page} (DoS protection). " \
|
|
313
|
-
"Configure max_page in searchable block to change this limit."
|
|
320
|
+
raise BetterModel::Errors::Searchable::InvalidPaginationError, "Page number exceeds maximum allowed"
|
|
314
321
|
end
|
|
315
322
|
|
|
316
323
|
# If per_page is not provided, return scope without LIMIT
|
|
317
324
|
return scope if per_page.nil?
|
|
318
325
|
|
|
319
326
|
# Validate per_page >= 1
|
|
320
|
-
|
|
327
|
+
if per_page < 1
|
|
328
|
+
raise BetterModel::Errors::Searchable::InvalidPaginationError, "Invalid pagination parameters"
|
|
329
|
+
end
|
|
321
330
|
|
|
322
|
-
# Respect max_per_page limit if configured
|
|
323
|
-
if searchable_config[:max_per_page].present?
|
|
324
|
-
per_page
|
|
331
|
+
# Respect max_per_page limit if configured - raise error if exceeded
|
|
332
|
+
if searchable_config[:max_per_page].present? && per_page > searchable_config[:max_per_page]
|
|
333
|
+
raise BetterModel::Errors::Searchable::InvalidPaginationError, "per_page must be <= #{searchable_config[:max_per_page]}"
|
|
325
334
|
end
|
|
326
335
|
|
|
327
336
|
offset = (page - 1) * per_page
|
|
@@ -355,9 +364,7 @@ module BetterModel
|
|
|
355
364
|
securities_config = searchable_config[:securities] || {}
|
|
356
365
|
|
|
357
366
|
unless securities_config.key?(security_name)
|
|
358
|
-
raise InvalidSecurityError,
|
|
359
|
-
"Unknown security: #{security_name}. " \
|
|
360
|
-
"Available securities: #{securities_config.keys.join(', ')}"
|
|
367
|
+
raise BetterModel::Errors::Searchable::InvalidSecurityError, "Unknown security policy"
|
|
361
368
|
end
|
|
362
369
|
|
|
363
370
|
# Ottieni i predicati obbligatori per questa security
|
|
@@ -373,9 +380,7 @@ module BetterModel
|
|
|
373
380
|
end
|
|
374
381
|
|
|
375
382
|
if missing_or_blank.any?
|
|
376
|
-
raise InvalidSecurityError,
|
|
377
|
-
"Security :#{security_name} requires the following predicates with valid values: #{missing_or_blank.join(', ')}. " \
|
|
378
|
-
"These predicates must be present and have non-blank values in the search parameters."
|
|
383
|
+
raise BetterModel::Errors::Searchable::InvalidSecurityError, "Required security predicates missing"
|
|
379
384
|
end
|
|
380
385
|
end
|
|
381
386
|
|
|
@@ -388,9 +393,7 @@ module BetterModel
|
|
|
388
393
|
securities_config = searchable_config[:securities] || {}
|
|
389
394
|
|
|
390
395
|
unless securities_config.key?(security_name)
|
|
391
|
-
raise InvalidSecurityError,
|
|
392
|
-
"Unknown security: #{security_name}. " \
|
|
393
|
-
"Available securities: #{securities_config.keys.join(', ')}"
|
|
396
|
+
raise BetterModel::Errors::Searchable::InvalidSecurityError, "Unknown security policy"
|
|
394
397
|
end
|
|
395
398
|
|
|
396
399
|
# Ottieni i predicati obbligatori per questa security
|
|
@@ -410,10 +413,7 @@ module BetterModel
|
|
|
410
413
|
end
|
|
411
414
|
|
|
412
415
|
if missing_or_blank.any?
|
|
413
|
-
raise InvalidSecurityError,
|
|
414
|
-
"Security :#{security_name} violation in OR condition ##{index + 1}. " \
|
|
415
|
-
"Each OR condition must include the following predicates with valid values: #{missing_or_blank.join(', ')}. " \
|
|
416
|
-
"This prevents bypassing security rules through OR logic."
|
|
416
|
+
raise BetterModel::Errors::Searchable::InvalidSecurityError, "Required security predicates missing in OR condition"
|
|
417
417
|
end
|
|
418
418
|
end
|
|
419
419
|
end
|
|
@@ -425,9 +425,7 @@ module BetterModel
|
|
|
425
425
|
total_predicates = predicates.size
|
|
426
426
|
|
|
427
427
|
if total_predicates > max_predicates
|
|
428
|
-
raise
|
|
429
|
-
"Query too complex: #{total_predicates} predicates exceeds maximum of #{max_predicates}. " \
|
|
430
|
-
"Configure max_predicates in searchable block to change this limit."
|
|
428
|
+
raise BetterModel::Errors::Searchable::ConfigurationError, "Invalid configuration"
|
|
431
429
|
end
|
|
432
430
|
|
|
433
431
|
# Limita il numero di condizioni OR (default 50, configurabile)
|
|
@@ -437,9 +435,7 @@ module BetterModel
|
|
|
437
435
|
or_count = Array(or_conditions).size
|
|
438
436
|
|
|
439
437
|
if or_count > max_or_conditions
|
|
440
|
-
raise
|
|
441
|
-
"Query too complex: #{or_count} OR conditions exceeds maximum of #{max_or_conditions}. " \
|
|
442
|
-
"Configure max_or_conditions in searchable block to change this limit."
|
|
438
|
+
raise BetterModel::Errors::Searchable::ConfigurationError, "Invalid configuration"
|
|
443
439
|
end
|
|
444
440
|
|
|
445
441
|
# Conta anche i predicati dentro ogni condizione OR
|
|
@@ -447,18 +443,16 @@ module BetterModel
|
|
|
447
443
|
total_with_or = total_predicates + or_predicates_count
|
|
448
444
|
|
|
449
445
|
if total_with_or > max_predicates
|
|
450
|
-
raise
|
|
451
|
-
"Query too complex: #{total_with_or} total predicates (including OR conditions) exceeds maximum of #{max_predicates}. " \
|
|
452
|
-
"Configure max_predicates in searchable block to change this limit."
|
|
446
|
+
raise BetterModel::Errors::Searchable::ConfigurationError, "Query complexity exceeds maximum allowed predicates"
|
|
453
447
|
end
|
|
454
448
|
end
|
|
455
449
|
end
|
|
456
450
|
|
|
457
|
-
#
|
|
451
|
+
# Instance Methods
|
|
458
452
|
|
|
459
|
-
#
|
|
453
|
+
# Returns search metadata for this record.
|
|
460
454
|
#
|
|
461
|
-
# @return [Hash] Hash
|
|
455
|
+
# @return [Hash] Hash with information about searchable and sortable fields
|
|
462
456
|
#
|
|
463
457
|
# @example
|
|
464
458
|
# article.search_metadata
|
|
@@ -532,13 +526,19 @@ module BetterModel
|
|
|
532
526
|
@config[:max_or_conditions] = count.to_i
|
|
533
527
|
end
|
|
534
528
|
|
|
535
|
-
def security(name, predicates_array)
|
|
529
|
+
def security(name, predicates_array = nil)
|
|
536
530
|
name = name.to_sym
|
|
531
|
+
|
|
532
|
+
# Check if predicates_array is provided
|
|
533
|
+
if predicates_array.nil?
|
|
534
|
+
raise BetterModel::Errors::Searchable::ConfigurationError, "Invalid configuration"
|
|
535
|
+
end
|
|
536
|
+
|
|
537
537
|
predicates_array = Array(predicates_array).map(&:to_sym)
|
|
538
538
|
|
|
539
539
|
# Valida che i predicati richiesti siano simboli validi
|
|
540
540
|
if predicates_array.empty?
|
|
541
|
-
raise
|
|
541
|
+
raise BetterModel::Errors::Searchable::ConfigurationError, "Invalid configuration"
|
|
542
542
|
end
|
|
543
543
|
|
|
544
544
|
@config[:securities][name] = predicates_array
|