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
@@ -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