frozen_record 0.1.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 +7 -0
- data/.gitignore +17 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +22 -0
- data/README.md +123 -0
- data/Rakefile +6 -0
- data/frozen_record.gemspec +25 -0
- data/lib/frozen_record/base.rb +120 -0
- data/lib/frozen_record/scope.rb +219 -0
- data/lib/frozen_record/version.rb +3 -0
- data/lib/frozen_record.rb +11 -0
- data/spec/fixtures/cars.yml +0 -0
- data/spec/fixtures/countries.yml +26 -0
- data/spec/frozen_record_spec.rb +100 -0
- data/spec/scope_spec.rb +358 -0
- data/spec/spec_helper.rb +24 -0
- data/spec/support/car.rb +2 -0
- data/spec/support/country.rb +7 -0
- metadata +140 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: c5d9ab606f1d5e70f62c490272bcee441f11d8b6
|
4
|
+
data.tar.gz: 96fed4747f8ae0a80c3944defdf9bb69548db474
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1a6a86f8a6d46490ccbb0d685f79e1393589609bdfaebcbae59963023b22c21e46cd3011b4dd9baab729b45f16aa4421b19de091f788c8d0a28db5b31b182a42
|
7
|
+
data.tar.gz: 4b49087f3cf1f2c763d5c79fb533814a18560b1c173f54bd6a5cd9c4cfb1d9b7da82a062d513c3c8b9f692895313a63c0b3e0b76c410ba47dfabcdd1dd5680d5
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 Jean Boussier
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,123 @@
|
|
1
|
+
# FrozenRecord
|
2
|
+
|
3
|
+
[](http://travis-ci.org/byroot/frozen_record)
|
4
|
+
[](https://codeclimate.com/github/byroot/frozen_record)
|
5
|
+
[](https://coveralls.io/r/byroot/frozen_record)
|
6
|
+
[](http://badge.fury.io/rb/frozen_record)
|
7
|
+
|
8
|
+
ActiveRecord-like interface for **read only** access to YAML static data.
|
9
|
+
|
10
|
+
## Installation
|
11
|
+
|
12
|
+
Add this line to your application's Gemfile:
|
13
|
+
|
14
|
+
gem 'frozen_record'
|
15
|
+
|
16
|
+
And then execute:
|
17
|
+
|
18
|
+
$ bundle
|
19
|
+
|
20
|
+
Or install it yourself as:
|
21
|
+
|
22
|
+
$ gem install frozen_record
|
23
|
+
|
24
|
+
## Models definition
|
25
|
+
|
26
|
+
Just like with ActiveRecord, your models need to inherits from `FrozenRecord::Base`:
|
27
|
+
|
28
|
+
```ruby
|
29
|
+
class Country < FrozenRecord::Base
|
30
|
+
end
|
31
|
+
```
|
32
|
+
|
33
|
+
But you also have to specify in which directory your data files are located.
|
34
|
+
You can either do it globaly
|
35
|
+
|
36
|
+
```ruby
|
37
|
+
FrozenRecord::Base.base_path = '/path/to/some/directory'
|
38
|
+
```
|
39
|
+
|
40
|
+
Or per model:
|
41
|
+
```ruby
|
42
|
+
class Country < FrozenRecord::Base
|
43
|
+
self.base_path = '/path/to/some/directory'
|
44
|
+
end
|
45
|
+
```
|
46
|
+
|
47
|
+
## Query interface
|
48
|
+
|
49
|
+
FrozenRecord aim to replicate only modern ActiveRecord querying interface, and only the non "string typed" ones.
|
50
|
+
|
51
|
+
e.g
|
52
|
+
```ruby
|
53
|
+
# Supported query interfaces
|
54
|
+
Country.
|
55
|
+
where(region: 'Europe').
|
56
|
+
where.not(language: 'English').
|
57
|
+
order(id: :desc).
|
58
|
+
limit(10).
|
59
|
+
offset(2).
|
60
|
+
pluck(:name)
|
61
|
+
|
62
|
+
# Non supported query interfaces
|
63
|
+
Country.
|
64
|
+
where('region = "Europe" AND language != "English"').
|
65
|
+
order('id DESC')
|
66
|
+
```
|
67
|
+
|
68
|
+
### Scopes
|
69
|
+
|
70
|
+
While the `scope :symbol, lambda` syntax is not supported, the class methods way is:
|
71
|
+
|
72
|
+
```ruby
|
73
|
+
class Country
|
74
|
+
def self.republics
|
75
|
+
where(king: nil)
|
76
|
+
end
|
77
|
+
|
78
|
+
def self.part_of_nato
|
79
|
+
where(nato: true)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
Country.republics.part_of_nato.order(id: :desc)
|
84
|
+
```
|
85
|
+
|
86
|
+
### Supported query methods
|
87
|
+
|
88
|
+
- where
|
89
|
+
- where.not
|
90
|
+
- order
|
91
|
+
- limit
|
92
|
+
- offset
|
93
|
+
|
94
|
+
### Supported finder methods
|
95
|
+
|
96
|
+
- find
|
97
|
+
- first
|
98
|
+
- last
|
99
|
+
- to_a
|
100
|
+
- exists?
|
101
|
+
|
102
|
+
### Supported calculation methods
|
103
|
+
|
104
|
+
- count
|
105
|
+
- pluck
|
106
|
+
- minimum
|
107
|
+
- minimum
|
108
|
+
- sum
|
109
|
+
- average
|
110
|
+
|
111
|
+
|
112
|
+
## Contributors
|
113
|
+
|
114
|
+
FrozenRecord is a from scratch reimplementation of a [Shopify](https://github.com/Shopify) project from 2007 named `YamlRecord`.
|
115
|
+
So thanks to:
|
116
|
+
|
117
|
+
- John Duff - [@jduff](https://github.com/jduff)
|
118
|
+
- Dennis O'Connor - [@dennisoconnor](https://github.com/dennisoconnor)
|
119
|
+
- Christopher Saunders - [@csaunders](https://github.com/csaunders)
|
120
|
+
- Jonathan Rudenberg - [@titanous](https://github.com/titanous)
|
121
|
+
- Jesse Storimer - [@jstorimer](https://github.com/jstorimer)
|
122
|
+
- Cody Fauser - [@codyfauser](https://github.com/codyfauser)
|
123
|
+
- Tobias Lütke - [@tobi](https://github.com/tobi)
|
data/Rakefile
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'frozen_record/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'frozen_record'
|
8
|
+
spec.version = FrozenRecord::VERSION
|
9
|
+
spec.authors = ['Jean Boussier']
|
10
|
+
spec.email = ['jean.boussier@gmail.com']
|
11
|
+
spec.summary = %q{ActiveRecord like interface to read only access and query static YAML files}
|
12
|
+
spec.homepage = ''
|
13
|
+
spec.license = 'MIT'
|
14
|
+
|
15
|
+
spec.files = `git ls-files`.split($/)
|
16
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
17
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
18
|
+
spec.require_paths = ['lib']
|
19
|
+
|
20
|
+
spec.add_runtime_dependency 'activemodel'
|
21
|
+
spec.add_development_dependency 'bundler', '~> 1.5'
|
22
|
+
spec.add_development_dependency 'rake'
|
23
|
+
spec.add_development_dependency 'rspec'
|
24
|
+
spec.add_development_dependency 'coveralls'
|
25
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
require 'set'
|
2
|
+
|
3
|
+
module FrozenRecord
|
4
|
+
class Base
|
5
|
+
extend ActiveModel::Naming
|
6
|
+
include ActiveModel::Conversion
|
7
|
+
include ActiveModel::AttributeMethods
|
8
|
+
include ActiveModel::Serializers::JSON
|
9
|
+
include ActiveModel::Serializers::Xml
|
10
|
+
|
11
|
+
FIND_BY_PATTERN = /\Afind_by_(\w+)(!?)/
|
12
|
+
FALSY_VALUES = [false, nil, 0, ''].to_set
|
13
|
+
|
14
|
+
class_attribute :base_path
|
15
|
+
|
16
|
+
class_attribute :primary_key
|
17
|
+
self.primary_key = :id
|
18
|
+
|
19
|
+
attribute_method_suffix '?'
|
20
|
+
|
21
|
+
class << self
|
22
|
+
|
23
|
+
def current_scope
|
24
|
+
@current_scope ||= Scope.new(self, load_records)
|
25
|
+
end
|
26
|
+
alias_method :all, :current_scope
|
27
|
+
|
28
|
+
def current_scope=(scope)
|
29
|
+
@current_scope = scope
|
30
|
+
end
|
31
|
+
|
32
|
+
delegate :find, :find_by_id, :where, :first, :first!, :last, :last!, :pluck, :order, :limit, :offset,
|
33
|
+
:minimum, :maximum, :average, :sum, to: :current_scope
|
34
|
+
|
35
|
+
def file_path
|
36
|
+
raise "You must define `#{name}.base_path`" unless base_path
|
37
|
+
File.join(base_path, "#{name.underscore.pluralize}.yml")
|
38
|
+
end
|
39
|
+
|
40
|
+
def respond_to_missing?(name, *)
|
41
|
+
if name.to_s =~ FIND_BY_PATTERN
|
42
|
+
return true if $1.split('_and_').all? { |attr| public_method_defined?(attr) }
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def method_missing(name, *args)
|
49
|
+
if name.to_s =~ FIND_BY_PATTERN
|
50
|
+
return dynamic_match($1, args, $2.present?)
|
51
|
+
end
|
52
|
+
super
|
53
|
+
end
|
54
|
+
|
55
|
+
def dynamic_match(expression, values, bang)
|
56
|
+
results = where(expression.split('_and_').zip(values))
|
57
|
+
bang ? results.first! : results.first
|
58
|
+
end
|
59
|
+
|
60
|
+
def load_records
|
61
|
+
@records ||= begin
|
62
|
+
records = YAML.load_file(file_path) || []
|
63
|
+
define_attributes!(list_attributes(records))
|
64
|
+
records.map(&method(:new)).freeze
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def list_attributes(records)
|
69
|
+
attributes = Set.new
|
70
|
+
records.each do |record|
|
71
|
+
record.keys.each do |key|
|
72
|
+
attributes.add(key.to_s)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
attributes
|
76
|
+
end
|
77
|
+
|
78
|
+
def define_attributes!(attributes)
|
79
|
+
attributes.each do |attr|
|
80
|
+
define_attribute_method(attr)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
85
|
+
|
86
|
+
attr_reader :attributes
|
87
|
+
|
88
|
+
def initialize(attrs = {})
|
89
|
+
@attributes = attrs.stringify_keys
|
90
|
+
end
|
91
|
+
|
92
|
+
def id
|
93
|
+
self[primary_key]
|
94
|
+
end
|
95
|
+
|
96
|
+
def [](attr)
|
97
|
+
@attributes[attr.to_s]
|
98
|
+
end
|
99
|
+
alias_method :attribute, :[]
|
100
|
+
|
101
|
+
def ==(other)
|
102
|
+
super || other.is_a?(self.class) && other.id == id
|
103
|
+
end
|
104
|
+
|
105
|
+
def persisted?
|
106
|
+
true
|
107
|
+
end
|
108
|
+
|
109
|
+
def to_key
|
110
|
+
[id]
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
def attribute?(attribute_name)
|
116
|
+
FALSY_VALUES.exclude?(self[attribute_name]) && self[attribute_name].present?
|
117
|
+
end
|
118
|
+
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,219 @@
|
|
1
|
+
module FrozenRecord
|
2
|
+
class Scope
|
3
|
+
BLACKLISTED_ARRAY_METHODS = [
|
4
|
+
:compact!, :flatten!, :reject!, :reverse!, :rotate!, :map!,
|
5
|
+
:shuffle!, :slice!, :sort!, :sort_by!, :delete_if,
|
6
|
+
:keep_if, :pop, :shift, :delete_at, :compact
|
7
|
+
].to_set
|
8
|
+
|
9
|
+
delegate :first, :last, :length, :collect, :map, :each, :all?, :include?, :to_ary, :to_json, :to_xml, :as_json, to: :to_a
|
10
|
+
|
11
|
+
class WhereChain
|
12
|
+
def initialize(scope)
|
13
|
+
@scope = scope
|
14
|
+
end
|
15
|
+
|
16
|
+
def not(criterias)
|
17
|
+
@scope.where_not(criterias)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def initialize(klass, records)
|
22
|
+
@klass = klass
|
23
|
+
@records = records
|
24
|
+
@where_values = []
|
25
|
+
@where_not_values = []
|
26
|
+
@order_values = []
|
27
|
+
@limit = nil
|
28
|
+
@offset = nil
|
29
|
+
end
|
30
|
+
|
31
|
+
def find_by_id(id)
|
32
|
+
matching_records.find { |r| r.id == id }
|
33
|
+
end
|
34
|
+
|
35
|
+
def find(id)
|
36
|
+
raise RecordNotFound, "Can't lookup record without ID" unless id
|
37
|
+
find_by_id(id) or raise RecordNotFound, "Couldn't find a record with ID = #{id.inspect}"
|
38
|
+
end
|
39
|
+
|
40
|
+
def first!
|
41
|
+
first or raise RecordNotFound, "No record matched"
|
42
|
+
end
|
43
|
+
|
44
|
+
def last!
|
45
|
+
last or raise RecordNotFound, "No record matched"
|
46
|
+
end
|
47
|
+
|
48
|
+
def to_a
|
49
|
+
@results ||= query_results
|
50
|
+
end
|
51
|
+
|
52
|
+
def pluck(*attributes)
|
53
|
+
case attributes.length
|
54
|
+
when 1
|
55
|
+
to_a.map(&attributes.first)
|
56
|
+
when 0
|
57
|
+
raise NotImplementedError, '`.pluck` without arguments is not supported yet'
|
58
|
+
else
|
59
|
+
to_a.map { |r| attributes.map { |a| r[a] }}
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def sum(attribute)
|
64
|
+
pluck(attribute).sum
|
65
|
+
end
|
66
|
+
|
67
|
+
def average(attribute)
|
68
|
+
pluck(attribute).sum.to_f / count
|
69
|
+
end
|
70
|
+
|
71
|
+
def minimum(attribute)
|
72
|
+
pluck(attribute).min
|
73
|
+
end
|
74
|
+
|
75
|
+
def maximum(attribute)
|
76
|
+
pluck(attribute).max
|
77
|
+
end
|
78
|
+
|
79
|
+
def exists?
|
80
|
+
!empty?
|
81
|
+
end
|
82
|
+
|
83
|
+
def where(criterias = :chain)
|
84
|
+
if criterias == :chain
|
85
|
+
WhereChain.new(self)
|
86
|
+
else
|
87
|
+
spawn.where!(criterias)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def where_not(criterias)
|
92
|
+
spawn.where_not!(criterias)
|
93
|
+
end
|
94
|
+
|
95
|
+
def order(*ordering)
|
96
|
+
spawn.order!(*ordering)
|
97
|
+
end
|
98
|
+
|
99
|
+
def limit(amount)
|
100
|
+
spawn.limit!(amount)
|
101
|
+
end
|
102
|
+
|
103
|
+
def offset(amount)
|
104
|
+
spawn.offset!(amount)
|
105
|
+
end
|
106
|
+
|
107
|
+
def respond_to_missing(method_name, *)
|
108
|
+
array_delegable?(method_name) || super
|
109
|
+
end
|
110
|
+
|
111
|
+
protected
|
112
|
+
|
113
|
+
def scoping
|
114
|
+
previous, @klass.current_scope = @klass.current_scope, self
|
115
|
+
yield
|
116
|
+
ensure
|
117
|
+
@klass.current_scope = previous
|
118
|
+
end
|
119
|
+
|
120
|
+
def spawn
|
121
|
+
clone.clear_cache!
|
122
|
+
end
|
123
|
+
|
124
|
+
def clear_cache!
|
125
|
+
@results = nil
|
126
|
+
@matches = nil
|
127
|
+
self
|
128
|
+
end
|
129
|
+
|
130
|
+
def query_results
|
131
|
+
@results ||= slice_records(matching_records)
|
132
|
+
end
|
133
|
+
|
134
|
+
def matching_records
|
135
|
+
@matches ||= sort_records(select_records(@records))
|
136
|
+
end
|
137
|
+
|
138
|
+
def select_records(records)
|
139
|
+
return records if @where_values.empty? && @where_not_values.empty?
|
140
|
+
|
141
|
+
records.select do |record|
|
142
|
+
@where_values.all? { |attr, value| record[attr] == value } &&
|
143
|
+
@where_not_values.all? { |attr, value| record[attr] != value }
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def sort_records(records)
|
148
|
+
return records if @order_values.empty?
|
149
|
+
|
150
|
+
records.sort do |a, b|
|
151
|
+
compare(a, b)
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def slice_records(records)
|
156
|
+
return records unless @limit || @offset
|
157
|
+
|
158
|
+
first = @offset || 0
|
159
|
+
last = first + (@limit || records.length)
|
160
|
+
records[first...last] || []
|
161
|
+
end
|
162
|
+
|
163
|
+
def compare(a, b)
|
164
|
+
@order_values.each do |attr, order|
|
165
|
+
a_value, b_value = a[attr], b[attr]
|
166
|
+
cmp = a_value <=> b_value
|
167
|
+
next if cmp == 0
|
168
|
+
return order == :asc ? cmp : -cmp
|
169
|
+
end
|
170
|
+
0
|
171
|
+
end
|
172
|
+
|
173
|
+
def method_missing(method_name, *args, &block)
|
174
|
+
if array_delegable?(method_name)
|
175
|
+
to_a.public_send(method_name, *args, &block)
|
176
|
+
elsif @klass.respond_to?(method_name)
|
177
|
+
delegate_to_class(method_name, *args, &block)
|
178
|
+
else
|
179
|
+
super
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
def delegate_to_class(*args, &block)
|
184
|
+
scoping { @klass.public_send(*args, &block) }
|
185
|
+
end
|
186
|
+
|
187
|
+
def array_delegable?(method)
|
188
|
+
Array.method_defined?(method) && BLACKLISTED_ARRAY_METHODS.exclude?(method)
|
189
|
+
end
|
190
|
+
|
191
|
+
def where!(criterias)
|
192
|
+
@where_values += criterias.to_a
|
193
|
+
self
|
194
|
+
end
|
195
|
+
|
196
|
+
def where_not!(criterias)
|
197
|
+
@where_not_values += criterias.to_a
|
198
|
+
self
|
199
|
+
end
|
200
|
+
|
201
|
+
def order!(*ordering)
|
202
|
+
@order_values += ordering.map do |order|
|
203
|
+
order.respond_to?(:to_a) ? order.to_a : [[order, :asc]]
|
204
|
+
end.flatten(1)
|
205
|
+
self
|
206
|
+
end
|
207
|
+
|
208
|
+
def limit!(amount)
|
209
|
+
@limit = amount
|
210
|
+
self
|
211
|
+
end
|
212
|
+
|
213
|
+
def offset!(amount)
|
214
|
+
@offset = amount
|
215
|
+
self
|
216
|
+
end
|
217
|
+
|
218
|
+
end
|
219
|
+
end
|
File without changes
|
@@ -0,0 +1,26 @@
|
|
1
|
+
---
|
2
|
+
- id: 1
|
3
|
+
name: Canada
|
4
|
+
capital: Ottawa
|
5
|
+
density: 3.5
|
6
|
+
population: 33.88
|
7
|
+
founded_on: 1867-07-01
|
8
|
+
updated_at: 2014-02-24T19:08:06-05:00
|
9
|
+
nato: true
|
10
|
+
king: Elisabeth II
|
11
|
+
|
12
|
+
- id: 2
|
13
|
+
name: France
|
14
|
+
capital: Paris
|
15
|
+
density: 116
|
16
|
+
population: 65.7
|
17
|
+
founded_on: 486-01-01
|
18
|
+
updated_at: 2014-02-12T19:02:03-02:00
|
19
|
+
|
20
|
+
- id: 3
|
21
|
+
name: Austria
|
22
|
+
capital: Vienna
|
23
|
+
density: 100.3
|
24
|
+
population: 8.462
|
25
|
+
founded_on: 1156-01-01
|
26
|
+
updated_at: 2014-02-12T19:02:03-02:00
|
@@ -0,0 +1,100 @@
|
|
1
|
+
require 'spec_helper'
|
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
|
+
Country.stub(:base_path).and_return(nil)
|
9
|
+
expect {
|
10
|
+
Country.file_path
|
11
|
+
}.to raise_error
|
12
|
+
end
|
13
|
+
|
14
|
+
end
|
15
|
+
|
16
|
+
describe '#==' do
|
17
|
+
|
18
|
+
it 'returns true if both instances are from the same class and have the same id' do
|
19
|
+
country = Country.first
|
20
|
+
second_country = country.dup
|
21
|
+
|
22
|
+
expect(country).to be == second_country
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'returns false if both instances are not from the same class' do
|
26
|
+
country = Country.first
|
27
|
+
car = Car.new(id: country.id)
|
28
|
+
|
29
|
+
expect(country).to_not be == car
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'returns false if both instances do not have the same id' do
|
33
|
+
country = Country.first
|
34
|
+
second_country = Country.last
|
35
|
+
|
36
|
+
expect(country).to_not be == second_country
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
|
41
|
+
describe '#attributes' do
|
42
|
+
|
43
|
+
it 'returns a Hash of the record attributes' do
|
44
|
+
attributes = Country.first.attributes
|
45
|
+
expect(attributes).to be == {
|
46
|
+
'id' => 1,
|
47
|
+
'name' => 'Canada',
|
48
|
+
'capital' => 'Ottawa',
|
49
|
+
'density' => 3.5,
|
50
|
+
'population' => 33.88,
|
51
|
+
'founded_on' => Date.parse('1867-07-01'),
|
52
|
+
'updated_at' => Time.parse('2014-02-24T19:08:06-05:00'),
|
53
|
+
'king' => 'Elisabeth II',
|
54
|
+
'nato' => true,
|
55
|
+
}
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
|
60
|
+
describe '`attribute`?' do
|
61
|
+
|
62
|
+
let(:blank) { Country.new(id: 0, name: '', nato: false, king: nil) }
|
63
|
+
|
64
|
+
let(:present) { Country.new(id: 42, name: 'Groland', nato: true, king: Object.new) }
|
65
|
+
|
66
|
+
it 'considers `0` as missing' do
|
67
|
+
expect(blank.id?).to be_false
|
68
|
+
end
|
69
|
+
|
70
|
+
it 'considers `""` as missing' do
|
71
|
+
expect(blank.name?).to be_false
|
72
|
+
end
|
73
|
+
|
74
|
+
it 'considers `false` as missing' do
|
75
|
+
expect(blank.nato?).to be_false
|
76
|
+
end
|
77
|
+
|
78
|
+
it 'considers `nil` as missing' do
|
79
|
+
expect(blank.king?).to be_false
|
80
|
+
end
|
81
|
+
|
82
|
+
it 'considers other numbers than `0` as present' do
|
83
|
+
expect(present.id?).to be_true
|
84
|
+
end
|
85
|
+
|
86
|
+
it 'considers other strings than `""` as present' do
|
87
|
+
expect(present.name?).to be_true
|
88
|
+
end
|
89
|
+
|
90
|
+
it 'considers `true` as present' do
|
91
|
+
expect(present.nato?).to be_true
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'considers not `nil` objects as present' do
|
95
|
+
expect(present.king?).to be_true
|
96
|
+
end
|
97
|
+
|
98
|
+
end
|
99
|
+
|
100
|
+
end
|
data/spec/scope_spec.rb
ADDED
@@ -0,0 +1,358 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'querying' do
|
4
|
+
|
5
|
+
describe '.first' do
|
6
|
+
|
7
|
+
it 'returns the first country' do
|
8
|
+
country = Country.first
|
9
|
+
expect(country.id).to be == 1
|
10
|
+
end
|
11
|
+
|
12
|
+
it 'can be called on any scope' do
|
13
|
+
country = Country.where(name: 'France').first
|
14
|
+
expect(country.id).to be == 2
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
describe '.first!' do
|
20
|
+
|
21
|
+
it 'raises if no record found' do
|
22
|
+
expect {
|
23
|
+
Country.where(name: 'not existing').first!
|
24
|
+
}.to raise_error(FrozenRecord::RecordNotFound)
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'doesn\'t raise if record found' do
|
28
|
+
expect {
|
29
|
+
Country.first!
|
30
|
+
}.to_not raise_error
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
describe '.last' do
|
36
|
+
|
37
|
+
it 'returns the last country' do
|
38
|
+
country = Country.last
|
39
|
+
expect(country.id).to be == 3
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'can be called on any scope' do
|
43
|
+
country = Country.where(name: 'Canada').last
|
44
|
+
expect(country.id).to be == 1
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
48
|
+
|
49
|
+
describe '.last!' do
|
50
|
+
|
51
|
+
it 'raises if no record found' do
|
52
|
+
expect {
|
53
|
+
Country.where(name: 'not existing').last!
|
54
|
+
}.to raise_error(FrozenRecord::RecordNotFound)
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'doesn\'t raise if record found' do
|
58
|
+
expect {
|
59
|
+
Country.last!
|
60
|
+
}.to_not raise_error
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
|
65
|
+
describe '.find' do
|
66
|
+
|
67
|
+
it 'allow to find records by id' do
|
68
|
+
country = Country.find(1)
|
69
|
+
expect(country.id).to be == 1
|
70
|
+
expect(country.name).to be == 'Canada'
|
71
|
+
end
|
72
|
+
|
73
|
+
it 'raises a FrozenRecord::RecordNotFound error if the id do not exist' do
|
74
|
+
expect {
|
75
|
+
Country.find(42)
|
76
|
+
}.to raise_error(FrozenRecord::RecordNotFound)
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'raises a FrozenRecord::RecordNotFound error if the id exist but do not match criterias' do
|
80
|
+
expect {
|
81
|
+
Country.where.not(id: 1).find(1)
|
82
|
+
}.to raise_error(FrozenRecord::RecordNotFound)
|
83
|
+
end
|
84
|
+
|
85
|
+
it 'is not restricted by :limit and :offset' do
|
86
|
+
country = Country.offset(100).limit(1).find(1)
|
87
|
+
expect(country).to be == Country.first
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
|
92
|
+
describe '.find_by_id' do
|
93
|
+
|
94
|
+
it 'allow to find records by id' do
|
95
|
+
country = Country.find_by_id(1)
|
96
|
+
expect(country.id).to be == 1
|
97
|
+
expect(country.name).to be == 'Canada'
|
98
|
+
end
|
99
|
+
|
100
|
+
it 'returns nil if the id do not exist' do
|
101
|
+
country = Country.find_by_id(42)
|
102
|
+
expect(country).to be_nil
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
106
|
+
|
107
|
+
describe 'dynamic_matchers' do
|
108
|
+
|
109
|
+
it 'returns the first matching record' do
|
110
|
+
country = Country.find_by_name_and_density('France', 116)
|
111
|
+
expect(country.name).to be == 'France'
|
112
|
+
end
|
113
|
+
|
114
|
+
it 'returns nil if no records match' do
|
115
|
+
country = Country.find_by_name_and_density('England', 116)
|
116
|
+
expect(country).to be_nil
|
117
|
+
end
|
118
|
+
|
119
|
+
it 'hook into respond_to?' do
|
120
|
+
expect(Country).to respond_to :find_by_name_and_density
|
121
|
+
end
|
122
|
+
|
123
|
+
it 'do not respond to unknown attributes' do
|
124
|
+
expect(Country).to_not respond_to :find_by_name_and_unknown_attribute
|
125
|
+
end
|
126
|
+
|
127
|
+
end
|
128
|
+
|
129
|
+
describe 'dynamic_matchers!' do
|
130
|
+
|
131
|
+
it 'returns the first matching record' do
|
132
|
+
country = Country.find_by_name_and_density!('France', 116)
|
133
|
+
expect(country.name).to be == 'France'
|
134
|
+
end
|
135
|
+
|
136
|
+
it 'returns nil if no records match' do
|
137
|
+
expect {
|
138
|
+
Country.find_by_name_and_density!('England', 116)
|
139
|
+
}.to raise_error(FrozenRecord::RecordNotFound)
|
140
|
+
end
|
141
|
+
|
142
|
+
end
|
143
|
+
|
144
|
+
describe '.where' do
|
145
|
+
|
146
|
+
it 'returns the records that match given criterias' do
|
147
|
+
countries = Country.where(name: 'France')
|
148
|
+
expect(countries.length).to be == 1
|
149
|
+
expect(countries.first.name).to be == 'France'
|
150
|
+
end
|
151
|
+
|
152
|
+
it 'is chainable' do
|
153
|
+
countries = Country.where(name: 'France').where(id: 1)
|
154
|
+
expect(countries).to be_empty
|
155
|
+
end
|
156
|
+
|
157
|
+
end
|
158
|
+
|
159
|
+
describe '.where.not' do
|
160
|
+
|
161
|
+
it 'returns the records that do not mach given criterias' do
|
162
|
+
countries = Country.where.not(name: 'France')
|
163
|
+
expect(countries.length).to be == 2
|
164
|
+
expect(countries.map(&:name)).to be == %w(Canada Austria)
|
165
|
+
end
|
166
|
+
|
167
|
+
it 'is chainable' do
|
168
|
+
countries = Country.where.not(name: 'France').where(id: 1)
|
169
|
+
expect(countries.length).to be == 1
|
170
|
+
expect(countries.map(&:name)).to be == %w(Canada)
|
171
|
+
end
|
172
|
+
|
173
|
+
end
|
174
|
+
|
175
|
+
describe '.order' do
|
176
|
+
|
177
|
+
context 'when pased one argument' do
|
178
|
+
|
179
|
+
it 'reorder records by given attribute in ascending order' do
|
180
|
+
countries = Country.order(:name).pluck(:name)
|
181
|
+
expect(countries).to be == %w(Austria Canada France)
|
182
|
+
end
|
183
|
+
|
184
|
+
end
|
185
|
+
|
186
|
+
context 'when passed multiple arguments' do
|
187
|
+
|
188
|
+
it 'reorder records by given attributes in ascending order' do
|
189
|
+
countries = Country.order(:updated_at, :name).pluck(:name)
|
190
|
+
expect(countries).to be == %w(Austria France Canada)
|
191
|
+
end
|
192
|
+
|
193
|
+
end
|
194
|
+
|
195
|
+
context 'when passed a hash' do
|
196
|
+
|
197
|
+
it 'records records by given attribute and specified order' do
|
198
|
+
countries = Country.order(name: :desc).pluck(:name)
|
199
|
+
expect(countries).to be == %w(France Canada Austria)
|
200
|
+
end
|
201
|
+
|
202
|
+
end
|
203
|
+
|
204
|
+
end
|
205
|
+
|
206
|
+
describe '.limit' do
|
207
|
+
|
208
|
+
it 'retuns only the amount of required records' do
|
209
|
+
countries = Country.limit(1)
|
210
|
+
expect(countries.length).to be == 1
|
211
|
+
expect(countries.to_a).to be == [Country.first]
|
212
|
+
end
|
213
|
+
|
214
|
+
end
|
215
|
+
|
216
|
+
describe '.offset' do
|
217
|
+
|
218
|
+
it 'skip the amount of required records' do
|
219
|
+
countries = Country.offset(1)
|
220
|
+
expect(countries.length).to be == 2
|
221
|
+
expect(countries.to_a).to be == [Country.find(2), Country.find(3)]
|
222
|
+
end
|
223
|
+
|
224
|
+
end
|
225
|
+
|
226
|
+
describe '.pluck' do
|
227
|
+
|
228
|
+
context 'when called with a single argument' do
|
229
|
+
|
230
|
+
it 'returns an array of values' do
|
231
|
+
names = Country.pluck(:name)
|
232
|
+
expect(names).to be == %w(Canada France Austria)
|
233
|
+
end
|
234
|
+
|
235
|
+
end
|
236
|
+
|
237
|
+
context 'when called with multiple arguments' do
|
238
|
+
|
239
|
+
it 'returns an array of arrays' do
|
240
|
+
names = Country.pluck(:id, :name)
|
241
|
+
expect(names).to be == [[1, 'Canada'], [2, 'France'], [3, 'Austria']]
|
242
|
+
end
|
243
|
+
|
244
|
+
end
|
245
|
+
|
246
|
+
context 'when called with multiple arguments' do
|
247
|
+
|
248
|
+
it 'returns an array of arrays' do
|
249
|
+
names = Country.pluck(:id, :name)
|
250
|
+
expect(names).to be == [[1, 'Canada'], [2, 'France'], [3, 'Austria']]
|
251
|
+
end
|
252
|
+
|
253
|
+
end
|
254
|
+
|
255
|
+
context 'when called without arguments' do
|
256
|
+
|
257
|
+
pending 'returns an array of arrays containing all attributes in order'
|
258
|
+
|
259
|
+
end
|
260
|
+
|
261
|
+
context 'when called on a scope' do
|
262
|
+
|
263
|
+
it 'returns only the attributes of matching records' do
|
264
|
+
names = Country.where(id: 1).pluck(:name)
|
265
|
+
expect(names).to be == %w(Canada)
|
266
|
+
end
|
267
|
+
|
268
|
+
end
|
269
|
+
|
270
|
+
end
|
271
|
+
|
272
|
+
describe '.exists?' do
|
273
|
+
|
274
|
+
it 'returns true if query match at least one record' do
|
275
|
+
scope = Country.where(name: 'France')
|
276
|
+
expect(scope).to exist
|
277
|
+
end
|
278
|
+
|
279
|
+
it 'returns true if query match no records' do
|
280
|
+
scope = Country.where(name: 'France', id: 42)
|
281
|
+
expect(scope).to_not exist
|
282
|
+
end
|
283
|
+
|
284
|
+
end
|
285
|
+
|
286
|
+
describe '.sum' do
|
287
|
+
|
288
|
+
it 'returns the sum of the column argument' do
|
289
|
+
sum = Country.sum(:population)
|
290
|
+
expect(sum).to be == 108.04200000000002
|
291
|
+
end
|
292
|
+
|
293
|
+
end
|
294
|
+
|
295
|
+
describe '.average' do
|
296
|
+
|
297
|
+
it 'returns the average of the column argument' do
|
298
|
+
average = Country.average(:density)
|
299
|
+
expect(average).to be == 73.26666666666667
|
300
|
+
end
|
301
|
+
|
302
|
+
end
|
303
|
+
|
304
|
+
describe '.minimum' do
|
305
|
+
|
306
|
+
it 'returns the average of the column argument' do
|
307
|
+
minimum = Country.minimum(:density)
|
308
|
+
expect(minimum).to be == 3.5
|
309
|
+
end
|
310
|
+
|
311
|
+
end
|
312
|
+
|
313
|
+
describe '.maximum' do
|
314
|
+
|
315
|
+
it 'returns the average of the column argument' do
|
316
|
+
maximum = Country.maximum(:density)
|
317
|
+
expect(maximum).to be == 116
|
318
|
+
end
|
319
|
+
|
320
|
+
end
|
321
|
+
|
322
|
+
describe '.to_json' do
|
323
|
+
|
324
|
+
it 'serialize the results' do
|
325
|
+
json = Country.all.to_json
|
326
|
+
expect(json).to be == Country.all.to_a.to_json
|
327
|
+
end
|
328
|
+
|
329
|
+
end
|
330
|
+
|
331
|
+
describe '.as_json' do
|
332
|
+
|
333
|
+
it 'serialize the results' do
|
334
|
+
json = Country.all.as_json
|
335
|
+
expect(json).to be == Country.all.to_a.as_json
|
336
|
+
end
|
337
|
+
|
338
|
+
end
|
339
|
+
|
340
|
+
describe '.to_xml' do
|
341
|
+
|
342
|
+
it 'serialize the results' do
|
343
|
+
json = Country.all.to_json
|
344
|
+
expect(json).to be == Country.all.to_a.to_json
|
345
|
+
end
|
346
|
+
|
347
|
+
end
|
348
|
+
|
349
|
+
describe 'class methods delegation' do
|
350
|
+
|
351
|
+
it 'can be called from a scope' do
|
352
|
+
ids = Country.where(name: 'France').republics.pluck(:id)
|
353
|
+
expect(ids).to be == [2]
|
354
|
+
end
|
355
|
+
|
356
|
+
end
|
357
|
+
|
358
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
lib = File.expand_path('../lib', __FILE__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
|
+
|
4
|
+
require 'simplecov'
|
5
|
+
require 'coveralls'
|
6
|
+
SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[
|
7
|
+
SimpleCov::Formatter::HTMLFormatter,
|
8
|
+
Coveralls::SimpleCov::Formatter
|
9
|
+
]
|
10
|
+
SimpleCov.start
|
11
|
+
|
12
|
+
require 'frozen_record'
|
13
|
+
|
14
|
+
FrozenRecord::Base.base_path = File.join(File.dirname(__FILE__), 'fixtures')
|
15
|
+
|
16
|
+
Dir[File.expand_path(File.join(File.dirname(__FILE__), 'support', '**', '*.rb'))].each { |f| require f }
|
17
|
+
|
18
|
+
RSpec.configure do |config|
|
19
|
+
config.treat_symbols_as_metadata_keys_with_true_values = true
|
20
|
+
config.run_all_when_everything_filtered = true
|
21
|
+
config.filter_run :focus
|
22
|
+
|
23
|
+
config.order = 'random'
|
24
|
+
end
|
data/spec/support/car.rb
ADDED
metadata
ADDED
@@ -0,0 +1,140 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: frozen_record
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Jean Boussier
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-03-11 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activemodel
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.5'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.5'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: coveralls
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
description:
|
84
|
+
email:
|
85
|
+
- jean.boussier@gmail.com
|
86
|
+
executables: []
|
87
|
+
extensions: []
|
88
|
+
extra_rdoc_files: []
|
89
|
+
files:
|
90
|
+
- ".gitignore"
|
91
|
+
- ".rspec"
|
92
|
+
- ".travis.yml"
|
93
|
+
- Gemfile
|
94
|
+
- LICENSE.txt
|
95
|
+
- README.md
|
96
|
+
- Rakefile
|
97
|
+
- frozen_record.gemspec
|
98
|
+
- lib/frozen_record.rb
|
99
|
+
- lib/frozen_record/base.rb
|
100
|
+
- lib/frozen_record/scope.rb
|
101
|
+
- lib/frozen_record/version.rb
|
102
|
+
- spec/fixtures/cars.yml
|
103
|
+
- spec/fixtures/countries.yml
|
104
|
+
- spec/frozen_record_spec.rb
|
105
|
+
- spec/scope_spec.rb
|
106
|
+
- spec/spec_helper.rb
|
107
|
+
- spec/support/car.rb
|
108
|
+
- spec/support/country.rb
|
109
|
+
homepage: ''
|
110
|
+
licenses:
|
111
|
+
- MIT
|
112
|
+
metadata: {}
|
113
|
+
post_install_message:
|
114
|
+
rdoc_options: []
|
115
|
+
require_paths:
|
116
|
+
- lib
|
117
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
118
|
+
requirements:
|
119
|
+
- - ">="
|
120
|
+
- !ruby/object:Gem::Version
|
121
|
+
version: '0'
|
122
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
123
|
+
requirements:
|
124
|
+
- - ">="
|
125
|
+
- !ruby/object:Gem::Version
|
126
|
+
version: '0'
|
127
|
+
requirements: []
|
128
|
+
rubyforge_project:
|
129
|
+
rubygems_version: 2.2.1
|
130
|
+
signing_key:
|
131
|
+
specification_version: 4
|
132
|
+
summary: ActiveRecord like interface to read only access and query static YAML files
|
133
|
+
test_files:
|
134
|
+
- spec/fixtures/cars.yml
|
135
|
+
- spec/fixtures/countries.yml
|
136
|
+
- spec/frozen_record_spec.rb
|
137
|
+
- spec/scope_spec.rb
|
138
|
+
- spec/spec_helper.rb
|
139
|
+
- spec/support/car.rb
|
140
|
+
- spec/support/country.rb
|