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
@@ -1,5 +1,8 @@
1
1
  module Lotus
2
2
  module Model
3
- VERSION = "0.0.0"
3
+ # Defines the version
4
+ #
5
+ # @since 0.1.0
6
+ VERSION = '0.1.0'
4
7
  end
5
8
  end
@@ -0,0 +1,620 @@
1
+ require 'lotus/utils/class_attribute'
2
+
3
+ module Lotus
4
+ # Mediates between the entities and the persistence layer, by offering an API
5
+ # to query and execute commands on a databse.
6
+ #
7
+ #
8
+ #
9
+ # IMPORTANT: A repository MUST be named after an entity, by appeding the
10
+ # `Repository` suffix to the entity class name.
11
+ #
12
+ # @example
13
+ # require 'lotus/model'
14
+ #
15
+ # class Article
16
+ # include Lotus::Entity
17
+ # end
18
+ #
19
+ # # valid
20
+ # class ArticleRepository
21
+ # include Lotus::Repository
22
+ # end
23
+ #
24
+ # # not valid for Article
25
+ # class PostRepository
26
+ # include Lotus::Repository
27
+ # end
28
+ #
29
+ #
30
+ #
31
+ # A repository is storage idenpendent.
32
+ # All the queries and commands are delegated to the current adapter.
33
+ #
34
+ # This architecture has several advantages:
35
+ #
36
+ # * Applications depends on an abstract API, instead of low level details
37
+ # (Dependency Inversion principle)
38
+ #
39
+ # * Applications depends on a stable API, that doesn't change if the
40
+ # storage changes
41
+ #
42
+ # * Developers can postpone storage decisions
43
+ #
44
+ # * Isolates the persistence logic at a low level
45
+ #
46
+ # Lotus::Model is shipped with two adapters:
47
+ #
48
+ # * SqlAdapter
49
+ # * MemoryAdapter
50
+ #
51
+ #
52
+ #
53
+ # All the queries and commands are private.
54
+ # This decision forces developers to define intention revealing API, instead
55
+ # leak storage API details outside of a repository.
56
+ #
57
+ # @example
58
+ # require 'lotus/model'
59
+ #
60
+ # # This is bad for several reasons:
61
+ # #
62
+ # # * The caller has an intimate knowledge of the internal mechanisms
63
+ # # of the Repository.
64
+ # #
65
+ # # * The caller works on several levels of abstraction.
66
+ # #
67
+ # # * It doesn't express a clear intent, it's just a chain of methods.
68
+ # #
69
+ # # * The caller can't be easily tested in isolation.
70
+ # #
71
+ # # * If we change the storage, we are forced to change the code of the
72
+ # # caller(s).
73
+ #
74
+ # ArticleRepository.where(author_id: 23).order(:published_at).limit(8)
75
+ #
76
+ #
77
+ #
78
+ # # This is a huge improvement:
79
+ # #
80
+ # # * The caller doesn't know how the repository fetches the entities.
81
+ # #
82
+ # # * The caller works on a single level of abstraction.
83
+ # # It doesn't even know about records, only works with entities.
84
+ # #
85
+ # # * It expresses a clear intent.
86
+ # #
87
+ # # * The caller can be easily tested in isolation.
88
+ # # It's just a matter of stub this method.
89
+ # #
90
+ # # * If we change the storage, the callers aren't affected.
91
+ #
92
+ # ArticleRepository.most_recent_by_author(author)
93
+ #
94
+ # class ArticleRepository
95
+ # include Lotus::Repository
96
+ #
97
+ # def self.most_recent_by_author(author, limit = 8)
98
+ # query do
99
+ # where(author_id: author.id).
100
+ # order(:published_at)
101
+ # end.limit(limit)
102
+ # end
103
+ # end
104
+ #
105
+ # @since 0.1.0
106
+ #
107
+ # @see Lotus::Entity
108
+ # @see http://martinfowler.com/eaaCatalog/repository.html
109
+ # @see http://en.wikipedia.org/wiki/Dependency_inversion_principle
110
+ module Repository
111
+ # Inject the public API into the hosting class.
112
+ #
113
+ # @since 0.1.0
114
+ #
115
+ # @example
116
+ # require 'lotus/model'
117
+ #
118
+ # class UserRepository
119
+ # include Lotus::Repository
120
+ # end
121
+ def self.included(base)
122
+ base.class_eval do
123
+ extend ClassMethods
124
+ include Lotus::Utils::ClassAttribute
125
+
126
+ class_attribute :collection
127
+ end
128
+ end
129
+
130
+ module ClassMethods
131
+ # Assigns an adapter.
132
+ #
133
+ # Lotus::Model is shipped with two adapters:
134
+ #
135
+ # * SqlAdapter
136
+ # * MemoryAdapter
137
+ #
138
+ # @param adapter [Object] an object that implements
139
+ # `Lotus::Model::Adapters::Abstract` interface
140
+ #
141
+ # @since 0.1.0
142
+ #
143
+ # @see Lotus::Model::Adapters::SqlAdapter
144
+ # @see Lotus::Model::Adapters::MemoryAdapter
145
+ #
146
+ # @example Memory adapter
147
+ # require 'lotus/model'
148
+ # require 'lotus/model/adapters/memory_adapter'
149
+ #
150
+ # mapper = Lotus::Model::Mapper.new do
151
+ # # ...
152
+ # end
153
+ #
154
+ # adapter = Lotus::Model::Adapters::MemoryAdapter.new(mapper)
155
+ #
156
+ # class UserRepository
157
+ # include Lotus::Repository
158
+ # end
159
+ #
160
+ # UserRepository.adapter = adapter
161
+ #
162
+ #
163
+ #
164
+ # @example SQL adapter with a Sqlite database
165
+ # require 'sqlite3'
166
+ # require 'lotus/model'
167
+ # require 'lotus/model/adapters/sql_adapter'
168
+ #
169
+ # mapper = Lotus::Model::Mapper.new do
170
+ # # ...
171
+ # end
172
+ #
173
+ # adapter = Lotus::Model::Adapters::SqlAdapter.new(mapper, 'sqlite://path/to/database.db')
174
+ #
175
+ # class UserRepository
176
+ # include Lotus::Repository
177
+ # end
178
+ #
179
+ # UserRepository.adapter = adapter
180
+ #
181
+ #
182
+ #
183
+ # @example SQL adapter with a Postgres database
184
+ # require 'pg'
185
+ # require 'lotus/model'
186
+ # require 'lotus/model/adapters/sql_adapter'
187
+ #
188
+ # mapper = Lotus::Model::Mapper.new do
189
+ # # ...
190
+ # end
191
+ #
192
+ # adapter = Lotus::Model::Adapters::SqlAdapter.new(mapper, 'postgres://host:port/database')
193
+ #
194
+ # class UserRepository
195
+ # include Lotus::Repository
196
+ # end
197
+ #
198
+ # UserRepository.adapter = adapter
199
+ def adapter=(adapter)
200
+ @adapter = adapter
201
+ end
202
+
203
+ # Creates or updates a record in the database for the given entity.
204
+ #
205
+ # @param entity [#id, #id=] the entity to persist
206
+ #
207
+ # @return [Object] the entity
208
+ #
209
+ # @since 0.1.0
210
+ #
211
+ # @see Lotus::Repository#create
212
+ # @see Lotus::Repository#update
213
+ #
214
+ # @example With a non persisted entity
215
+ # require 'lotus/model'
216
+ #
217
+ # class ArticleRepository
218
+ # include Lotus::Repository
219
+ # end
220
+ #
221
+ # article = Article.new(title: 'Introducing Lotus::Model')
222
+ # article.id # => nil
223
+ #
224
+ # ArticleRepository.persist(article) # creates a record
225
+ # article.id # => 23
226
+ #
227
+ # @example With a persisted entity
228
+ # require 'lotus/model'
229
+ #
230
+ # class ArticleRepository
231
+ # include Lotus::Repository
232
+ # end
233
+ #
234
+ # article = ArticleRepository.find(23)
235
+ # article.id # => 23
236
+ #
237
+ # article.title = 'Launching Lotus::Model'
238
+ # ArticleRepository.persist(article) # updates the record
239
+ #
240
+ # article = ArticleRepository.find(23)
241
+ # article.title # => "Launching Lotus::Model"
242
+ def persist(entity)
243
+ @adapter.persist(collection, entity)
244
+ end
245
+
246
+ # Creates a record in the database for the given entity.
247
+ # It assigns the `id` attribute, in case of success.
248
+ #
249
+ # If already persisted (`id` present) it does nothing.
250
+ #
251
+ # @param entity [#id,#id=] the entity to create
252
+ #
253
+ # @return [Object] the entity
254
+ #
255
+ # @since 0.1.0
256
+ #
257
+ # @see Lotus::Repository#persist
258
+ #
259
+ # @example
260
+ # require 'lotus/model'
261
+ #
262
+ # class ArticleRepository
263
+ # include Lotus::Repository
264
+ # end
265
+ #
266
+ # article = Article.new(title: 'Introducing Lotus::Model')
267
+ # article.id # => nil
268
+ #
269
+ # ArticleRepository.persist(article) # creates a record
270
+ # article.id # => 23
271
+ #
272
+ # ArticleRepository.persist(article) # no-op
273
+ def create(entity)
274
+ unless entity.id
275
+ @adapter.create(collection, entity)
276
+ end
277
+
278
+ entity
279
+ end
280
+
281
+ # Updates a record in the database corresponding to the given entity.
282
+ #
283
+ # If not already persisted (`id` present) it raises an exception.
284
+ #
285
+ # @param entity [#id] the entity to update
286
+ #
287
+ # @return [Object] the entity
288
+ #
289
+ # @raise [Lotus::Model::NonPersistedEntityError] if the given entity
290
+ # wasn't already persisted.
291
+ #
292
+ # @since 0.1.0
293
+ #
294
+ # @see Lotus::Repository#persist
295
+ # @see Lotus::Model::NonPersistedEntityError
296
+ #
297
+ # @example With a persisted entity
298
+ # require 'lotus/model'
299
+ #
300
+ # class ArticleRepository
301
+ # include Lotus::Repository
302
+ # end
303
+ #
304
+ # article = ArticleRepository.find(23)
305
+ # article.id # => 23
306
+ # article.title = 'Launching Lotus::Model'
307
+ #
308
+ # ArticleRepository.update(article) # updates the record
309
+ #
310
+ #
311
+ #
312
+ # @example With a non persisted entity
313
+ # require 'lotus/model'
314
+ #
315
+ # class ArticleRepository
316
+ # include Lotus::Repository
317
+ # end
318
+ #
319
+ # article = Article.new(title: 'Introducing Lotus::Model')
320
+ # article.id # => nil
321
+ #
322
+ # ArticleRepository.update(article) # raises Lotus::Model::NonPersistedEntityError
323
+ def update(entity)
324
+ if entity.id
325
+ @adapter.update(collection, entity)
326
+ else
327
+ raise Lotus::Model::NonPersistedEntityError
328
+ end
329
+
330
+ entity
331
+ end
332
+
333
+ # Deletes a record in the database corresponding to the given entity.
334
+ #
335
+ # If not already persisted (`id` present) it raises an exception.
336
+ #
337
+ # @param entity [#id] the entity to delete
338
+ #
339
+ # @return [Object] the entity
340
+ #
341
+ # @raise [Lotus::Model::NonPersistedEntityError] if the given entity
342
+ # wasn't already persisted.
343
+ #
344
+ # @since 0.1.0
345
+ #
346
+ # @see Lotus::Model::NonPersistedEntityError
347
+ #
348
+ # @example With a persisted entity
349
+ # require 'lotus/model'
350
+ #
351
+ # class ArticleRepository
352
+ # include Lotus::Repository
353
+ # end
354
+ #
355
+ # article = ArticleRepository.find(23)
356
+ # article.id # => 23
357
+ #
358
+ # ArticleRepository.delete(article) # deletes the record
359
+ #
360
+ #
361
+ #
362
+ # @example With a non persisted entity
363
+ # require 'lotus/model'
364
+ #
365
+ # class ArticleRepository
366
+ # include Lotus::Repository
367
+ # end
368
+ #
369
+ # article = Article.new(title: 'Introducing Lotus::Model')
370
+ # article.id # => nil
371
+ #
372
+ # ArticleRepository.delete(article) # raises Lotus::Model::NonPersistedEntityError
373
+ def delete(entity)
374
+ if entity.id
375
+ @adapter.delete(collection, entity)
376
+ else
377
+ raise Lotus::Model::NonPersistedEntityError
378
+ end
379
+
380
+ entity
381
+ end
382
+
383
+ # Returns all the persisted entities.
384
+ #
385
+ # @return [Array<Object>] the result of the query
386
+ #
387
+ # @since 0.1.0
388
+ #
389
+ # @example
390
+ # require 'lotus/model'
391
+ #
392
+ # class ArticleRepository
393
+ # include Lotus::Repository
394
+ # end
395
+ #
396
+ # ArticleRepository.all # => [ #<Article:0x007f9b19a60098> ]
397
+ def all
398
+ @adapter.all(collection)
399
+ end
400
+
401
+ # Finds an entity by its identity.
402
+ #
403
+ # If used with a SQL database, it corresponds to the primary key.
404
+ #
405
+ # @param id [Object] the identity of the entity
406
+ #
407
+ # @return [Object] the result of the query
408
+ #
409
+ # @raise [Lotus::Model::EntityNotFound] if the entity cannot be found.
410
+ #
411
+ # @since 0.1.0
412
+ #
413
+ # @see Lotus::Model::EntityNotFound
414
+ #
415
+ # @example With a persisted entity
416
+ # require 'lotus/model'
417
+ #
418
+ # class ArticleRepository
419
+ # include Lotus::Repository
420
+ # end
421
+ #
422
+ # ArticleRepository.find(9) # => raises Lotus::Model::EntityNotFound
423
+ def find(id)
424
+ @adapter.find(collection, id).tap do |record|
425
+ raise Lotus::Model::EntityNotFound.new unless record
426
+ end
427
+ end
428
+
429
+ # Returns the first entity in the database.
430
+ #
431
+ # @return [Object,nil] the result of the query
432
+ #
433
+ # @since 0.1.0
434
+ #
435
+ # @see Lotus::Repository#last
436
+ #
437
+ # @example With at least one persisted entity
438
+ # require 'lotus/model'
439
+ #
440
+ # class ArticleRepository
441
+ # include Lotus::Repository
442
+ # end
443
+ #
444
+ # ArticleRepository.first # => #<Article:0x007f8c71d98a28>
445
+ #
446
+ # @example With an empty collection
447
+ # require 'lotus/model'
448
+ #
449
+ # class ArticleRepository
450
+ # include Lotus::Repository
451
+ # end
452
+ #
453
+ # ArticleRepository.first # => nil
454
+ def first
455
+ @adapter.first(collection)
456
+ end
457
+
458
+ # Returns the last entity in the database.
459
+ #
460
+ # @return [Object,nil] the result of the query
461
+ #
462
+ # @since 0.1.0
463
+ #
464
+ # @see Lotus::Repository#last
465
+ #
466
+ # @example With at least one persisted entity
467
+ # require 'lotus/model'
468
+ #
469
+ # class ArticleRepository
470
+ # include Lotus::Repository
471
+ # end
472
+ #
473
+ # ArticleRepository.last # => #<Article:0x007f8c71d98a28>
474
+ #
475
+ # @example With an empty collection
476
+ # require 'lotus/model'
477
+ #
478
+ # class ArticleRepository
479
+ # include Lotus::Repository
480
+ # end
481
+ #
482
+ # ArticleRepository.last # => nil
483
+ def last
484
+ @adapter.last(collection)
485
+ end
486
+
487
+ # Deletes all the records from the current collection.
488
+ #
489
+ # If used with a SQL database it executes a `DELETE FROM <table>`.
490
+ #
491
+ # @since 0.1.0
492
+ #
493
+ # @example
494
+ # require 'lotus/model'
495
+ #
496
+ # class ArticleRepository
497
+ # include Lotus::Repository
498
+ # end
499
+ #
500
+ # ArticleRepository.clear # deletes all the records
501
+ def clear
502
+ @adapter.clear(collection)
503
+ end
504
+
505
+ private
506
+ # Fabricates a query and yields the given block to access the low level
507
+ # APIs exposed by the query itself.
508
+ #
509
+ # This is a Ruby private method, because we wanted to prevent outside
510
+ # objects to query directly the database. However, this is a public API
511
+ # method, and this is the only way to filter entities.
512
+ #
513
+ # The returned query SHOULD be lazy: the entities should be fetched by
514
+ # the database only when needed.
515
+ #
516
+ # The returned query SHOULD refer to the entire collection by default.
517
+ #
518
+ # Queries can be reused and combined together. See the example below.
519
+ # IMPORTANT: This feature works only with the Sql adapter.
520
+ #
521
+ # A repository is storage independent.
522
+ # All the queries are deletegated to the current adapter, which is
523
+ # responsible to implement a querying API.
524
+ #
525
+ # Lotus::Model is shipped with two adapters:
526
+ #
527
+ # * SqlAdapter, which yields a Lotus::Model::Adapters::Sql::Query
528
+ # * MemoryAdapter, which yields a Lotus::Model::Adapters::Memory::Query
529
+ #
530
+ # @param blk [Proc] a block of code that is executed in the context of a
531
+ # query
532
+ #
533
+ # @return a query, the type depends on the current adapter
534
+ #
535
+ # @api public
536
+ # @since 0.1.0
537
+ #
538
+ # @see Lotus::Model::Adapters::Sql::Query
539
+ # @see Lotus::Model::Adapters::Memory::Query
540
+ #
541
+ # @example
542
+ # require 'lotus/model'
543
+ #
544
+ # class ArticleRepository
545
+ # include Lotus::Repository
546
+ #
547
+ # def self.most_recent_by_author(author, limit = 8)
548
+ # query do
549
+ # where(author_id: author.id).
550
+ # desc(:published_at).
551
+ # limit(limit)
552
+ # end
553
+ # end
554
+ #
555
+ # def self.most_recent_published_by_author(author, limit = 8)
556
+ # # combine .most_recent_published_by_author and .published queries
557
+ # most_recent_by_author(author, limit).published
558
+ # end
559
+ #
560
+ # def self.published
561
+ # query do
562
+ # where(published: true)
563
+ # end
564
+ # end
565
+ #
566
+ # def self.rank
567
+ # # reuse .published, which returns a query that respond to #desc
568
+ # published.desc(:comments_count)
569
+ # end
570
+ #
571
+ # def self.best_article_ever
572
+ # # reuse .published, which returns a query that respond to #limit
573
+ # rank.limit(1)
574
+ # end
575
+ #
576
+ # def self.comments_average
577
+ # query.average(:comments_count)
578
+ # end
579
+ # end
580
+ def query(&blk)
581
+ @adapter.query(collection, self, &blk)
582
+ end
583
+
584
+ # Negates the filtering conditions of a the given query with the logical
585
+ # opposite operator.
586
+ #
587
+ # This is only supported by the SqlAdapter.
588
+ #
589
+ # @param query [Object] a query
590
+ #
591
+ # @return a negated query, the type depends on the current adapter
592
+ #
593
+ # @api public
594
+ # @since 0.1.0
595
+ #
596
+ # @see Lotus::Model::Adapters::Sql::Query#negate!
597
+ #
598
+ # @example
599
+ # require 'lotus/model'
600
+ #
601
+ # class ProjectRepository
602
+ # include Lotus::Repository
603
+ #
604
+ # def self.cool
605
+ # query do
606
+ # where(language: 'ruby')
607
+ # end
608
+ # end
609
+ #
610
+ # def self.not_cool
611
+ # exclude cool
612
+ # end
613
+ # end
614
+ def exclude(query)
615
+ query.negate!
616
+ query
617
+ end
618
+ end
619
+ end
620
+ end