frozen_record 0.14.0 → 0.19.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: aca3d3a4084fea57cbcbc396e95d35ee24750b44
4
- data.tar.gz: 5fe3827d178da90300e05c49062e1c1259bc366d
2
+ SHA256:
3
+ metadata.gz: e1006ddbdc9f21010a819e6085800b05fc549c9b513c75431b329f20d46e055a
4
+ data.tar.gz: b3f65c04430375bf7fe2be7ecd233515e9a8b4a1dc7ebc29a430fa4934fad8be
5
5
  SHA512:
6
- metadata.gz: 6afd96b57e571297ebd78bc218c0cfe8e06249be474231a50a3f573ad74f2ee8236bc289265362d67067769e7313acd7633ff721bf1349b868d23f917c94c2bc
7
- data.tar.gz: cfd9d2c57b1238c8df69d311a738cc31d90138523b451f7cabc6bcf7a0c54f553740d47fbf10c92a9f80065e2661c9618d00f97db6975d259262dbcceb2bd84e
6
+ metadata.gz: db63b827feb659e1228812947551db15ef777b33c71b38d3d9c77aa304d148b2e23eaaa6075e3dda90bd759abd9774635030bd01947b6d7b61fa01a53114601c
7
+ data.tar.gz: a11f92b0a5ff35033c348b69735821969f33a93fdca861f58b3dd4c5a58263da643ce6efe6c9f7f473e754615e91c94712dce7901f7d49b2893bd3a808b9bc7b
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
@@ -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
@@ -138,6 +140,22 @@ Country.republics.part_of_nato.order(id: :desc)
138
140
  - average
139
141
 
140
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
+
141
159
  ## Configuration
142
160
 
143
161
  ### Reloading
@@ -181,7 +199,7 @@ class CountryTest < ActiveSupport::TestCase
181
199
  teardown do
182
200
  FrozenRecord::TestHelper.unload_fixtures
183
201
  end
184
-
202
+
185
203
  test "countries have a valid name" do
186
204
  # ...
187
205
  ```
@@ -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,11 +145,27 @@ 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
 
158
+ def scope(name, body)
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))
167
+ end
168
+
109
169
  private
110
170
 
111
171
  def file_changed?
@@ -118,6 +178,18 @@ module FrozenRecord
118
178
  @store ||= ThreadSafeStorage.new(name)
119
179
  end
120
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
+
121
193
  def method_missing(name, *args)
122
194
  if name.to_s =~ FIND_BY_PATTERN
123
195
  return dynamic_match($1, args, $2.present?)
@@ -133,17 +205,15 @@ module FrozenRecord
133
205
  def list_attributes(records)
134
206
  attributes = Set.new
135
207
  records.each do |record|
136
- record.keys.each do |key|
137
- attributes.add(key.to_s)
138
- end
208
+ attributes.merge(record.keys)
139
209
  end
140
- attributes.to_a
210
+ attributes
141
211
  end
142
212
 
143
213
  end
144
214
 
145
215
  def initialize(attrs = {})
146
- @attributes = attrs.stringify_keys.freeze
216
+ @attributes = attrs.freeze
147
217
  end
148
218
 
149
219
  def attributes
@@ -151,7 +221,7 @@ module FrozenRecord
151
221
  end
152
222
 
153
223
  def id
154
- self[primary_key.to_s]
224
+ self[self.class.primary_key]
155
225
  end
156
226
 
157
227
  def [](attr)
@@ -177,5 +247,8 @@ module FrozenRecord
177
247
  FALSY_VALUES.exclude?(self[attribute_name]) && self[attribute_name].present?
178
248
  end
179
249
 
250
+ def attribute_method?(attribute_name)
251
+ respond_to_without_attributes?(:attributes) && self.class.attributes.include?(attribute_name)
252
+ end
180
253
  end
181
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,58 @@
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
+ @index.fetch(value, EMPTY_ARRAY)
24
+ end
25
+
26
+ def reset
27
+ @index = nil
28
+ end
29
+
30
+ def build(records)
31
+ @index = records.each_with_object({}) do |record, index|
32
+ entry = (index[record[attribute]] ||= [])
33
+ entry << record
34
+ end
35
+ @index.values.each(&:freeze)
36
+ @index.freeze
37
+ end
38
+ end
39
+
40
+ class UniqueIndex < Index
41
+ def unique?
42
+ true
43
+ end
44
+
45
+ def query(value)
46
+ record = @index[value]
47
+ record ? [record] : EMPTY_ARRAY
48
+ end
49
+
50
+ def build(records)
51
+ @index = records.to_h { |r| [r[attribute], r] }
52
+ if @index.size != records.size
53
+ raise AttributeNonUnique, "#{model}##{attribute.inspect} is not unique."
54
+ end
55
+ @index.freeze
56
+ end
57
+ end
58
+ 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 = [
@@ -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
@@ -152,9 +175,20 @@ module FrozenRecord
152
175
  def select_records(records)
153
176
  return records if @where_values.empty? && @where_not_values.empty?
154
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
+
155
189
  records.select do |record|
156
- @where_values.all? { |attr, value| compare_value(record[attr], value) } &&
157
- @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) }
158
192
  end
159
193
  end
160
194
 
@@ -203,12 +237,12 @@ module FrozenRecord
203
237
  end
204
238
 
205
239
  def where!(criterias)
206
- @where_values += criterias.to_a
240
+ @where_values += criterias.map { |k, v| [k.to_s, v] }
207
241
  self
208
242
  end
209
243
 
210
244
  def where_not!(criterias)
211
- @where_not_values += criterias.to_a
245
+ @where_not_values += criterias.map { |k, v| [k.to_s, v] }
212
246
  self
213
247
  end
214
248
 
@@ -230,6 +264,7 @@ module FrozenRecord
230
264
  end
231
265
 
232
266
  private
267
+
233
268
  def compare_value(actual, requested)
234
269
  return actual == requested unless requested.is_a?(Array) || requested.is_a?(Range)
235
270
  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.14.0'
4
+ VERSION = '0.19.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,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,25 +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
+
83
+ describe '.scope' do
84
+
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
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
+
74
103
  describe '#load_records' do
75
104
 
76
105
  it 'processes erb by default' do
77
- country = Country.first
106
+ country = country_model.first
78
107
  expect(country.capital).to be == 'Ottawa'
79
108
  end
80
109
 
81
110
  it 'loads records with a custom backend' do
82
- animal = Animal.first
111
+ animal = animal_model.first
83
112
  expect(animal.name).to be == 'cat'
84
113
  end
85
114
 
@@ -88,22 +117,22 @@ describe FrozenRecord::Base do
88
117
  describe '#==' do
89
118
 
90
119
  it 'returns true if both instances are from the same class and have the same id' do
91
- country = Country.first
120
+ country = country_model.first
92
121
  second_country = country.dup
93
122
 
94
123
  expect(country).to be == second_country
95
124
  end
96
125
 
97
126
  it 'returns false if both instances are not from the same class' do
98
- country = Country.first
99
- car = Car.new(id: country.id)
127
+ country = country_model.first
128
+ car = car_model.new(id: country.id)
100
129
 
101
130
  expect(country).to_not be == car
102
131
  end
103
132
 
104
133
  it 'returns false if both instances do not have the same id' do
105
- country = Country.first
106
- second_country = Country.last
134
+ country = country_model.first
135
+ second_country = country_model.last
107
136
 
108
137
  expect(country).to_not be == second_country
109
138
  end
@@ -113,7 +142,7 @@ describe FrozenRecord::Base do
113
142
  describe '#attributes' do
114
143
 
115
144
  it 'returns a Hash of the record attributes' do
116
- attributes = Country.first.attributes
145
+ attributes = country_model.first.attributes
117
146
  expect(attributes).to be == {
118
147
  'id' => 1,
119
148
  'name' => 'Canada',
@@ -124,6 +153,9 @@ describe FrozenRecord::Base do
124
153
  'updated_at' => Time.parse('2014-02-24T19:08:06-05:00'),
125
154
  'king' => 'Elisabeth II',
126
155
  'nato' => true,
156
+ 'continent' => 'North America',
157
+ 'available' => true,
158
+ 'contemporary' => true,
127
159
  }
128
160
  end
129
161
 
@@ -131,9 +163,9 @@ describe FrozenRecord::Base do
131
163
 
132
164
  describe '`attribute`?' do
133
165
 
134
- let(:blank) { Country.new(id: 0, name: '', nato: false, king: nil) }
166
+ let(:blank) { country_model.new(id: 0, name: '', nato: false, king: nil) }
135
167
 
136
- 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) }
137
169
 
138
170
  it 'considers `0` as missing' do
139
171
  expect(blank.id?).to be false
@@ -169,15 +201,50 @@ describe FrozenRecord::Base do
169
201
 
170
202
  end
171
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
+
172
212
  describe '#count' do
173
213
 
174
214
  it 'can count objects with no records' do
175
- expect(Car.count).to be 0
215
+ expect(car_model.count).to be 0
176
216
  end
177
217
 
178
218
  it 'can count objects with records' do
179
- 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)
180
239
  end
181
240
 
182
241
  end
183
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,10 @@ 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
206
210
  end
207
211
 
208
212
  describe '.where.not' do
@@ -417,6 +421,23 @@ describe 'querying' do
417
421
 
418
422
  end
419
423
 
424
+ describe '#==' do
425
+ it 'returns true when two scopes share the same hashed attributes' do
426
+ scope_a = Country.republics.nato
427
+ scope_b = Country.republics.nato
428
+ expect(scope_a.object_id).not_to be == scope_b.object_id
429
+ expect(scope_a).to be == scope_b
430
+ end
431
+
432
+ it 'returns true when the same scope has be rechained' do
433
+ scope_a = Country.nato.republics.nato.republics
434
+ scope_b = Country.republics.nato
435
+ expect(scope_a.instance_variable_get(:@where_values)).to be == [['nato', true], ['king', nil], ['nato', true], ['king', nil]]
436
+ expect(scope_b.instance_variable_get(:@where_values)).to be == [['king', nil], ['nato', true]]
437
+ expect(scope_a).to be == scope_b
438
+ end
439
+ end
440
+
420
441
  describe 'class methods delegation' do
421
442
 
422
443
  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,27 @@
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)
5
9
  end
6
10
 
11
+ def self.nato
12
+ where(nato: true)
13
+ end
14
+
7
15
  def reverse_name
8
16
  name.reverse
9
17
  end
10
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.14.0
4
+ version: 0.19.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-04-02 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,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,23 +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
- rubyforge_project:
150
- rubygems_version: 2.5.1
151
- signing_key:
159
+ rubygems_version: 3.1.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