frozen_record 0.26.2 → 0.27.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: e162699e1ec4c32223be26afd36c1222bedeea90b8d0f26e05407641c9f01ccd
4
- data.tar.gz: f4f6dcb51344bf99558e278a746e4ae5527355ed8771c7be26186af2cab630a1
3
+ metadata.gz: 38d59eb398601918420c427ca2608c32d88e272948f39f502c831e92be2d6017
4
+ data.tar.gz: 1762510ed02e71fdfc67fcfe6f41ba4db83f766231481490e0172a4ea0c91f5b
5
5
  SHA512:
6
- metadata.gz: 594beed9888fc9379882815828f1d85a0941b143b03d177662fd02a8460867135003eeeb1391ad473f393c78f299c9e0c3ed45c9dcee88dee7bc7940ce3662c4
7
- data.tar.gz: bcc049e4643b24bca1c98f8947a924c97f829a2a66798fbe798cbeba744d9934c0b81cbb2bb5c29f686d8d6af723f99299312d56e7e484524021c57613d02f4d
6
+ metadata.gz: a58d5c272f4beb88c10a2b8186f6664843da73ee67a920a706258b1d3fd0c719fe7dcfd27d1f97485304f743c2cef86b8d5ed8924d59c69d162edf389fb0ab34
7
+ data.tar.gz: 1d587f2b2c7043e1137537ae0eb44fd84e1643876d8ca30d1704dc41c45b0739fe6612c71db97d14f32de3950e52fee289fcec34969e57505b7aebb5403264e3
@@ -8,11 +8,11 @@ jobs:
8
8
  strategy:
9
9
  fail-fast: false
10
10
  matrix:
11
- ruby: [ '2.5', '2.6', '2.7', '3.0' , '3.1']
11
+ ruby: [ '2.5', '2.6', '2.7', '3.0' , '3.1', '3.2']
12
12
  minimal: [ false, true ]
13
13
  name: Ruby ${{ matrix.ruby }} tests, minimal=${{ matrix.minimal }}
14
14
  steps:
15
- - uses: actions/checkout@v3
15
+ - uses: actions/checkout@v4
16
16
  - name: Set up Ruby
17
17
  uses: ruby/setup-ruby@v1
18
18
  with:
data/CHANGELOG.md CHANGED
@@ -1,7 +1,18 @@
1
1
  # Unreleased
2
2
 
3
+ # v0.27.1
4
+
5
+ - TestHelper.unload_fixture: handle models without data.
6
+
7
+ # v0.27.0
8
+
9
+ - Allow to define some richer attibute types, somewhat akin to Active Record `serialize` attributes. See the README for more information.
3
10
  - Fix `Model.find_by` fastpath raising an error when called before records are loaded.
4
11
 
12
+ # v026.2
13
+
14
+ - Properly load records when entiring the single attribute lookup fastpath.
15
+
5
16
  # v0.26.1
6
17
 
7
18
  - Optimized single attribute lookup.
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # FrozenRecord
2
2
 
3
- [![Build Status](https://secure.travis-ci.org/byroot/frozen_record.svg)](http://travis-ci.org/byroot/frozen_record)
4
- [![Gem Version](https://badge.fury.io/rb/frozen_record.svg)](http://badge.fury.io/rb/frozen_record)
3
+ [![Build Status](https://secure.travis-ci.org/byroot/frozen_record.svg)](https://travis-ci.org/byroot/frozen_record)
4
+ [![Gem Version](https://badge.fury.io/rb/frozen_record.svg)](https://badge.fury.io/rb/frozen_record)
5
5
 
6
6
  Active Record-like interface for **read only** access to static data files of reasonable size.
7
7
 
@@ -29,7 +29,7 @@ end
29
29
  ```
30
30
 
31
31
  But you also have to specify in which directory your data files are located.
32
- You can either do it globaly
32
+ You can either do it globally
33
33
 
34
34
  ```ruby
35
35
  FrozenRecord::Base.base_path = '/path/to/some/directory'
@@ -42,9 +42,27 @@ class Country < FrozenRecord::Base
42
42
  end
43
43
  ```
44
44
 
45
- You can also specify a custom backend. Backends are classes that know how to
46
- load records from a static file. By default FrozenRecord expects an YAML file,
47
- but this option can be changed per model:
45
+ FrozenRecord has two built-in backends, for JSON and YAML.
46
+ Backends are classes that know how to load records from a static file.
47
+
48
+ The default backend is YAML and it expects a file that looks like this:
49
+
50
+ ```yaml
51
+ - id: 'se'
52
+ name: 'Sweden'
53
+ region: 'Europe'
54
+ language: 'Swedish'
55
+ population: 10420000
56
+ - id: 'de'
57
+ name: 'Germany'
58
+ region: 'Europe'
59
+ language: 'German'
60
+ population: 83200000
61
+
62
+ # …
63
+ ```
64
+
65
+ You can also specify a custom backend:
48
66
 
49
67
  ```ruby
50
68
  class Country < FrozenRecord::Base
@@ -74,18 +92,18 @@ end
74
92
 
75
93
  FrozenRecord aim to replicate only modern Active Record querying interface, and only the non "string typed" ones.
76
94
 
77
- e.g
78
95
  ```ruby
79
96
  # Supported query interfaces
80
97
  Country.
81
98
  where(region: 'Europe').
82
99
  where.not(language: 'English').
100
+ where(population: 10_000_000..).
83
101
  order(id: :desc).
84
102
  limit(10).
85
103
  offset(2).
86
104
  pluck(:name)
87
105
 
88
- # Non supported query interfaces
106
+ # Non-supported query interfaces
89
107
  Country.
90
108
  where('region = "Europe" AND language != "English"').
91
109
  order('id DESC')
@@ -141,7 +159,7 @@ Country.european.republics.part_of_nato.order(id: :desc)
141
159
  ## Indexing
142
160
 
143
161
  Querying is implemented as a simple linear search (`O(n)`). However if you are using Frozen Record with larger datasets, or are querying
144
- a collection repetedly, you can define indices for faster access.
162
+ a collection repeatedly, you can define indices for faster access.
145
163
 
146
164
  ```ruby
147
165
  class Country < FrozenRecord::Base
@@ -154,6 +172,31 @@ Composite index keys are not supported.
154
172
 
155
173
  The primary key isn't indexed by default.
156
174
 
175
+ ## Rich Types
176
+
177
+ The `attribute` method can be used to provide a custom class to convert an attribute to a richer type.
178
+ The class must implement a `load` class method that takes the raw attribute value and returns the deserialized value (similar to
179
+ [ActiveRecord serialization](https://api.rubyonrails.org/v7.0.4/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html#method-i-serialize)).
180
+
181
+ ```ruby
182
+ class ContinentString < String
183
+ class << self
184
+ alias_method :load, :new
185
+ end
186
+ end
187
+
188
+ Size = Struct.new(:length, :width, :depth) do
189
+ def self.load(value) # value is lxwxd eg: "23x12x5"
190
+ new(*value.split('x'))
191
+ end
192
+ end
193
+
194
+ class Country < FrozenRecord::Base
195
+ attribute :continent, ContinentString
196
+ attribute :size, Size
197
+ end
198
+ ```
199
+
157
200
  ## Limitations
158
201
 
159
202
  Frozen Record is not meant to operate on large unindexed datasets.
@@ -192,7 +235,7 @@ FrozenRecord::TestHelper.unload_fixtures
192
235
  Here's a Rails-specific example:
193
236
 
194
237
  ```ruby
195
- require "test_helper"
238
+ require 'test_helper'
196
239
  require 'frozen_record/test_helper'
197
240
 
198
241
  class CountryTest < ActiveSupport::TestCase
@@ -205,7 +248,7 @@ class CountryTest < ActiveSupport::TestCase
205
248
  FrozenRecord::TestHelper.unload_fixtures
206
249
  end
207
250
 
208
- test "countries have a valid name" do
251
+ test 'countries have a valid name' do
209
252
  # ...
210
253
  ```
211
254
 
@@ -30,8 +30,10 @@ module FrozenRecord
30
30
 
31
31
  class_attribute :base_path, :primary_key, :backend, :auto_reloading, :default_attributes, instance_accessor: false
32
32
  class_attribute :index_definitions, instance_accessor: false
33
+ class_attribute :attribute_deserializers, instance_accessor: false
33
34
  class_attribute :max_records_scan, instance_accessor: false
34
35
  self.index_definitions = {}.freeze
36
+ self.attribute_deserializers = {}.freeze
35
37
 
36
38
  self.primary_key = 'id'
37
39
 
@@ -154,6 +156,10 @@ module FrozenRecord
154
156
  self.index_definitions = index_definitions.merge(index.attribute => index).freeze
155
157
  end
156
158
 
159
+ def attribute(attribute, klass)
160
+ self.attribute_deserializers = attribute_deserializers.merge(attribute.to_s => klass).freeze
161
+ end
162
+
157
163
  def memsize(object = self, seen = Set.new.compare_by_identity)
158
164
  return 0 unless seen.add?(object)
159
165
 
@@ -195,8 +201,8 @@ module FrozenRecord
195
201
 
196
202
  @records ||= begin
197
203
  records = backend.load(file_path)
198
- if default_attributes
199
- records = records.map { |r| assign_defaults!(r.dup).freeze }.freeze
204
+ if attribute_deserializers.any? || default_attributes
205
+ records = records.map { |r| assign_defaults!(deserialize_attributes!(r.dup)).freeze }.freeze
200
206
  end
201
207
  @attributes = list_attributes(records).freeze
202
208
  define_attribute_methods(@attributes.to_a)
@@ -214,7 +220,7 @@ module FrozenRecord
214
220
  private :load
215
221
 
216
222
  def new(attrs = {})
217
- load(assign_defaults!(attrs.transform_keys(&:to_s)))
223
+ load(assign_defaults!(deserialize_attributes!(attrs.transform_keys(&:to_s))))
218
224
  end
219
225
 
220
226
  private
@@ -241,6 +247,18 @@ module FrozenRecord
241
247
  record
242
248
  end
243
249
 
250
+ def deserialize_attributes!(record)
251
+ if attribute_deserializers.any?
252
+ attribute_deserializers.each do |key, deserializer|
253
+ if record.key?(key)
254
+ record[key] = deserializer.load(record[key])
255
+ end
256
+ end
257
+ end
258
+
259
+ record
260
+ end
261
+
244
262
  def method_missing(name, *args)
245
263
  if name.to_s =~ FIND_BY_PATTERN
246
264
  return dynamic_match($1, args, $2.present?)
@@ -13,8 +13,8 @@ module FrozenRecord
13
13
 
14
14
  @records ||= begin
15
15
  records = backend.load(file_path)
16
- if default_attributes
17
- records = records.map { |r| assign_defaults!(r.dup).freeze }.freeze
16
+ if attribute_deserializers.any? || default_attributes
17
+ records = records.map { |r| assign_defaults!(deserialize_attributes!(r.dup)).freeze }.freeze
18
18
  end
19
19
  @attributes = list_attributes(records).freeze
20
20
  build_attributes_cache
@@ -12,7 +12,7 @@ module FrozenRecord
12
12
 
13
13
  return if @cache.key?(model_class)
14
14
 
15
- @cache[model_class] ||= model_class.base_path
15
+ @cache[model_class] = base_path_if_file_present(model_class)
16
16
 
17
17
  model_class.base_path = alternate_base_path
18
18
  model_class.load_records(force: true)
@@ -26,9 +26,10 @@ module FrozenRecord
26
26
  return unless @cache.key?(model_class)
27
27
 
28
28
  old_base_path = @cache[model_class]
29
- model_class.base_path = old_base_path
30
- model_class.load_records(force: true)
31
-
29
+ if old_base_path
30
+ model_class.base_path = old_base_path
31
+ model_class.load_records(force: true)
32
+ end
32
33
  @cache.delete(model_class)
33
34
  end
34
35
 
@@ -40,6 +41,19 @@ module FrozenRecord
40
41
 
41
42
  private
42
43
 
44
+ # Checks for the existence of the file for the frozen_record in the default directory.
45
+ # Returns the base_path if the file is present, otherwise nil.
46
+ # Some tests define specific test classes that do ONLY exist in the alternate directory.
47
+ # As `unload_fixture(s)` tries to force load the default file, it would raise an error for
48
+ # the "test only" fixtures. The nil value in the cache handles that case gracefully.
49
+ def base_path_if_file_present(model_class)
50
+ if File.exist?(model_class.file_path)
51
+ model_class.base_path
52
+ else
53
+ nil
54
+ end
55
+ end
56
+
43
57
  def ensure_model_class_is_frozenrecord(model_class)
44
58
  unless model_class < FrozenRecord::Base
45
59
  raise ArgumentError, "Model class (#{model_class}) does not inherit from #{FrozenRecord::Base}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FrozenRecord
4
- VERSION = '0.26.2'
4
+ VERSION = '0.27.1'
5
5
  end
@@ -9,6 +9,7 @@
9
9
  nato: true
10
10
  king: Elisabeth II
11
11
  continent: North America
12
+ currency_code: CAD
12
13
 
13
14
  - id: 2
14
15
  name: France
@@ -29,3 +30,4 @@
29
30
  updated_at: 2014-02-12T19:02:03-02:00
30
31
  continent: Europe
31
32
  available: false
33
+ currency_code: EUR
@@ -0,0 +1,3 @@
1
+ ---
2
+ - id: 1
3
+ name: Some continent
@@ -80,6 +80,41 @@ RSpec.shared_examples 'main' do
80
80
 
81
81
  end
82
82
 
83
+ describe '.attribute' do
84
+
85
+ it 'deserializes the attribute' do
86
+ expect(country_model.find_by(name: 'Canada').continent).to be_a(TectonicString)
87
+ end
88
+
89
+ it 'sets the default value as default' do
90
+ expect(country_model.find_by(name: 'France').currency_code).to be == CurrencyCode.load("EUR")
91
+ end
92
+
93
+ it 'returns the first matching record' do
94
+ country = Country.find_by(currency_code: CurrencyCode.load('EUR'))
95
+ expect(country.name).to be == 'France'
96
+ end
97
+
98
+ it 'returns nil if record not found' do
99
+ country = Country.find_by(currency_code: CurrencyCode.load('THB'))
100
+ expect(country).to be_nil
101
+ end
102
+
103
+ it 'raises if no record found!' do
104
+ expect {
105
+ Country.find_by!(currency_code: CurrencyCode.load('THB'))
106
+ }.to raise_error(FrozenRecord::RecordNotFound)
107
+ end
108
+
109
+ it 'deserializes in the initializer' do
110
+ expect(country_model.new(currency_code: "CHF").currency_code).to be == CurrencyCode.load('CHF')
111
+ end
112
+
113
+ it 'also sets the default in the initializer' do
114
+ expect(country_model.new.currency_code).to be == CurrencyCode.load('EUR')
115
+ end
116
+ end
117
+
83
118
  describe '.scope' do
84
119
 
85
120
  it 'defines a scope method' do
@@ -189,6 +224,7 @@ RSpec.shared_examples 'main' do
189
224
  'continent' => 'North America',
190
225
  'available' => true,
191
226
  'contemporary' => true,
227
+ 'currency_code' => CurrencyCode.load('CAD'),
192
228
  }
193
229
  end
194
230
 
@@ -1,9 +1,26 @@
1
+ class CurrencyCode
2
+ class << self
3
+ def load(value)
4
+ value.to_sym
5
+ end
6
+ end
7
+ end
8
+
9
+ class TectonicString < String
10
+ class << self
11
+ alias_method :load, :new
12
+ end
13
+ end
14
+
1
15
  class Country < FrozenRecord::Base
2
- self.default_attributes = { contemporary: true, available: true }
16
+ self.default_attributes = { contemporary: true, available: true, currency_code: CurrencyCode.load('EUR') }
3
17
 
4
18
  add_index :name, unique: true
5
19
  add_index :continent
6
20
 
21
+ attribute :currency_code, CurrencyCode
22
+ attribute :continent, TectonicString
23
+
7
24
  def self.republics
8
25
  where(king: nil)
9
26
  end
@@ -49,6 +49,18 @@ describe 'test fixture loading' do
49
49
  expect(Continent.count).to be == 1
50
50
  expect(Country.count).to be == 3
51
51
  end
52
+
53
+ context "when the test fixture does not exist in normal base path" do
54
+ class OnlyInTest < FrozenRecord::Base; end
55
+ before do
56
+ test_fixtures_base_path = File.join(File.dirname(__FILE__), 'fixtures', 'test_helper')
57
+ FrozenRecord::TestHelper.load_fixture(OnlyInTest, test_fixtures_base_path)
58
+ end
59
+ it 'unload fixture gracefully recovers from an ' do
60
+
61
+ expect { FrozenRecord::TestHelper.unload_fixture(OnlyInTest) }.not_to raise_error
62
+ end
63
+ end
52
64
  end
53
65
 
54
66
  describe '.unload_fixtures' do
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.26.2
4
+ version: 0.27.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean Boussier
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-08-12 00:00:00.000000000 Z
11
+ date: 2024-02-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -95,6 +95,7 @@ files:
95
95
  - spec/fixtures/prices.yml.erb
96
96
  - spec/fixtures/test_helper/continents.yml.erb
97
97
  - spec/fixtures/test_helper/countries.yml.erb
98
+ - spec/fixtures/test_helper/only_in_tests.yml.erb
98
99
  - spec/frozen_record_spec.rb
99
100
  - spec/scope_spec.rb
100
101
  - spec/spec_helper.rb
@@ -124,7 +125,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
124
125
  - !ruby/object:Gem::Version
125
126
  version: '0'
126
127
  requirements: []
127
- rubygems_version: 3.3.7
128
+ rubygems_version: 3.5.3
128
129
  signing_key:
129
130
  specification_version: 4
130
131
  summary: ActiveRecord like interface to read only access and query static YAML files
@@ -136,6 +137,7 @@ test_files:
136
137
  - spec/fixtures/prices.yml.erb
137
138
  - spec/fixtures/test_helper/continents.yml.erb
138
139
  - spec/fixtures/test_helper/countries.yml.erb
140
+ - spec/fixtures/test_helper/only_in_tests.yml.erb
139
141
  - spec/frozen_record_spec.rb
140
142
  - spec/scope_spec.rb
141
143
  - spec/spec_helper.rb