lotus-model 0.0.0 → 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.
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