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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +96 -3
- data/lib/eav_field/attribute_definition.rb +34 -0
- data/lib/eav_field/definition_cache.rb +71 -0
- data/lib/eav_field/has_fields.rb +73 -21
- data/lib/eav_field/query.rb +137 -0
- data/lib/eav_field/railtie.rb +1 -0
- data/lib/eav_field/validation_rules.rb +96 -0
- data/lib/eav_field/value.rb +6 -4
- data/lib/eav_field/version.rb +1 -1
- data/lib/eav_field.rb +3 -0
- data/lib/generators/eav_field/install/templates/create_eav_field_tables.rb.tt +1 -0
- data/lib/generators/eav_field/upgrade/templates/add_eav_field_validation_rules.rb.tt +25 -0
- data/lib/generators/eav_field/upgrade/upgrade_generator.rb +25 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b896c149cbc241d3b66424de96a67efe6c0c8681b9e2e30236e75e2139483904
|
|
4
|
+
data.tar.gz: 86a6a9238de4349fd387806ffd1e26b859c03b09de5cdd8265d2f2088e950dbf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
data/lib/eav_field/has_fields.rb
CHANGED
|
@@ -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 :
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
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
|
-
|
|
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
|
data/lib/eav_field/railtie.rb
CHANGED
|
@@ -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
|
data/lib/eav_field/value.rb
CHANGED
|
@@ -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.
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
data/lib/eav_field/version.rb
CHANGED
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
|
|
@@ -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.
|
|
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.
|
|
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:
|