frozen_record 0.13.0 → 0.18.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 +5 -5
- data/.travis.yml +3 -3
- data/Gemfile +2 -0
- data/README.md +7 -5
- data/benchmark/attribute-read +22 -0
- data/benchmark/memory-usage +10 -0
- data/benchmark/setup.rb +6 -0
- data/frozen_record.gemspec +2 -0
- data/lib/frozen_record.rb +10 -0
- data/lib/frozen_record/backends.rb +8 -0
- data/lib/frozen_record/backends/json.rb +18 -0
- data/lib/frozen_record/backends/yaml.rb +24 -3
- data/lib/frozen_record/base.rb +101 -29
- data/lib/frozen_record/compact.rb +77 -0
- data/lib/frozen_record/deduplication.rb +57 -0
- data/lib/frozen_record/railtie.rb +9 -0
- data/lib/frozen_record/scope.rb +25 -1
- data/lib/frozen_record/test_helper.rb +2 -0
- data/lib/frozen_record/version.rb +3 -1
- data/spec/deduplication_spec.rb +31 -0
- data/spec/fixtures/{countries.yml → countries.yml.erb} +5 -0
- data/spec/fixtures/test_helper/{countries.yml → countries.yml.erb} +0 -0
- data/spec/frozen_record_spec.rb +114 -29
- data/spec/scope_spec.rb +17 -0
- data/spec/support/animal.rb +9 -13
- data/spec/support/car.rb +10 -0
- data/spec/support/country.rb +14 -0
- metadata +22 -13
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a47352d34eb964f32bf8d253cffe682abc221f80db5782ed7dce6aee76967661
|
4
|
+
data.tar.gz: ea62e1c1bcc0cf927a2614f2fb4cfc1acbb6780cdfec273a44dbbe1bd14a55e2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 33f76d7869d6a1e8a9cae954b074fa93cb89ffa3fcee9bc170d3e23b5ee2fcb39164a7e9de0b0f1570588bd7919d88b288bb35bc9c965cb32b77e3e9c6b5928a
|
7
|
+
data.tar.gz: e38977cfbdb6136aed4042060e130bc260f6e33c8db9c876f05f832d65037ba504fe7119a5b338c62e9a17391b2d1472ab2e2de5df5ae2c90b72772bd0599cd8
|
data/.travis.yml
CHANGED
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -50,7 +50,7 @@ but this option can be changed per model:
|
|
50
50
|
|
51
51
|
```ruby
|
52
52
|
class Country < FrozenRecord::Base
|
53
|
-
self.backend = FrozenRecord::Backends::
|
53
|
+
self.backend = FrozenRecord::Backends::Json
|
54
54
|
end
|
55
55
|
```
|
56
56
|
|
@@ -60,7 +60,7 @@ A custom backend must implement the methods `filename` and `load` as follows:
|
|
60
60
|
|
61
61
|
```ruby
|
62
62
|
module MyCustomBackend
|
63
|
-
extend
|
63
|
+
extend self
|
64
64
|
|
65
65
|
def filename(model_name)
|
66
66
|
# Returns the file name as a String
|
@@ -95,10 +95,12 @@ Country.
|
|
95
95
|
|
96
96
|
### Scopes
|
97
97
|
|
98
|
-
|
98
|
+
Basic `scope :symbol, lambda` syntax is now supported in addition to class method syntax.
|
99
99
|
|
100
100
|
```ruby
|
101
101
|
class Country
|
102
|
+
scope :european, -> { where(continent: 'Europe' ) }
|
103
|
+
|
102
104
|
def self.republics
|
103
105
|
where(king: nil)
|
104
106
|
end
|
@@ -108,7 +110,7 @@ class Country
|
|
108
110
|
end
|
109
111
|
end
|
110
112
|
|
111
|
-
Country.republics.part_of_nato.order(id: :desc)
|
113
|
+
Country.european.republics.part_of_nato.order(id: :desc)
|
112
114
|
```
|
113
115
|
|
114
116
|
### Supported query methods
|
@@ -181,7 +183,7 @@ class CountryTest < ActiveSupport::TestCase
|
|
181
183
|
teardown do
|
182
184
|
FrozenRecord::TestHelper.unload_fixtures
|
183
185
|
end
|
184
|
-
|
186
|
+
|
185
187
|
test "countries have a valid name" do
|
186
188
|
# ...
|
187
189
|
```
|
@@ -0,0 +1,22 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require_relative 'setup'
|
5
|
+
|
6
|
+
regular = Country.first
|
7
|
+
compact = Compact::Country.first
|
8
|
+
|
9
|
+
|
10
|
+
puts "=== record.attribute ==="
|
11
|
+
Benchmark.ips do |x|
|
12
|
+
x.report('regular') { regular.name }
|
13
|
+
x.report('compact') { compact.name }
|
14
|
+
x.compare!
|
15
|
+
end
|
16
|
+
|
17
|
+
puts "=== record[:attribute] ==="
|
18
|
+
Benchmark.ips do |x|
|
19
|
+
x.report('regular') { regular[:name] }
|
20
|
+
x.report('compact') { compact[:name] }
|
21
|
+
x.compare!
|
22
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require_relative 'setup'
|
5
|
+
|
6
|
+
puts "regular: #{Country.memsize} bytes"
|
7
|
+
puts "compact: #{Compact::Country.memsize} bytes"
|
8
|
+
|
9
|
+
diff = (Compact::Country.memsize - Country.memsize).to_f / Country.memsize
|
10
|
+
puts "diff: #{(diff * 100).round(2)}%"
|
data/benchmark/setup.rb
ADDED
data/frozen_record.gemspec
CHANGED
@@ -17,6 +17,8 @@ Gem::Specification.new do |spec|
|
|
17
17
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
18
18
|
spec.require_paths = ['lib']
|
19
19
|
|
20
|
+
spec.required_ruby_version = '>= 2.5'
|
21
|
+
|
20
22
|
spec.add_runtime_dependency 'activemodel'
|
21
23
|
spec.add_development_dependency 'bundler'
|
22
24
|
spec.add_development_dependency 'rake'
|
data/lib/frozen_record.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'yaml'
|
2
4
|
require 'set'
|
3
5
|
require 'active_support/all'
|
@@ -6,13 +8,21 @@ require 'active_model'
|
|
6
8
|
require 'frozen_record/version'
|
7
9
|
require 'frozen_record/scope'
|
8
10
|
require 'frozen_record/base'
|
11
|
+
require 'frozen_record/compact'
|
12
|
+
require 'frozen_record/deduplication'
|
9
13
|
|
10
14
|
module FrozenRecord
|
11
15
|
RecordNotFound = Class.new(StandardError)
|
12
16
|
|
13
17
|
class << self
|
18
|
+
attr_accessor :deprecated_yaml_erb_backend
|
19
|
+
|
14
20
|
def eager_load!
|
15
21
|
Base.descendants.each(&:eager_load!)
|
16
22
|
end
|
17
23
|
end
|
24
|
+
|
25
|
+
self.deprecated_yaml_erb_backend = true
|
18
26
|
end
|
27
|
+
|
28
|
+
require 'frozen_record/railtie' if defined?(Rails)
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FrozenRecord
|
4
|
+
module Backends
|
5
|
+
module Json
|
6
|
+
extend self
|
7
|
+
|
8
|
+
def filename(model_name)
|
9
|
+
"#{model_name.underscore.pluralize}.json"
|
10
|
+
end
|
11
|
+
|
12
|
+
def load(file_path)
|
13
|
+
json_data = File.read(file_path)
|
14
|
+
JSON.parse(json_data) || []
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module FrozenRecord
|
2
4
|
module Backends
|
3
5
|
module Yaml
|
@@ -17,10 +19,29 @@ module FrozenRecord
|
|
17
19
|
# @param format [String] the file path
|
18
20
|
# @return [Array] an Array of Hash objects with keys being attributes.
|
19
21
|
def load(file_path)
|
20
|
-
|
21
|
-
|
22
|
+
if !File.exist?(file_path) && File.exist?("#{file_path}.erb")
|
23
|
+
file_path = "#{file_path}.erb"
|
24
|
+
end
|
25
|
+
|
26
|
+
if FrozenRecord.deprecated_yaml_erb_backend
|
27
|
+
yml_erb_data = File.read(file_path)
|
28
|
+
yml_data = ERB.new(yml_erb_data).result
|
29
|
+
|
30
|
+
unless file_path.end_with?('.erb')
|
31
|
+
if yml_data != yml_erb_data
|
32
|
+
basename = File.basename(file_path)
|
33
|
+
raise "[FrozenRecord] Deprecated: `#{basename}` contains ERB tags and should be renamed `#{basename}.erb`.\nSet FrozenRecord.deprecated_yaml_erb_backend = false to enable the future behavior"
|
34
|
+
end
|
35
|
+
end
|
22
36
|
|
23
|
-
|
37
|
+
YAML.load(yml_data) || []
|
38
|
+
else
|
39
|
+
if file_path.end_with?('.erb')
|
40
|
+
YAML.load(ERB.new(File.read(file_path)).result) || []
|
41
|
+
else
|
42
|
+
YAML.load_file(file_path) || []
|
43
|
+
end
|
44
|
+
end
|
24
45
|
end
|
25
46
|
end
|
26
47
|
end
|
data/lib/frozen_record/base.rb
CHANGED
@@ -1,6 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'set'
|
2
4
|
require 'active_support/descendants_tracker'
|
3
|
-
require 'frozen_record/backends
|
5
|
+
require 'frozen_record/backends'
|
6
|
+
require 'objspace'
|
4
7
|
|
5
8
|
module FrozenRecord
|
6
9
|
class Base
|
@@ -15,19 +18,15 @@ module FrozenRecord
|
|
15
18
|
end
|
16
19
|
|
17
20
|
FIND_BY_PATTERN = /\Afind_by_(\w+)(!?)/
|
18
|
-
FALSY_VALUES = [false, nil, 0, ''].to_set
|
21
|
+
FALSY_VALUES = [false, nil, 0, -''].to_set
|
19
22
|
|
20
|
-
class_attribute :base_path
|
23
|
+
class_attribute :base_path, :primary_key, :backend, :auto_reloading, :default_attributes, instance_accessor: false
|
21
24
|
|
22
|
-
|
23
|
-
self.primary_key = :id
|
25
|
+
self.primary_key = 'id'
|
24
26
|
|
25
|
-
class_attribute :backend
|
26
27
|
self.backend = FrozenRecord::Backends::Yaml
|
27
28
|
|
28
|
-
|
29
|
-
|
30
|
-
attribute_method_suffix '?'
|
29
|
+
attribute_method_suffix -'?'
|
31
30
|
|
32
31
|
class ThreadSafeStorage
|
33
32
|
|
@@ -48,8 +47,34 @@ module FrozenRecord
|
|
48
47
|
end
|
49
48
|
|
50
49
|
class << self
|
50
|
+
alias_method :set_default_attributes, :default_attributes=
|
51
|
+
private :set_default_attributes
|
52
|
+
def default_attributes=(default_attributes)
|
53
|
+
set_default_attributes(Deduplication.deep_deduplicate!(default_attributes.stringify_keys))
|
54
|
+
end
|
55
|
+
|
56
|
+
alias_method :set_primary_key, :primary_key=
|
57
|
+
private :set_primary_key
|
58
|
+
def primary_key=(primary_key)
|
59
|
+
set_primary_key(-primary_key.to_s)
|
60
|
+
end
|
61
|
+
|
62
|
+
alias_method :set_base_path, :base_path=
|
63
|
+
private :set_base_path
|
64
|
+
def base_path=(base_path)
|
65
|
+
@file_path = nil
|
66
|
+
set_base_path(base_path)
|
67
|
+
end
|
68
|
+
|
51
69
|
attr_accessor :abstract_class
|
52
70
|
|
71
|
+
def attributes
|
72
|
+
@attributes ||= begin
|
73
|
+
load_records
|
74
|
+
@attributes
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
53
78
|
def abstract_class?
|
54
79
|
defined?(@abstract_class) && @abstract_class
|
55
80
|
end
|
@@ -68,11 +93,33 @@ module FrozenRecord
|
|
68
93
|
|
69
94
|
def file_path
|
70
95
|
raise ArgumentError, "You must define `#{name}.base_path`" unless base_path
|
71
|
-
|
96
|
+
@file_path ||= begin
|
97
|
+
file_path = File.join(base_path, backend.filename(name))
|
98
|
+
if !File.exist?(file_path) && File.exist?("#{file_path}.erb")
|
99
|
+
"#{file_path}.erb"
|
100
|
+
else
|
101
|
+
file_path
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def memsize(object = self, seen = Set.new.compare_by_identity)
|
107
|
+
return 0 unless seen.add?(object)
|
108
|
+
|
109
|
+
size = ObjectSpace.memsize_of(object)
|
110
|
+
object.instance_variables.each { |v| size += memsize(object.instance_variable_get(v), seen) }
|
111
|
+
|
112
|
+
case object
|
113
|
+
when Hash
|
114
|
+
object.each { |k, v| size += memsize(k, seen) + memsize(v, seen) }
|
115
|
+
when Array
|
116
|
+
object.each { |i| size += memsize(i, seen) }
|
117
|
+
end
|
118
|
+
size
|
72
119
|
end
|
73
120
|
|
74
121
|
def respond_to_missing?(name, *)
|
75
|
-
if name =~ FIND_BY_PATTERN
|
122
|
+
if name.to_s =~ FIND_BY_PATTERN
|
76
123
|
load_records # ensure attribute methods are defined
|
77
124
|
return true if $1.split('_and_').all? { |attr| instance_method_already_implemented?(attr) }
|
78
125
|
end
|
@@ -92,12 +139,26 @@ module FrozenRecord
|
|
92
139
|
|
93
140
|
@records ||= begin
|
94
141
|
records = backend.load(file_path)
|
95
|
-
|
96
|
-
records
|
97
|
-
|
98
|
-
|
99
|
-
|
142
|
+
records.each { |r| assign_defaults!(r) }
|
143
|
+
records = Deduplication.deep_deduplicate!(records)
|
144
|
+
@attributes = list_attributes(records).freeze
|
145
|
+
define_attribute_methods(@attributes.to_a)
|
146
|
+
records.map { |r| load(r) }.freeze
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def scope(name, body)
|
151
|
+
unless body.respond_to?(:call)
|
152
|
+
raise ArgumentError, "The scope body needs to be callable."
|
100
153
|
end
|
154
|
+
singleton_class.send(:define_method, name) { |*args| body.call(*args) }
|
155
|
+
end
|
156
|
+
|
157
|
+
alias_method :load, :new
|
158
|
+
private :load
|
159
|
+
|
160
|
+
def new(attrs = {})
|
161
|
+
load(assign_defaults!(attrs.stringify_keys))
|
101
162
|
end
|
102
163
|
|
103
164
|
private
|
@@ -112,45 +173,54 @@ module FrozenRecord
|
|
112
173
|
@store ||= ThreadSafeStorage.new(name)
|
113
174
|
end
|
114
175
|
|
176
|
+
def assign_defaults!(record)
|
177
|
+
if default_attributes
|
178
|
+
default_attributes.each do |key, value|
|
179
|
+
unless record.key?(key)
|
180
|
+
record[key] = value
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
record
|
186
|
+
end
|
187
|
+
|
115
188
|
def method_missing(name, *args)
|
116
|
-
if name =~ FIND_BY_PATTERN
|
189
|
+
if name.to_s =~ FIND_BY_PATTERN
|
117
190
|
return dynamic_match($1, args, $2.present?)
|
118
191
|
end
|
119
192
|
super
|
120
193
|
end
|
121
194
|
|
122
195
|
def dynamic_match(expression, values, bang)
|
123
|
-
results = where(expression.split('_and_').zip(values))
|
196
|
+
results = where(expression.split('_and_'.freeze).zip(values))
|
124
197
|
bang ? results.first! : results.first
|
125
198
|
end
|
126
199
|
|
127
200
|
def list_attributes(records)
|
128
201
|
attributes = Set.new
|
129
202
|
records.each do |record|
|
130
|
-
record.keys
|
131
|
-
attributes.add(key.to_sym)
|
132
|
-
end
|
203
|
+
attributes.merge(record.keys)
|
133
204
|
end
|
134
|
-
attributes
|
205
|
+
attributes
|
135
206
|
end
|
136
207
|
|
137
208
|
end
|
138
209
|
|
139
210
|
def initialize(attrs = {})
|
140
|
-
@attributes = attrs
|
211
|
+
@attributes = attrs.freeze
|
141
212
|
end
|
142
213
|
|
143
214
|
def attributes
|
144
|
-
|
145
|
-
@attributes.stringify_keys
|
215
|
+
@attributes.dup
|
146
216
|
end
|
147
217
|
|
148
218
|
def id
|
149
|
-
self[primary_key]
|
219
|
+
self[self.class.primary_key]
|
150
220
|
end
|
151
221
|
|
152
222
|
def [](attr)
|
153
|
-
@attributes[attr.
|
223
|
+
@attributes[attr.to_s]
|
154
224
|
end
|
155
225
|
alias_method :attribute, :[]
|
156
226
|
|
@@ -169,9 +239,11 @@ module FrozenRecord
|
|
169
239
|
private
|
170
240
|
|
171
241
|
def attribute?(attribute_name)
|
172
|
-
|
173
|
-
FALSY_VALUES.exclude?(value) && value.present?
|
242
|
+
FALSY_VALUES.exclude?(self[attribute_name]) && self[attribute_name].present?
|
174
243
|
end
|
175
244
|
|
245
|
+
def attribute_method?(attribute_name)
|
246
|
+
respond_to_without_attributes?(:attributes) && self.class.attributes.include?(attribute_name)
|
247
|
+
end
|
176
248
|
end
|
177
249
|
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FrozenRecord
|
4
|
+
module Compact
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
module ClassMethods
|
8
|
+
def load_records(force: false)
|
9
|
+
if force || (auto_reloading && file_changed?)
|
10
|
+
@records = nil
|
11
|
+
undefine_attribute_methods
|
12
|
+
end
|
13
|
+
|
14
|
+
@records ||= begin
|
15
|
+
records = backend.load(file_path)
|
16
|
+
records.each { |r| assign_defaults!(r) }
|
17
|
+
records = Deduplication.deep_deduplicate!(records)
|
18
|
+
@attributes = list_attributes(records).freeze
|
19
|
+
build_attributes_cache
|
20
|
+
define_attribute_methods(@attributes.to_a)
|
21
|
+
records.map { |r| load(r) }.freeze
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
if ActiveModel.gem_version >= Gem::Version.new('6.1.0.alpha')
|
26
|
+
def define_method_attribute(attr, owner:)
|
27
|
+
owner << "attr_reader #{attr.inspect}"
|
28
|
+
end
|
29
|
+
else
|
30
|
+
def define_method_attribute(attr)
|
31
|
+
generated_attribute_methods.attr_reader(attr)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
attr_reader :_attributes_cache
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def build_attributes_cache
|
40
|
+
@_attributes_cache = @attributes.each_with_object({}) do |attr, cache|
|
41
|
+
var = :"@#{attr}"
|
42
|
+
cache[attr.to_s] = var
|
43
|
+
cache[attr.to_sym] = var
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def initialize(attrs = {})
|
49
|
+
self.attributes = attrs
|
50
|
+
end
|
51
|
+
|
52
|
+
def attributes
|
53
|
+
self.class.attributes.each_with_object({}) do |attr, hash|
|
54
|
+
hash[attr] = self[attr]
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def [](attr)
|
59
|
+
if var = self.class._attributes_cache[attr]
|
60
|
+
instance_variable_get(var)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def attributes=(attributes)
|
67
|
+
self.class.attributes.each do |attr|
|
68
|
+
instance_variable_set(self.class._attributes_cache[attr], Deduplication.deep_deduplicate!(attributes[attr]))
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def attribute?(attribute_name)
|
73
|
+
val = self[attribute_name]
|
74
|
+
Base::FALSY_VALUES.exclude?(val) && val.present?
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/core_ext/object/duplicable'
|
4
|
+
|
5
|
+
module FrozenRecord
|
6
|
+
module Deduplication
|
7
|
+
extend self
|
8
|
+
|
9
|
+
# We deduplicate data in place because it is assumed it directly
|
10
|
+
# comes from the parser, and won't be held by anyone.
|
11
|
+
#
|
12
|
+
# Frozen Hashes and Arrays are ignored because they are likely
|
13
|
+
# the result of the use of YAML anchor. Meaning we already deduplicated
|
14
|
+
# them.
|
15
|
+
if RUBY_VERSION >= '2.7'
|
16
|
+
def deep_deduplicate!(data)
|
17
|
+
case data
|
18
|
+
when Hash
|
19
|
+
return data if data.frozen?
|
20
|
+
data.transform_keys! { |k| deep_deduplicate!(k) }
|
21
|
+
data.transform_values! { |v| deep_deduplicate!(v) }
|
22
|
+
data.freeze
|
23
|
+
when Array
|
24
|
+
return data if data.frozen?
|
25
|
+
data.map! { |d| deep_deduplicate!(d) }.freeze
|
26
|
+
when String
|
27
|
+
-data
|
28
|
+
else
|
29
|
+
data.duplicable? ? data.freeze : data
|
30
|
+
end
|
31
|
+
end
|
32
|
+
else
|
33
|
+
def deep_deduplicate!(data)
|
34
|
+
case data
|
35
|
+
when Hash
|
36
|
+
return data if data.frozen?
|
37
|
+
data.transform_keys! { |k| deep_deduplicate!(k) }
|
38
|
+
data.transform_values! { |v| deep_deduplicate!(v) }
|
39
|
+
data.freeze
|
40
|
+
when Array
|
41
|
+
return data if data.frozen?
|
42
|
+
data.map! { |d| deep_deduplicate!(d) }.freeze
|
43
|
+
when String
|
44
|
+
# String#-@ doesn't deduplicate the string if it's tainted.
|
45
|
+
# So in such case we need to untaint it first.
|
46
|
+
if data.tainted?
|
47
|
+
-(+data).untaint
|
48
|
+
else
|
49
|
+
-data
|
50
|
+
end
|
51
|
+
else
|
52
|
+
data.duplicable? ? data.freeze : data
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
data/lib/frozen_record/scope.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module FrozenRecord
|
2
4
|
class Scope
|
3
5
|
BLACKLISTED_ARRAY_METHODS = [
|
@@ -62,7 +64,7 @@ module FrozenRecord
|
|
62
64
|
def pluck(*attributes)
|
63
65
|
case attributes.length
|
64
66
|
when 1
|
65
|
-
to_a.map(&attributes.first)
|
67
|
+
to_a.map(&attributes.first.to_sym)
|
66
68
|
when 0
|
67
69
|
raise NotImplementedError, '`.pluck` without arguments is not supported yet'
|
68
70
|
else
|
@@ -122,8 +124,28 @@ module FrozenRecord
|
|
122
124
|
array_delegable?(method_name) || @klass.respond_to?(method_name) || super
|
123
125
|
end
|
124
126
|
|
127
|
+
def hash
|
128
|
+
comparable_attributes.hash
|
129
|
+
end
|
130
|
+
|
131
|
+
def ==(other)
|
132
|
+
self.class === other &&
|
133
|
+
comparable_attributes == other.comparable_attributes
|
134
|
+
end
|
135
|
+
|
125
136
|
protected
|
126
137
|
|
138
|
+
def comparable_attributes
|
139
|
+
@comparable_attributes ||= {
|
140
|
+
klass: @klass,
|
141
|
+
where_values: @where_values.uniq.sort,
|
142
|
+
where_not_values: @where_not_values.uniq.sort,
|
143
|
+
order_values: @order_values.uniq,
|
144
|
+
limit: @limit,
|
145
|
+
offset: @offset,
|
146
|
+
}
|
147
|
+
end
|
148
|
+
|
127
149
|
def scoping
|
128
150
|
previous, @klass.current_scope = @klass.current_scope, self
|
129
151
|
yield
|
@@ -136,6 +158,7 @@ module FrozenRecord
|
|
136
158
|
end
|
137
159
|
|
138
160
|
def clear_cache!
|
161
|
+
@comparable_attributes = nil
|
139
162
|
@results = nil
|
140
163
|
@matches = nil
|
141
164
|
self
|
@@ -230,6 +253,7 @@ module FrozenRecord
|
|
230
253
|
end
|
231
254
|
|
232
255
|
private
|
256
|
+
|
233
257
|
def compare_value(actual, requested)
|
234
258
|
return actual == requested unless requested.is_a?(Array) || requested.is_a?(Range)
|
235
259
|
requested.include?(actual)
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'deduplication' do
|
4
|
+
|
5
|
+
it 'deduplicate string values' do
|
6
|
+
pending("Strings can't be deduplicated before Ruby 2.5") if RUBY_VERSION < '2.5'
|
7
|
+
|
8
|
+
records = [
|
9
|
+
{ 'name' => 'George'.dup },
|
10
|
+
]
|
11
|
+
|
12
|
+
expect(records[0]['name']).to_not equal 'George'.freeze
|
13
|
+
FrozenRecord::Deduplication.deep_deduplicate!(records)
|
14
|
+
expect(records[0]['name']).to equal 'George'.freeze
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'handles duplicated references' do
|
18
|
+
# This simulates the YAML anchor behavior
|
19
|
+
tags = { 'foo' => 'bar' }
|
20
|
+
records = [
|
21
|
+
{ 'name' => 'George', 'tags' => tags },
|
22
|
+
{ 'name' => 'Peter', 'tags' => tags },
|
23
|
+
]
|
24
|
+
|
25
|
+
expect(records[0]['tags']).to_not be_frozen
|
26
|
+
FrozenRecord::Deduplication.deep_deduplicate!(records)
|
27
|
+
expect(records[0]['tags']).to be_frozen
|
28
|
+
expect(records[0]['tags']).to equal records[1]['tags']
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
@@ -8,6 +8,7 @@
|
|
8
8
|
updated_at: 2014-02-24T19:08:06-05:00
|
9
9
|
nato: true
|
10
10
|
king: Elisabeth II
|
11
|
+
continent: North America
|
11
12
|
|
12
13
|
- id: 2
|
13
14
|
name: France
|
@@ -16,6 +17,8 @@
|
|
16
17
|
population: 65.7
|
17
18
|
founded_on: 486-01-01
|
18
19
|
updated_at: 2014-02-12T19:02:03-02:00
|
20
|
+
nato: true
|
21
|
+
continent: Europe
|
19
22
|
|
20
23
|
- id: 3
|
21
24
|
name: Austria
|
@@ -24,3 +27,5 @@
|
|
24
27
|
population: 8.462
|
25
28
|
founded_on: 1156-01-01
|
26
29
|
updated_at: 2014-02-12T19:02:03-02:00
|
30
|
+
continent: Europe
|
31
|
+
available: false
|
File without changes
|
data/spec/frozen_record_spec.rb
CHANGED
@@ -1,14 +1,21 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
|
-
|
3
|
+
RSpec.shared_examples 'main' do
|
4
4
|
|
5
|
-
describe '.
|
5
|
+
describe '.primary_key' do
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
7
|
+
around do |example|
|
8
|
+
previous_primary_key = country_model.primary_key
|
9
|
+
begin
|
10
|
+
example.run
|
11
|
+
ensure
|
12
|
+
country_model.primary_key = previous_primary_key
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'is coerced to string' do
|
17
|
+
country_model.primary_key = :foobar
|
18
|
+
expect(country_model.primary_key).to be == 'foobar'
|
12
19
|
end
|
13
20
|
|
14
21
|
end
|
@@ -18,24 +25,24 @@ describe FrozenRecord::Base do
|
|
18
25
|
context 'when enabled' do
|
19
26
|
|
20
27
|
around do |example|
|
21
|
-
previous_auto_reloading =
|
22
|
-
|
28
|
+
previous_auto_reloading = country_model.auto_reloading
|
29
|
+
country_model.auto_reloading = true
|
23
30
|
begin
|
24
31
|
example.run
|
25
32
|
ensure
|
26
|
-
|
33
|
+
country_model.auto_reloading = previous_auto_reloading
|
27
34
|
end
|
28
35
|
end
|
29
36
|
|
30
37
|
it 'reloads the records if the file mtime changed' do
|
31
|
-
mtime = File.mtime(
|
38
|
+
mtime = File.mtime(country_model.file_path)
|
32
39
|
expect {
|
33
|
-
File.utime(mtime + 1, mtime + 1,
|
34
|
-
}.to change {
|
40
|
+
File.utime(mtime + 1, mtime + 1, country_model.file_path)
|
41
|
+
}.to change { country_model.first.object_id }
|
35
42
|
end
|
36
43
|
|
37
44
|
it 'does not reload if the file has not changed' do
|
38
|
-
expect(
|
45
|
+
expect(country_model.first.object_id).to be == country_model.first.object_id
|
39
46
|
end
|
40
47
|
|
41
48
|
end
|
@@ -43,25 +50,65 @@ describe FrozenRecord::Base do
|
|
43
50
|
context 'when disabled' do
|
44
51
|
|
45
52
|
it 'does not reloads the records if the file mtime changed' do
|
46
|
-
mtime = File.mtime(
|
53
|
+
mtime = File.mtime(country_model.file_path)
|
47
54
|
expect {
|
48
|
-
File.utime(mtime + 1, mtime + 1,
|
49
|
-
}.to_not change {
|
55
|
+
File.utime(mtime + 1, mtime + 1, country_model.file_path)
|
56
|
+
}.to_not change { country_model.first.object_id }
|
50
57
|
end
|
51
58
|
|
52
59
|
end
|
53
60
|
|
54
61
|
end
|
55
62
|
|
63
|
+
describe '.default_attributes' do
|
64
|
+
|
65
|
+
it 'define the attribute' do
|
66
|
+
expect(country_model.new).to respond_to :contemporary
|
67
|
+
end
|
68
|
+
|
69
|
+
it 'sets the value as default' do
|
70
|
+
expect(country_model.find_by(name: 'Austria').contemporary).to be == true
|
71
|
+
end
|
72
|
+
|
73
|
+
it 'gives precedence to the data file' do
|
74
|
+
expect(country_model.find_by(name: 'Austria').available).to be == false
|
75
|
+
end
|
76
|
+
|
77
|
+
it 'is also set in the initializer' do
|
78
|
+
expect(country_model.new.contemporary).to be == true
|
79
|
+
end
|
80
|
+
|
81
|
+
end
|
82
|
+
|
83
|
+
describe '.scope' do
|
84
|
+
|
85
|
+
it 'defines a scope method' do
|
86
|
+
country_model.scope :north_american, -> { country_model.where(continent: 'North America') }
|
87
|
+
expect(country_model).to respond_to(:north_american)
|
88
|
+
expect(country_model.north_american.first.name).to be == 'Canada'
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
|
93
|
+
describe '.memsize' do
|
94
|
+
|
95
|
+
it 'retuns the records memory footprint' do
|
96
|
+
# Memory footprint is very dependent on the Ruby implementation and version
|
97
|
+
expect(country_model.memsize).to be > 0
|
98
|
+
expect(car_model.memsize).to be > 0
|
99
|
+
end
|
100
|
+
|
101
|
+
end
|
102
|
+
|
56
103
|
describe '#load_records' do
|
57
104
|
|
58
105
|
it 'processes erb by default' do
|
59
|
-
country =
|
106
|
+
country = country_model.first
|
60
107
|
expect(country.capital).to be == 'Ottawa'
|
61
108
|
end
|
62
109
|
|
63
110
|
it 'loads records with a custom backend' do
|
64
|
-
animal =
|
111
|
+
animal = animal_model.first
|
65
112
|
expect(animal.name).to be == 'cat'
|
66
113
|
end
|
67
114
|
|
@@ -70,22 +117,22 @@ describe FrozenRecord::Base do
|
|
70
117
|
describe '#==' do
|
71
118
|
|
72
119
|
it 'returns true if both instances are from the same class and have the same id' do
|
73
|
-
country =
|
120
|
+
country = country_model.first
|
74
121
|
second_country = country.dup
|
75
122
|
|
76
123
|
expect(country).to be == second_country
|
77
124
|
end
|
78
125
|
|
79
126
|
it 'returns false if both instances are not from the same class' do
|
80
|
-
country =
|
81
|
-
car =
|
127
|
+
country = country_model.first
|
128
|
+
car = car_model.new(id: country.id)
|
82
129
|
|
83
130
|
expect(country).to_not be == car
|
84
131
|
end
|
85
132
|
|
86
133
|
it 'returns false if both instances do not have the same id' do
|
87
|
-
country =
|
88
|
-
second_country =
|
134
|
+
country = country_model.first
|
135
|
+
second_country = country_model.last
|
89
136
|
|
90
137
|
expect(country).to_not be == second_country
|
91
138
|
end
|
@@ -95,7 +142,7 @@ describe FrozenRecord::Base do
|
|
95
142
|
describe '#attributes' do
|
96
143
|
|
97
144
|
it 'returns a Hash of the record attributes' do
|
98
|
-
attributes =
|
145
|
+
attributes = country_model.first.attributes
|
99
146
|
expect(attributes).to be == {
|
100
147
|
'id' => 1,
|
101
148
|
'name' => 'Canada',
|
@@ -106,6 +153,9 @@ describe FrozenRecord::Base do
|
|
106
153
|
'updated_at' => Time.parse('2014-02-24T19:08:06-05:00'),
|
107
154
|
'king' => 'Elisabeth II',
|
108
155
|
'nato' => true,
|
156
|
+
'continent' => 'North America',
|
157
|
+
'available' => true,
|
158
|
+
'contemporary' => true,
|
109
159
|
}
|
110
160
|
end
|
111
161
|
|
@@ -113,9 +163,9 @@ describe FrozenRecord::Base do
|
|
113
163
|
|
114
164
|
describe '`attribute`?' do
|
115
165
|
|
116
|
-
let(:blank) {
|
166
|
+
let(:blank) { country_model.new(id: 0, name: '', nato: false, king: nil) }
|
117
167
|
|
118
|
-
let(:present) {
|
168
|
+
let(:present) { country_model.new(id: 42, name: 'Groland', nato: true, king: Object.new) }
|
119
169
|
|
120
170
|
it 'considers `0` as missing' do
|
121
171
|
expect(blank.id?).to be false
|
@@ -151,15 +201,50 @@ describe FrozenRecord::Base do
|
|
151
201
|
|
152
202
|
end
|
153
203
|
|
204
|
+
describe '#present?' do
|
205
|
+
|
206
|
+
it 'returns true' do
|
207
|
+
expect(country_model.first).to be_present
|
208
|
+
end
|
209
|
+
|
210
|
+
end
|
211
|
+
|
154
212
|
describe '#count' do
|
155
213
|
|
156
214
|
it 'can count objects with no records' do
|
157
|
-
expect(
|
215
|
+
expect(car_model.count).to be 0
|
158
216
|
end
|
159
217
|
|
160
218
|
it 'can count objects with records' do
|
161
|
-
expect(
|
219
|
+
expect(country_model.count).to be 3
|
162
220
|
end
|
163
221
|
|
164
222
|
end
|
165
223
|
end
|
224
|
+
|
225
|
+
describe FrozenRecord::Base do
|
226
|
+
let(:country_model) { Country }
|
227
|
+
let(:car_model) { Car }
|
228
|
+
let(:animal_model) { Animal }
|
229
|
+
|
230
|
+
it_behaves_like 'main'
|
231
|
+
|
232
|
+
describe '.base_path' do
|
233
|
+
|
234
|
+
it 'raise a RuntimeError on first query attempt if not set' do
|
235
|
+
allow(country_model).to receive_message_chain(:base_path).and_return(nil)
|
236
|
+
expect {
|
237
|
+
country_model.file_path
|
238
|
+
}.to raise_error(ArgumentError)
|
239
|
+
end
|
240
|
+
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
describe FrozenRecord::Compact do
|
245
|
+
let(:country_model) { Compact::Country }
|
246
|
+
let(:car_model) { Compact::Car }
|
247
|
+
let(:animal_model) { Compact::Animal }
|
248
|
+
|
249
|
+
it_behaves_like 'main'
|
250
|
+
end
|
data/spec/scope_spec.rb
CHANGED
@@ -417,6 +417,23 @@ describe 'querying' do
|
|
417
417
|
|
418
418
|
end
|
419
419
|
|
420
|
+
describe '#==' do
|
421
|
+
it 'returns true when two scopes share the same hashed attributes' do
|
422
|
+
scope_a = Country.republics.nato
|
423
|
+
scope_b = Country.republics.nato
|
424
|
+
expect(scope_a.object_id).not_to be == scope_b.object_id
|
425
|
+
expect(scope_a).to be == scope_b
|
426
|
+
end
|
427
|
+
|
428
|
+
it 'returns true when the same scope has be rechained' do
|
429
|
+
scope_a = Country.nato.republics.nato.republics
|
430
|
+
scope_b = Country.republics.nato
|
431
|
+
expect(scope_a.instance_variable_get(:@where_values)).to be == [[:nato, true ], [:king, nil ], [:nato, true], [:king, nil]]
|
432
|
+
expect(scope_b.instance_variable_get(:@where_values)).to be == [[:king, nil ], [:nato, true]]
|
433
|
+
expect(scope_a).to be == scope_b
|
434
|
+
end
|
435
|
+
end
|
436
|
+
|
420
437
|
describe 'class methods delegation' do
|
421
438
|
|
422
439
|
it 'can be called from a scope' do
|
data/spec/support/animal.rb
CHANGED
@@ -1,16 +1,12 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
def filename(model_name)
|
5
|
-
"#{model_name.underscore.pluralize}.json"
|
6
|
-
end
|
7
|
-
|
8
|
-
def load(file_path)
|
9
|
-
json_data = File.read(file_path)
|
10
|
-
JSON.parse(json_data) || []
|
11
|
-
end
|
1
|
+
class Animal < FrozenRecord::Base
|
2
|
+
self.backend = FrozenRecord::Backends::Json
|
12
3
|
end
|
13
4
|
|
14
|
-
|
15
|
-
|
5
|
+
module Compact
|
6
|
+
class Animal < ::Animal
|
7
|
+
include FrozenRecord::Compact
|
8
|
+
def self.file_path
|
9
|
+
superclass.file_path
|
10
|
+
end
|
11
|
+
end
|
16
12
|
end
|
data/spec/support/car.rb
CHANGED
data/spec/support/country.rb
CHANGED
@@ -1,10 +1,24 @@
|
|
1
1
|
class Country < FrozenRecord::Base
|
2
|
+
self.default_attributes = { contemporary: true, available: true }
|
2
3
|
|
3
4
|
def self.republics
|
4
5
|
where(king: nil)
|
5
6
|
end
|
6
7
|
|
8
|
+
def self.nato
|
9
|
+
where(nato: true)
|
10
|
+
end
|
11
|
+
|
7
12
|
def reverse_name
|
8
13
|
name.reverse
|
9
14
|
end
|
10
15
|
end
|
16
|
+
|
17
|
+
module Compact
|
18
|
+
class Country < ::Country
|
19
|
+
include FrozenRecord::Compact
|
20
|
+
def self.file_path
|
21
|
+
superclass.file_path
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: frozen_record
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.18.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jean Boussier
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2020-07-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activemodel
|
@@ -94,7 +94,7 @@ dependencies:
|
|
94
94
|
- - ">="
|
95
95
|
- !ruby/object:Gem::Version
|
96
96
|
version: '0'
|
97
|
-
description:
|
97
|
+
description:
|
98
98
|
email:
|
99
99
|
- jean.boussier@gmail.com
|
100
100
|
executables: []
|
@@ -108,17 +108,26 @@ files:
|
|
108
108
|
- LICENSE.txt
|
109
109
|
- README.md
|
110
110
|
- Rakefile
|
111
|
+
- benchmark/attribute-read
|
112
|
+
- benchmark/memory-usage
|
113
|
+
- benchmark/setup.rb
|
111
114
|
- frozen_record.gemspec
|
112
115
|
- lib/frozen_record.rb
|
116
|
+
- lib/frozen_record/backends.rb
|
117
|
+
- lib/frozen_record/backends/json.rb
|
113
118
|
- lib/frozen_record/backends/yaml.rb
|
114
119
|
- lib/frozen_record/base.rb
|
120
|
+
- lib/frozen_record/compact.rb
|
121
|
+
- lib/frozen_record/deduplication.rb
|
122
|
+
- lib/frozen_record/railtie.rb
|
115
123
|
- lib/frozen_record/scope.rb
|
116
124
|
- lib/frozen_record/test_helper.rb
|
117
125
|
- lib/frozen_record/version.rb
|
126
|
+
- spec/deduplication_spec.rb
|
118
127
|
- spec/fixtures/animals.json
|
119
128
|
- spec/fixtures/cars.yml
|
120
|
-
- spec/fixtures/countries.yml
|
121
|
-
- spec/fixtures/test_helper/countries.yml
|
129
|
+
- spec/fixtures/countries.yml.erb
|
130
|
+
- spec/fixtures/test_helper/countries.yml.erb
|
122
131
|
- spec/frozen_record_spec.rb
|
123
132
|
- spec/scope_spec.rb
|
124
133
|
- spec/spec_helper.rb
|
@@ -131,7 +140,7 @@ homepage: https://github.com/byroot/frozen_record
|
|
131
140
|
licenses:
|
132
141
|
- MIT
|
133
142
|
metadata: {}
|
134
|
-
post_install_message:
|
143
|
+
post_install_message:
|
135
144
|
rdoc_options: []
|
136
145
|
require_paths:
|
137
146
|
- lib
|
@@ -139,23 +148,23 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
139
148
|
requirements:
|
140
149
|
- - ">="
|
141
150
|
- !ruby/object:Gem::Version
|
142
|
-
version: '
|
151
|
+
version: '2.5'
|
143
152
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
144
153
|
requirements:
|
145
154
|
- - ">="
|
146
155
|
- !ruby/object:Gem::Version
|
147
156
|
version: '0'
|
148
157
|
requirements: []
|
149
|
-
|
150
|
-
|
151
|
-
signing_key:
|
158
|
+
rubygems_version: 3.1.2
|
159
|
+
signing_key:
|
152
160
|
specification_version: 4
|
153
161
|
summary: ActiveRecord like interface to read only access and query static YAML files
|
154
162
|
test_files:
|
163
|
+
- spec/deduplication_spec.rb
|
155
164
|
- spec/fixtures/animals.json
|
156
165
|
- spec/fixtures/cars.yml
|
157
|
-
- spec/fixtures/countries.yml
|
158
|
-
- spec/fixtures/test_helper/countries.yml
|
166
|
+
- spec/fixtures/countries.yml.erb
|
167
|
+
- spec/fixtures/test_helper/countries.yml.erb
|
159
168
|
- spec/frozen_record_spec.rb
|
160
169
|
- spec/scope_spec.rb
|
161
170
|
- spec/spec_helper.rb
|