zermelo 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +16 -0
  3. data/.rspec +10 -0
  4. data/.travis.yml +27 -0
  5. data/Gemfile +20 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +512 -0
  8. data/Rakefile +1 -0
  9. data/lib/zermelo/associations/association_data.rb +24 -0
  10. data/lib/zermelo/associations/belongs_to.rb +115 -0
  11. data/lib/zermelo/associations/class_methods.rb +244 -0
  12. data/lib/zermelo/associations/has_and_belongs_to_many.rb +128 -0
  13. data/lib/zermelo/associations/has_many.rb +120 -0
  14. data/lib/zermelo/associations/has_one.rb +109 -0
  15. data/lib/zermelo/associations/has_sorted_set.rb +124 -0
  16. data/lib/zermelo/associations/index.rb +50 -0
  17. data/lib/zermelo/associations/index_data.rb +18 -0
  18. data/lib/zermelo/associations/unique_index.rb +44 -0
  19. data/lib/zermelo/backends/base.rb +115 -0
  20. data/lib/zermelo/backends/influxdb_backend.rb +178 -0
  21. data/lib/zermelo/backends/redis_backend.rb +281 -0
  22. data/lib/zermelo/filters/base.rb +235 -0
  23. data/lib/zermelo/filters/influxdb_filter.rb +162 -0
  24. data/lib/zermelo/filters/redis_filter.rb +558 -0
  25. data/lib/zermelo/filters/steps/base_step.rb +22 -0
  26. data/lib/zermelo/filters/steps/diff_range_step.rb +17 -0
  27. data/lib/zermelo/filters/steps/diff_step.rb +17 -0
  28. data/lib/zermelo/filters/steps/intersect_range_step.rb +17 -0
  29. data/lib/zermelo/filters/steps/intersect_step.rb +17 -0
  30. data/lib/zermelo/filters/steps/limit_step.rb +17 -0
  31. data/lib/zermelo/filters/steps/offset_step.rb +17 -0
  32. data/lib/zermelo/filters/steps/sort_step.rb +17 -0
  33. data/lib/zermelo/filters/steps/union_range_step.rb +17 -0
  34. data/lib/zermelo/filters/steps/union_step.rb +17 -0
  35. data/lib/zermelo/locks/no_lock.rb +16 -0
  36. data/lib/zermelo/locks/redis_lock.rb +221 -0
  37. data/lib/zermelo/records/base.rb +62 -0
  38. data/lib/zermelo/records/class_methods.rb +127 -0
  39. data/lib/zermelo/records/collection.rb +14 -0
  40. data/lib/zermelo/records/errors.rb +24 -0
  41. data/lib/zermelo/records/influxdb_record.rb +35 -0
  42. data/lib/zermelo/records/instance_methods.rb +224 -0
  43. data/lib/zermelo/records/key.rb +19 -0
  44. data/lib/zermelo/records/redis_record.rb +27 -0
  45. data/lib/zermelo/records/type_validator.rb +20 -0
  46. data/lib/zermelo/version.rb +3 -0
  47. data/lib/zermelo.rb +102 -0
  48. data/spec/lib/zermelo/associations/belongs_to_spec.rb +6 -0
  49. data/spec/lib/zermelo/associations/has_many_spec.rb +6 -0
  50. data/spec/lib/zermelo/associations/has_one_spec.rb +6 -0
  51. data/spec/lib/zermelo/associations/has_sorted_set.spec.rb +6 -0
  52. data/spec/lib/zermelo/associations/index_spec.rb +6 -0
  53. data/spec/lib/zermelo/associations/unique_index_spec.rb +6 -0
  54. data/spec/lib/zermelo/backends/influxdb_backend_spec.rb +0 -0
  55. data/spec/lib/zermelo/backends/moneta_backend_spec.rb +0 -0
  56. data/spec/lib/zermelo/filters/influxdb_filter_spec.rb +0 -0
  57. data/spec/lib/zermelo/filters/redis_filter_spec.rb +0 -0
  58. data/spec/lib/zermelo/locks/redis_lock_spec.rb +170 -0
  59. data/spec/lib/zermelo/records/influxdb_record_spec.rb +258 -0
  60. data/spec/lib/zermelo/records/key_spec.rb +6 -0
  61. data/spec/lib/zermelo/records/redis_record_spec.rb +1426 -0
  62. data/spec/lib/zermelo/records/type_validator_spec.rb +6 -0
  63. data/spec/lib/zermelo/version_spec.rb +6 -0
  64. data/spec/lib/zermelo_spec.rb +6 -0
  65. data/spec/spec_helper.rb +67 -0
  66. data/spec/support/profile_all_formatter.rb +44 -0
  67. data/spec/support/uncolored_doc_formatter.rb +74 -0
  68. data/zermelo.gemspec +30 -0
  69. metadata +174 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: a3d59a8d79a91b5703b7b9b40760f6dd25e668dd
4
+ data.tar.gz: 5f92bcd3c18207b37f2220aee647aa3cc08d1d36
5
+ SHA512:
6
+ metadata.gz: dff50d1c6b883cb73f33dbeaf379d7a8cc25f87bb55eec340850b0362be0f34857d39f2bbc6584203d0bd6acdbd8d2725cd5e910a89c3fb88e4d650d755a16e4
7
+ data.tar.gz: c6e64def6eaa3fe7212fd78eb5221dba0e32babc74d7a04711134e411a0f69f10598d4617383b2d6051efa7ac54cfd0cd706d43c1748a4e29c6ab0b9f4d1d88e
data/.gitignore ADDED
@@ -0,0 +1,16 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ InstalledFiles
7
+ _yardoc
8
+ coverage
9
+ doc/
10
+ lib/bundler/man
11
+ pkg
12
+ rdoc
13
+ spec/reports
14
+ test/tmp
15
+ test/version_tmp
16
+ tmp
data/.rspec ADDED
@@ -0,0 +1,10 @@
1
+ --color
2
+ --format progress
3
+ --format html
4
+ --out tmp/spec.html
5
+ --require ./spec/support/uncolored_doc_formatter.rb
6
+ --format UncoloredDocFormatter
7
+ --out tmp/spec_doc.txt
8
+ --require ./spec/support/profile_all_formatter.rb
9
+ --format ProfileAllFormatter
10
+ --out tmp/spec_profile.txt
data/.travis.yml ADDED
@@ -0,0 +1,27 @@
1
+ language: ruby
2
+ rvm:
3
+ - '2.0'
4
+ - '2.1'
5
+ - '2.2'
6
+ script: "bundle exec rspec"
7
+ services:
8
+ - redis-server
9
+ before_install:
10
+ - gem --version
11
+ - wget http://s3.amazonaws.com/influxdb/influxdb_0.8.8_amd64.deb
12
+ - sudo dpkg -i influxdb_0.8.8_amd64.deb
13
+ - sudo /etc/init.d/influxdb start
14
+ - sleep 10
15
+ - 'curl -X POST ''http://localhost:8086/db?u=root&p=root'' -d ''{"name": "zermelo_test"}'''
16
+ - 'curl -X POST ''http://localhost:8086/db/zermelo_test/users?u=root&p=root'' -d
17
+ ''{"name": "zermelo", "password": "zermelo"}'''
18
+ - 'curl -X POST ''http://localhost:8086/db/zermelo_test/users/zermelo?u=root&p=root''
19
+ -d ''{"admin": true}'''
20
+ notifications:
21
+ hipchat:
22
+ template:
23
+ - '%{repository}#%{build_number} (%{branch} - %{commit} : %{author}): %{message}
24
+ (<a href="%{build_url}">Details</a>/<a href="%{compare_url}">Change view</a>)'
25
+ format: html
26
+ rooms:
27
+ secure: GrQkFR0osJal/ciXSMydKYoQFzNwSxJCtWcaZtUgxEjba+xYbNEmT/RiRpq0MhGTAn5DUEcqHKENC0qVOxiBp8WPkCzcDLjmDzTpci1QDelB0faORfG8/71JpkrOoSvWzqg0QU3H4OgQaROE9mq3MdjYml6bH3M1ZtWSArX257Y=
data/Gemfile ADDED
@@ -0,0 +1,20 @@
1
+ source 'https://rubygems.org'
2
+
3
+ if RUBY_VERSION.split('.')[0] == '1' && RUBY_VERSION.split('.')[1] == '8'
4
+ gemspec :name => 'zermelo-ruby1.8'
5
+ else
6
+ gemspec :name => 'zermelo'
7
+ end
8
+
9
+ group :test do
10
+ gem 'influxdb'
11
+ gem 'redis'
12
+ gem 'rspec', '>= 3.0.0'
13
+ gem 'simplecov', :require => false
14
+
15
+ if RUBY_VERSION.split('.')[0] == '1' && RUBY_VERSION.split('.')[1] == '8'
16
+ gem 'timecop', '0.6.1'
17
+ else
18
+ gem 'timecop'
19
+ end
20
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Ali Graham
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,512 @@
1
+ # zermelo
2
+
3
+ [![Build Status](https://travis-ci.org/flapjack/zermelo.png)](https://travis-ci.org/flapjack/zermelo)
4
+
5
+ Zermelo is an [ActiveModel](http://yehudakatz.com/2010/01/10/activemodel-make-any-ruby-object-feel-like-activerecord/)-based [Object-Relational Mapper](http://en.wikipedia.org/wiki/Object-relational_mapping) for [Redis](http://redis.io/), written in [Ruby](http://www.ruby-lang.org/).
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ gem 'zermelo', :github => 'flapjack/zermelo', :branch => 'master'
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install zermelo
20
+
21
+ ## Requirements
22
+
23
+ * Redis 2.4.0 or higher (as it uses the multiple arguments provided in 2.4 for some commands). This could probaby be lowered to 2.0 with some branching for backwards compatibility.
24
+
25
+ * Ruby 1.8.7 or higher.
26
+
27
+ ## Usage
28
+
29
+ ### Initialisation
30
+
31
+ Firstly, you'll need to set up **zermelo**'s Redis access, e.g.
32
+
33
+ ```ruby
34
+ Zermelo.redis = Redis.new(:host => '127.0.0.1', :db => 8)
35
+ ```
36
+
37
+ You can optionally set `Zermelo.logger` to an instance of a Ruby `Logger` class, or something with a compatible interface, and Zermelo will log the method calls (and arguments) being made to the Redis driver.
38
+
39
+ ### Class ids
40
+
41
+ Include **zermelo**'s Record module in the class you want to persist data from:
42
+
43
+ ```ruby
44
+ class Post
45
+ include Zermelo:Record
46
+ end
47
+ ```
48
+
49
+ and then create and save an instance of that class:
50
+
51
+ ```ruby
52
+ post = Post.new(:id => 'abcde')
53
+ post.save
54
+ ```
55
+
56
+ Behind the scenes, this will run the following Redis command:
57
+
58
+ ```
59
+ SADD post::attrs:ids 'abcde'
60
+ ```
61
+
62
+ (along with a few others which we'll discuss shortly).
63
+
64
+ ### Simple instance attributes
65
+
66
+ A data record without any actual data isn't very useful, so let's add a few simple data fields to the Post model:
67
+
68
+ ```ruby
69
+ class Post
70
+ include Zermelo:Record
71
+ define_attributes :title => :string,
72
+ :score => :integer,
73
+ :timestamp => :timestamp,
74
+ :published => :boolean
75
+ end
76
+ ```
77
+
78
+ and create and save an instance of that model class:
79
+
80
+ ```ruby
81
+ post = Post.new(:title => 'Introduction to Zermelo',
82
+ :score => 100, :timestamp => Time.parse('Jan 1 2000'), :published => false)
83
+ post.save
84
+ ```
85
+
86
+ An `:id => :string` attribute is implicitly defined, but in this case no id was passed, so **zermelo** generates a UUID:
87
+
88
+ ```
89
+ HMSET post:03c839ac-24af-432e-aa58-fd1d4bf73f24:attrs title 'Introduction to Zermelo' score 100 timestamp 1384473626.36478 published 'false'
90
+ SADD post::attrs:ids 03c839ac-24af-432e-aa58-fd1d4bf73f24
91
+ ```
92
+
93
+ which can then be verified by inspection of the object's attributes, e.g.:
94
+
95
+ ```ruby
96
+ post.attributes.inpsect # == {:id => '03c839ac-24af-432e-aa58-fd1d4bf73f24', :title => 'Introduction to Zermelo', :score => 100, :timestamp => '2000-01-01 00:00:00 UTC', :published => false}
97
+ ```
98
+
99
+ Zermelo supports the following simple attribute types, and automatically
100
+ validates that the values are of the correct class, casting if possible:
101
+
102
+ | Type | Ruby class | Notes |
103
+ |------------|-------------------------------|-------|
104
+ | :string | String | |
105
+ | :integer | Integer | |
106
+ | :float | Float | |
107
+ | :id | String | |
108
+ | :timestamp | Integer or Time or DateTime | Stored as a float value |
109
+ | :boolean | TrueClass or FalseClass | Stored as string 'true' or 'false' |
110
+
111
+ ### Complex instance attributes
112
+
113
+ **Zermelo** also provides mappings for the compound data structures supported by Redis.
114
+
115
+ So if we add tags to the Post data definition:
116
+
117
+ ```ruby
118
+ class Post
119
+ include Zermelo:Record
120
+ define_attributes :title => :string,
121
+ :score => :integer,
122
+ :timestamp => :timestamp,
123
+ :published => :boolean,
124
+ :tags => :set
125
+ end
126
+ ```
127
+
128
+ and then create another
129
+
130
+ ```ruby
131
+ post = Post.new(:id => 1, :tags => Set.new(['database', 'ORM']))
132
+ post.save
133
+ ```
134
+
135
+ which would run the following Redis commands:
136
+
137
+ ```
138
+ SADD post:1:attrs:tags 'database' 'ORM'
139
+ SADD post::attrs:ids 1
140
+ ```
141
+
142
+ Zermelo supports the following complex attribute types, and automatically
143
+ validates that the values are of the correct class, casting if possible:
144
+
145
+ | Type | Ruby class | Notes |
146
+ |------------|---------------|---------------------------------------------------------|
147
+ | :list | Enumerable | Stored as a Redis [LIST](http://redis.io/commands#list) |
148
+ | :set | Array or Set | Stored as a Redis [SET](http://redis.io/commands#set) |
149
+ | :hash | Hash | Stored as a Redis [HASH](http://redis.io/commands#hash) |
150
+
151
+ Structure data members must be primitives that will cast OK to and from Redis via the
152
+ driver, thus String, Integer and Float.
153
+
154
+ Redis [sorted sets](http://redis.io/commands#sorted_set) are only supported through associations, for which see later on.
155
+
156
+ ### Validations
157
+
158
+ All of the [validations](http://api.rubyonrails.org/classes/ActiveModel/Validations/ClassMethods.html) offered by ActiveModel are available in **zermelo** objects.
159
+
160
+ So an attribute which should be present:
161
+
162
+ ```ruby
163
+ class Post
164
+ include Zermelo:Record
165
+ define_attributes :title => :string,
166
+ :score => :integer
167
+ validates :title, :presence => true
168
+ end
169
+ ```
170
+
171
+ but isn't:
172
+
173
+ ```ruby
174
+ post = Post.new(:score => 85)
175
+ post.valid? # == false
176
+
177
+ post.errors.full_messages # == ["Title can't be blank"]
178
+ post.save # calls valid? before saving, fails and returns false
179
+ ```
180
+
181
+ produces the results you would expect.
182
+
183
+ ### Callbacks
184
+
185
+ ActiveModel's [lifecycle callbacks](http://api.rubyonrails.org/classes/ActiveModel/Callbacks.html) are also supported, and **zermelo** uses similar invocations to ActiveRecord's:
186
+
187
+ ```
188
+ before_create, around_create, after_create,
189
+ before_update, around_update, after_update,
190
+ before_destroy, around_destroy, after_destroy
191
+ ```
192
+
193
+ As noted in the linked documentation, you'll need to `yield` from within an `around_*` callback, or the original action won't be carried out.
194
+
195
+ ### Detecting changes
196
+
197
+ Another feature added by ActiveModel is the ability to detect changed data in record instances using [ActiveModel::Dirty](http://api.rubyonrails.org/classes/ActiveModel/Dirty.html).
198
+
199
+ ### Locking around changes
200
+
201
+ **Zermelo** will lock operations to ensure that changes are applied consistently. The locking code is based on [redis-lock](https://github.com/mlanett/redis-lock), but has been extended and customised to allow **zermelo** to lock more than one class at a time. Record saving and destroying is implicitly locked, while if you want to carry out complex queries or changes without worring about what else may be changing data at the same time, you can use the `lock` class method as follows:
202
+
203
+ ```ruby
204
+ class Author
205
+ include Zermelo:Record
206
+ end
207
+
208
+ class Post
209
+ include Zermelo:Record
210
+ end
211
+
212
+ class Comment
213
+ include Zermelo:Record
214
+ end
215
+
216
+ Author.lock(Post, Comment) do
217
+ # ... complicated data operations ...
218
+ end
219
+ ```
220
+
221
+ ### Loading data
222
+
223
+ Assuming a saved `Post` instance has been created:
224
+
225
+ ```ruby
226
+ class Post
227
+ include Zermelo:Record
228
+ define_attributes :title => :string,
229
+ :score => :integer,
230
+ :timestamp => :timestamp,
231
+ :published => :boolean
232
+ end
233
+
234
+ post = Post.new(:id => '1234', :title => 'Introduction to Zermelo',
235
+ :score => 100, :timestamp => Time.parse('Jan 1 2000')), :published => false)
236
+ post.save
237
+ ```
238
+
239
+ which executes the following Redis calls:
240
+
241
+ ```
242
+ HMSET post:1234:attrs title 'Introduction to Zermelo' score 100 timestamp 1384473626.36478 published 'false'
243
+ SADD post::attrs:ids 1234
244
+ ```
245
+
246
+ This data can be loaded into a fresh `Post` instance using the `find_by_id(ID)` class method:
247
+
248
+ ```ruby
249
+ same_post = Post.find_by_id('1234')
250
+ same_post.attributes # == {:id => '1234', :score => 100, :timestamp => '2000-01-01 00:00:00 UTC', :published => false}
251
+ ```
252
+
253
+ You can load more than one record using the `find_by_ids(ID, ID, ...)` class method (returns an array), and raise exceptions if records matching the ids are not found using `find_by_id!(ID)` and `find_by_ids!(ID, ID, ...)`.
254
+
255
+ ### Class methods
256
+
257
+ Classes that include `Zermelo::Record` have the following class methods made available to them.
258
+
259
+ |Name | Arguments | Returns |
260
+ |-------------------------|---------------|---------|
261
+ |`all` | | Returns an Array of all the records stored for this class |
262
+ |`each` | | Yields all records to the provided block, returns the same Array as .all(): [Array#each](http://ruby-doc.org/core-2.1.2/Array.html#method-i-each) |
263
+ |`collect` / `map` | | Yields all records to the provided block, returns an Array with the values returned from the block: [Array#collect](http://ruby-doc.org/core-2.1.2/Array.html#method-i-collect) |
264
+ |`select` / `find_all` | | Yields all records to the provided block, returns an Array with each record where the block returned true: [Array#select](http://ruby-doc.org/core-2.1.2/Array.html#method-i-select) |
265
+ |`reject` | | Yields all records to the provided block, returns an Array with each record where the block returned false: [Array#reject](http://ruby-doc.org/core-2.1.2/Array.html#method-i-reject) |
266
+ |`ids` | | Returns an Array with the ids of all stored records |
267
+ |`count` | | Returns an Integer count of the number of stored records |
268
+ |`empty?` | | Returns true if no records are stored, false otherwise |
269
+ |`destroy_all` | | Removes all stored records |
270
+ |`exists?` | ID | Returns true if the record with the id is present, false if not |
271
+ |`find_by_id` | ID | Returns the instantiated record for the id, or nil if not present |
272
+ |`find_by_ids` | ID, ID, ... | Returns an Array of instantiated records for the ids, with nils if the respective record is not present |
273
+ |`find_by_id!` | ID | Returns the instantiated record for the id, or raises a Zermelo::Records::RecordNotFound exception if not present |
274
+ |`find_by_ids!` | ID, ID, ... | Returns an Array of instantiated records for the ids, or raises a Zermelo::Records::RecordsNotFound exception if any are not present |
275
+ |`associated_ids_for` | association | (Defined in the `Associations` section below) |
276
+
277
+ ### Instance methods
278
+
279
+ Instances of classes including `Zermelo::Record` have the following methods:
280
+
281
+ |Name | Arguments | Returns |
282
+ |---------------------|---------------|---------|
283
+ |`persisted?` | | returns true if the record has been saved, false if not |
284
+ |`load` | ID | loads the record with the provided ID, discarding current state |
285
+ |`refresh` | | refreshes the record from saved data, discarding current changes |
286
+ |`save` | | returns false if validations fail, true and saves data if valid |
287
+ |`update_attributes` | HASH | mass assignment of attribute accessors, calls `save()` after attribute changes have been applied |
288
+ |`destroy` | | removes the saved data for the record |
289
+
290
+ Instances also have attribute accessors and the various methods included from the ActiveModel classes mentioned earlier.
291
+
292
+ ### Associations
293
+
294
+ **Zermelo** supports multiple association types, which are named similarly to those provided by ActiveRecord:
295
+
296
+ |Name | Type | Redis data structure | Notes |
297
+ |---------------------------|---------------------------|----------------------|-------|
298
+ | `has_many` | one-to-many | [SET](http://redis.io/commands#set) | |
299
+ | `has_sorted_set` | one-to-many | [ZSET](http://redis.io/commands#sorted_set) | |
300
+ | `has_one` | one-to-one | [HASH](http://redis.io/commands#hash) | |
301
+ | `belongs_to` | many-to-one or one-to-one | [HASH](http://redis.io/commands#hash) or [STRING](http://redis.io/commands#string) | Inverse of any of the above three |
302
+ | `has_and_belongs_to_many` | many-to-many | 2 [SET](http://redis.io/commands#set)s | Mirrored by an inverse HaBtM association on the other side. |
303
+
304
+ ```ruby
305
+ class Post
306
+ include Zermelo:Record
307
+ has_many :comments, :class_name => 'Comment', :inverse_of => :post
308
+ end
309
+
310
+ class Comment
311
+ include Zermelo:Record
312
+ belongs_to :post, :class_name => 'Post', :inverse_of => :comments
313
+ end
314
+ ```
315
+
316
+ Class names of the associated class are used, instead of a reference to the class itself, to avoid circular dependencies being established. The inverse association is provided in order that multiple associations between the same two classes can be created.
317
+
318
+ Records are added and removed from their parent one-to-many or many-to-many associations like so:
319
+
320
+ ```ruby
321
+ post.comments.add(comment) # or post.comments << comment
322
+ ```
323
+
324
+ Associations' `.add` can also take more than one argument:
325
+
326
+ ```ruby
327
+ post.comments.add(comment1, comment2, comment3)
328
+ ```
329
+
330
+ `has_one` associations are simply set with an `=` method on the association:
331
+
332
+ ```ruby
333
+ class User
334
+ include Zermelo:Record
335
+ has_one :preferences, :class_name => 'Preferences', :inverse_of => :user
336
+ end
337
+
338
+ class Preferences
339
+ include Zermelo:Record
340
+ belongs_to :user, :class_name => 'User', :inverse_of => :preferences
341
+ end
342
+
343
+ user = User.new
344
+ user.save
345
+ prefs = Preferences.new
346
+ prefs.save
347
+
348
+ user.preferences = prefs
349
+ ```
350
+
351
+ The class methods defined above can be applied to associations references as well, so the resulting data will be filtered by the data relationships applying in the association, e.g.
352
+
353
+ ```ruby
354
+ post = Post.new(:id => 'a')
355
+ post.save
356
+ comment1 = Comment.new(:id => '1')
357
+ comment1.save
358
+ comment2 = Comment.new(:id => '2')
359
+ comment2.save
360
+
361
+ p post.comments.ids # == []
362
+ p Comment.ids # == [1, 2]
363
+ post.comments << comment1
364
+ p post.comments.ids # == [1]
365
+ ```
366
+
367
+ `associated_ids_for` is somewhat of a special case; it uses the smallest/simplest queries possible to get the ids of the associated records of a set of records, e.g. for the data directly above:
368
+
369
+ ```ruby
370
+ Post.associated_ids_for(:comments) # => {'a' => ['1']}
371
+
372
+ post_b = Post.new(:id => 'b')
373
+ post_b.save
374
+ post_b.comments << comment2
375
+ comment3 = Comment.new(:id => '3')
376
+ comment3.save
377
+ post.comments << comment3
378
+
379
+ Post.associated_ids_for(:comments) # => {'a' => ['1', '3'], 'b' => ['2']}
380
+ Post.intersect(:id => 'a').associated_ids_for(:comments) # => {'a' => ['1', '3']}
381
+ ```
382
+
383
+ For `belongs to` associations, you may pass an extra option to `associated_ids_for`, `:inversed => true`, and you'll get the data back as if it were applied from the inverse side; however the data will only cover that used as the query root. Again, assuming the data from the last two code blocks, e.g.
384
+
385
+ ```ruby
386
+ Comment.associated_ids_for(:post) # => {'1' => 'a', '2' => 'b', '3' => 'a'}
387
+ Comment.associated_ids_for(:post, :inversed => true) # => {'a' => ['1', '3'], 'b' => ['2']}
388
+
389
+ Comment.intersect(:id => ['1', '2']).associated_ids_for(:post) # => {'1' => 'a', '2' => 'b'}
390
+ Comment.intersect(:id => ['1', '2']).associated_ids_for(:post, :inversed => true) # => {'a' => ['1'], 'b' => ['2']}
391
+ ```
392
+
393
+ ### Class data indexing
394
+
395
+ Simple instance attributes, as defined above, can be indexed by value (and those indices can be queried).
396
+
397
+ Using the code from the instance attributes section, and adding indexing:
398
+
399
+ ```ruby
400
+ class Post
401
+ include Zermelo:Record
402
+ define_attributes :title => :string,
403
+ :score => :integer,
404
+ :timestamp => :timestamp,
405
+ :published => :boolean
406
+
407
+ unique_index_by :title
408
+ index_by :published
409
+
410
+ validates :title, :presence => true
411
+ end
412
+ ```
413
+
414
+ when we again create and save our instance of that model class:
415
+
416
+ ```ruby
417
+ post = Post.new(:title => 'Introduction to Zermelo',
418
+ :score => 100, :timestamp => Time.parse('Jan 1 2000'), :published => false)
419
+ post.save
420
+ ```
421
+
422
+ some extra class-level data is saved, in order that it is able to be queried later:
423
+
424
+ ```
425
+ HMSET post:03c839ac-24af-432e-aa58-fd1d4bf73f24:attrs title 'Introduction to Zermelo' score 100 timestamp 1384473626.36478 published 'false'
426
+ SADD post::attrs:ids 03c839ac-24af-432e-aa58-fd1d4bf73f24
427
+ HSET post::indices:by_title 'Introduction to Zermelo' 03c839ac-24af-432e-aa58-fd1d4bf73f24
428
+ SADD post::indices:by_published:boolean:false 03c839ac-24af-432e-aa58-fd1d4bf73f24
429
+ ```
430
+
431
+
432
+ ### Queries against these indices
433
+
434
+ `Zermelo` will construct Redis queries for you based on higher-level data expressions. Only those properties that are indexed can be queried against, as well as `:id` -- this ensures that most operations are carried out purely within Redis against collections of id values.
435
+
436
+
437
+ | Name | Input | Output | Arguments | Options |
438
+ |-----------------|-----------------------|--------------|---------------------------------------|------------------------------------------|
439
+ | intersect | `set` or `sorted_set` | `set` | Query hash | |
440
+ | union | `set` or `sorted_set` | `set` | Query hash | |
441
+ | diff | `set` or `sorted_set` | `set` | Query hash | |
442
+ | intersect_range | `sorted_set` | `sorted_set` | start (`Integer`), finish (`Integer`) | :desc (`Boolean`), :by_score (`Boolean`) |
443
+ | union_range | `sorted_set` | `sorted_set` | start (`Integer`), finish (`Integer`) | :desc (`Boolean`), :by_score (`Boolean`) |
444
+ | diff_range | `sorted_set` | `sorted_set` | start (`Integer`), finish (`Integer`) | :desc (`Boolean`), :by_score (`Boolean`) |
445
+ | sort | `set` or `sorted_set` | `list` | keys (Symbol or Array of Symbols) | :limit (`Integer`), :offset (`Integer`) |
446
+ | offset | `list` | `list` | amount (`Integer`) | |
447
+ | limit | `list` | `list` | amount (`Integer`) | |
448
+
449
+ These queries can be applied against all instances of a class, or against associations belonging to an instance, e.g.
450
+
451
+ ```ruby
452
+ post.comments.intersect(:title => 'Interesting')
453
+ Comment.intersect(:title => 'Interesting')
454
+ ```
455
+
456
+ are both valid, and the `Comment` instances returned by the first query would be contained in those returned by the second.
457
+
458
+ The chained queries are only executed when the results are invoked (lazy evaluation) by the addition of one of the class methods listed above; e.g.
459
+
460
+ ```ruby
461
+ Comment.intersect(:title => 'Interesting').all # -> [Comment, Comment, ...]
462
+ Comment.intersect(:title => 'Interesting', :promoted => true).count # -> Integer
463
+ ```
464
+
465
+ Assuming one `Comment` record exists, the first of these (`.all`) will execute the Redis commands
466
+
467
+ ```
468
+ SINTER comment::attrs:ids comment::indices:by_title:string:Interesting
469
+ HGET comment:ca9e427d-4d81-47f8-bcfe-bb614d40528c:attrs title
470
+ ```
471
+
472
+ with the result being an Array with one member, a Comment record with `{:id => 'ca9e427d-4d81-47f8-bcfe-bb614d40528c', :title => 'Interesting'}`
473
+
474
+ and the second (`.count`) will execute these Redis commands.
475
+
476
+ ```
477
+ SINTERSTORE comment::tmp:fe8dd59e4a1197f62d19c8aa942c4ff9 comment::indices:by_title:string:Interesting comment::indices:by_promoted:boolean:true
478
+ SCARD comment::tmp:fe8dd59e4a1197f62d19c8aa942c4ff9
479
+ DEL comment::tmp:fe8dd59e4a1197f62d19c8aa942c4ff9
480
+ ```
481
+
482
+ (where the name of the temporary Redis `SET` will of course change every time)
483
+
484
+ The current implementation of the filtering is somewhat ad-hoc, and has these limitations:
485
+
486
+ * no conversion of `list`s back into `set`s is allowed
487
+ * `sort`/`offset`/`limit` can only be used once in a filter chain
488
+
489
+ I plan to fix these as soon as I possibly can.
490
+
491
+ ### Future
492
+
493
+ Some possible changes:
494
+
495
+ * pluggable key naming strategies
496
+ * pluggable id generation strategies
497
+ * instrumentation for benchmarking etc.
498
+ * multiple data backends; there's currently an experimental InfluxDB backend, and more are planned.
499
+
500
+ ## License
501
+
502
+ Zermelo is released under the MIT license:
503
+
504
+ www.opensource.org/licenses/MIT
505
+
506
+ ## Contributing
507
+
508
+ 1. Fork it
509
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
510
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
511
+ 4. Push to the branch (`git push origin my-new-feature`)
512
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require 'bundler/gem_tasks'
@@ -0,0 +1,24 @@
1
+ module Zermelo
2
+ module Associations
3
+ class AssociationData
4
+ attr_writer :data_klass_name, :related_klass_names
5
+ attr_accessor :name, :type_klass, :inverse, :sort_key, :callbacks
6
+
7
+ def initialize(opts = {})
8
+ [:name, :type_klass, :inverse, :sort_key, :callbacks, :data_klass_name,
9
+ :related_klass_names].each do |a|
10
+
11
+ send("#{a}=".to_sym, opts[a])
12
+ end
13
+ end
14
+
15
+ def data_klass
16
+ @data_klass ||= @data_klass_name.constantize
17
+ end
18
+
19
+ def related_klasses
20
+ @related_klasses ||= (@related_klass_names || []).map(&:constantize)
21
+ end
22
+ end
23
+ end
24
+ end