frozen_record 0.16.0 → 0.19.2

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