frozen_record 0.13.0 → 0.18.0

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