lotus-model 0.0.0 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.travis.yml +6 -0
  4. data/.yardopts +5 -0
  5. data/EXAMPLE.md +217 -0
  6. data/Gemfile +14 -2
  7. data/README.md +303 -3
  8. data/Rakefile +17 -1
  9. data/lib/lotus-model.rb +1 -0
  10. data/lib/lotus/entity.rb +157 -0
  11. data/lib/lotus/model.rb +23 -2
  12. data/lib/lotus/model/adapters/abstract.rb +167 -0
  13. data/lib/lotus/model/adapters/implementation.rb +111 -0
  14. data/lib/lotus/model/adapters/memory/collection.rb +132 -0
  15. data/lib/lotus/model/adapters/memory/command.rb +90 -0
  16. data/lib/lotus/model/adapters/memory/query.rb +457 -0
  17. data/lib/lotus/model/adapters/memory_adapter.rb +149 -0
  18. data/lib/lotus/model/adapters/sql/collection.rb +209 -0
  19. data/lib/lotus/model/adapters/sql/command.rb +67 -0
  20. data/lib/lotus/model/adapters/sql/query.rb +615 -0
  21. data/lib/lotus/model/adapters/sql_adapter.rb +154 -0
  22. data/lib/lotus/model/mapper.rb +101 -0
  23. data/lib/lotus/model/mapping.rb +23 -0
  24. data/lib/lotus/model/mapping/coercer.rb +80 -0
  25. data/lib/lotus/model/mapping/collection.rb +336 -0
  26. data/lib/lotus/model/version.rb +4 -1
  27. data/lib/lotus/repository.rb +620 -0
  28. data/lotus-model.gemspec +15 -11
  29. data/test/entity_test.rb +126 -0
  30. data/test/fixtures.rb +81 -0
  31. data/test/model/adapters/abstract_test.rb +75 -0
  32. data/test/model/adapters/implementation_test.rb +22 -0
  33. data/test/model/adapters/memory/query_test.rb +91 -0
  34. data/test/model/adapters/memory_adapter_test.rb +1044 -0
  35. data/test/model/adapters/sql/query_test.rb +121 -0
  36. data/test/model/adapters/sql_adapter_test.rb +1078 -0
  37. data/test/model/mapper_test.rb +94 -0
  38. data/test/model/mapping/coercer_test.rb +27 -0
  39. data/test/model/mapping/collection_test.rb +82 -0
  40. data/test/repository_test.rb +283 -0
  41. data/test/test_helper.rb +30 -0
  42. data/test/version_test.rb +7 -0
  43. metadata +109 -11
@@ -0,0 +1,132 @@
1
+ module Lotus
2
+ module Model
3
+ module Adapters
4
+ module Memory
5
+ # Acts like a SQL database table.
6
+ #
7
+ # @api private
8
+ # @since 0.1.0
9
+ class Collection
10
+ # A counter that simulates autoincrement primary key of a SQL table.
11
+ #
12
+ # @api private
13
+ # @since 0.1.0
14
+ class PrimaryKey
15
+ # Initialize
16
+ #
17
+ # @return [Lotus::Model::Adapters::Memory::Collection::PrimaryKey]
18
+ #
19
+ # @api private
20
+ # @since 0.1.0
21
+ def initialize
22
+ @current = 0
23
+ end
24
+
25
+ # Increment the current count by 1 and yields the given block
26
+ #
27
+ # @return [Fixnum] the incremented counter
28
+ #
29
+ # @api private
30
+ # @since 0.1.0
31
+ def increment!
32
+ yield(@current += 1)
33
+ @current
34
+ end
35
+ end
36
+
37
+ # @attr_reader name [Symbol] the name of the collection (eg. `:users`)
38
+ #
39
+ # @since 0.1.0
40
+ # @api private
41
+ attr_reader :name
42
+
43
+ # @attr_reader identity [Symbol] the primary key of the collection
44
+ # (eg. `:id`)
45
+ #
46
+ # @since 0.1.0
47
+ # @api private
48
+ attr_reader :identity
49
+
50
+ # @attr_reader records [Hash] a set of records
51
+ #
52
+ # @since 0.1.0
53
+ # @api private
54
+ attr_reader :records
55
+
56
+ # Initialize a collection
57
+ #
58
+ # @param name [Symbol] the name of the collection (eg. `:users`).
59
+ # @param identity [Symbol] the primary key of the collection
60
+ # (eg. `:id`).
61
+ #
62
+ # @api private
63
+ # @since 0.1.0
64
+ def initialize(name, identity)
65
+ @name, @identity = name, identity
66
+ clear
67
+ end
68
+
69
+ # Creates a record for the given entity and assigns an id.
70
+ #
71
+ # @param entity [Object] the entity to persist
72
+ #
73
+ # @see Lotus::Model::Adapters::Memory::Command#create
74
+ #
75
+ # @return the primary key of the created record
76
+ #
77
+ # @api private
78
+ # @since 0.1.0
79
+ def create(entity)
80
+ @primary_key.increment! do |id|
81
+ entity[identity] = id
82
+ records[id] = entity
83
+ end
84
+ end
85
+
86
+ # Updates the record corresponding to the given entity.
87
+ #
88
+ # @param entity [Object] the entity to persist
89
+ #
90
+ # @see Lotus::Model::Adapters::Memory::Command#update
91
+ #
92
+ # @api private
93
+ # @since 0.1.0
94
+ def update(entity)
95
+ records[entity.fetch(identity)] = entity
96
+ end
97
+
98
+ # Deletes the record corresponding to the given entity.
99
+ #
100
+ # @param entity [Object] the entity to delete
101
+ #
102
+ # @see Lotus::Model::Adapters::Memory::Command#delete
103
+ #
104
+ # @api private
105
+ # @since 0.1.0
106
+ def delete(entity)
107
+ records.delete(entity.id)
108
+ end
109
+
110
+ # Returns all the raw records
111
+ #
112
+ # @return [Array<Hash>]
113
+ #
114
+ # @api private
115
+ # @since 0.1.0
116
+ def all
117
+ records.values
118
+ end
119
+
120
+ # Deletes all the records and resets the identity counter.
121
+ #
122
+ # @api private
123
+ # @since 0.1.0
124
+ def clear
125
+ @records = {}
126
+ @primary_key = PrimaryKey.new
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,90 @@
1
+ module Lotus
2
+ module Model
3
+ module Adapters
4
+ module Memory
5
+ # Execute a command for the given collection.
6
+ #
7
+ # @see Lotus::Model::Adapters::Memory::Collection
8
+ # @see Lotus::Model::Mapping::Collection
9
+ #
10
+ # @api private
11
+ # @since 0.1.0
12
+ class Command
13
+ # Initialize a command
14
+ #
15
+ # @param dataset [Lotus::Model::Adapters::Memory::Collection]
16
+ # @param collection [Lotus::Model::Mapping::Collection]
17
+ #
18
+ # @api private
19
+ # @since 0.1.0
20
+ def initialize(dataset, collection)
21
+ @dataset, @collection = dataset, collection
22
+ end
23
+
24
+ # Creates a record for the given entity.
25
+ #
26
+ # @param entity [Object] the entity to persist
27
+ #
28
+ # @see Lotus::Model::Adapters::Memory::Collection#insert
29
+ #
30
+ # @return the primary key of the just created record.
31
+ #
32
+ # @api private
33
+ # @since 0.1.0
34
+ def create(entity)
35
+ @dataset.create(
36
+ _serialize(entity)
37
+ )
38
+ end
39
+
40
+ # Updates the corresponding record for the given entity.
41
+ #
42
+ # @param entity [Object] the entity to persist
43
+ #
44
+ # @see Lotus::Model::Adapters::Memory::Collection#update
45
+ #
46
+ # @api private
47
+ # @since 0.1.0
48
+ def update(entity)
49
+ @dataset.update(
50
+ _serialize(entity)
51
+ )
52
+ end
53
+
54
+ # Deletes the corresponding record for the given entity.
55
+ #
56
+ # @param entity [Object] the entity to delete
57
+ #
58
+ # @see Lotus::Model::Adapters::Memory::Collection#delete
59
+ #
60
+ # @api private
61
+ # @since 0.1.0
62
+ def delete(entity)
63
+ @dataset.delete(entity)
64
+ end
65
+
66
+ # Deletes all the records from the table.
67
+ #
68
+ # @see Lotus::Model::Adapters::Memory::Collection#clear
69
+ #
70
+ # @api private
71
+ # @since 0.1.0
72
+ def clear
73
+ @dataset.clear
74
+ end
75
+
76
+ private
77
+ # Serialize the given entity before to persist in the database.
78
+ #
79
+ # @return [Hash] the serialized entity
80
+ #
81
+ # @api private
82
+ # @since 0.1.0
83
+ def _serialize(entity)
84
+ @collection.serialize(entity)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,457 @@
1
+ require 'forwardable'
2
+ require 'lotus/utils/kernel'
3
+
4
+ module Lotus
5
+ module Model
6
+ module Adapters
7
+ module Memory
8
+ # Query the in-memory database with a powerful API.
9
+ #
10
+ # All the methods are chainable, it allows advanced composition of
11
+ # conditions.
12
+ #
13
+ # This works as a lazy filtering mechanism: the records are fetched from
14
+ # the database only when needed.
15
+ #
16
+ # @example
17
+ #
18
+ # query.where(language: 'ruby')
19
+ # .and(framework: 'lotus')
20
+ # .desc(:users_count).all
21
+ #
22
+ # # the records are fetched only when we invoke #all
23
+ #
24
+ # It implements Ruby's `Enumerable` and borrows some methods from `Array`.
25
+ # Expect a query to act like them.
26
+ #
27
+ # @since 0.1.0
28
+ class Query
29
+ include Enumerable
30
+ extend Forwardable
31
+
32
+ def_delegators :all, :each, :to_s, :empty?
33
+
34
+ # @attr_reader conditions [Array] an accumulator for the conditions
35
+ #
36
+ # @since 0.1.0
37
+ # @api private
38
+ attr_reader :conditions
39
+
40
+ # @attr_reader modifiers [Array] an accumulator for the modifiers
41
+ #
42
+ # @since 0.1.0
43
+ # @api private
44
+ attr_reader :modifiers
45
+
46
+ # Initialize a query
47
+ #
48
+ # @param dataset [Lotus::Model::Adapters::Memory::Collection]
49
+ # @param collection [Lotus::Model::Mapping::Collection]
50
+ # @param blk [Proc] an optional block that gets yielded in the
51
+ # context of the current query
52
+ #
53
+ # @since 0.1.0
54
+ # @api private
55
+ def initialize(dataset, collection, &blk)
56
+ @dataset = dataset
57
+ @collection = collection
58
+ @conditions = []
59
+ @modifiers = []
60
+ instance_eval(&blk) if block_given?
61
+ end
62
+
63
+ # Resolves the query by fetching records from the database and
64
+ # translating them into entities.
65
+ #
66
+ # @return [Array] a collection of entities
67
+ #
68
+ # @since 0.1.0
69
+ def all
70
+ @collection.deserialize(run)
71
+ end
72
+
73
+ # Adds a condition that behaves like SQL `WHERE`.
74
+ #
75
+ # It accepts a `Hash` with only one pair.
76
+ # The key must be the name of the column expressed as a `Symbol`.
77
+ # The value is the one used by the internal filtering logic.
78
+ #
79
+ # @param condition [Hash]
80
+ #
81
+ # @return self
82
+ #
83
+ # @since 0.1.0
84
+ #
85
+ # @example Fixed value
86
+ #
87
+ # query.where(language: 'ruby')
88
+ #
89
+ # @example Array
90
+ #
91
+ # query.where(id: [1, 3])
92
+ #
93
+ # @example Range
94
+ #
95
+ # query.where(year: 1900..1982)
96
+ #
97
+ # @example Multiple conditions
98
+ #
99
+ # query.where(language: 'ruby')
100
+ # .where(framework: 'lotus')
101
+ def where(condition)
102
+ column, value = _expand_condition(condition)
103
+ conditions.push(Proc.new{ find_all{|r| r.fetch(column) == value} })
104
+ self
105
+ end
106
+
107
+ alias_method :and, :where
108
+ alias_method :or, :where
109
+
110
+ # Logical negation of a #where condition.
111
+ #
112
+ # It accepts a `Hash` with only one pair.
113
+ # The key must be the name of the column expressed as a `Symbol`.
114
+ # The value is the one used by the internal filtering logic.
115
+ #
116
+ # @param condition [Hash]
117
+ #
118
+ # @since 0.1.0
119
+ #
120
+ # @return self
121
+ #
122
+ # @example Fixed value
123
+ #
124
+ # query.exclude(language: 'java')
125
+ #
126
+ # @example Array
127
+ #
128
+ # query.exclude(id: [4, 9])
129
+ #
130
+ # @example Range
131
+ #
132
+ # query.where(year: 1900..1982)
133
+ #
134
+ # @example Multiple conditions
135
+ #
136
+ # query.where(language: 'java')
137
+ # .where(company: 'enterprise')
138
+ def exclude(condition)
139
+ column, value = _expand_condition(condition)
140
+ conditions.push(Proc.new{ reject! {|r| r.fetch(column) == value} })
141
+ self
142
+ end
143
+
144
+ alias_method :not, :exclude
145
+
146
+ # Select only the specified columns.
147
+ #
148
+ # By default a query selects all the mapped columns.
149
+ #
150
+ # @param columns [Array<Symbol>]
151
+ #
152
+ # @return self
153
+ #
154
+ # @since 0.1.0
155
+ #
156
+ # @example Single column
157
+ #
158
+ # query.select(:name)
159
+ #
160
+ # @example Multiple columns
161
+ #
162
+ # query.select(:name, :year)
163
+ def select(*columns)
164
+ columns = Lotus::Utils::Kernel.Array(columns).uniq
165
+ modifiers.push(Proc.new{ flatten!; each {|r| r.delete_if {|k,_| !columns.include?(k)} } })
166
+ end
167
+
168
+ # Specify the ascending order of the records, sorted by the given
169
+ # columns.
170
+ #
171
+ # @param columns [Array<Symbol>] the column names
172
+ #
173
+ # @return self
174
+ #
175
+ # @since 0.1.0
176
+ #
177
+ # @see Lotus::Model::Adapters::Sql::Query#desc
178
+ #
179
+ # @example Single column
180
+ #
181
+ # query.order(:name)
182
+ #
183
+ # @example Multiple columns
184
+ #
185
+ # query.order(:name, :year)
186
+ #
187
+ # @example Multiple invokations
188
+ #
189
+ # query.order(:name).order(:year)
190
+ def order(*columns)
191
+ Lotus::Utils::Kernel.Array(columns).each do |column|
192
+ conditions.push(Proc.new{ sort_by{|r| r.fetch(column)} })
193
+ end
194
+
195
+ self
196
+ end
197
+
198
+ alias_method :asc, :order
199
+
200
+ # Specify the descending order of the records, sorted by the given
201
+ # columns.
202
+ #
203
+ # @param columns [Array<Symbol>] the column names
204
+ #
205
+ # @return self
206
+ #
207
+ # @since 0.1.0
208
+ #
209
+ # @see Lotus::Model::Adapters::Sql::Query#order
210
+ #
211
+ # @example Single column
212
+ #
213
+ # query.desc(:name)
214
+ #
215
+ # @example Multiple columns
216
+ #
217
+ # query.desc(:name, :year)
218
+ #
219
+ # @example Multiple invokations
220
+ #
221
+ # query.desc(:name).desc(:year)
222
+ def desc(*columns)
223
+ Lotus::Utils::Kernel.Array(columns).each do |column|
224
+ conditions.push(Proc.new{ sort_by{|r| r.fetch(column)}.reverse })
225
+ end
226
+
227
+ self
228
+ end
229
+
230
+ # Limit the number of records to return.
231
+ #
232
+ # @param number [Fixnum]
233
+ #
234
+ # @return self
235
+ #
236
+ # @since 0.1.0
237
+ #
238
+ # @example
239
+ #
240
+ # query.limit(1)
241
+ def limit(number)
242
+ modifiers.push(Proc.new{ replace(flatten.first(number)) })
243
+ self
244
+ end
245
+
246
+ # Simulate an `OFFSET` clause, without the need of specify a limit.
247
+ #
248
+ # @param number [Fixnum]
249
+ #
250
+ # @return self
251
+ #
252
+ # @since 0.1.0
253
+ #
254
+ # @example
255
+ #
256
+ # query.offset(10)
257
+ def offset(number)
258
+ modifiers.unshift(Proc.new{ replace(flatten.last(number)) })
259
+ self
260
+ end
261
+
262
+ # Returns the sum of the values for the given column.
263
+ #
264
+ # @param column [Symbol] the colum name
265
+ #
266
+ # @return [Numeric]
267
+ #
268
+ # @since 0.1.0
269
+ #
270
+ # @example
271
+ #
272
+ # query.sum(:comments_count)
273
+ def sum(column)
274
+ result = all
275
+
276
+ if result.any?
277
+ result.inject(0.0) do |acc, record|
278
+ if value = record.public_send(column)
279
+ acc += value
280
+ end
281
+
282
+ acc
283
+ end
284
+ end
285
+ end
286
+
287
+ # Returns the average of the values for the given column.
288
+ #
289
+ # @param column [Symbol] the colum name
290
+ #
291
+ # @return [Numeric]
292
+ #
293
+ # @since 0.1.0
294
+ #
295
+ # @example
296
+ #
297
+ # query.average(:comments_count)
298
+ def average(column)
299
+ if s = sum(column)
300
+ s / _all_with_present_column(column).count.to_f
301
+ end
302
+ end
303
+
304
+ alias_method :avg, :average
305
+
306
+ # Returns the maximum value for the given column.
307
+ #
308
+ # @param column [Symbol] the colum name
309
+ #
310
+ # @return result
311
+ #
312
+ # @since 0.1.0
313
+ #
314
+ # @example
315
+ #
316
+ # query.max(:comments_count)
317
+ def max(column)
318
+ _all_with_present_column(column).max
319
+ end
320
+
321
+ # Returns the minimum value for the given column.
322
+ #
323
+ # @param column [Symbol] the colum name
324
+ #
325
+ # @return result
326
+ #
327
+ # @since 0.1.0
328
+ #
329
+ # @example
330
+ #
331
+ # query.min(:comments_count)
332
+ def min(column)
333
+ _all_with_present_column(column).min
334
+ end
335
+
336
+ # Returns the difference between the MAX and MIN for the given column.
337
+ #
338
+ # @param column [Symbol] the colum name
339
+ #
340
+ # @return [Numeric]
341
+ #
342
+ # @since 0.1.0
343
+ #
344
+ # @see Lotus::Model::Adapters::Memory::Query#max
345
+ # @see Lotus::Model::Adapters::Memory::Query#min
346
+ #
347
+ # @example
348
+ #
349
+ # query.interval(:comments_count)
350
+ def interval(column)
351
+ max(column) - min(column)
352
+ rescue NoMethodError
353
+ end
354
+
355
+ # Returns a range of values between the MAX and the MIN for the given
356
+ # column.
357
+ #
358
+ # @param column [Symbol] the colum name
359
+ #
360
+ # @return [Range]
361
+ #
362
+ # @since 0.1.0
363
+ #
364
+ # @see Lotus::Model::Adapters::Memory::Query#max
365
+ # @see Lotus::Model::Adapters::Memory::Query#min
366
+ #
367
+ # @example
368
+ #
369
+ # query.range(:comments_count)
370
+ def range(column)
371
+ min(column)..max(column)
372
+ end
373
+
374
+ # Checks if at least one record exists for the current conditions.
375
+ #
376
+ # @return [TrueClass,FalseClass]
377
+ #
378
+ # @since 0.1.0
379
+ #
380
+ # @example
381
+ #
382
+ # query.where(author_id: 23).exists? # => true
383
+ def exist?
384
+ !count.zero?
385
+ end
386
+
387
+ # Returns a count of the records for the current conditions.
388
+ #
389
+ # @return [Fixnum]
390
+ #
391
+ # @since 0.1.0
392
+ #
393
+ # @example
394
+ #
395
+ # query.where(author_id: 23).count # => 5
396
+ def count
397
+ run.count
398
+ end
399
+
400
+ # This method is defined in order to make the interface of
401
+ # `Memory::Query` identical to `Sql::Query`, but this feature is NOT
402
+ # implemented
403
+ #
404
+ # @raise [NotImplementedError]
405
+ #
406
+ # @since 0.1.0
407
+ #
408
+ # @see Lotus::Model::Adapters::Sql::Query#negate!
409
+ def negate!
410
+ raise NotImplementedError
411
+ end
412
+
413
+ protected
414
+ def method_missing(m, *args, &blk)
415
+ if @context.respond_to?(m)
416
+ apply @context.public_send(m, *args, &blk)
417
+ else
418
+ super
419
+ end
420
+ end
421
+
422
+ private
423
+ # Apply all the conditions and returns a filtered collection.
424
+ #
425
+ # This operation is idempotent, but the records are actually fetched
426
+ # from the memory store.
427
+ #
428
+ # @return [Array]
429
+ #
430
+ # @api private
431
+ # @since 0.1.0
432
+ def run
433
+ result = @dataset.all.dup
434
+
435
+ result = conditions.map do |condition|
436
+ result.instance_exec(&condition)
437
+ end if conditions.any?
438
+
439
+ modifiers.map do |modifier|
440
+ result.instance_exec(&modifier)
441
+ end
442
+
443
+ Lotus::Utils::Kernel.Array(result)
444
+ end
445
+
446
+ def _all_with_present_column(column)
447
+ all.map {|record| record.public_send(column) }.compact
448
+ end
449
+
450
+ def _expand_condition(condition)
451
+ Array(condition).flatten
452
+ end
453
+ end
454
+ end
455
+ end
456
+ end
457
+ end