eav_field 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad4c68d056641acdb72729aa099b55285fb948de48ed41511fdb2472f1a6292c
4
- data.tar.gz: 74be0f6ec80589ee374f5604fe347d97a5e07bf4933ab7bc5b6ac8cbe7dc8302
3
+ metadata.gz: b896c149cbc241d3b66424de96a67efe6c0c8681b9e2e30236e75e2139483904
4
+ data.tar.gz: 86a6a9238de4349fd387806ffd1e26b859c03b09de5cdd8265d2f2088e950dbf
5
5
  SHA512:
6
- metadata.gz: afedd539adaa261644b67ab9078edb02e845a1189abfaa31e975d74bc5a8284b361da247564fdc0149cca96812ba75bafd23d0f55c0f5eacc07fa0ff974f6bf6
7
- data.tar.gz: 359ba7b4509ed4f67723922a37c8849c5ca15ce5ef78abd7f63dbca9ada9040ccea75c165b8359564e95fbdad40cd7a811e6e92b5cac94ef4532cbf9787013db
6
+ metadata.gz: 686203fd2a23f9ef7d5d3aa6067cd361d1314e3aa47bda04a49c1fead8f387dbbbb8136e718f9872d78d5025f5fae5c46a9f09bd93bdc196898febd18af209fc
7
+ data.tar.gz: 3bdc289b5159e9aa213254dfd55758d72444b1006a40c650d64760cb35c21c1c75649459afed43f61a9c14ee162377a916f7564abae30b82c8bb1f091e4b3f3c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 — Unreleased
4
+
5
+ - Add persisted minimum/maximum, length, and allowed-choice validation rules with owner errors and validated defaults.
6
+ - Add application-defined `validate_eav_field` blocks with inheritance and support for defaults.
7
+ - Add typed comparisons, ranges, inclusion, inequality, and missing-row filters.
8
+ - Add EAV sorting with effective defaults and explicit null placement.
9
+ - Add collection preloading, owner definition snapshots, and execution-local definition cache blocks.
10
+ - Invalidate local definition caches on changes, commits, and rollbacks; provide explicit refresh for external or bulk changes.
11
+ - Add an upgrade generator for existing 0.1.0 installations and preserve stored values during SQLite rollback.
12
+
3
13
  ## 0.1.0 — 2026-10-09
4
14
 
5
15
  - Add `has_eav_fields` for existing Active Record models.
data/README.md CHANGED
@@ -4,6 +4,8 @@ EavField adds an entity-attribute-value (EAV) system to existing Active Record m
4
4
 
5
5
  Requires Ruby 3.2+ and Active Record/Railties 7.1–8.x. The gem constrains JSON to 2.x for compatibility with these Rails versions. The development suite uses SQLite and Minitest.
6
6
 
7
+ The published release is 0.1.0. This checkout prepares 0.2.0 with validation rules, advanced queries, sorting, and collection preloading. Use a local path or the GitHub dependency below to try these additions before publication.
8
+
7
9
  ## Install in a Rails application
8
10
 
9
11
  Add the gem to your application's `Gemfile`:
@@ -22,6 +24,12 @@ bin/rails db:migrate
22
24
 
23
25
  For local gem development, replace the dependency with `gem "eav_field", path: "../eav_field"`, adjusting the path to this checkout.
24
26
 
27
+ To use the upcoming 0.2.0 code from GitHub:
28
+
29
+ ```ruby
30
+ gem "eav_field", github: "Mouados-byte/eav_field", branch: "main"
31
+ ```
32
+
25
33
  The generator creates `eav_field_definitions` and `eav_field_values`. Owner IDs use `bigint` by default. For PostgreSQL UUID owners, generate with `--entity-id-type=uuid`; for string IDs, use `--entity-id-type=string`. All participating models must have IDs compatible with the selected column type.
26
34
 
27
35
  Opt in on any existing model:
@@ -32,6 +40,17 @@ class Product < ApplicationRecord
32
40
  end
33
41
  ```
34
42
 
43
+ ## Upgrade an existing 0.1.0 installation
44
+
45
+ After updating the dependency to the 0.2.0 code, generate and run the additive migration:
46
+
47
+ ```sh
48
+ bin/rails generate eav_field:upgrade
49
+ bin/rails db:migrate
50
+ ```
51
+
52
+ This adds the nullable `validation_rules` JSON column and preserves definitions and values. Existing definitions start without extra rules. Fresh installations already include this column; run only `eav_field:install` for those. Roll back the upgrade migration before switching the application back to 0.1.0. Rolling back discards validation rules but preserves stored values.
53
+
35
54
  ## Define and use fields
36
55
 
37
56
  Create definitions after migrating, for example in a Rails console or data migration:
@@ -85,7 +104,43 @@ product.save!
85
104
  product.eav_fields[:color] # => "black"
86
105
  ```
87
106
 
88
- Definition names, types, and entity scopes are immutable after creation. Defaults and `required` flags can be updated through `Product.eav_field_definitions`. Adding a required definition affects validation of existing owners on their next save.
107
+ Definition names, types, and entity scopes are immutable after creation. Defaults, `required` flags, and `validation_rules` can be updated through `Product.eav_field_definitions`. Adding or tightening a rule affects existing owners on their next validation; it does not rewrite stored values.
108
+
109
+ ## Validation rules
110
+
111
+ ```ruby
112
+ Product.define_eav_field(:weight, type: :decimal, min: 0, max: "100.5")
113
+ Product.define_eav_field(:size, type: :string, choices: %w[small medium large])
114
+ Product.define_eav_field(:serial, type: :string, min_length: 3, max_length: 20)
115
+ Product.define_eav_field(:released_on, type: :date, min: "2026-01-01")
116
+
117
+ product.eav_fields[:weight] = -1
118
+ product.valid? # => false
119
+ product.errors["eav_fields.weight"] # => ["must be greater than or equal to 0"]
120
+ ```
121
+
122
+ `min` and `max` are inclusive and support integer, decimal, date, and datetime fields. `min_length` and `max_length` count characters in strings and text. `choices` accepts a nonempty array of values matching the field's type; each choice must also satisfy the bounds and length rules. Optional `nil` values skip these rules; use `required: true` to reject missing values. Unknown or incompatible rules and defaults that violate rules are rejected when saving the definition.
123
+
124
+ Type errors still raise immediately on assignment. Rule violations participate in owner validation, so forms can display errors before saving. Direct value saves also enforce the persisted rules. To edit rules, replace the definition's hash:
125
+
126
+ ```ruby
127
+ Product.eav_field_definitions.find_by!(name: "weight")
128
+ .update!(validation_rules: { min: 1, max: 200 })
129
+ ```
130
+
131
+ Keep custom validators in model code. They run when validating an owner, receive its effective value including defaults, and can add ordinary Rails errors:
132
+
133
+ ```ruby
134
+ class Product < ApplicationRecord
135
+ has_eav_fields
136
+
137
+ validate_eav_field(:quantity) do |record, value|
138
+ record.errors.add("eav_fields.quantity", "must be even") if value && value.odd?
139
+ end
140
+ end
141
+ ```
142
+
143
+ Multiple blocks accumulate and are inherited by subclasses. Registering a block does not create a definition; define the field separately after migration. Custom blocks run through owner validation, so save the owner when using them.
89
144
 
90
145
  ## Query fields
91
146
 
@@ -93,9 +148,47 @@ Definition names, types, and entity scopes are immutable after creation. Default
93
148
  Product.with_eav_field(:color, "blue")
94
149
  Product.where(name: "Chair").with_eav_field(:weight, "1.25")
95
150
  Product.with_eav_field(:color, "blue").with_eav_field(:available, false)
151
+ Product.with_eav_field(:weight, gt: "1.25", lte: "10")
152
+ Product.with_eav_field(:weight, 1...10)
153
+ Product.with_eav_field(:color, in: %w[blue green])
154
+ Product.with_eav_field(:color, missing: true)
155
+ Product.order_by_eav_field(:weight, :asc, nulls: :last).order(:id)
156
+ ```
157
+
158
+ These return chainable Active Record relations and use persisted values. Matching defaults include owners without a stored override; explicit null overrides remain distinct from non-null defaults. Filter inputs are type-cast but need not satisfy validation rules: `gt: 0` is valid even if a field requires `min: 5`.
159
+
160
+ | Operator | Behavior |
161
+ | --- | --- |
162
+ | `eq`, `not_eq` | Equality and inequality; a positional value means `eq`. |
163
+ | `gt`, `gte`, `lt`, `lte` | Numeric, date, or datetime comparisons. |
164
+ | `in` | Match a typed array of values; `nil` matches null values; an empty array matches nothing. |
165
+ | `between` | A Ruby range or inclusive `[lower, upper]` pair; exclusive and open-ended ranges are supported. A positional range means `between`. |
166
+ | `missing` | `true` means no stored row, even when a default exists; `false` means a stored row exists, including an explicit null. Use this operator alone. |
167
+
168
+ Multiple operators in one call use AND. Supply either a positional value or keyword operators. Inequality follows SQL null semantics: `not_eq: "blue"` excludes nulls; `not_eq: nil` selects non-null effective values. JSON supports `missing` filters; other comparisons and sorting raise `EavField::UnsupportedQuery` because their behavior differs between adapters.
169
+
170
+ Sorting uses effective defaults, preserves explicit null overrides, and supports `:asc`/`:desc` and `nulls: :first`/`:last`. It appends to existing ordering. Add a normal column such as `.order(:id)` to break ties. String ordering follows the database's collation.
171
+
172
+ ## Efficient loading and definition caching
173
+
174
+ ```ruby
175
+ products = Product.preload_eav_fields(Product.where(name: "Chair").limit(100))
176
+ products.each do |product|
177
+ puts product.eav_fields[:color]
178
+ puts product.eav_fields[:weight]
179
+ end
180
+
181
+ Product.with_eav_definition_cache do
182
+ Product.with_eav_field(:color, "blue").count
183
+ Product.with_eav_field(:weight, gt: 1).count
184
+ end
96
185
  ```
97
186
 
98
- These return chainable Active Record relations with typed equality comparisons. Matching defaults include owners without a stored override; explicit null overrides remain distinct from non-null defaults. Queries use persisted values. JSON equality raises `EavField::UnsupportedQuery` because JSON comparisons differ between database adapters.
187
+ `preload_eav_fields` returns an array, batches value loading, and shares one definition snapshot across the records. Repeated reads of preloaded fields require no queries. Pass owners from one model or its STI subclasses; unsaved changes are preserved. For large exports, call it for each `find_in_batches` batch.
188
+
189
+ Owners cache their definition snapshot on first use. `with_eav_definition_cache` additionally shares definitions within an execution-local block, including class-level lookups. Nested blocks share the same cache, which is removed when the outer block exits.
190
+
191
+ Definition creates, updates, deletes, commits, and rollbacks through Active Record callbacks invalidate snapshots in the current process. Loaded owners retain snapshots of changes made by another process, just as loaded associations do. Refresh them with `product.reload_eav_field_definitions` or `product.reload`; call `Product.reset_eav_field_cache` after bulk changes that bypass callbacks. These caches do not retain definition records globally across requests or database connections.
99
192
 
100
193
  ## Persistence and lifecycle
101
194
 
@@ -117,7 +210,7 @@ bundle exec rake test # Minitest, including real Rails installation and ro
117
210
  bundle exec rubocop # Lint and check formatting.
118
211
  bundle exec rubocop -a # Apply safe formatting corrections.
119
212
  bundle exec rake # Tests and lint together.
120
- bundle exec rake build # Build pkg/eav_field-0.1.0.gem.
213
+ bundle exec rake build # Build pkg/eav_field-0.2.0.gem.
121
214
  ```
122
215
 
123
216
  To check a different Rails series:
@@ -13,7 +13,14 @@ module EavField
13
13
  validates :required, inclusion: { in: [ true, false ] }
14
14
 
15
15
  before_validation :normalize_default
16
+ before_validation :normalize_validation_rules
16
17
  validate :identity_is_immutable, on: :update
18
+ validate :default_satisfies_rules
19
+
20
+ after_save :invalidate_definition_cache
21
+ after_destroy :invalidate_definition_cache
22
+ after_commit :invalidate_definition_cache
23
+ after_rollback :invalidate_definition_cache
17
24
 
18
25
  def cast(value)
19
26
  Types.cast(data_type, value)
@@ -23,8 +30,35 @@ module EavField
23
30
  cast(default_value)
24
31
  end
25
32
 
33
+ def validation_errors(value)
34
+ errors = ValidationRules.new(data_type, validation_rules || {}).errors_for(cast(value))
35
+ errors.unshift("can't be blank") if required? && Types.missing?(value)
36
+ errors
37
+ end
38
+
26
39
  private
27
40
 
41
+ def normalize_validation_rules
42
+ return unless Types::COLUMNS.key?(data_type)
43
+
44
+ input = validation_rules.nil? ? {} : validation_rules
45
+ self.validation_rules = ValidationRules.new(data_type, input).rules
46
+ rescue InvalidValue => error
47
+ errors.add(:validation_rules, error.message)
48
+ end
49
+
50
+ def default_satisfies_rules
51
+ return if default_value.nil? || errors.any? || !Types::COLUMNS.key?(data_type)
52
+
53
+ ValidationRules.new(data_type, validation_rules || {}).errors_for(default).each do |message|
54
+ errors.add(:default_value, message)
55
+ end
56
+ end
57
+
58
+ def invalidate_definition_cache
59
+ DefinitionCache.invalidate(entity_type)
60
+ end
61
+
28
62
  def normalize_default
29
63
  return unless Types::COLUMNS.key?(data_type)
30
64
 
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EavField
4
+ # Cache records on owners and within explicit blocks, never for an entire process.
5
+ module DefinitionCache
6
+ STATE_KEY = :eav_field_definition_cache
7
+ @versions = Hash.new(0)
8
+ @mutex = Mutex.new
9
+
10
+ class Snapshot
11
+ def initialize(model)
12
+ @model = model.base_class
13
+ @key = DefinitionCache.key(@model.polymorphic_name)
14
+ end
15
+
16
+ def definitions
17
+ version = DefinitionCache.version(@key)
18
+ if @version != version
19
+ @definitions = @model.eav_field_definitions.to_a.index_by(&:name).freeze
20
+ @version = version
21
+ end
22
+ @definitions
23
+ end
24
+
25
+ def find!(name)
26
+ definitions.fetch(name.to_s) do
27
+ raise UnknownField, "Unknown EAV field #{name.inspect} for #{@model.name}"
28
+ end
29
+ end
30
+
31
+ def reload
32
+ @version = nil
33
+ definitions
34
+ self
35
+ end
36
+ end
37
+
38
+ module_function
39
+
40
+ def key(entity_type)
41
+ [ AttributeDefinition.connection_pool, entity_type ]
42
+ end
43
+
44
+ def version(key)
45
+ @mutex.synchronize { @versions[key] }
46
+ end
47
+
48
+ def invalidate(entity_type)
49
+ @mutex.synchronize { @versions[key(entity_type)] += 1 }
50
+ end
51
+
52
+ def fetch(model)
53
+ cache = ActiveSupport::IsolatedExecutionState[STATE_KEY]
54
+ return Snapshot.new(model) unless cache
55
+
56
+ cache[key(model.polymorphic_name)] ||= Snapshot.new(model)
57
+ end
58
+
59
+ def active?
60
+ !!ActiveSupport::IsolatedExecutionState[STATE_KEY]
61
+ end
62
+
63
+ def with
64
+ previous = ActiveSupport::IsolatedExecutionState[STATE_KEY]
65
+ ActiveSupport::IsolatedExecutionState[STATE_KEY] = previous || {}
66
+ yield
67
+ ensure
68
+ ActiveSupport::IsolatedExecutionState[STATE_KEY] = previous
69
+ end
70
+ end
71
+ end
@@ -4,6 +4,10 @@ module EavField
4
4
  module HasFields
5
5
  extend ActiveSupport::Concern
6
6
 
7
+ included do
8
+ class_attribute :eav_field_validators, instance_accessor: false, default: {}.freeze
9
+ end
10
+
7
11
  class_methods do
8
12
  def has_eav_fields
9
13
  association = reflect_on_association(:eav_values)
@@ -12,7 +16,7 @@ module EavField
12
16
 
13
17
  has_many :eav_values, -> { includes(:attribute_definition) }, as: :entity,
14
18
  class_name: "EavField::Value", inverse_of: :entity, autosave: true, dependent: :destroy
15
- validate :validate_required_eav_fields
19
+ validate :validate_eav_fields
16
20
  end
17
21
 
18
22
  def eav_field_definitions
@@ -20,28 +24,56 @@ module EavField
20
24
  AttributeDefinition.where(entity_type: polymorphic_name)
21
25
  end
22
26
 
23
- def define_eav_field(name, type:, required: false, default: nil)
27
+ def define_eav_field(name, type:, required: false, default: nil, **rules)
24
28
  eav_field_definitions.create!(name: name.to_s, data_type: type.to_s,
25
- required: required, default_value: default)
29
+ required: required, default_value: default, validation_rules: rules)
26
30
  end
27
31
 
28
32
  def find_eav_field!(name)
33
+ return DefinitionCache.fetch(self).find!(name) if DefinitionCache.active?
34
+
29
35
  eav_field_definitions.find_by(name: name.to_s) ||
30
36
  raise(UnknownField, "Unknown EAV field #{name.inspect} for #{self.name}")
31
37
  end
32
38
 
33
- def with_eav_field(name, value)
34
- definition = find_eav_field!(name)
35
- raise UnsupportedQuery, "JSON field equality is not portable across databases" if definition.data_type == "json"
39
+ def with_eav_field(name, value = Query::UNSET, **conditions)
40
+ Query.new(self, find_eav_field!(name)).filter(value, conditions)
41
+ end
42
+
43
+ def order_by_eav_field(name, direction = :asc, nulls: :last)
44
+ Query.new(self, find_eav_field!(name)).order(direction, nulls: nulls)
45
+ end
46
+
47
+ def validate_eav_field(name, &validator)
48
+ raise ArgumentError, "A validator block is required" unless validator
49
+
50
+ validators = [ *eav_field_validators[name.to_s], validator ].freeze
51
+ self.eav_field_validators = eav_field_validators.merge(name.to_s => validators).freeze
52
+ end
53
+
54
+ def with_eav_definition_cache(&block)
55
+ ensure_eav_fields_enabled!
56
+ DefinitionCache.with(&block)
57
+ end
58
+
59
+ def reset_eav_field_cache
60
+ ensure_eav_fields_enabled!
61
+ DefinitionCache.invalidate(polymorphic_name)
62
+ end
36
63
 
37
- cast_value = definition.cast(value)
38
- entries = Value.where(attribute_definition_id: definition.id, entity_type: polymorphic_name)
39
- matching = entries.where(Types.column_for(definition.data_type) => cast_value).select(:entity_id)
40
- result = where(primary_key => matching)
41
- if cast_value == definition.default
42
- result = result.or(where.not(primary_key => entries.select(:entity_id)))
64
+ def preload_eav_fields(records)
65
+ ensure_eav_fields_enabled!
66
+ records = records.to_a
67
+ unless records.all? { |record| record.is_a?(base_class) }
68
+ raise ArgumentError, "Records must belong to #{base_class.name}"
43
69
  end
44
- result
70
+ return records if records.empty?
71
+
72
+ snapshot = DefinitionCache.fetch(self)
73
+ snapshot.definitions
74
+ ActiveRecord::Associations::Preloader.new(records: records, associations: :eav_values).call
75
+ records.each { |record| record.instance_variable_set(:@eav_definition_snapshot, snapshot) }
76
+ records
45
77
  end
46
78
 
47
79
  private
@@ -63,27 +95,27 @@ module EavField
63
95
  raise ArgumentError, "eav_fields must be a hash" unless fields.respond_to?(:each_pair)
64
96
 
65
97
  assignments = fields.each_pair.map do |name, value|
66
- definition = self.class.find_eav_field!(name)
98
+ definition = eav_definition_snapshot.find!(name)
67
99
  [ definition, definition.cast(value) ]
68
100
  end
69
101
  assignments.each { |definition, value| assign_eav_value(definition, value) }
70
102
  end
71
103
 
72
104
  def read_eav_field(name)
73
- definition = self.class.find_eav_field!(name)
105
+ definition = eav_definition_snapshot.find!(name)
74
106
  entry = eav_entry_for(definition)
75
107
  entry ? entry.value : definition.default
76
108
  end
77
109
 
78
110
  def write_eav_field(name, value)
79
- definition = self.class.find_eav_field!(name)
111
+ definition = eav_definition_snapshot.find!(name)
80
112
  assign_eav_value(definition, definition.cast(value))
81
113
  value
82
114
  end
83
115
 
84
116
  # Removing an override restores the definition's default on the next read.
85
117
  def delete_eav_field(name)
86
- definition = self.class.find_eav_field!(name)
118
+ definition = eav_definition_snapshot.find!(name)
87
119
  entry = eav_entry_for(definition)
88
120
  previous_value = entry ? entry.value : definition.default
89
121
  entry&.mark_for_destruction
@@ -93,14 +125,28 @@ module EavField
93
125
  def eav_fields_snapshot
94
126
  entries = eav_values.reject { |entry| entry.marked_for_destruction? || entry.destroyed? }
95
127
  .index_by(&:attribute_definition_id)
96
- self.class.eav_field_definitions.each_with_object({}.with_indifferent_access) do |definition, fields|
128
+ eav_definition_snapshot.definitions.each_value.with_object({}.with_indifferent_access) do |definition, fields|
97
129
  entry = entries[definition.id]
98
130
  fields[definition.name] = entry ? entry.value : definition.default
99
131
  end
100
132
  end
101
133
 
134
+ def reload(...)
135
+ super.tap { @eav_definition_snapshot&.reload }
136
+ end
137
+
138
+ def reload_eav_field_definitions
139
+ eav_definition_snapshot.reload
140
+ self
141
+ end
142
+
102
143
  private
103
144
 
145
+ def eav_definition_snapshot
146
+ self.class.send(:ensure_eav_fields_enabled!)
147
+ @eav_definition_snapshot ||= DefinitionCache.fetch(self.class)
148
+ end
149
+
104
150
  def eav_entry_for(definition)
105
151
  eav_values.detect do |entry|
106
152
  entry.attribute_definition_id == definition.id && !entry.marked_for_destruction? && !entry.destroyed?
@@ -116,14 +162,20 @@ module EavField
116
162
  entry = deleted_entry.reload if deleted_entry
117
163
  end
118
164
  entry ||= eav_values.build(attribute_definition: definition)
165
+ entry.attribute_definition = definition
119
166
  entry.value = value
120
167
  end
121
168
 
122
- def validate_required_eav_fields
123
- self.class.eav_field_definitions.where(required: true).each do |definition|
169
+ def validate_eav_fields
170
+ eav_definition_snapshot.definitions.each_value do |definition|
124
171
  entry = eav_entry_for(definition)
125
172
  value = entry ? entry.value : definition.default
126
- errors.add("eav_fields.#{definition.name}", "can't be blank") if Types.missing?(value)
173
+ definition.validation_errors(value).each do |message|
174
+ errors.add("eav_fields.#{definition.name}", message)
175
+ end
176
+ Array(self.class.eav_field_validators[definition.name]).each do |validator|
177
+ validator.call(self, value)
178
+ end
127
179
  end
128
180
  end
129
181
  end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EavField
4
+ class Query
5
+ UNSET = Object.new.freeze
6
+ OPERATORS = %i[ eq not_eq gt gte lt lte in between missing ].freeze
7
+
8
+ def initialize(model, definition)
9
+ @model = model
10
+ @definition = definition
11
+ @scope = model.all
12
+ @entries = Value.where(attribute_definition_id: definition.id, entity_type: model.polymorphic_name)
13
+ @column = Value.arel_table[Types.column_for(definition.data_type)]
14
+ end
15
+
16
+ def filter(value, conditions)
17
+ conditions = normalize_conditions(value, conditions)
18
+ if conditions.key?(:missing)
19
+ unless conditions.size == 1 && [ true, false ].include?(conditions[:missing])
20
+ raise UnsupportedQuery, "missing must be a boolean and used on its own"
21
+ end
22
+ ids = @entries.select(:entity_id)
23
+ return conditions[:missing] ? @scope.where.not(@model.primary_key => ids) : @scope.where(@model.primary_key => ids)
24
+ end
25
+ ensure_scalar!
26
+
27
+ predicates = []
28
+ default_predicates = []
29
+ default = Arel::Nodes.build_quoted(@definition.default, @column)
30
+ conditions.each do |operator, input|
31
+ predicates << predicate_for(@column, operator, input)
32
+ default_predicates << predicate_for(default, operator, input)
33
+ end
34
+ matching = @entries.where(predicates.reduce(&:and)).select(:entity_id)
35
+ result = @scope.where(@model.primary_key => matching)
36
+ # Evaluate fallback defaults in SQL using the same operators as stored values.
37
+ missing = @scope.where.not(@model.primary_key => @entries.select(:entity_id))
38
+ .where(default_predicates.reduce(&:and))
39
+ result.or(missing)
40
+ end
41
+
42
+ def order(direction, nulls:)
43
+ ensure_scalar!
44
+ direction = direction.to_s
45
+ nulls = nulls.to_s
46
+ raise UnsupportedQuery, "Direction must be asc or desc" unless %w[ asc desc ].include?(direction)
47
+ raise UnsupportedQuery, "nulls must be first or last" unless %w[ first last ].include?(nulls)
48
+
49
+ owner = @model.arel_table
50
+ values = Value.arel_table.alias("eav_sort_#{@definition.id}")
51
+ join = owner.join(values, Arel::Nodes::OuterJoin).on(
52
+ values[:entity_id].eq(owner[@model.primary_key])
53
+ .and(values[:entity_type].eq(@model.polymorphic_name))
54
+ .and(values[:attribute_definition_id].eq(@definition.id))
55
+ ).join_sources
56
+ default = Arel::Nodes.build_quoted(@definition.default, @column)
57
+ effective = Arel::Nodes::Case.new.when(values[:id].eq(nil)).then(default)
58
+ .else(values[Types.column_for(@definition.data_type)])
59
+ null_order = Arel::Nodes::Case.new.when(effective.eq(nil)).then(nulls == "last" ? 1 : 0)
60
+ .else(nulls == "last" ? 0 : 1).asc
61
+ @scope.joins(join).order(null_order, direction == "asc" ? effective.asc : effective.desc)
62
+ end
63
+
64
+ private
65
+
66
+ def normalize_conditions(value, conditions)
67
+ unless value.equal?(UNSET)
68
+ raise UnsupportedQuery, "Pass a value or operators, not both" if conditions.any?
69
+
70
+ conditions = value.is_a?(Range) ? { between: value } : { eq: value }
71
+ end
72
+ unknown = conditions.keys - OPERATORS
73
+ if conditions.empty? || unknown.any?
74
+ raise UnsupportedQuery, "Supported operators: #{OPERATORS.join(', ')}"
75
+ end
76
+ conditions
77
+ end
78
+
79
+ def ensure_scalar!
80
+ raise UnsupportedQuery, "JSON field comparisons and sorting are not portable across databases" if @definition.data_type == "json"
81
+ end
82
+
83
+ def predicate_for(column, operator, input)
84
+ case operator
85
+ when :eq, :not_eq
86
+ value = @definition.cast(input)
87
+ operator == :eq ? column.eq(value) : column.not_eq(value)
88
+ when :in
89
+ inclusion_predicate(column, input)
90
+ when :between
91
+ range_predicate(column, input)
92
+ else
93
+ comparison_predicate(column, operator, input)
94
+ end
95
+ end
96
+
97
+ def inclusion_predicate(column, input)
98
+ raise UnsupportedQuery, "in requires an array" unless input.is_a?(Array)
99
+
100
+ values = input.map { |value| @definition.cast(value) }.uniq
101
+ predicate = column.in(values.compact)
102
+ predicate = predicate.or(column.eq(nil)) if values.include?(nil)
103
+ predicate
104
+ end
105
+
106
+ def comparison_predicate(column, operator, input)
107
+ ensure_ordered!
108
+ value = @definition.cast(input)
109
+ raise UnsupportedQuery, "Comparison bounds cannot be nil" if value.nil?
110
+
111
+ arel_operator = { gt: :gt, gte: :gteq, lt: :lt, lte: :lteq }.fetch(operator)
112
+ column.public_send(arel_operator, value)
113
+ end
114
+
115
+ def range_predicate(column, input)
116
+ ensure_ordered!
117
+ unless input.is_a?(Range) || (input.is_a?(Array) && input.size == 2)
118
+ raise UnsupportedQuery, "between requires a range or a two-element array"
119
+ end
120
+
121
+ lower, upper = input.is_a?(Range) ? [ input.begin, input.end ] : input
122
+ raise UnsupportedQuery, "A range needs at least one bound" if lower.nil? && upper.nil?
123
+
124
+ conditions = []
125
+ conditions << comparison_predicate(column, :gte, lower) unless lower.nil?
126
+ upper_operator = input.is_a?(Range) && input.exclude_end? ? :lt : :lte
127
+ conditions << comparison_predicate(column, upper_operator, upper) unless upper.nil?
128
+ conditions.reduce(&:and)
129
+ end
130
+
131
+ def ensure_ordered!
132
+ unless ValidationRules::ORDERED_TYPES.include?(@definition.data_type)
133
+ raise UnsupportedQuery, "Ordered comparisons require a numeric, date, or datetime field"
134
+ end
135
+ end
136
+ end
137
+ end
@@ -4,6 +4,7 @@ module EavField
4
4
  class Railtie < Rails::Railtie
5
5
  generators do
6
6
  require "generators/eav_field/install/install_generator"
7
+ require "generators/eav_field/upgrade/upgrade_generator"
7
8
  end
8
9
  end
9
10
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EavField
4
+ class ValidationRules
5
+ ORDERED_TYPES = %w[ integer decimal date datetime ].freeze
6
+ KEYS = %w[ min max min_length max_length choices ].freeze
7
+
8
+ attr_reader :rules
9
+
10
+ def initialize(type, input)
11
+ @type = type.to_s
12
+ raise InvalidValue, "Validation rules must be a hash" unless input.is_a?(Hash)
13
+
14
+ @rules = input.transform_keys(&:to_s)
15
+ unknown = @rules.keys - KEYS
16
+ raise InvalidValue, "Unknown validation rules: #{unknown.join(', ')}" if unknown.any?
17
+
18
+ normalize_bounds
19
+ normalize_lengths
20
+ normalize_choices
21
+ end
22
+
23
+ def errors_for(value)
24
+ return [] if value.nil?
25
+
26
+ errors = constraint_errors(value)
27
+ if rules.key?("choices") && !rules["choices"].any? { |choice| Types.cast(@type, choice) == value }
28
+ errors << "is not included in the allowed choices"
29
+ end
30
+ errors
31
+ end
32
+
33
+ private
34
+
35
+ def constraint_errors(value)
36
+ return [] if value.nil?
37
+
38
+ errors = []
39
+ if rules.key?("min") && value < Types.cast(@type, rules["min"])
40
+ errors << "must be greater than or equal to #{rules['min']}"
41
+ end
42
+ if rules.key?("max") && value > Types.cast(@type, rules["max"])
43
+ errors << "must be less than or equal to #{rules['max']}"
44
+ end
45
+ if rules.key?("min_length") && value.length < rules["min_length"]
46
+ errors << "is too short (minimum is #{rules['min_length']} characters)"
47
+ end
48
+ if rules.key?("max_length") && value.length > rules["max_length"]
49
+ errors << "is too long (maximum is #{rules['max_length']} characters)"
50
+ end
51
+ errors
52
+ end
53
+
54
+ def normalize_bounds
55
+ bounds = rules.keys & %w[ min max ]
56
+ return if bounds.empty?
57
+ raise InvalidValue, "Minimum and maximum require a numeric, date, or datetime field" unless ORDERED_TYPES.include?(@type)
58
+
59
+ bounds.each do |key|
60
+ raise InvalidValue, "#{key} cannot be nil" if rules[key].nil?
61
+
62
+ rules[key] = Types.dump(@type, rules[key])
63
+ end
64
+ if bounds.size == 2 && Types.cast(@type, rules["min"]) > Types.cast(@type, rules["max"])
65
+ raise InvalidValue, "Minimum cannot exceed maximum"
66
+ end
67
+ end
68
+
69
+ def normalize_lengths
70
+ lengths = rules.keys & %w[ min_length max_length ]
71
+ return if lengths.empty?
72
+ raise InvalidValue, "Length rules require a string or text field" unless %w[ string text ].include?(@type)
73
+
74
+ lengths.each do |key|
75
+ unless rules[key].is_a?(Integer) && rules[key] >= 0
76
+ raise InvalidValue, "#{key} must be a nonnegative integer"
77
+ end
78
+ end
79
+ if lengths.size == 2 && rules["min_length"] > rules["max_length"]
80
+ raise InvalidValue, "Minimum length cannot exceed maximum length"
81
+ end
82
+ end
83
+
84
+ def normalize_choices
85
+ return unless rules.key?("choices")
86
+ unless rules["choices"].is_a?(Array) && !rules["choices"].empty?
87
+ raise InvalidValue, "Choices must be a nonempty array"
88
+ end
89
+
90
+ rules["choices"] = rules["choices"].map { |choice| Types.dump(@type, choice) }.uniq
91
+ if rules["choices"].any? { |choice| constraint_errors(Types.cast(@type, choice)).any? }
92
+ raise InvalidValue, "Choices must satisfy the field's bounds and length rules"
93
+ end
94
+ end
95
+ end
96
+ end
@@ -38,11 +38,13 @@ module EavField
38
38
  if Types::COLUMNS.values.any? { |column| column != active_column && !self[column].nil? }
39
39
  errors.add(:value, "must use only the column matching its field type")
40
40
  end
41
- attribute_definition.cast(value)
42
- if attribute_definition.required? && Types.missing?(value)
43
- errors.add(:value, "can't be blank")
41
+ definition = if entity && entity.class.polymorphic_name == attribute_definition.entity_type
42
+ entity.send(:eav_definition_snapshot).find!(attribute_definition.name)
43
+ else
44
+ attribute_definition
44
45
  end
45
- rescue InvalidValue => error
46
+ definition.validation_errors(value).each { |message| errors.add(:value, message) }
47
+ rescue InvalidValue, UnknownField => error
46
48
  errors.add(:value, error.message)
47
49
  end
48
50
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EavField
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/eav_field.rb CHANGED
@@ -8,9 +8,12 @@ require "active_support/core_ext/hash/indifferent_access"
8
8
  require "eav_field/version"
9
9
  require "eav_field/errors"
10
10
  require "eav_field/types"
11
+ require "eav_field/validation_rules"
11
12
  require "eav_field/attribute_definition"
13
+ require "eav_field/definition_cache"
12
14
  require "eav_field/value"
13
15
  require "eav_field/field_set"
16
+ require "eav_field/query"
14
17
  require "eav_field/has_fields"
15
18
 
16
19
  ActiveSupport.on_load(:active_record) do
@@ -8,6 +8,7 @@ class CreateEavFieldTables < ActiveRecord::Migration[<%= migration_version %>]
8
8
  t.string :data_type, limit: 20, null: false
9
9
  t.boolean :required, default: false, null: false
10
10
  t.json :default_value
11
+ t.json :validation_rules
11
12
  t.timestamps
12
13
  end
13
14
 
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddEavFieldValidationRules < ActiveRecord::Migration[<%= migration_version %>]
4
+ # SQLite cannot disable foreign keys inside a transaction. Its table rebuild
5
+ # would otherwise cascade-delete values when rolling back this column.
6
+ disable_ddl_transaction!
7
+
8
+ def up
9
+ add_column :eav_field_definitions, :validation_rules, :json
10
+ end
11
+
12
+ def down
13
+ if connection.adapter_name == "SQLite"
14
+ if connection.transaction_open?
15
+ raise ActiveRecord::IrreversibleMigration, "Run this SQLite rollback outside an existing transaction"
16
+ end
17
+
18
+ connection.disable_referential_integrity do
19
+ remove_column :eav_field_definitions, :validation_rules, :json
20
+ end
21
+ else
22
+ remove_column :eav_field_definitions, :validation_rules, :json
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record/migration"
5
+
6
+ module EavField
7
+ module Generators
8
+ class UpgradeGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+ desc "Upgrade EavField 0.1 tables to support persisted validation rules."
13
+
14
+ def copy_migration
15
+ migration_template "add_eav_field_validation_rules.rb.tt", "db/migrate/add_eav_field_validation_rules.rb"
16
+ end
17
+
18
+ private
19
+
20
+ def migration_version
21
+ "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"
22
+ end
23
+ end
24
+ end
25
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eav_field
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mouad Imam
@@ -82,15 +82,20 @@ files:
82
82
  - README.md
83
83
  - lib/eav_field.rb
84
84
  - lib/eav_field/attribute_definition.rb
85
+ - lib/eav_field/definition_cache.rb
85
86
  - lib/eav_field/errors.rb
86
87
  - lib/eav_field/field_set.rb
87
88
  - lib/eav_field/has_fields.rb
89
+ - lib/eav_field/query.rb
88
90
  - lib/eav_field/railtie.rb
89
91
  - lib/eav_field/types.rb
92
+ - lib/eav_field/validation_rules.rb
90
93
  - lib/eav_field/value.rb
91
94
  - lib/eav_field/version.rb
92
95
  - lib/generators/eav_field/install/install_generator.rb
93
96
  - lib/generators/eav_field/install/templates/create_eav_field_tables.rb.tt
97
+ - lib/generators/eav_field/upgrade/templates/add_eav_field_validation_rules.rb.tt
98
+ - lib/generators/eav_field/upgrade/upgrade_generator.rb
94
99
  homepage: https://github.com/Mouados-byte/eav_field
95
100
  licenses:
96
101
  - MIT
@@ -100,7 +105,7 @@ metadata:
100
105
  source_code_uri: https://github.com/Mouados-byte/eav_field
101
106
  documentation_uri: https://github.com/Mouados-byte/eav_field#readme
102
107
  bug_tracker_uri: https://github.com/Mouados-byte/eav_field/issues
103
- changelog_uri: https://github.com/Mouados-byte/eav_field/blob/v0.1.0/CHANGELOG.md
108
+ changelog_uri: https://github.com/Mouados-byte/eav_field/blob/v0.2.0/CHANGELOG.md
104
109
  post_install_message:
105
110
  rdoc_options: []
106
111
  require_paths: