frozen_record 0.15.0 → 0.19.1

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: ce0b59f2960394e86f2278a1c574680bfc27491bead98f9991ba70aa9a90766e
4
- data.tar.gz: c194c8fa251cc4b9e6be6a41869ad383e4aa0ebd54021ddb36b2b285257dc347
3
+ metadata.gz: d75dfc07a86429e4dcc6a17c076aa87081a19e812b3d9b8d9722281d8a14aba9
4
+ data.tar.gz: a445c878d8cddaa87cb59db1213cf4099e3cbf10fa854d92fbd28669867dad4a
5
5
  SHA512:
6
- metadata.gz: 6bf7ce5df1f3a4c2dd67e4b391fd26bb3499ec572bf4de600bdf50728292959cef2330844d19e3deebec714183da7931802bb735fd5c2d0281541235f07fcb3d
7
- data.tar.gz: 18b96a420a7f74e12c57362b856609b685e60923e4eb39c83d1d0623321f8dade128d53f6435008431589d22f218200c9d3c42a497ead3406013714208a09cdb
6
+ metadata.gz: 54b3c787e7c0f028c8c58a2a1fb214a5b3ac0c4b8b06ac4a42d4a35526baa0c50573967a7aa7faad0921c028d19d985dda831257f2244b1ba5af56960187ced1
7
+ data.tar.gz: 8159f7e45d32256ab9565bbf5ff81c6685e912c57603f97b335f8c5dbb2529b4153bb0e2937e4c709da9a78d0f303f485e33daf4933aa129be9599313964ec8d
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,14 +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
28
+
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,13 @@ 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, default: {}.freeze
31
25
 
32
26
  self.primary_key = 'id'
33
27
 
34
- class_attribute :backend
35
28
  self.backend = FrozenRecord::Backends::Yaml
36
29
 
37
- class_attribute :auto_reloading
38
-
39
30
  attribute_method_suffix -'?'
40
31
 
41
32
  class ThreadSafeStorage
@@ -57,8 +48,34 @@ module FrozenRecord
57
48
  end
58
49
 
59
50
  class << self
51
+ alias_method :set_default_attributes, :default_attributes=
52
+ private :set_default_attributes
53
+ def default_attributes=(default_attributes)
54
+ set_default_attributes(Deduplication.deep_deduplicate!(default_attributes.stringify_keys))
55
+ end
56
+
57
+ alias_method :set_primary_key, :primary_key=
58
+ private :set_primary_key
59
+ def primary_key=(primary_key)
60
+ set_primary_key(-primary_key.to_s)
61
+ end
62
+
63
+ alias_method :set_base_path, :base_path=
64
+ private :set_base_path
65
+ def base_path=(base_path)
66
+ @file_path = nil
67
+ set_base_path(base_path)
68
+ end
69
+
60
70
  attr_accessor :abstract_class
61
71
 
72
+ def attributes
73
+ @attributes ||= begin
74
+ load_records
75
+ @attributes
76
+ end
77
+ end
78
+
62
79
  def abstract_class?
63
80
  defined?(@abstract_class) && @abstract_class
64
81
  end
@@ -77,7 +94,34 @@ module FrozenRecord
77
94
 
78
95
  def file_path
79
96
  raise ArgumentError, "You must define `#{name}.base_path`" unless base_path
80
- File.join(base_path, backend.filename(name))
97
+ @file_path ||= begin
98
+ file_path = File.join(base_path, backend.filename(name))
99
+ if !File.exist?(file_path) && File.exist?("#{file_path}.erb")
100
+ "#{file_path}.erb"
101
+ else
102
+ file_path
103
+ end
104
+ end
105
+ end
106
+
107
+ def add_index(attribute, unique: false)
108
+ index = unique ? UniqueIndex.new(self, attribute) : Index.new(self, attribute)
109
+ self.index_definitions = index_definitions.merge(index.attribute => index).freeze
110
+ end
111
+
112
+ def memsize(object = self, seen = Set.new.compare_by_identity)
113
+ return 0 unless seen.add?(object)
114
+
115
+ size = ObjectSpace.memsize_of(object)
116
+ object.instance_variables.each { |v| size += memsize(object.instance_variable_get(v), seen) }
117
+
118
+ case object
119
+ when Hash
120
+ object.each { |k, v| size += memsize(k, seen) + memsize(v, seen) }
121
+ when Array
122
+ object.each { |i| size += memsize(i, seen) }
123
+ end
124
+ size
81
125
  end
82
126
 
83
127
  def respond_to_missing?(name, *)
@@ -101,16 +145,25 @@ module FrozenRecord
101
145
 
102
146
  @records ||= begin
103
147
  records = backend.load(file_path)
104
- define_attribute_methods(list_attributes(records))
105
- records.map(&method(:new)).freeze
148
+ records.each { |r| assign_defaults!(r) }
149
+ records = Deduplication.deep_deduplicate!(records)
150
+ @attributes = list_attributes(records).freeze
151
+ define_attribute_methods(@attributes.to_a)
152
+ records = records.map { |r| load(r) }.freeze
153
+ index_definitions.values.each { |index| index.build(records) }
154
+ records
106
155
  end
107
156
  end
108
157
 
109
158
  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) }
159
+ singleton_class.send(:define_method, name, &body)
160
+ end
161
+
162
+ alias_method :load, :new
163
+ private :load
164
+
165
+ def new(attrs = {})
166
+ load(assign_defaults!(attrs.stringify_keys))
114
167
  end
115
168
 
116
169
  private
@@ -125,6 +178,18 @@ module FrozenRecord
125
178
  @store ||= ThreadSafeStorage.new(name)
126
179
  end
127
180
 
181
+ def assign_defaults!(record)
182
+ if default_attributes
183
+ default_attributes.each do |key, value|
184
+ unless record.key?(key)
185
+ record[key] = value
186
+ end
187
+ end
188
+ end
189
+
190
+ record
191
+ end
192
+
128
193
  def method_missing(name, *args)
129
194
  if name.to_s =~ FIND_BY_PATTERN
130
195
  return dynamic_match($1, args, $2.present?)
@@ -140,17 +205,15 @@ module FrozenRecord
140
205
  def list_attributes(records)
141
206
  attributes = Set.new
142
207
  records.each do |record|
143
- record.keys.each do |key|
144
- attributes.add(key.to_s)
145
- end
208
+ attributes.merge(record.keys)
146
209
  end
147
- attributes.to_a
210
+ attributes
148
211
  end
149
212
 
150
213
  end
151
214
 
152
215
  def initialize(attrs = {})
153
- @attributes = attrs.stringify_keys.freeze
216
+ @attributes = attrs.freeze
154
217
  end
155
218
 
156
219
  def attributes
@@ -158,7 +221,7 @@ module FrozenRecord
158
221
  end
159
222
 
160
223
  def id
161
- self[primary_key.to_s]
224
+ self[self.class.primary_key]
162
225
  end
163
226
 
164
227
  def [](attr)
@@ -184,5 +247,8 @@ module FrozenRecord
184
247
  FALSY_VALUES.exclude?(self[attribute_name]) && self[attribute_name].present?
185
248
  end
186
249
 
250
+ def attribute_method?(attribute_name)
251
+ respond_to_without_attributes?(:attributes) && self.class.attributes.include?(attribute_name)
252
+ end
187
253
  end
188
254
  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.to_h { |r| [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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FrozenRecord
4
+ class Railtie < Rails::Railtie
5
+ initializer "frozen_record.setup" do |app|
6
+ app.config.eager_load_namespaces << FrozenRecord
7
+ end
8
+ end
9
+ end
@@ -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.15.0'
4
+ VERSION = '0.19.1'
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.15.0
4
+ version: 0.19.1
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-08-27 00:00:00.000000000 Z
11
+ date: 2020-07-16 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,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
123
+ - lib/frozen_record/railtie.rb
115
124
  - lib/frozen_record/scope.rb
116
125
  - lib/frozen_record/test_helper.rb
117
126
  - lib/frozen_record/version.rb
127
+ - spec/deduplication_spec.rb
118
128
  - spec/fixtures/animals.json
119
129
  - spec/fixtures/cars.yml
120
- - spec/fixtures/countries.yml
121
- - spec/fixtures/test_helper/countries.yml
130
+ - spec/fixtures/countries.yml.erb
131
+ - spec/fixtures/test_helper/countries.yml.erb
122
132
  - spec/frozen_record_spec.rb
123
133
  - spec/scope_spec.rb
124
134
  - spec/spec_helper.rb
@@ -131,7 +141,7 @@ homepage: https://github.com/byroot/frozen_record
131
141
  licenses:
132
142
  - MIT
133
143
  metadata: {}
134
- post_install_message:
144
+ post_install_message:
135
145
  rdoc_options: []
136
146
  require_paths:
137
147
  - lib
@@ -139,22 +149,23 @@ required_ruby_version: !ruby/object:Gem::Requirement
139
149
  requirements:
140
150
  - - ">="
141
151
  - !ruby/object:Gem::Version
142
- version: '0'
152
+ version: '2.5'
143
153
  required_rubygems_version: !ruby/object:Gem::Requirement
144
154
  requirements:
145
155
  - - ">="
146
156
  - !ruby/object:Gem::Version
147
157
  version: '0'
148
158
  requirements: []
149
- rubygems_version: 3.0.4
150
- signing_key:
159
+ rubygems_version: 3.1.2
160
+ signing_key:
151
161
  specification_version: 4
152
162
  summary: ActiveRecord like interface to read only access and query static YAML files
153
163
  test_files:
164
+ - spec/deduplication_spec.rb
154
165
  - spec/fixtures/animals.json
155
166
  - spec/fixtures/cars.yml
156
- - spec/fixtures/countries.yml
157
- - spec/fixtures/test_helper/countries.yml
167
+ - spec/fixtures/countries.yml.erb
168
+ - spec/fixtures/test_helper/countries.yml.erb
158
169
  - spec/frozen_record_spec.rb
159
170
  - spec/scope_spec.rb
160
171
  - spec/spec_helper.rb