gitmodel 0.0.2 → 0.0.3
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.
- data/.gitignore +1 -0
- data/Gemfile.lock +13 -15
- data/README.md +68 -33
- data/gitmodel.gemspec +2 -2
- data/lib/gitmodel/errors.rb +10 -2
- data/lib/gitmodel/index.rb +79 -0
- data/lib/gitmodel/persistable.rb +108 -28
- data/lib/gitmodel.rb +8 -1
- data/spec/gitmodel/index_spec.rb +108 -0
- data/spec/gitmodel/persistable_spec.rb +168 -17
- data/spec/gitmodel_spec.rb +14 -5
- data/spec/spec_helper.rb +9 -1
- metadata +13 -12
data/.gitignore
CHANGED
data/Gemfile.lock
CHANGED
@@ -6,20 +6,19 @@ PATH
|
|
6
6
|
activesupport (>= 3.0.1)
|
7
7
|
grit (>= 2.3.0)
|
8
8
|
lockfile (>= 1.4.3)
|
9
|
+
yajl-ruby (>= 0.8.2)
|
9
10
|
|
10
11
|
GEM
|
11
12
|
remote: http://rubygems.org/
|
12
13
|
specs:
|
13
14
|
ZenTest (4.5.0)
|
14
|
-
activemodel (3.0.
|
15
|
-
activesupport (= 3.0.
|
15
|
+
activemodel (3.0.9)
|
16
|
+
activesupport (= 3.0.9)
|
16
17
|
builder (~> 2.1.2)
|
17
|
-
i18n (~> 0.
|
18
|
-
activesupport (3.0.
|
18
|
+
i18n (~> 0.5.0)
|
19
|
+
activesupport (3.0.9)
|
19
20
|
autotest (4.4.6)
|
20
21
|
ZenTest (>= 4.4.1)
|
21
|
-
autotest-fsevent (0.2.4)
|
22
|
-
sys-uname
|
23
22
|
builder (2.1.2)
|
24
23
|
diff-lcs (1.1.2)
|
25
24
|
grit (2.4.1)
|
@@ -28,15 +27,15 @@ GEM
|
|
28
27
|
i18n (0.5.0)
|
29
28
|
lockfile (1.4.3)
|
30
29
|
mime-types (1.16)
|
31
|
-
rspec (2.
|
32
|
-
rspec-core (~> 2.
|
33
|
-
rspec-expectations (~> 2.
|
34
|
-
rspec-mocks (~> 2.
|
35
|
-
rspec-core (2.
|
36
|
-
rspec-expectations (2.
|
30
|
+
rspec (2.6.0)
|
31
|
+
rspec-core (~> 2.6.0)
|
32
|
+
rspec-expectations (~> 2.6.0)
|
33
|
+
rspec-mocks (~> 2.6.0)
|
34
|
+
rspec-core (2.6.4)
|
35
|
+
rspec-expectations (2.6.0)
|
37
36
|
diff-lcs (~> 1.1.2)
|
38
|
-
rspec-mocks (2.
|
39
|
-
|
37
|
+
rspec-mocks (2.6.0)
|
38
|
+
yajl-ruby (0.8.2)
|
40
39
|
|
41
40
|
PLATFORMS
|
42
41
|
ruby
|
@@ -44,6 +43,5 @@ PLATFORMS
|
|
44
43
|
DEPENDENCIES
|
45
44
|
ZenTest (>= 4.4.0)
|
46
45
|
autotest (>= 4.4.1)
|
47
|
-
autotest-fsevent (>= 0.2.3)
|
48
46
|
gitmodel!
|
49
47
|
rspec (>= 2.0.1)
|
data/README.md
CHANGED
@@ -17,13 +17,6 @@ machines, manipulated with standard Git client tools, can be branched and
|
|
17
17
|
merged, and of course keeps the history of all changes.
|
18
18
|
|
19
19
|
|
20
|
-
Status
|
21
|
-
------
|
22
|
-
|
23
|
-
_It is nowhere near production ready but I'm working on it. Please feel free to
|
24
|
-
contribute tests and/or code to help!_
|
25
|
-
|
26
|
-
|
27
20
|
Why it's awesome
|
28
21
|
----------------
|
29
22
|
|
@@ -38,6 +31,7 @@ Why it's awesome
|
|
38
31
|
* Experiment on production data using branches, for example to test a
|
39
32
|
migration
|
40
33
|
* Distributed (synced using standard Git push/pull)
|
34
|
+
* All ActiveModel
|
41
35
|
* Transactions
|
42
36
|
* Metadata for all database changes (Git commit messages, date & time, etc.)
|
43
37
|
* In order to be easily human-editable, the database is simply files and
|
@@ -48,6 +42,22 @@ Why it's awesome
|
|
48
42
|
* Clean and easy-to-use API
|
49
43
|
|
50
44
|
|
45
|
+
Status
|
46
|
+
------
|
47
|
+
|
48
|
+
_It is not yet production ready but I'm working on it. Please feel free to
|
49
|
+
contribute tests and/or code to help!_
|
50
|
+
|
51
|
+
See the "To do" section below for details, but the main thing that needs
|
52
|
+
finishing is support for querying. Right now you can find an instance by it's
|
53
|
+
id, but there is incomplete support (90% complete) for querying, e.g.:
|
54
|
+
|
55
|
+
Post.find(:category => 'ruby', :date => lambda{|d| d > 1.month.ago} :order_by => :date, :order => :asc, :limit => 5)
|
56
|
+
|
57
|
+
This includes support for indexing all attributes so that queries don't need to
|
58
|
+
load every object.
|
59
|
+
|
60
|
+
|
51
61
|
Installation
|
52
62
|
------------
|
53
63
|
|
@@ -77,6 +87,8 @@ Usage
|
|
77
87
|
p1.image = some_binary_data
|
78
88
|
p1.save!
|
79
89
|
|
90
|
+
p = Post.find('lessons-learned')
|
91
|
+
|
80
92
|
p2 = Post.new(:id => 'hotdog-eating-contest', :title => 'I won!')
|
81
93
|
p2.body = 'This weekend I won a hotdog eating contest!'
|
82
94
|
p2.image = some_binary_data
|
@@ -96,6 +108,7 @@ Usage
|
|
96
108
|
c1 = Comment.create!(:id => '2010-01-03-328', :text => '...')
|
97
109
|
c2 = Comment.create!(:id => '2010-05-29-742', :text => '...')
|
98
110
|
|
111
|
+
|
99
112
|
An example of a project that uses GitModel is [Balisong](https://github.com/pauldowman/balisong), a blogging app for coders (but it doesn't save objects to the data store. It's read-only so far, assuming that posts will be edited with a text editor).
|
100
113
|
|
101
114
|
|
@@ -116,42 +129,64 @@ For example, the database for the example above would have a directory
|
|
116
129
|
structure that looks like this:
|
117
130
|
|
118
131
|
* db-root
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
132
|
+
* comments
|
133
|
+
* 2010-01-03-328
|
134
|
+
* _attributes.json_
|
135
|
+
* 2010-05-29-742
|
136
|
+
* _attributes.json_
|
137
|
+
* posts
|
138
|
+
* hotdog-eating-contest
|
139
|
+
* _attributes.json_
|
140
|
+
* _hotdogs.jpg_
|
141
|
+
* _image_
|
142
|
+
* _the-aftermath.jpg_
|
143
|
+
* lessons-learned
|
144
|
+
* _attributes.json_
|
145
|
+
* _image_
|
146
|
+
* running-with-scissors
|
147
|
+
* _attributes.json_
|
148
|
+
|
149
|
+
|
150
|
+
Contributing
|
137
151
|
------------
|
138
152
|
|
139
|
-
|
153
|
+
Do you have an improvement to make? Please submit a pull request on GitHub or a
|
154
|
+
patch, including a test written with RSpec. To run all tests simply run
|
155
|
+
`autospec`.
|
156
|
+
|
157
|
+
The main author is [Paul Dowman](http://pauldowman.com/about) ([@pauldowman](http://twitter.com/pauldowman)).
|
140
158
|
|
159
|
+
Thanks to everyone who has contributed so far:
|
141
160
|
|
142
|
-
|
161
|
+
* [Alex Bartlow](https://github.com/alexbartlow)
|
162
|
+
|
163
|
+
|
164
|
+
To do
|
143
165
|
-----
|
144
166
|
|
167
|
+
* Finish Query support
|
168
|
+
* Update index (efficiently) when Persistable objects are saved
|
169
|
+
* Add Rake task to generate index
|
170
|
+
* Update README
|
145
171
|
* Add validations and other feature examples to sample code in README
|
146
|
-
* Querying
|
147
|
-
* Use AREL?
|
148
172
|
* Finish some pending specs
|
149
|
-
* Associations
|
150
173
|
* API documentation
|
151
174
|
* Rails integration
|
152
|
-
|
153
|
-
|
175
|
+
* Generators
|
176
|
+
* Rake tasks
|
154
177
|
* Performance
|
155
|
-
|
178
|
+
* Haven't optimized for performance yet.
|
179
|
+
* Use [Rugged](https://github.com/libgit2/rugged) instead of Grit
|
180
|
+
* Remove the transaction lock (see transaction.rb line 19)
|
181
|
+
* Ability to iterate over result set without eager loading of all instances
|
182
|
+
* Persistable.find/find_all/etc could be based on staged files so that queries reflect uncommitted changes
|
183
|
+
* Better query support
|
184
|
+
* Associations
|
185
|
+
* Use AREL?
|
186
|
+
|
187
|
+
|
188
|
+
Bugs
|
189
|
+
------------
|
156
190
|
|
191
|
+
* Grit 2.4.1 has [an issue with non-ASCII characters](https://github.com/mojombo/grit/commit/696761d8047ffd038dc2828e6a1998e3f7c3b419)
|
157
192
|
|
data/gitmodel.gemspec
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
Gem::Specification.new do |s|
|
2
2
|
s.name = 'gitmodel'
|
3
|
-
s.version = '0.0.
|
3
|
+
s.version = '0.0.3'
|
4
4
|
s.platform = Gem::Platform::RUBY
|
5
5
|
|
6
6
|
s.authors = ["Paul Dowman"]
|
@@ -21,10 +21,10 @@ Gem::Specification.new do |s|
|
|
21
21
|
s.add_dependency 'activesupport', '>= 3.0.1'
|
22
22
|
s.add_dependency 'grit', '>= 2.3.0'
|
23
23
|
s.add_dependency 'lockfile', '>= 1.4.3'
|
24
|
+
s.add_dependency 'yajl-ruby', '>= 0.8.2'
|
24
25
|
|
25
26
|
s.add_development_dependency 'ZenTest', '>= 4.4.0'
|
26
27
|
s.add_development_dependency 'autotest', '>= 4.4.1'
|
27
|
-
s.add_development_dependency 'autotest-fsevent', '>= 0.2.3' if RUBY_PLATFORM.downcase.include?("darwin") # OS X only
|
28
28
|
s.add_development_dependency 'rspec', '>= 2.0.1'
|
29
29
|
|
30
30
|
s.files = `git ls-files`.split("\n")
|
data/lib/gitmodel/errors.rb
CHANGED
@@ -8,8 +8,8 @@ module GitModel
|
|
8
8
|
class RecordNotFound < GitModelError
|
9
9
|
end
|
10
10
|
|
11
|
-
# Raised by GitModel::Persistable.save! and GitModel::Persistable.create!
|
12
|
-
# saved because record is invalid.
|
11
|
+
# Raised by GitModel::Persistable.save! and GitModel::Persistable.create!
|
12
|
+
# methods when record cannot be saved because record is invalid.
|
13
13
|
class RecordNotSaved < GitModelError
|
14
14
|
end
|
15
15
|
|
@@ -22,4 +22,12 @@ module GitModel
|
|
22
22
|
class NullId < GitModelError
|
23
23
|
end
|
24
24
|
|
25
|
+
# Raised by GitModel::Persistable.find_all when query conditions are given
|
26
|
+
# but there is no index generated
|
27
|
+
class IndexRequired < GitModelError
|
28
|
+
end
|
29
|
+
|
30
|
+
class AttributeNotIndexed < GitModelError
|
31
|
+
end
|
32
|
+
|
25
33
|
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
module GitModel
|
2
|
+
class Index
|
3
|
+
def initialize(model_class)
|
4
|
+
@model_class = model_class
|
5
|
+
end
|
6
|
+
|
7
|
+
def generate!
|
8
|
+
GitModel.logger.debug "Generating indexes for #{@model_class}"
|
9
|
+
# TODO it sucks to load every instance here, optimize later
|
10
|
+
@indexes = {}
|
11
|
+
@model_class.find_all.each do |o|
|
12
|
+
o.attributes.each do |attr, value|
|
13
|
+
@indexes[attr] ||= {}
|
14
|
+
@indexes[attr][value] ||= SortedSet.new
|
15
|
+
@indexes[attr][value] << o.id
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def attr_index(attr)
|
21
|
+
self.load unless @indexes
|
22
|
+
return nil unless @indexes # this is just so that we can stub self.load in tests
|
23
|
+
|
24
|
+
ret = @indexes[attr.to_s]
|
25
|
+
raise GitModel::AttributeNotIndexed.new(attr.to_s) unless ret
|
26
|
+
return ret
|
27
|
+
end
|
28
|
+
|
29
|
+
def filename
|
30
|
+
File.join(@model_class.db_subdir, '_indexes.json')
|
31
|
+
end
|
32
|
+
|
33
|
+
def generated?
|
34
|
+
(GitModel.current_tree / filename) ? true : false
|
35
|
+
end
|
36
|
+
|
37
|
+
def save(options = {})
|
38
|
+
GitModel.logger.debug "Saving indexes for #{@model_class}..."
|
39
|
+
transaction = options.delete(:transaction) || GitModel::Transaction.new(options)
|
40
|
+
result = transaction.execute do |t|
|
41
|
+
# convert to array because JSON hash keys must be strings
|
42
|
+
data = []
|
43
|
+
@indexes.each do |attr,values|
|
44
|
+
values_and_ids = []
|
45
|
+
values.each do |value, ids|
|
46
|
+
values_and_ids << [value, ids.to_a]
|
47
|
+
end
|
48
|
+
data << [attr,values_and_ids]
|
49
|
+
end
|
50
|
+
data = Yajl::Encoder.encode(data, nil, :pretty => true)
|
51
|
+
t.index.add(filename, data)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def load
|
56
|
+
unless generated?
|
57
|
+
GitModel.logger.debug "No index generated for #{@model_class}, not loading."
|
58
|
+
return
|
59
|
+
end
|
60
|
+
|
61
|
+
GitModel.logger.debug "Loading indexes for #{@model_class}..."
|
62
|
+
@indexes = {}
|
63
|
+
blob = GitModel.current_tree / filename
|
64
|
+
|
65
|
+
data = Yajl::Parser.parse(blob.data)
|
66
|
+
data.each do |attr_and_values|
|
67
|
+
attr = attr_and_values[0]
|
68
|
+
values = {}
|
69
|
+
attr_and_values[1].each do |value_and_ids|
|
70
|
+
value = value_and_ids[0]
|
71
|
+
ids = SortedSet.new(value_and_ids[1])
|
72
|
+
values[value] = ids
|
73
|
+
end
|
74
|
+
@indexes[attr] = values
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
79
|
+
end
|
data/lib/gitmodel/persistable.rb
CHANGED
@@ -13,6 +13,9 @@ module GitModel
|
|
13
13
|
|
14
14
|
define_model_callbacks :initialize, :find, :touch, :only => :after
|
15
15
|
define_model_callbacks :save, :create, :update, :destroy
|
16
|
+
|
17
|
+
cattr_accessor :index, true
|
18
|
+
self.index = GitModel::Index.new(self)
|
16
19
|
end
|
17
20
|
|
18
21
|
base.extend(ClassMethods)
|
@@ -84,31 +87,32 @@ module GitModel
|
|
84
87
|
# :commit_message
|
85
88
|
# Returns false if validations failed, otherwise returns the SHA of the commit
|
86
89
|
def save(options = {})
|
87
|
-
|
90
|
+
_run_save_callbacks do
|
91
|
+
raise GitModel::NullId unless self.id
|
88
92
|
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
93
|
+
if new_record?
|
94
|
+
raise GitModel::RecordExists if self.class.exists?(self.id)
|
95
|
+
else
|
96
|
+
raise GitModel::RecordDoesntExist unless self.class.exists?(self.id)
|
97
|
+
end
|
94
98
|
|
95
|
-
|
99
|
+
GitModel.logger.debug "Saving #{self.class.name} with id: #{id}"
|
96
100
|
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
t.index.add(File.join(dir, 'attributes.json'), JSON.pretty_generate(attributes.to_hash))
|
101
|
+
dir = File.join(self.class.db_subdir, self.id)
|
102
|
+
|
103
|
+
transaction = options.delete(:transaction) || GitModel::Transaction.new(options)
|
104
|
+
result = transaction.execute do |t|
|
105
|
+
# Write the attributes to the attributes file
|
106
|
+
t.index.add(File.join(dir, 'attributes.json'), Yajl::Encoder.encode(attributes, nil, :pretty => true))
|
104
107
|
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
+
# Write the blob files
|
109
|
+
blobs.each do |name, data|
|
110
|
+
t.index.add(File.join(dir, name), data)
|
111
|
+
end
|
108
112
|
end
|
109
|
-
end
|
110
113
|
|
111
|
-
|
114
|
+
result
|
115
|
+
end
|
112
116
|
end
|
113
117
|
|
114
118
|
# Same as #save but raises an exception on error
|
@@ -139,11 +143,13 @@ module GitModel
|
|
139
143
|
self.id = File.basename(dir)
|
140
144
|
@new_record = false
|
141
145
|
|
146
|
+
GitModel.logger.debug "Loading #{self.class.name} with id: #{id}"
|
147
|
+
|
142
148
|
# load the attributes
|
143
149
|
object = GitModel.current_tree / File.join(dir, 'attributes.json')
|
144
150
|
raise GitModel::RecordNotFound if object.nil?
|
145
151
|
|
146
|
-
self.attributes =
|
152
|
+
self.attributes = Yajl::Parser.parse(object.data)
|
147
153
|
|
148
154
|
# load all other non-hidden files in the dir as blobs
|
149
155
|
blobs = (GitModel.current_tree / dir).blobs.reject{|b| b.name[0] == '.' || b.name == 'attributes.json'}
|
@@ -184,25 +190,92 @@ module GitModel
|
|
184
190
|
end
|
185
191
|
|
186
192
|
def exists?(id)
|
193
|
+
GitModel.logger.debug "Checking existence of #{name} with id: #{id}"
|
187
194
|
GitModel.repo.commits.any? && !(GitModel.current_tree / File.join(db_subdir, id, 'attributes.json')).nil?
|
188
195
|
end
|
189
196
|
|
190
197
|
def find_all(conditions = {})
|
198
|
+
# TODO Refactor this spaghetti
|
191
199
|
GitModel.logger.debug "Finding all #{name.pluralize} with conditions: #{conditions.inspect}"
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
+
return [] unless GitModel.current_tree
|
201
|
+
|
202
|
+
order = conditions.delete(:order) || :asc
|
203
|
+
order_by = conditions.delete(:order_by) || :id
|
204
|
+
limit = conditions.delete(:limit)
|
205
|
+
|
206
|
+
matching_ids = []
|
207
|
+
if conditions.empty? # load all objects
|
208
|
+
trees = (GitModel.current_tree / db_subdir).trees
|
209
|
+
trees.each do |t|
|
210
|
+
matching_ids << t.name if t.blobs.any?
|
200
211
|
end
|
212
|
+
else # only load objects that match conditions
|
213
|
+
matching_ids_for_condition = {}
|
214
|
+
conditions.each do |k,v|
|
215
|
+
matching_ids_for_condition[k] = []
|
216
|
+
if k == :id # id isn't indexed
|
217
|
+
if v.is_a?(Proc)
|
218
|
+
trees = (GitModel.current_tree / db_subdir).trees
|
219
|
+
trees.each do |t|
|
220
|
+
matching_ids_for_condition[k] << t.name if t.blobs.any? && v.call(t.name)
|
221
|
+
end
|
222
|
+
else
|
223
|
+
# an unlikely use case but supporting it for completeness
|
224
|
+
matching_ids_for_condition[k] << v if (GitModel.current_tree / db_subdir / v)
|
225
|
+
end
|
226
|
+
else
|
227
|
+
raise GitModel::IndexRequired unless index.generated?
|
228
|
+
attr_index = index.attr_index(k)
|
229
|
+
if v.is_a?(Proc)
|
230
|
+
attr_index.each do |value, ids|
|
231
|
+
matching_ids_for_condition[k] += ids.to_a if v.call(value)
|
232
|
+
end
|
233
|
+
else
|
234
|
+
matching_ids_for_condition[k] += attr_index[v].to_a
|
235
|
+
end
|
236
|
+
end
|
237
|
+
end
|
238
|
+
matching_ids += matching_ids_for_condition.values.inject{|memo, obj| memo & obj}
|
239
|
+
end
|
240
|
+
|
241
|
+
results = nil
|
242
|
+
if order_by != :id
|
243
|
+
GitModel.logger.warn "Ordering by an attribute other than id requires loading all matching objects before applying limit, this will be slow" if limit
|
244
|
+
results = matching_ids.map{|k| find(k)}
|
245
|
+
|
246
|
+
if order == :asc
|
247
|
+
results = results.sort{|a,b| a.send(order_by) <=> b.send(order_by)}
|
248
|
+
elsif order == :desc
|
249
|
+
results = results.sort{|b,a| a.send(order_by) <=> b.send(order_by)}
|
250
|
+
else
|
251
|
+
raise GitModel::InvalidParams("invalid order: '#{order}'")
|
252
|
+
end
|
253
|
+
|
254
|
+
if limit
|
255
|
+
results = results[0, limit]
|
256
|
+
end
|
257
|
+
else
|
258
|
+
if limit
|
259
|
+
matching_ids = matching_ids[0, limit]
|
260
|
+
end
|
261
|
+
if order == :asc
|
262
|
+
matching_ids = matching_ids.sort{|a,b| a <=> b}
|
263
|
+
elsif order == :desc
|
264
|
+
matching_ids = matching_ids.sort{|b,a| a <=> b}
|
265
|
+
else
|
266
|
+
raise GitModel::InvalidParams("invalid order: '#{order}'")
|
267
|
+
end
|
268
|
+
results = matching_ids.map{|k| find(k)}
|
201
269
|
end
|
202
270
|
|
203
271
|
return results
|
204
272
|
end
|
205
273
|
|
274
|
+
def all_values_for_attr(attr)
|
275
|
+
attr_index = index.attr_index(attr.to_s)
|
276
|
+
values = attr_index ? attr_index.keys : []
|
277
|
+
end
|
278
|
+
|
206
279
|
def create(args)
|
207
280
|
if args.is_a?(Array)
|
208
281
|
args.map{|arg| create(arg)}
|
@@ -224,6 +297,7 @@ module GitModel
|
|
224
297
|
end
|
225
298
|
|
226
299
|
def delete(id, options = {})
|
300
|
+
GitModel.logger.debug "Deleting #{name} with id: #{id}"
|
227
301
|
path = File.join(db_subdir, id)
|
228
302
|
transaction = options.delete(:transaction) || GitModel::Transaction.new(options)
|
229
303
|
result = transaction.execute do |t|
|
@@ -232,12 +306,18 @@ module GitModel
|
|
232
306
|
end
|
233
307
|
|
234
308
|
def delete_all(options = {})
|
309
|
+
GitModel.logger.debug "Deleting all #{name.pluralize}"
|
235
310
|
transaction = options.delete(:transaction) || GitModel::Transaction.new(options)
|
236
311
|
result = transaction.execute do |t|
|
237
312
|
delete_tree(db_subdir, t.index, options)
|
238
313
|
end
|
239
314
|
end
|
240
315
|
|
316
|
+
def index!
|
317
|
+
index.generate!
|
318
|
+
index.save
|
319
|
+
end
|
320
|
+
|
241
321
|
|
242
322
|
private
|
243
323
|
|
data/lib/gitmodel.rb
CHANGED
@@ -4,12 +4,13 @@ require 'bundler/setup'
|
|
4
4
|
require 'active_model'
|
5
5
|
require 'active_support/all' # TODO we don't really want all here, clean this up
|
6
6
|
require 'grit'
|
7
|
-
require '
|
7
|
+
require 'yajl'
|
8
8
|
require 'lockfile'
|
9
9
|
require 'pp'
|
10
10
|
|
11
11
|
$:.unshift(File.dirname(__FILE__))
|
12
12
|
require 'gitmodel/errors'
|
13
|
+
require 'gitmodel/index'
|
13
14
|
require 'gitmodel/persistable'
|
14
15
|
require 'gitmodel/transaction'
|
15
16
|
|
@@ -73,4 +74,10 @@ module GitModel
|
|
73
74
|
c ? c.tree : nil
|
74
75
|
end
|
75
76
|
|
77
|
+
def self.index!
|
78
|
+
dirs = (GitModel.current_tree).trees
|
79
|
+
dirs.each do |dir|
|
80
|
+
dir.name.classify.constantize.index!
|
81
|
+
end
|
82
|
+
end
|
76
83
|
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe GitModel::Index do
|
4
|
+
before(:each) do
|
5
|
+
TestEntity.create!(:id => "foo", :attributes => {:x => 1, :y => 2})
|
6
|
+
TestEntity.create!(:id => "bar", :attributes => {:x => 1, :y => 3})
|
7
|
+
TestEntity.create!(:id => "baz", :attributes => {:x => 2, :y => 2})
|
8
|
+
|
9
|
+
@i = GitModel::Index.new(TestEntity)
|
10
|
+
@i.generate!
|
11
|
+
end
|
12
|
+
|
13
|
+
it "has a hash for each attribute of the model" do
|
14
|
+
@i.attr_index(:x).should be_a(Hash)
|
15
|
+
end
|
16
|
+
|
17
|
+
it "knows the id's of all instances with a given value for an attribute" do
|
18
|
+
@i.attr_index(:x)[1].should == SortedSet.new(["foo", "bar"])
|
19
|
+
@i.attr_index(:x)[2].should == SortedSet.new(["baz"])
|
20
|
+
@i.attr_index(:y)[2].should == SortedSet.new(["foo", "baz"])
|
21
|
+
end
|
22
|
+
|
23
|
+
it "can regenerate itself" do
|
24
|
+
@i.attr_index(:x).clear
|
25
|
+
@i.attr_index(:x).should be_empty
|
26
|
+
@i.generate!
|
27
|
+
@i.attr_index(:x).should == {1 => SortedSet.new(["foo", "bar"]), 2 => SortedSet.new(["baz"])}
|
28
|
+
end
|
29
|
+
|
30
|
+
it "knows it's filename" do
|
31
|
+
@i.filename.should == "test_entities/_indexes.json"
|
32
|
+
end
|
33
|
+
|
34
|
+
it "can save itself to a JSON file" do
|
35
|
+
@i.save
|
36
|
+
json = <<-END.strip
|
37
|
+
[
|
38
|
+
[
|
39
|
+
"x",
|
40
|
+
[
|
41
|
+
[
|
42
|
+
1,
|
43
|
+
[
|
44
|
+
"bar",
|
45
|
+
"foo"
|
46
|
+
]
|
47
|
+
],
|
48
|
+
[
|
49
|
+
2,
|
50
|
+
[
|
51
|
+
"baz"
|
52
|
+
]
|
53
|
+
]
|
54
|
+
]
|
55
|
+
],
|
56
|
+
[
|
57
|
+
"y",
|
58
|
+
[
|
59
|
+
[
|
60
|
+
3,
|
61
|
+
[
|
62
|
+
"bar"
|
63
|
+
]
|
64
|
+
],
|
65
|
+
[
|
66
|
+
2,
|
67
|
+
[
|
68
|
+
"baz",
|
69
|
+
"foo"
|
70
|
+
]
|
71
|
+
]
|
72
|
+
]
|
73
|
+
]
|
74
|
+
]
|
75
|
+
END
|
76
|
+
repo = Grit::Repo.new(GitModel.db_root)
|
77
|
+
# We should be able to use just repo.commits.first here but
|
78
|
+
# this is a workaround for this bug:
|
79
|
+
# http://github.com/mojombo/grit/issues/issue/38
|
80
|
+
(repo.commits("master^..master").first.tree / @i.filename).data.should == json
|
81
|
+
end
|
82
|
+
|
83
|
+
it "can save and load itself from a file" do
|
84
|
+
@i.save
|
85
|
+
@i.attr_index(:x).clear
|
86
|
+
@i.load
|
87
|
+
@i.attr_index(:x).should == {1 => SortedSet.new(["foo", "bar"]), 2 => SortedSet.new(["baz"])}
|
88
|
+
end
|
89
|
+
|
90
|
+
describe "#attr_index" do
|
91
|
+
it "loads itself" do
|
92
|
+
i = GitModel::Index.new(TestEntity)
|
93
|
+
i.should_receive(:load)
|
94
|
+
i.attr_index(:foo)
|
95
|
+
end
|
96
|
+
|
97
|
+
describe "with an index file already created" do
|
98
|
+
before(:each) { @i.save }
|
99
|
+
|
100
|
+
it "loads itself from file" do
|
101
|
+
i = GitModel::Index.new(TestEntity)
|
102
|
+
i.should_not_receive(:generate!)
|
103
|
+
i.attr_index(:x)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
end
|
@@ -1,9 +1,5 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
|
-
class TestEntity
|
4
|
-
include GitModel::Persistable
|
5
|
-
end
|
6
|
-
|
7
3
|
class LintTest < ActiveModel::TestCase
|
8
4
|
include ActiveModel::Lint::Tests
|
9
5
|
|
@@ -51,7 +47,7 @@ describe GitModel::Persistable do
|
|
51
47
|
|
52
48
|
repo = Grit::Repo.new(GitModel.db_root)
|
53
49
|
attrs = (repo.commits.first.tree / File.join(TestEntity.db_subdir, id, 'attributes.json')).data
|
54
|
-
r =
|
50
|
+
r = Yajl::Parser.parse(attrs)
|
55
51
|
r.size.should == 2
|
56
52
|
r['one'].should == 1
|
57
53
|
r['two'].should == 2
|
@@ -83,6 +79,10 @@ describe GitModel::Persistable do
|
|
83
79
|
it 'returns the SHA of the commit if the save was successful'
|
84
80
|
|
85
81
|
it 'deletes blobs that have been removed'
|
82
|
+
|
83
|
+
it 'updates the index' do
|
84
|
+
# TODO
|
85
|
+
end
|
86
86
|
end
|
87
87
|
|
88
88
|
describe '#save!' do
|
@@ -223,6 +223,7 @@ describe GitModel::Persistable do
|
|
223
223
|
TestEntity.create!(:id => 'ape')
|
224
224
|
|
225
225
|
TestEntity.delete_all
|
226
|
+
TestEntity.index!
|
226
227
|
TestEntity.find_all.should be_empty
|
227
228
|
end
|
228
229
|
|
@@ -246,7 +247,7 @@ describe GitModel::Persistable do
|
|
246
247
|
|
247
248
|
end
|
248
249
|
|
249
|
-
describe '
|
250
|
+
describe '.find' do
|
250
251
|
|
251
252
|
#it 'can load an object from an empty subdir of db_root' do
|
252
253
|
# id = "foo"
|
@@ -316,25 +317,158 @@ describe GitModel::Persistable do
|
|
316
317
|
|
317
318
|
end
|
318
319
|
|
319
|
-
describe '
|
320
|
+
describe '.find_all' do
|
321
|
+
describe 'with no parameters' do
|
322
|
+
it 'returns an array of all objects' do
|
323
|
+
TestEntity.create!(:id => 'one')
|
324
|
+
TestEntity.create!(:id => 'two')
|
325
|
+
TestEntity.create!(:id => 'three')
|
320
326
|
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
327
|
+
r = TestEntity.find_all
|
328
|
+
r.size.should == 3
|
329
|
+
end
|
330
|
+
|
331
|
+
it 'returns an empty array if there are no objects of the current type' do
|
332
|
+
r = TestEntity.find_all
|
333
|
+
r.should == []
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
describe 'with conditions but no index' do
|
338
|
+
it 'raises an exception' do
|
339
|
+
TestEntity.create!(:id => 'one')
|
340
|
+
lambda {TestEntity.find_all(:a => "b")}.should raise_error(GitModel::IndexRequired)
|
341
|
+
end
|
342
|
+
end
|
343
|
+
|
344
|
+
describe 'with one condition' do
|
345
|
+
describe 'with a literal value' do
|
346
|
+
it 'returns an array of all objects that match' do
|
347
|
+
TestEntity.create!(:id => 'one', :attributes => {:a => 1, :b => 1})
|
348
|
+
TestEntity.create!(:id => 'two', :attributes => {:a => 2, :b => 2})
|
349
|
+
TestEntity.create!(:id => 'three', :attributes => {:a => 1, :b => 3})
|
350
|
+
TestEntity.index!
|
351
|
+
|
352
|
+
r = TestEntity.find_all(:a => 1)
|
353
|
+
r.size.should == 2
|
354
|
+
r.first.id.should == 'one'
|
355
|
+
r.second.id.should == 'three'
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
359
|
+
describe 'with a lambda as the value' do
|
360
|
+
it 'returns an array of all objects that match' do
|
361
|
+
TestEntity.create!(:id => 'one', :attributes => {:a => 1, :b => 1})
|
362
|
+
TestEntity.create!(:id => 'two', :attributes => {:a => 2, :b => 2})
|
363
|
+
TestEntity.create!(:id => 'three', :attributes => {:a => 1, :b => 3})
|
364
|
+
TestEntity.index!
|
365
|
+
|
366
|
+
r = TestEntity.find_all(:b => lambda{|b| b > 1}, :order => :asc)
|
367
|
+
r.size.should == 2
|
368
|
+
r.first.id.should == 'three'
|
369
|
+
r.second.id.should == 'two'
|
370
|
+
end
|
371
|
+
end
|
372
|
+
end
|
373
|
+
|
374
|
+
describe 'with multiple conditions' do
|
375
|
+
describe 'with a literal value' do
|
376
|
+
it 'returns an array of all objects that match both (i.e. AND)' do
|
377
|
+
TestEntity.create!(:id => 'one', :attributes => {:a => 1, :b => 2})
|
378
|
+
TestEntity.create!(:id => 'two', :attributes => {:a => 1, :b => 2})
|
379
|
+
TestEntity.create!(:id => 'three', :attributes => {:a => 1, :b => 1})
|
380
|
+
TestEntity.index!
|
381
|
+
|
382
|
+
r = TestEntity.find_all(:a => 1, :b => 2, :order => :asc)
|
383
|
+
r.size.should == 2
|
384
|
+
r.first.id.should == 'one'
|
385
|
+
r.second.id.should == 'two'
|
386
|
+
end
|
387
|
+
end
|
388
|
+
|
389
|
+
describe 'with a lambda as the value' do
|
390
|
+
it 'returns an array of all objects that match both (i.e. AND)' do
|
391
|
+
TestEntity.create!(:id => 'one', :attributes => {:a => 1, :b => 3})
|
392
|
+
TestEntity.create!(:id => 'two', :attributes => {:a => 2, :b => 2})
|
393
|
+
TestEntity.create!(:id => 'three', :attributes => {:a => 1, :b => 1})
|
394
|
+
TestEntity.create!(:id => 'four', :attributes => {:a => 3, :b => 3})
|
395
|
+
TestEntity.index!
|
396
|
+
|
397
|
+
r = TestEntity.find_all(:a => lambda{|a| a > 1}, :b => lambda{|b| b > 2}, :order => :asc)
|
398
|
+
r.size.should == 1
|
399
|
+
r.first.id.should == 'four'
|
400
|
+
end
|
401
|
+
|
402
|
+
end
|
403
|
+
end
|
404
|
+
|
405
|
+
describe 'with the id attribute in the conditions' do
|
406
|
+
describe 'with a literal value' do
|
407
|
+
it 'returns an array that includes the object with that id' do
|
408
|
+
TestEntity.create!(:id => 'one')
|
409
|
+
TestEntity.create!(:id => 'two')
|
410
|
+
TestEntity.create!(:id => 'three')
|
411
|
+
|
412
|
+
r = TestEntity.find_all(:id => 'one')
|
413
|
+
r.size.should == 1
|
414
|
+
r.first.id.should == 'one'
|
415
|
+
end
|
416
|
+
end
|
417
|
+
|
418
|
+
describe 'with a lambda as the value' do
|
419
|
+
it 'returns an array that includes the object with that id' do
|
420
|
+
TestEntity.create!(:id => 'one')
|
421
|
+
TestEntity.create!(:id => 'two')
|
422
|
+
TestEntity.create!(:id => 'three')
|
423
|
+
|
424
|
+
r = TestEntity.find_all(:id => lambda{|id| id =~ /o/})
|
425
|
+
r.size.should == 2
|
426
|
+
r.first.id.should == 'one'
|
427
|
+
r.second.id.should == 'two'
|
428
|
+
end
|
429
|
+
|
430
|
+
end
|
431
|
+
end
|
432
|
+
|
433
|
+
it 'can return results in ascending order' do
|
434
|
+
TestEntity.create!(:id => 'one', :attributes => {:a => 1, :b => 1})
|
435
|
+
TestEntity.create!(:id => 'two', :attributes => {:a => 2, :b => 2})
|
436
|
+
TestEntity.create!(:id => 'three', :attributes => {:a => 1, :b => 3})
|
437
|
+
TestEntity.index!
|
438
|
+
|
439
|
+
r = TestEntity.find_all(:a => 1, :order => :asc)
|
440
|
+
r.size.should == 2
|
441
|
+
r.first.id.should == 'one'
|
442
|
+
r.second.id.should == 'three'
|
443
|
+
end
|
444
|
+
|
445
|
+
it 'can return results in descending order' do
|
446
|
+
TestEntity.create!(:id => 'one', :attributes => {:a => 1, :b => 1})
|
447
|
+
TestEntity.create!(:id => 'two', :attributes => {:a => 2, :b => 2})
|
448
|
+
TestEntity.create!(:id => 'three', :attributes => {:a => 1, :b => 3})
|
449
|
+
TestEntity.index!
|
325
450
|
|
326
|
-
r = TestEntity.find_all
|
327
|
-
r.size.should ==
|
451
|
+
r = TestEntity.find_all(:a => 1, :order => :desc)
|
452
|
+
r.size.should == 2
|
453
|
+
r.first.id.should == 'three'
|
454
|
+
r.second.id.should == 'one'
|
328
455
|
end
|
329
456
|
|
330
|
-
it '
|
331
|
-
|
332
|
-
|
457
|
+
it 'can limit the number of results returned' do
|
458
|
+
TestEntity.create!(:id => 'one', :attributes => {:a => 1, :b => 1})
|
459
|
+
TestEntity.create!(:id => 'two', :attributes => {:a => 1, :b => 2})
|
460
|
+
TestEntity.create!(:id => 'three', :attributes => {:a => 1, :b => 3})
|
461
|
+
TestEntity.index!
|
462
|
+
|
463
|
+
r = TestEntity.find_all(:a => 1, :limit => 2)
|
464
|
+
r.size.should == 2
|
465
|
+
r.first.id.should == 'one'
|
466
|
+
r.second.id.should == 'three'
|
333
467
|
end
|
334
468
|
|
335
469
|
end
|
336
470
|
|
337
|
-
describe '
|
471
|
+
describe '.exists?' do
|
338
472
|
|
339
473
|
it 'returns true if the record exists' do
|
340
474
|
TestEntity.create!(:id => 'one')
|
@@ -347,6 +481,23 @@ describe GitModel::Persistable do
|
|
347
481
|
|
348
482
|
end
|
349
483
|
|
484
|
+
describe ".index!" do
|
485
|
+
it "generates and saves the index" do
|
486
|
+
TestEntity.index.should_receive(:generate!)
|
487
|
+
TestEntity.index.should_receive(:save)
|
488
|
+
TestEntity.index!
|
489
|
+
end
|
490
|
+
end
|
491
|
+
|
492
|
+
describe '.all_values_for_attr' do
|
493
|
+
it 'returns a list of all values that exist for a given attribute' do
|
494
|
+
o = TestEntity.create!(:id => 'first', :attributes => {"a" => 1, "b" => 2})
|
495
|
+
o = TestEntity.create!(:id => 'second', :attributes => {"a" => 3, "b" => 4})
|
496
|
+
TestEntity.index!
|
497
|
+
TestEntity.all_values_for_attr("a").should == [1, 3]
|
498
|
+
end
|
499
|
+
end
|
500
|
+
|
350
501
|
describe '#attributes' do
|
351
502
|
it 'accepts symbols or strings interchangeably as strings' do
|
352
503
|
o = TestEntity.new(:id => 'lol', :attributes => {"one" => 1, :two => 2})
|
data/spec/gitmodel_spec.rb
CHANGED
@@ -2,8 +2,7 @@ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
|
2
2
|
|
3
3
|
describe GitModel do
|
4
4
|
|
5
|
-
describe "
|
6
|
-
|
5
|
+
describe ".last_commit" do
|
7
6
|
it "returns nil if there are no commits" do
|
8
7
|
GitModel.last_commit.should == nil
|
9
8
|
end
|
@@ -17,11 +16,9 @@ describe GitModel do
|
|
17
16
|
|
18
17
|
GitModel.last_commit.to_s.should == sha
|
19
18
|
end
|
20
|
-
|
21
19
|
end
|
22
20
|
|
23
|
-
describe "
|
24
|
-
|
21
|
+
describe ".current_tree" do
|
25
22
|
it "returns nil if there are no commits" do
|
26
23
|
GitModel.current_tree.should == nil
|
27
24
|
end
|
@@ -32,7 +29,19 @@ describe GitModel do
|
|
32
29
|
GitModel.should_receive(:last_commit).with('master').and_return(last_commit)
|
33
30
|
GitModel.current_tree('master')
|
34
31
|
end
|
32
|
+
end
|
35
33
|
|
34
|
+
describe ".index" do
|
35
|
+
before(:each) do
|
36
|
+
TestEntity.create!(:id => "foo")
|
37
|
+
TestEntity2.create!(:id => "bar")
|
38
|
+
end
|
39
|
+
|
40
|
+
it "calls index! on each Persistable model class" do
|
41
|
+
TestEntity.should_receive(:index!)
|
42
|
+
TestEntity2.should_receive(:index!)
|
43
|
+
GitModel.index!
|
44
|
+
end
|
36
45
|
end
|
37
46
|
|
38
47
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -6,7 +6,15 @@ require 'gitmodel'
|
|
6
6
|
|
7
7
|
Dir[File.join(File.dirname(__FILE__), "support/**/*.rb")].each {|f| require f}
|
8
8
|
|
9
|
-
|
9
|
+
RSpec.configure do |c|
|
10
10
|
c.mock_with :rspec
|
11
11
|
end
|
12
12
|
|
13
|
+
class TestEntity
|
14
|
+
include GitModel::Persistable
|
15
|
+
end
|
16
|
+
class TestEntity2
|
17
|
+
include GitModel::Persistable
|
18
|
+
end
|
19
|
+
|
20
|
+
#GitModel.logger.level = ::Logger::DEBUG
|
metadata
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
name: gitmodel
|
3
3
|
version: !ruby/object:Gem::Version
|
4
4
|
prerelease:
|
5
|
-
version: 0.0.
|
5
|
+
version: 0.0.3
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
8
8
|
- Paul Dowman
|
@@ -10,8 +10,7 @@ autorequire:
|
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
12
|
|
13
|
-
date: 2011-
|
14
|
-
default_executable:
|
13
|
+
date: 2011-07-04 00:00:00 Z
|
15
14
|
dependencies:
|
16
15
|
- !ruby/object:Gem::Dependency
|
17
16
|
name: activemodel
|
@@ -58,36 +57,36 @@ dependencies:
|
|
58
57
|
type: :runtime
|
59
58
|
version_requirements: *id004
|
60
59
|
- !ruby/object:Gem::Dependency
|
61
|
-
name:
|
60
|
+
name: yajl-ruby
|
62
61
|
prerelease: false
|
63
62
|
requirement: &id005 !ruby/object:Gem::Requirement
|
64
63
|
none: false
|
65
64
|
requirements:
|
66
65
|
- - ">="
|
67
66
|
- !ruby/object:Gem::Version
|
68
|
-
version:
|
69
|
-
type: :
|
67
|
+
version: 0.8.2
|
68
|
+
type: :runtime
|
70
69
|
version_requirements: *id005
|
71
70
|
- !ruby/object:Gem::Dependency
|
72
|
-
name:
|
71
|
+
name: ZenTest
|
73
72
|
prerelease: false
|
74
73
|
requirement: &id006 !ruby/object:Gem::Requirement
|
75
74
|
none: false
|
76
75
|
requirements:
|
77
76
|
- - ">="
|
78
77
|
- !ruby/object:Gem::Version
|
79
|
-
version: 4.4.
|
78
|
+
version: 4.4.0
|
80
79
|
type: :development
|
81
80
|
version_requirements: *id006
|
82
81
|
- !ruby/object:Gem::Dependency
|
83
|
-
name: autotest
|
82
|
+
name: autotest
|
84
83
|
prerelease: false
|
85
84
|
requirement: &id007 !ruby/object:Gem::Requirement
|
86
85
|
none: false
|
87
86
|
requirements:
|
88
87
|
- - ">="
|
89
88
|
- !ruby/object:Gem::Version
|
90
|
-
version:
|
89
|
+
version: 4.4.1
|
91
90
|
type: :development
|
92
91
|
version_requirements: *id007
|
93
92
|
- !ruby/object:Gem::Dependency
|
@@ -122,14 +121,15 @@ files:
|
|
122
121
|
- gitmodel.gemspec
|
123
122
|
- lib/gitmodel.rb
|
124
123
|
- lib/gitmodel/errors.rb
|
124
|
+
- lib/gitmodel/index.rb
|
125
125
|
- lib/gitmodel/persistable.rb
|
126
126
|
- lib/gitmodel/transaction.rb
|
127
|
+
- spec/gitmodel/index_spec.rb
|
127
128
|
- spec/gitmodel/persistable_spec.rb
|
128
129
|
- spec/gitmodel/transaction_spec.rb
|
129
130
|
- spec/gitmodel_spec.rb
|
130
131
|
- spec/spec_helper.rb
|
131
132
|
- spec/support/setup.rb
|
132
|
-
has_rdoc: true
|
133
133
|
homepage: http://github.com/pauldowman/gitmodel
|
134
134
|
licenses: []
|
135
135
|
|
@@ -153,11 +153,12 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
153
153
|
requirements: []
|
154
154
|
|
155
155
|
rubyforge_project:
|
156
|
-
rubygems_version: 1.5
|
156
|
+
rubygems_version: 1.8.5
|
157
157
|
signing_key:
|
158
158
|
specification_version: 3
|
159
159
|
summary: An ActiveModel-compliant persistence framework for Ruby that uses Git for versioning and remote syncing.
|
160
160
|
test_files:
|
161
|
+
- spec/gitmodel/index_spec.rb
|
161
162
|
- spec/gitmodel/persistable_spec.rb
|
162
163
|
- spec/gitmodel/transaction_spec.rb
|
163
164
|
- spec/gitmodel_spec.rb
|