frozen_record 0.16.0 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 146bcc09f8a4a9622397a90709cb33fa124baca6820547de51337a827124054d
4
- data.tar.gz: f9f9d76a732523aca217be6a76ab2c1a64ea3faf994dc3a2f94ad93100e9439e
3
+ metadata.gz: 501a982beb96747b9e2dfd6250e9685e73ffbb2932cb5fddb84b7b44f778c3dd
4
+ data.tar.gz: 9396c1416991e6a932d8a16fa430471c531281be58e8547af8a1900ebf68d4cc
5
5
  SHA512:
6
- metadata.gz: 6f9a8ef22f72605fd9e3156e145522748fb7b75720c61e76bdffc157f8321cb2bb3ef260e9bf9b7eaac16761a2c207b6f81489c49b66c97a872619c11c665c7e
7
- data.tar.gz: c030a6f630f96d474f0d441a310dc1c79649fb3c03b5ac3fa558b04b22c7d8838f717383724efec2bbfdcaf3fe92f93fdc777ce5d96d4570fbb1039905675db9
6
+ metadata.gz: be0b8317169536a900a01769866f5b7b181974ed24dfd812abcfbd5b332324da1d8d9870c80c89af50bb3fa1565342a5e1229b198b8b6fc141d93c237566a152
7
+ data.tar.gz: 3a4c490e06150033c098905817842e6c2cc9b2719262f28f53e063de34815236910e18da5cf3e9848f4aed1e1db2e738d2b33dcce90f429fa1cdba2840cf4556
data/.gitignore CHANGED
@@ -16,3 +16,4 @@ test/tmp
16
16
  test/version_tmp
17
17
  tmp
18
18
  vendor
19
+ .byebug_history
@@ -1,5 +1,5 @@
1
1
  sudo: false
2
2
  rvm:
3
- - '2.4.5'
4
- - '2.5.3'
5
- - '2.6.1'
3
+ - '2.5'
4
+ - '2.6'
5
+ - '2.7'
data/Gemfile CHANGED
@@ -1,3 +1,5 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
+ gem 'benchmark-ips'
4
+ gem 'byebug'
3
5
  gemspec
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::Yaml
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 self
63
+ extend self
64
64
 
65
65
  def filename(model_name)
66
66
  # Returns the file name as a String
@@ -140,6 +140,22 @@ Country.european.republics.part_of_nato.order(id: :desc)
140
140
  - average
141
141
 
142
142
 
143
+ ## Indexing
144
+
145
+ Querying is implemented as a simple linear search (`O(n)`). However if you are using Frozen Record with larger datasets, or are querying
146
+ a collection repetedly, you can define indices for faster access.
147
+
148
+ ```ruby
149
+ class Country < FrozenRecord::Base
150
+ add_index :name, unique: true
151
+ add_index :continent
152
+ end
153
+ ```
154
+
155
+ Composite index keys are not supported.
156
+
157
+ The primary key isn't indexed by default.
158
+
143
159
  ## Configuration
144
160
 
145
161
  ### Reloading
@@ -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)}%"
@@ -0,0 +1,6 @@
1
+ require 'bundler/setup'
2
+ require 'benchmark/ips'
3
+
4
+ require 'frozen_record'
5
+ require_relative '../spec/support/country'
6
+ FrozenRecord::Base.base_path = File.expand_path('../spec/fixtures', __dir__)
@@ -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'
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'yaml'
2
4
  require 'set'
3
5
  require 'active_support/all'
@@ -5,16 +7,23 @@ require 'active_model'
5
7
 
6
8
  require 'frozen_record/version'
7
9
  require 'frozen_record/scope'
10
+ require 'frozen_record/index'
8
11
  require 'frozen_record/base'
12
+ require 'frozen_record/compact'
13
+ require 'frozen_record/deduplication'
9
14
 
10
15
  module FrozenRecord
11
16
  RecordNotFound = Class.new(StandardError)
12
17
 
13
18
  class << self
19
+ attr_accessor :deprecated_yaml_erb_backend
20
+
14
21
  def eager_load!
15
22
  Base.descendants.each(&:eager_load!)
16
23
  end
17
24
  end
25
+
26
+ self.deprecated_yaml_erb_backend = true
18
27
  end
19
28
 
20
29
  require 'frozen_record/railtie' if defined?(Rails)
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FrozenRecord
4
+ module Backends
5
+ autoload :Json, 'frozen_record/backends/json'
6
+ autoload :Yaml, 'frozen_record/backends/yaml'
7
+ end
8
+ end
@@ -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
- yml_erb_data = File.read(file_path)
21
- yml_data = ERB.new(yml_erb_data).result
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
- YAML.load(yml_data) || []
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
@@ -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/yaml'
5
+ require 'frozen_record/backends'
6
+ require 'objspace'
4
7
 
5
8
  module FrozenRecord
6
9
  class Base
@@ -17,25 +20,14 @@ module FrozenRecord
17
20
  FIND_BY_PATTERN = /\Afind_by_(\w+)(!?)/
18
21
  FALSY_VALUES = [false, nil, 0, -''].to_set
19
22
 
20
- class_attribute :base_path
21
-
22
- class_attribute :primary_key
23
-
24
- class << self
25
- alias_method :original_primary_key=, :primary_key=
26
-
27
- def primary_key=(primary_key)
28
- self.original_primary_key = -primary_key.to_s
29
- end
30
- end
23
+ class_attribute :base_path, :primary_key, :backend, :auto_reloading, :default_attributes, instance_accessor: false
24
+ class_attribute :index_definitions, instance_accessor: false
25
+ self.index_definitions = {}.freeze
31
26
 
32
27
  self.primary_key = 'id'
33
28
 
34
- class_attribute :backend
35
29
  self.backend = FrozenRecord::Backends::Yaml
36
30
 
37
- class_attribute :auto_reloading
38
-
39
31
  attribute_method_suffix -'?'
40
32
 
41
33
  class ThreadSafeStorage
@@ -57,8 +49,34 @@ module FrozenRecord
57
49
  end
58
50
 
59
51
  class << self
52
+ alias_method :set_default_attributes, :default_attributes=
53
+ private :set_default_attributes
54
+ def default_attributes=(default_attributes)
55
+ set_default_attributes(Deduplication.deep_deduplicate!(default_attributes.stringify_keys))
56
+ end
57
+
58
+ alias_method :set_primary_key, :primary_key=
59
+ private :set_primary_key
60
+ def primary_key=(primary_key)
61
+ set_primary_key(-primary_key.to_s)
62
+ end
63
+
64
+ alias_method :set_base_path, :base_path=
65
+ private :set_base_path
66
+ def base_path=(base_path)
67
+ @file_path = nil
68
+ set_base_path(base_path)
69
+ end
70
+
60
71
  attr_accessor :abstract_class
61
72
 
73
+ def attributes
74
+ @attributes ||= begin
75
+ load_records
76
+ @attributes
77
+ end
78
+ end
79
+
62
80
  def abstract_class?
63
81
  defined?(@abstract_class) && @abstract_class
64
82
  end
@@ -77,7 +95,34 @@ module FrozenRecord
77
95
 
78
96
  def file_path
79
97
  raise ArgumentError, "You must define `#{name}.base_path`" unless base_path
80
- File.join(base_path, backend.filename(name))
98
+ @file_path ||= begin
99
+ file_path = File.join(base_path, backend.filename(name))
100
+ if !File.exist?(file_path) && File.exist?("#{file_path}.erb")
101
+ "#{file_path}.erb"
102
+ else
103
+ file_path
104
+ end
105
+ end
106
+ end
107
+
108
+ def add_index(attribute, unique: false)
109
+ index = unique ? UniqueIndex.new(self, attribute) : Index.new(self, attribute)
110
+ self.index_definitions = index_definitions.merge(index.attribute => index).freeze
111
+ end
112
+
113
+ def memsize(object = self, seen = Set.new.compare_by_identity)
114
+ return 0 unless seen.add?(object)
115
+
116
+ size = ObjectSpace.memsize_of(object)
117
+ object.instance_variables.each { |v| size += memsize(object.instance_variable_get(v), seen) }
118
+
119
+ case object
120
+ when Hash
121
+ object.each { |k, v| size += memsize(k, seen) + memsize(v, seen) }
122
+ when Array
123
+ object.each { |i| size += memsize(i, seen) }
124
+ end
125
+ size
81
126
  end
82
127
 
83
128
  def respond_to_missing?(name, *)
@@ -101,16 +146,25 @@ module FrozenRecord
101
146
 
102
147
  @records ||= begin
103
148
  records = backend.load(file_path)
104
- define_attribute_methods(list_attributes(records))
105
- records.map(&method(:new)).freeze
149
+ records.each { |r| assign_defaults!(r) }
150
+ records = Deduplication.deep_deduplicate!(records)
151
+ @attributes = list_attributes(records).freeze
152
+ define_attribute_methods(@attributes.to_a)
153
+ records = records.map { |r| load(r) }.freeze
154
+ index_definitions.values.each { |index| index.build(records) }
155
+ records
106
156
  end
107
157
  end
108
158
 
109
159
  def scope(name, body)
110
- unless body.respond_to?(:call)
111
- raise ArgumentError, "The scope body needs to be callable."
112
- end
113
- singleton_class.send(:define_method, name) { |*args| body.call(*args) }
160
+ singleton_class.send(:define_method, name, &body)
161
+ end
162
+
163
+ alias_method :load, :new
164
+ private :load
165
+
166
+ def new(attrs = {})
167
+ load(assign_defaults!(attrs.stringify_keys))
114
168
  end
115
169
 
116
170
  private
@@ -125,6 +179,18 @@ module FrozenRecord
125
179
  @store ||= ThreadSafeStorage.new(name)
126
180
  end
127
181
 
182
+ def assign_defaults!(record)
183
+ if default_attributes
184
+ default_attributes.each do |key, value|
185
+ unless record.key?(key)
186
+ record[key] = value
187
+ end
188
+ end
189
+ end
190
+
191
+ record
192
+ end
193
+
128
194
  def method_missing(name, *args)
129
195
  if name.to_s =~ FIND_BY_PATTERN
130
196
  return dynamic_match($1, args, $2.present?)
@@ -140,17 +206,15 @@ module FrozenRecord
140
206
  def list_attributes(records)
141
207
  attributes = Set.new
142
208
  records.each do |record|
143
- record.keys.each do |key|
144
- attributes.add(key.to_s)
145
- end
209
+ attributes.merge(record.keys)
146
210
  end
147
- attributes.to_a
211
+ attributes
148
212
  end
149
213
 
150
214
  end
151
215
 
152
216
  def initialize(attrs = {})
153
- @attributes = attrs.stringify_keys.freeze
217
+ @attributes = attrs.freeze
154
218
  end
155
219
 
156
220
  def attributes
@@ -158,7 +222,7 @@ module FrozenRecord
158
222
  end
159
223
 
160
224
  def id
161
- self[primary_key.to_s]
225
+ self[self.class.primary_key]
162
226
  end
163
227
 
164
228
  def [](attr)
@@ -184,5 +248,8 @@ module FrozenRecord
184
248
  FALSY_VALUES.exclude?(self[attribute_name]) && self[attribute_name].present?
185
249
  end
186
250
 
251
+ def attribute_method?(attribute_name)
252
+ respond_to_without_attributes?(:attributes) && self.class.attributes.include?(attribute_name)
253
+ end
187
254
  end
188
255
  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
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FrozenRecord
4
+ class Index
5
+ EMPTY_ARRAY = [].freeze
6
+ private_constant :EMPTY_ARRAY
7
+
8
+ AttributeNonUnique = Class.new(StandardError)
9
+
10
+ attr_reader :attribute, :model
11
+
12
+ def initialize(model, attribute, unique: false)
13
+ @model = model
14
+ @attribute = -attribute.to_s
15
+ @index = nil
16
+ end
17
+
18
+ def unique?
19
+ false
20
+ end
21
+
22
+ def query(value)
23
+ case value
24
+ when Array, Range
25
+ lookup_multi(value)
26
+ else
27
+ lookup(value)
28
+ end
29
+ end
30
+
31
+ def lookup_multi(values)
32
+ values.flat_map { |v| lookup(v) }
33
+ end
34
+
35
+ def lookup(value)
36
+ @index.fetch(value, EMPTY_ARRAY)
37
+ end
38
+
39
+ def reset
40
+ @index = nil
41
+ end
42
+
43
+ def build(records)
44
+ @index = records.each_with_object({}) do |record, index|
45
+ entry = (index[record[attribute]] ||= [])
46
+ entry << record
47
+ end
48
+ @index.values.each(&:freeze)
49
+ @index.freeze
50
+ end
51
+ end
52
+
53
+ class UniqueIndex < Index
54
+ def unique?
55
+ true
56
+ end
57
+
58
+ def lookup_multi(values)
59
+ results = @index.values_at(*values)
60
+ results.compact!
61
+ results
62
+ end
63
+
64
+ def lookup(value)
65
+ record = @index[value]
66
+ record ? [record] : EMPTY_ARRAY
67
+ end
68
+
69
+ def build(records)
70
+ @index = records.each_with_object({}) { |r, index| index[r[attribute]] = r }
71
+ if @index.size != records.size
72
+ raise AttributeNonUnique, "#{model}##{attribute.inspect} is not unique."
73
+ end
74
+ @index.freeze
75
+ end
76
+ end
77
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module FrozenRecord
2
4
  class Railtie < Rails::Railtie
3
5
  initializer "frozen_record.setup" do |app|
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module FrozenRecord
2
4
  class Scope
3
5
  BLACKLISTED_ARRAY_METHODS = [
@@ -173,9 +175,20 @@ module FrozenRecord
173
175
  def select_records(records)
174
176
  return records if @where_values.empty? && @where_not_values.empty?
175
177
 
178
+ indices = @klass.index_definitions
179
+ indexed_where_values, unindexed_where_values = @where_values.partition { |criteria| indices.key?(criteria.first) }
180
+
181
+ unless indexed_where_values.empty?
182
+ attribute, value = indexed_where_values.shift
183
+ records = indices[attribute].query(value)
184
+ indexed_where_values.each do |(attribute, value)|
185
+ records &= indices[attribute].query(value)
186
+ end
187
+ end
188
+
176
189
  records.select do |record|
177
- @where_values.all? { |attr, value| compare_value(record[attr], value) } &&
178
- @where_not_values.all? { |attr, value| !compare_value(record[attr], value) }
190
+ unindexed_where_values.all? { |attr, value| compare_value(record[attr], value) } &&
191
+ !@where_not_values.any? { |attr, value| compare_value(record[attr], value) }
179
192
  end
180
193
  end
181
194
 
@@ -224,12 +237,12 @@ module FrozenRecord
224
237
  end
225
238
 
226
239
  def where!(criterias)
227
- @where_values += criterias.to_a
240
+ @where_values += criterias.map { |k, v| [k.to_s, v] }
228
241
  self
229
242
  end
230
243
 
231
244
  def where_not!(criterias)
232
- @where_not_values += criterias.to_a
245
+ @where_not_values += criterias.map { |k, v| [k.to_s, v] }
233
246
  self
234
247
  end
235
248
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module FrozenRecord
2
4
  module TestHelper
3
5
  NoFixturesLoaded = Class.new(StandardError)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module FrozenRecord
2
- VERSION = '0.16.0'
4
+ VERSION = '0.19.2'
3
5
  end
@@ -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
@@ -20,7 +20,6 @@
20
20
  nato: true
21
21
  continent: Europe
22
22
 
23
-
24
23
  - id: 3
25
24
  name: Austria
26
25
  capital: <%= 'Vienna' %>
@@ -29,3 +28,4 @@
29
28
  founded_on: 1156-01-01
30
29
  updated_at: 2014-02-12T19:02:03-02:00
31
30
  continent: Europe
31
+ available: false
@@ -1,32 +1,21 @@
1
1
  require 'spec_helper'
2
2
 
3
- describe FrozenRecord::Base do
4
-
5
- describe '.base_path' do
6
-
7
- it 'raise a RuntimeError on first query attempt if not set' do
8
- allow(Country).to receive_message_chain(:base_path).and_return(nil)
9
- expect {
10
- Country.file_path
11
- }.to raise_error(ArgumentError)
12
- end
13
-
14
- end
3
+ RSpec.shared_examples 'main' do
15
4
 
16
5
  describe '.primary_key' do
17
6
 
18
7
  around do |example|
19
- previous_primary_key = Country.primary_key
8
+ previous_primary_key = country_model.primary_key
20
9
  begin
21
10
  example.run
22
11
  ensure
23
- Country.primary_key = previous_primary_key
12
+ country_model.primary_key = previous_primary_key
24
13
  end
25
14
  end
26
15
 
27
16
  it 'is coerced to string' do
28
- Country.primary_key = :foobar
29
- expect(Country.primary_key).to be == 'foobar'
17
+ country_model.primary_key = :foobar
18
+ expect(country_model.primary_key).to be == 'foobar'
30
19
  end
31
20
 
32
21
  end
@@ -36,24 +25,24 @@ describe FrozenRecord::Base do
36
25
  context 'when enabled' do
37
26
 
38
27
  around do |example|
39
- previous_auto_reloading = Country.auto_reloading
40
- Country.auto_reloading = true
28
+ previous_auto_reloading = country_model.auto_reloading
29
+ country_model.auto_reloading = true
41
30
  begin
42
31
  example.run
43
32
  ensure
44
- Country.auto_reloading = previous_auto_reloading
33
+ country_model.auto_reloading = previous_auto_reloading
45
34
  end
46
35
  end
47
36
 
48
37
  it 'reloads the records if the file mtime changed' do
49
- mtime = File.mtime(Country.file_path)
38
+ mtime = File.mtime(country_model.file_path)
50
39
  expect {
51
- File.utime(mtime + 1, mtime + 1, Country.file_path)
52
- }.to change { Country.first.object_id }
40
+ File.utime(mtime + 1, mtime + 1, country_model.file_path)
41
+ }.to change { country_model.first.object_id }
53
42
  end
54
43
 
55
44
  it 'does not reload if the file has not changed' do
56
- expect(Country.first.object_id).to be == Country.first.object_id
45
+ expect(country_model.first.object_id).to be == country_model.first.object_id
57
46
  end
58
47
 
59
48
  end
@@ -61,34 +50,65 @@ describe FrozenRecord::Base do
61
50
  context 'when disabled' do
62
51
 
63
52
  it 'does not reloads the records if the file mtime changed' do
64
- mtime = File.mtime(Country.file_path)
53
+ mtime = File.mtime(country_model.file_path)
65
54
  expect {
66
- File.utime(mtime + 1, mtime + 1, Country.file_path)
67
- }.to_not change { Country.first.object_id }
55
+ File.utime(mtime + 1, mtime + 1, country_model.file_path)
56
+ }.to_not change { country_model.first.object_id }
68
57
  end
69
58
 
70
59
  end
71
60
 
72
61
  end
73
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
+
74
83
  describe '.scope' do
84
+
75
85
  it 'defines a scope method' do
86
+ country_model.scope :north_american, -> { 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
76
94
 
77
- Country.scope :north_american, -> { Country.where(continent: 'North America') }
78
- expect(Country).to respond_to(:north_american)
79
- expect(Country.north_american.first.name).to be == 'Canada'
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
80
99
  end
100
+
81
101
  end
82
102
 
83
103
  describe '#load_records' do
84
104
 
85
105
  it 'processes erb by default' do
86
- country = Country.first
106
+ country = country_model.first
87
107
  expect(country.capital).to be == 'Ottawa'
88
108
  end
89
109
 
90
110
  it 'loads records with a custom backend' do
91
- animal = Animal.first
111
+ animal = animal_model.first
92
112
  expect(animal.name).to be == 'cat'
93
113
  end
94
114
 
@@ -97,22 +117,22 @@ describe FrozenRecord::Base do
97
117
  describe '#==' do
98
118
 
99
119
  it 'returns true if both instances are from the same class and have the same id' do
100
- country = Country.first
120
+ country = country_model.first
101
121
  second_country = country.dup
102
122
 
103
123
  expect(country).to be == second_country
104
124
  end
105
125
 
106
126
  it 'returns false if both instances are not from the same class' do
107
- country = Country.first
108
- car = Car.new(id: country.id)
127
+ country = country_model.first
128
+ car = car_model.new(id: country.id)
109
129
 
110
130
  expect(country).to_not be == car
111
131
  end
112
132
 
113
133
  it 'returns false if both instances do not have the same id' do
114
- country = Country.first
115
- second_country = Country.last
134
+ country = country_model.first
135
+ second_country = country_model.last
116
136
 
117
137
  expect(country).to_not be == second_country
118
138
  end
@@ -122,7 +142,7 @@ describe FrozenRecord::Base do
122
142
  describe '#attributes' do
123
143
 
124
144
  it 'returns a Hash of the record attributes' do
125
- attributes = Country.first.attributes
145
+ attributes = country_model.first.attributes
126
146
  expect(attributes).to be == {
127
147
  'id' => 1,
128
148
  'name' => 'Canada',
@@ -134,6 +154,8 @@ describe FrozenRecord::Base do
134
154
  'king' => 'Elisabeth II',
135
155
  'nato' => true,
136
156
  'continent' => 'North America',
157
+ 'available' => true,
158
+ 'contemporary' => true,
137
159
  }
138
160
  end
139
161
 
@@ -141,9 +163,9 @@ describe FrozenRecord::Base do
141
163
 
142
164
  describe '`attribute`?' do
143
165
 
144
- let(:blank) { Country.new(id: 0, name: '', nato: false, king: nil) }
166
+ let(:blank) { country_model.new(id: 0, name: '', nato: false, king: nil) }
145
167
 
146
- let(:present) { Country.new(id: 42, name: 'Groland', nato: true, king: Object.new) }
168
+ let(:present) { country_model.new(id: 42, name: 'Groland', nato: true, king: Object.new) }
147
169
 
148
170
  it 'considers `0` as missing' do
149
171
  expect(blank.id?).to be false
@@ -179,15 +201,50 @@ describe FrozenRecord::Base do
179
201
 
180
202
  end
181
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
+
182
212
  describe '#count' do
183
213
 
184
214
  it 'can count objects with no records' do
185
- expect(Car.count).to be 0
215
+ expect(car_model.count).to be 0
186
216
  end
187
217
 
188
218
  it 'can count objects with records' do
189
- expect(Country.count).to be 3
219
+ expect(country_model.count).to be 3
220
+ end
221
+
222
+ end
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)
190
239
  end
191
240
 
192
241
  end
193
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
@@ -203,6 +203,18 @@ describe 'querying' do
203
203
  expect(countries.length).to be == 2
204
204
  end
205
205
 
206
+ it 'can combine indices' do
207
+ countries = Country.where(name: 'France', continent: 'Europe')
208
+ expect(countries.length).to be == 1
209
+ end
210
+
211
+ it 'can use indices with inclusion query' do
212
+ countries = Country.where(continent: ['Europe', 'North America'])
213
+ expect(countries.length).to be == 3
214
+
215
+ countries = Country.where(name: ['France', 'Canada'])
216
+ expect(countries.length).to be == 2
217
+ end
206
218
  end
207
219
 
208
220
  describe '.where.not' do
@@ -428,8 +440,8 @@ describe 'querying' do
428
440
  it 'returns true when the same scope has be rechained' do
429
441
  scope_a = Country.nato.republics.nato.republics
430
442
  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]]
443
+ expect(scope_a.instance_variable_get(:@where_values)).to be == [['nato', true], ['king', nil], ['nato', true], ['king', nil]]
444
+ expect(scope_b.instance_variable_get(:@where_values)).to be == [['king', nil], ['nato', true]]
433
445
  expect(scope_a).to be == scope_b
434
446
  end
435
447
  end
@@ -1,16 +1,12 @@
1
- module JsonBackend
2
- extend self
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
- class Animal < FrozenRecord::Base
15
- self.backend = JsonBackend
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
@@ -1,2 +1,12 @@
1
1
  class Car < FrozenRecord::Base
2
2
  end
3
+
4
+ module Compact
5
+ class Car < ::Car
6
+ include FrozenRecord::Compact
7
+
8
+ def self.file_path
9
+ superclass.file_path
10
+ end
11
+ end
12
+ end
@@ -1,4 +1,8 @@
1
1
  class Country < FrozenRecord::Base
2
+ self.default_attributes = { contemporary: true, available: true }
3
+
4
+ add_index :name, unique: true
5
+ add_index :continent
2
6
 
3
7
  def self.republics
4
8
  where(king: nil)
@@ -12,3 +16,12 @@ class Country < FrozenRecord::Base
12
16
  name.reverse
13
17
  end
14
18
  end
19
+
20
+ module Compact
21
+ class Country < ::Country
22
+ include FrozenRecord::Compact
23
+ def self.file_path
24
+ superclass.file_path
25
+ end
26
+ end
27
+ 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.16.0
4
+ version: 0.19.2
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: 2019-09-03 00:00:00.000000000 Z
11
+ date: 2020-08-06 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,18 +108,27 @@ 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/index.rb
115
123
  - lib/frozen_record/railtie.rb
116
124
  - lib/frozen_record/scope.rb
117
125
  - lib/frozen_record/test_helper.rb
118
126
  - lib/frozen_record/version.rb
127
+ - spec/deduplication_spec.rb
119
128
  - spec/fixtures/animals.json
120
129
  - spec/fixtures/cars.yml
121
- - spec/fixtures/countries.yml
122
- - spec/fixtures/test_helper/countries.yml
130
+ - spec/fixtures/countries.yml.erb
131
+ - spec/fixtures/test_helper/countries.yml.erb
123
132
  - spec/frozen_record_spec.rb
124
133
  - spec/scope_spec.rb
125
134
  - spec/spec_helper.rb
@@ -132,7 +141,7 @@ homepage: https://github.com/byroot/frozen_record
132
141
  licenses:
133
142
  - MIT
134
143
  metadata: {}
135
- post_install_message:
144
+ post_install_message:
136
145
  rdoc_options: []
137
146
  require_paths:
138
147
  - lib
@@ -140,22 +149,23 @@ required_ruby_version: !ruby/object:Gem::Requirement
140
149
  requirements:
141
150
  - - ">="
142
151
  - !ruby/object:Gem::Version
143
- version: '0'
152
+ version: '2.5'
144
153
  required_rubygems_version: !ruby/object:Gem::Requirement
145
154
  requirements:
146
155
  - - ">="
147
156
  - !ruby/object:Gem::Version
148
157
  version: '0'
149
158
  requirements: []
150
- rubygems_version: 3.0.4
151
- signing_key:
159
+ rubygems_version: 3.0.2
160
+ signing_key:
152
161
  specification_version: 4
153
162
  summary: ActiveRecord like interface to read only access and query static YAML files
154
163
  test_files:
164
+ - spec/deduplication_spec.rb
155
165
  - spec/fixtures/animals.json
156
166
  - spec/fixtures/cars.yml
157
- - spec/fixtures/countries.yml
158
- - spec/fixtures/test_helper/countries.yml
167
+ - spec/fixtures/countries.yml.erb
168
+ - spec/fixtures/test_helper/countries.yml.erb
159
169
  - spec/frozen_record_spec.rb
160
170
  - spec/scope_spec.rb
161
171
  - spec/spec_helper.rb