frozen_record 0.26.2 → 0.27.1

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
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