frozen_record 0.13.0 → 0.18.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|