hanami-model 0.0.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -0
  3. data/EXAMPLE.md +212 -0
  4. data/LICENSE.md +22 -0
  5. data/README.md +600 -7
  6. data/hanami-model.gemspec +17 -12
  7. data/lib/hanami-model.rb +1 -0
  8. data/lib/hanami/entity.rb +298 -0
  9. data/lib/hanami/entity/dirty_tracking.rb +74 -0
  10. data/lib/hanami/model.rb +204 -2
  11. data/lib/hanami/model/adapters/abstract.rb +281 -0
  12. data/lib/hanami/model/adapters/file_system_adapter.rb +288 -0
  13. data/lib/hanami/model/adapters/implementation.rb +111 -0
  14. data/lib/hanami/model/adapters/memory/collection.rb +132 -0
  15. data/lib/hanami/model/adapters/memory/command.rb +113 -0
  16. data/lib/hanami/model/adapters/memory/query.rb +653 -0
  17. data/lib/hanami/model/adapters/memory_adapter.rb +179 -0
  18. data/lib/hanami/model/adapters/null_adapter.rb +24 -0
  19. data/lib/hanami/model/adapters/sql/collection.rb +287 -0
  20. data/lib/hanami/model/adapters/sql/command.rb +73 -0
  21. data/lib/hanami/model/adapters/sql/console.rb +33 -0
  22. data/lib/hanami/model/adapters/sql/consoles/mysql.rb +49 -0
  23. data/lib/hanami/model/adapters/sql/consoles/postgresql.rb +48 -0
  24. data/lib/hanami/model/adapters/sql/consoles/sqlite.rb +26 -0
  25. data/lib/hanami/model/adapters/sql/query.rb +788 -0
  26. data/lib/hanami/model/adapters/sql_adapter.rb +296 -0
  27. data/lib/hanami/model/coercer.rb +74 -0
  28. data/lib/hanami/model/config/adapter.rb +116 -0
  29. data/lib/hanami/model/config/mapper.rb +45 -0
  30. data/lib/hanami/model/configuration.rb +275 -0
  31. data/lib/hanami/model/error.rb +7 -0
  32. data/lib/hanami/model/mapper.rb +124 -0
  33. data/lib/hanami/model/mapping.rb +48 -0
  34. data/lib/hanami/model/mapping/attribute.rb +85 -0
  35. data/lib/hanami/model/mapping/coercers.rb +314 -0
  36. data/lib/hanami/model/mapping/collection.rb +490 -0
  37. data/lib/hanami/model/mapping/collection_coercer.rb +79 -0
  38. data/lib/hanami/model/migrator.rb +324 -0
  39. data/lib/hanami/model/migrator/adapter.rb +170 -0
  40. data/lib/hanami/model/migrator/connection.rb +133 -0
  41. data/lib/hanami/model/migrator/mysql_adapter.rb +72 -0
  42. data/lib/hanami/model/migrator/postgres_adapter.rb +119 -0
  43. data/lib/hanami/model/migrator/sqlite_adapter.rb +110 -0
  44. data/lib/hanami/model/version.rb +4 -1
  45. data/lib/hanami/repository.rb +872 -0
  46. metadata +100 -16
  47. data/.gitignore +0 -9
  48. data/Gemfile +0 -4
  49. data/Rakefile +0 -2
  50. data/bin/console +0 -14
  51. data/bin/setup +0 -8
@@ -0,0 +1,73 @@
1
+ module Hanami
2
+ module Model
3
+ module Adapters
4
+ module Sql
5
+ # Execute a command for the given query.
6
+ #
7
+ # @see Hanami::Model::Adapters::Sql::Query
8
+ #
9
+ # @api private
10
+ # @since 0.1.0
11
+ class Command
12
+ # Initialize a command
13
+ #
14
+ # @param query [Hanami::Model::Adapters::Sql::Query]
15
+ #
16
+ # @api private
17
+ # @since 0.1.0
18
+ def initialize(query)
19
+ @collection = query.scoped
20
+ end
21
+
22
+ # Creates a record for the given entity.
23
+ #
24
+ # @param entity [Object] the entity to persist
25
+ #
26
+ # @see Hanami::Model::Adapters::Sql::Collection#insert
27
+ #
28
+ # @return the primary key of the just created record.
29
+ #
30
+ # @api private
31
+ # @since 0.1.0
32
+ def create(entity)
33
+ @collection.insert(entity)
34
+ rescue Sequel::DatabaseError => e
35
+ raise Hanami::Model::Error.new(e.message)
36
+ end
37
+
38
+ # Updates the corresponding record for the given entity.
39
+ #
40
+ # @param entity [Object] the entity to persist
41
+ #
42
+ # @see Hanami::Model::Adapters::Sql::Collection#update
43
+ #
44
+ # @api private
45
+ # @since 0.1.0
46
+ def update(entity)
47
+ @collection.update(entity)
48
+ rescue Sequel::DatabaseError => e
49
+ raise Hanami::Model::Error.new(e.message)
50
+ end
51
+
52
+ # Deletes all the records for the current query.
53
+ #
54
+ # It's used to delete a single record or an entire database table.
55
+ #
56
+ # @see Hanami::Model::Adapters::SqlAdapter#delete
57
+ # @see Hanami::Model::Adapters::SqlAdapter#clear
58
+ #
59
+ # @api private
60
+ # @since 0.1.0
61
+ def delete
62
+ @collection.delete
63
+ rescue Sequel::DatabaseError => e
64
+ raise Hanami::Model::Error.new(e.message)
65
+ end
66
+
67
+ alias_method :clear, :delete
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+
@@ -0,0 +1,33 @@
1
+ module Hanami
2
+ module Model
3
+ module Adapters
4
+ module Sql
5
+ class Console
6
+ extend Forwardable
7
+
8
+ def_delegator :console, :connection_string
9
+
10
+ def initialize(uri)
11
+ @uri = URI.parse(uri)
12
+ end
13
+
14
+ private
15
+
16
+ def console
17
+ case @uri.scheme
18
+ when 'sqlite'
19
+ require 'hanami/model/adapters/sql/consoles/sqlite'
20
+ Consoles::Sqlite.new(@uri)
21
+ when 'postgres'
22
+ require 'hanami/model/adapters/sql/consoles/postgresql'
23
+ Consoles::Postgresql.new(@uri)
24
+ when 'mysql', 'mysql2'
25
+ require 'hanami/model/adapters/sql/consoles/mysql'
26
+ Consoles::Mysql.new(@uri)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,49 @@
1
+ require 'shellwords'
2
+ module Hanami
3
+ module Model
4
+ module Adapters
5
+ module Sql
6
+ module Consoles
7
+ class Mysql
8
+ def initialize(uri)
9
+ @uri = uri
10
+ end
11
+
12
+ def connection_string
13
+ str = 'mysql'
14
+ str << host
15
+ str << database
16
+ str << port if port
17
+ str << username if username
18
+ str << password if password
19
+ str
20
+ end
21
+
22
+ private
23
+
24
+ def host
25
+ " -h #{@uri.host}"
26
+ end
27
+
28
+ def database
29
+ " -D #{@uri.path.sub(/^\//, '')}"
30
+ end
31
+
32
+ def port
33
+ " -P #{@uri.port}" if @uri.port
34
+ end
35
+
36
+ def username
37
+ " -u #{@uri.user}" if @uri.user
38
+ end
39
+
40
+ def password
41
+ " -p #{@uri.password}" if @uri.password
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+
@@ -0,0 +1,48 @@
1
+ require 'shellwords'
2
+ module Hanami
3
+ module Model
4
+ module Adapters
5
+ module Sql
6
+ module Consoles
7
+ class Postgresql
8
+ def initialize(uri)
9
+ @uri = uri
10
+ end
11
+
12
+ def connection_string
13
+ configure_password
14
+ str = 'psql'
15
+ str << host
16
+ str << database
17
+ str << port if port
18
+ str << username if username
19
+ str
20
+ end
21
+
22
+ private
23
+
24
+ def host
25
+ " -h #{@uri.host}"
26
+ end
27
+
28
+ def database
29
+ " -d #{@uri.path.sub(/^\//, '')}"
30
+ end
31
+
32
+ def port
33
+ " -p #{@uri.port}" if @uri.port
34
+ end
35
+
36
+ def username
37
+ " -U #{@uri.user}" if @uri.user
38
+ end
39
+
40
+ def configure_password
41
+ ENV['PGPASSWORD'] = @uri.password if @uri.password
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,26 @@
1
+ require 'shellwords'
2
+ module Hanami
3
+ module Model
4
+ module Adapters
5
+ module Sql
6
+ module Consoles
7
+ class Sqlite
8
+ def initialize(uri)
9
+ @uri = uri
10
+ end
11
+
12
+ def connection_string
13
+ "sqlite3 #{@uri.host}#{database}"
14
+ end
15
+
16
+ private
17
+
18
+ def database
19
+ Shellwords.escape(@uri.path)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,788 @@
1
+ require 'forwardable'
2
+ require 'hanami/utils/kernel'
3
+
4
+ module Hanami
5
+ module Model
6
+ module Adapters
7
+ module Sql
8
+ # Query the database with a powerful API.
9
+ #
10
+ # All the methods are chainable, it allows advanced composition of
11
+ # SQL 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: 'hanami')
20
+ # .reverse_order(: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
+ # Define negations for operators.
30
+ #
31
+ # @see Hanami::Model::Adapters::Sql::Query#negate!
32
+ #
33
+ # @api private
34
+ # @since 0.1.0
35
+ OPERATORS_MAPPING = {
36
+ where: :exclude,
37
+ exclude: :where
38
+ }.freeze
39
+
40
+ include Enumerable
41
+ extend Forwardable
42
+
43
+ def_delegators :all, :each, :to_s, :empty?
44
+
45
+ # @attr_reader conditions [Array] an accumulator for the called
46
+ # methods
47
+ #
48
+ # @since 0.1.0
49
+ # @api private
50
+ attr_reader :conditions
51
+
52
+ # Initialize a query
53
+ #
54
+ # @param collection [Hanami::Model::Adapters::Sql::Collection] the
55
+ # collection to query
56
+ #
57
+ # @param blk [Proc] an optional block that gets yielded in the
58
+ # context of the current query
59
+ #
60
+ # @return [Hanami::Model::Adapters::Sql::Query]
61
+ def initialize(collection, context = nil, &blk)
62
+ @collection, @context = collection, context
63
+ @conditions = []
64
+
65
+ instance_eval(&blk) if block_given?
66
+ end
67
+
68
+ # Resolves the query by fetching records from the database and
69
+ # translating them into entities.
70
+ #
71
+ # @return [Array] a collection of entities
72
+ #
73
+ # @raise [Hanami::Model::InvalidQueryError] if there is some issue when
74
+ # hitting the database for fetching records
75
+ #
76
+ # @since 0.1.0
77
+ def all
78
+ run.to_a
79
+ rescue Sequel::DatabaseError => e
80
+ raise Hanami::Model::InvalidQueryError.new(e.message)
81
+ end
82
+
83
+ # Adds a SQL `WHERE` condition.
84
+ #
85
+ # It accepts a `Hash` with only one pair.
86
+ # The key must be the name of the column expressed as a `Symbol`.
87
+ # The value is the one used by the SQL query
88
+ #
89
+ # @param condition [Hash]
90
+ #
91
+ # @return self
92
+ #
93
+ # @since 0.1.0
94
+ #
95
+ # @example Fixed value
96
+ #
97
+ # query.where(language: 'ruby')
98
+ #
99
+ # # => SELECT * FROM `projects` WHERE (`language` = 'ruby')
100
+ #
101
+ # @example Array
102
+ #
103
+ # query.where(id: [1, 3])
104
+ #
105
+ # # => SELECT * FROM `articles` WHERE (`id` IN (1, 3))
106
+ #
107
+ # @example Range
108
+ #
109
+ # query.where(year: 1900..1982)
110
+ #
111
+ # # => SELECT * FROM `people` WHERE ((`year` >= 1900) AND (`year` <= 1982))
112
+ #
113
+ # @example Multiple conditions
114
+ #
115
+ # query.where(language: 'ruby')
116
+ # .where(framework: 'hanami')
117
+ #
118
+ # # => SELECT * FROM `projects` WHERE (`language` = 'ruby') AND (`framework` = 'hanami')
119
+ #
120
+ # @example Expressions
121
+ #
122
+ # query.where{ age > 10 }
123
+ #
124
+ # # => SELECT * FROM `users` WHERE (`age` > 31)
125
+ def where(condition = nil, &blk)
126
+ _push_to_conditions(:where, condition || blk)
127
+ self
128
+ end
129
+
130
+ alias_method :and, :where
131
+
132
+ # Adds a SQL `OR` condition.
133
+ #
134
+ # It accepts a `Hash` with only one pair.
135
+ # The key must be the name of the column expressed as a `Symbol`.
136
+ # The value is the one used by the SQL query
137
+ #
138
+ # This condition will be ignored if not used with WHERE.
139
+ #
140
+ # @param condition [Hash]
141
+ #
142
+ # @return self
143
+ #
144
+ # @since 0.1.0
145
+ #
146
+ # @example Fixed value
147
+ #
148
+ # query.where(language: 'ruby').or(framework: 'hanami')
149
+ #
150
+ # # => SELECT * FROM `projects` WHERE ((`language` = 'ruby') OR (`framework` = 'hanami'))
151
+ #
152
+ # @example Array
153
+ #
154
+ # query.where(id: 1).or(author_id: [15, 23])
155
+ #
156
+ # # => SELECT * FROM `articles` WHERE ((`id` = 1) OR (`author_id` IN (15, 23)))
157
+ #
158
+ # @example Range
159
+ #
160
+ # query.where(country: 'italy').or(year: 1900..1982)
161
+ #
162
+ # # => SELECT * FROM `people` WHERE ((`country` = 'italy') OR ((`year` >= 1900) AND (`year` <= 1982)))
163
+ #
164
+ # @example Expressions
165
+ #
166
+ # query.where(name: 'John').or{ age > 31 }
167
+ #
168
+ # # => SELECT * FROM `users` WHERE ((`name` = 'John') OR (`age` < 32))
169
+ def or(condition = nil, &blk)
170
+ _push_to_conditions(:or, condition || blk)
171
+ self
172
+ end
173
+
174
+ # Logical negation of a WHERE condition.
175
+ #
176
+ # It accepts a `Hash` with only one pair.
177
+ # The key must be the name of the column expressed as a `Symbol`.
178
+ # The value is the one used by the SQL query
179
+ #
180
+ # @param condition [Hash]
181
+ #
182
+ # @since 0.1.0
183
+ #
184
+ # @return self
185
+ #
186
+ # @example Fixed value
187
+ #
188
+ # query.exclude(language: 'java')
189
+ #
190
+ # # => SELECT * FROM `projects` WHERE (`language` != 'java')
191
+ #
192
+ # @example Array
193
+ #
194
+ # query.exclude(id: [4, 9])
195
+ #
196
+ # # => SELECT * FROM `articles` WHERE (`id` NOT IN (1, 3))
197
+ #
198
+ # @example Range
199
+ #
200
+ # query.exclude(year: 1900..1982)
201
+ #
202
+ # # => SELECT * FROM `people` WHERE ((`year` < 1900) AND (`year` > 1982))
203
+ #
204
+ # @example Multiple conditions
205
+ #
206
+ # query.exclude(language: 'java')
207
+ # .exclude(company: 'enterprise')
208
+ #
209
+ # # => SELECT * FROM `projects` WHERE (`language` != 'java') AND (`company` != 'enterprise')
210
+ #
211
+ # @example Expressions
212
+ #
213
+ # query.exclude{ age > 31 }
214
+ #
215
+ # # => SELECT * FROM `users` WHERE (`age` <= 31)
216
+ def exclude(condition = nil, &blk)
217
+ _push_to_conditions(:exclude, condition || blk)
218
+ self
219
+ end
220
+
221
+ alias_method :not, :exclude
222
+
223
+ # Select only the specified columns.
224
+ #
225
+ # By default a query selects all the columns of a table (`SELECT *`).
226
+ #
227
+ # @param columns [Array<Symbol>]
228
+ #
229
+ # @return self
230
+ #
231
+ # @since 0.1.0
232
+ #
233
+ # @example Single column
234
+ #
235
+ # query.select(:name)
236
+ #
237
+ # # => SELECT `name` FROM `people`
238
+ #
239
+ # @example Multiple columns
240
+ #
241
+ # query.select(:name, :year)
242
+ #
243
+ # # => SELECT `name`, `year` FROM `people`
244
+ def select(*columns)
245
+ conditions.push([:select, *columns])
246
+ self
247
+ end
248
+
249
+ # Limit the number of records to return.
250
+ #
251
+ # This operation is performed at the database level with `LIMIT`.
252
+ #
253
+ # @param number [Fixnum]
254
+ #
255
+ # @return self
256
+ #
257
+ # @since 0.1.0
258
+ #
259
+ # @example
260
+ #
261
+ # query.limit(1)
262
+ #
263
+ # # => SELECT * FROM `people` LIMIT 1
264
+ def limit(number)
265
+ conditions.push([:limit, number])
266
+ self
267
+ end
268
+
269
+ # Specify an `OFFSET` clause.
270
+ #
271
+ # Due to SQL syntax restriction, offset MUST be used with `#limit`.
272
+ #
273
+ # @param number [Fixnum]
274
+ #
275
+ # @return self
276
+ #
277
+ # @since 0.1.0
278
+ #
279
+ # @see Hanami::Model::Adapters::Sql::Query#limit
280
+ #
281
+ # @example
282
+ #
283
+ # query.limit(1).offset(10)
284
+ #
285
+ # # => SELECT * FROM `people` LIMIT 1 OFFSET 10
286
+ def offset(number)
287
+ conditions.push([:offset, number])
288
+ self
289
+ end
290
+
291
+ # Specify the ascending order of the records, sorted by the given
292
+ # columns.
293
+ #
294
+ # @param columns [Array<Symbol>] the column names
295
+ #
296
+ # @return self
297
+ #
298
+ # @since 0.1.0
299
+ #
300
+ # @see Hanami::Model::Adapters::Sql::Query#reverse_order
301
+ #
302
+ # @example Single column
303
+ #
304
+ # query.order(:name)
305
+ #
306
+ # # => SELECT * FROM `people` ORDER BY (`name`)
307
+ #
308
+ # @example Multiple columns
309
+ #
310
+ # query.order(:name, :year)
311
+ #
312
+ # # => SELECT * FROM `people` ORDER BY `name`, `year`
313
+ #
314
+ # @example Multiple invokations
315
+ #
316
+ # query.order(:name).order(:year)
317
+ #
318
+ # # => SELECT * FROM `people` ORDER BY `name`, `year`
319
+ def order(*columns)
320
+ conditions.push([_order_operator, *columns])
321
+ self
322
+ end
323
+
324
+ # Alias for order
325
+ #
326
+ # @since 0.1.0
327
+ #
328
+ # @see Hanami::Model::Adapters::Sql::Query#order
329
+ #
330
+ # @example Single column
331
+ #
332
+ # query.asc(:name)
333
+ #
334
+ # # => SELECT * FROM `people` ORDER BY (`name`)
335
+ #
336
+ # @example Multiple columns
337
+ #
338
+ # query.asc(:name, :year)
339
+ #
340
+ # # => SELECT * FROM `people` ORDER BY `name`, `year`
341
+ #
342
+ # @example Multiple invokations
343
+ #
344
+ # query.asc(:name).asc(:year)
345
+ #
346
+ # # => SELECT * FROM `people` ORDER BY `name`, `year`
347
+ alias_method :asc, :order
348
+
349
+ # Specify the descending order of the records, sorted by the given
350
+ # columns.
351
+ #
352
+ # @param columns [Array<Symbol>] the column names
353
+ #
354
+ # @return self
355
+ #
356
+ # @since 0.3.1
357
+ #
358
+ # @see Hanami::Model::Adapters::Sql::Query#order
359
+ #
360
+ # @example Single column
361
+ #
362
+ # query.reverse_order(:name)
363
+ #
364
+ # # => SELECT * FROM `people` ORDER BY (`name`) DESC
365
+ #
366
+ # @example Multiple columns
367
+ #
368
+ # query.reverse_order(:name, :year)
369
+ #
370
+ # # => SELECT * FROM `people` ORDER BY `name`, `year` DESC
371
+ #
372
+ # @example Multiple invokations
373
+ #
374
+ # query.reverse_order(:name).reverse_order(:year)
375
+ #
376
+ # # => SELECT * FROM `people` ORDER BY `name`, `year` DESC
377
+ def reverse_order(*columns)
378
+ Array(columns).each do |column|
379
+ conditions.push([_order_operator, Sequel.desc(column)])
380
+ end
381
+
382
+ self
383
+ end
384
+
385
+ # Alias for reverse_order
386
+ #
387
+ # @since 0.1.0
388
+ #
389
+ # @see Hanami::Model::Adapters::Sql::Query#reverse_order
390
+ #
391
+ # @example Single column
392
+ #
393
+ # query.desc(:name)
394
+ #
395
+ # @example Multiple columns
396
+ #
397
+ # query.desc(:name, :year)
398
+ #
399
+ # @example Multiple invokations
400
+ #
401
+ # query.desc(:name).desc(:year)
402
+ alias_method :desc, :reverse_order
403
+
404
+ # Group by the specified columns.
405
+ #
406
+ # @param columns [Array<Symbol>]
407
+ #
408
+ # @return self
409
+ #
410
+ # @since 0.5.0
411
+ #
412
+ # @example Single column
413
+ #
414
+ # query.group(:name)
415
+ #
416
+ # # => SELECT * FROM `people` GROUP BY `name`
417
+ #
418
+ # @example Multiple columns
419
+ #
420
+ # query.group(:name, :year)
421
+ #
422
+ # # => SELECT * FROM `people` GROUP BY `name`, `year`
423
+ def group(*columns)
424
+ conditions.push([:group, *columns])
425
+ self
426
+ end
427
+
428
+ # Returns the sum of the values for the given column.
429
+ #
430
+ # @param column [Symbol] the column name
431
+ #
432
+ # @return [Numeric]
433
+ #
434
+ # @since 0.1.0
435
+ #
436
+ # @example
437
+ #
438
+ # query.sum(:comments_count)
439
+ #
440
+ # # => SELECT SUM(`comments_count`) FROM articles
441
+ def sum(column)
442
+ run.sum(column)
443
+ end
444
+
445
+ # Returns the average of the values for the given column.
446
+ #
447
+ # @param column [Symbol] the column name
448
+ #
449
+ # @return [Numeric]
450
+ #
451
+ # @since 0.1.0
452
+ #
453
+ # @example
454
+ #
455
+ # query.average(:comments_count)
456
+ #
457
+ # # => SELECT AVG(`comments_count`) FROM articles
458
+ def average(column)
459
+ run.avg(column)
460
+ end
461
+
462
+ alias_method :avg, :average
463
+
464
+ # Returns the maximum value for the given column.
465
+ #
466
+ # @param column [Symbol] the column name
467
+ #
468
+ # @return result
469
+ #
470
+ # @since 0.1.0
471
+ #
472
+ # @example With numeric type
473
+ #
474
+ # query.max(:comments_count)
475
+ #
476
+ # # => SELECT MAX(`comments_count`) FROM articles
477
+ #
478
+ # @example With string type
479
+ #
480
+ # query.max(:title)
481
+ #
482
+ # # => SELECT MAX(`title`) FROM articles
483
+ def max(column)
484
+ run.max(column)
485
+ end
486
+
487
+ # Returns the minimum value for the given column.
488
+ #
489
+ # @param column [Symbol] the column name
490
+ #
491
+ # @return result
492
+ #
493
+ # @since 0.1.0
494
+ #
495
+ # @example With numeric type
496
+ #
497
+ # query.min(:comments_count)
498
+ #
499
+ # # => SELECT MIN(`comments_count`) FROM articles
500
+ #
501
+ # @example With string type
502
+ #
503
+ # query.min(:title)
504
+ #
505
+ # # => SELECT MIN(`title`) FROM articles
506
+ def min(column)
507
+ run.min(column)
508
+ end
509
+
510
+ # Returns the difference between the MAX and MIN for the given column.
511
+ #
512
+ # @param column [Symbol] the column name
513
+ #
514
+ # @return [Numeric]
515
+ #
516
+ # @since 0.1.0
517
+ #
518
+ # @see Hanami::Model::Adapters::Sql::Query#max
519
+ # @see Hanami::Model::Adapters::Sql::Query#min
520
+ #
521
+ # @example
522
+ #
523
+ # query.interval(:comments_count)
524
+ #
525
+ # # => SELECT (MAX(`comments_count`) - MIN(`comments_count`)) FROM articles
526
+ def interval(column)
527
+ run.interval(column)
528
+ end
529
+
530
+ # Returns a range of values between the MAX and the MIN for the given
531
+ # column.
532
+ #
533
+ # @param column [Symbol] the column name
534
+ #
535
+ # @return [Range]
536
+ #
537
+ # @since 0.1.0
538
+ #
539
+ # @see Hanami::Model::Adapters::Sql::Query#max
540
+ # @see Hanami::Model::Adapters::Sql::Query#min
541
+ #
542
+ # @example
543
+ #
544
+ # query.range(:comments_count)
545
+ #
546
+ # # => SELECT MAX(`comments_count`) AS v1, MIN(`comments_count`) AS v2 FROM articles
547
+ def range(column)
548
+ run.range(column)
549
+ end
550
+
551
+ # Checks if at least one record exists for the current conditions.
552
+ #
553
+ # @return [TrueClass,FalseClass]
554
+ #
555
+ # @since 0.1.0
556
+ #
557
+ # @example
558
+ #
559
+ # query.where(author_id: 23).exists? # => true
560
+ def exist?
561
+ !count.zero?
562
+ end
563
+
564
+ # Returns a count of the records for the current conditions.
565
+ #
566
+ # @return [Fixnum]
567
+ #
568
+ # @since 0.1.0
569
+ #
570
+ # @example
571
+ #
572
+ # query.where(author_id: 23).count # => 5
573
+ def count
574
+ run.count
575
+ end
576
+
577
+ # Negates the current where/exclude conditions with the logical
578
+ # opposite operator.
579
+ #
580
+ # All the other conditions will be ignored.
581
+ #
582
+ # @since 0.1.0
583
+ #
584
+ # @see Hanami::Model::Adapters::Sql::Query#where
585
+ # @see Hanami::Model::Adapters::Sql::Query#exclude
586
+ # @see Hanami::Repository#exclude
587
+ #
588
+ # @example
589
+ #
590
+ # query.where(language: 'java').negate!.all
591
+ #
592
+ # # => SELECT * FROM `projects` WHERE (`language` != 'java')
593
+ def negate!
594
+ conditions.map! do |(operator, condition)|
595
+ [OPERATORS_MAPPING.fetch(operator) { operator }, condition]
596
+ end
597
+ end
598
+
599
+ # Apply all the conditions and returns a filtered collection.
600
+ #
601
+ # This operation is idempotent, and the returned result didn't
602
+ # fetched the records yet.
603
+ #
604
+ # @return [Hanami::Model::Adapters::Sql::Collection]
605
+ #
606
+ # @since 0.1.0
607
+ def scoped
608
+ scope = @collection
609
+
610
+ conditions.each do |(method,*args)|
611
+ scope = scope.public_send(method, *args)
612
+ end
613
+
614
+ scope
615
+ end
616
+
617
+ alias_method :run, :scoped
618
+
619
+ # Specify an `INNER JOIN` clause.
620
+ #
621
+ # @param collection [String]
622
+ # @param options [Hash]
623
+ # @option key [Symbol] the key
624
+ # @option foreign_key [Symbol] the foreign key
625
+ #
626
+ # @return self
627
+ #
628
+ # @since 0.5.0
629
+ #
630
+ # @example
631
+ #
632
+ # query.join(:users)
633
+ #
634
+ # # => SELECT * FROM `posts` INNER JOIN `users` ON `posts`.`user_id` = `users`.`id`
635
+ def join(collection, options = {})
636
+ _join(collection, options.merge(join: :inner))
637
+ end
638
+
639
+ alias_method :inner_join, :join
640
+
641
+ # Specify a `LEFT JOIN` clause.
642
+ #
643
+ # @param collection [String]
644
+ # @param options [Hash]
645
+ # @option key [Symbol] the key
646
+ # @option foreign_key [Symbol] the foreign key
647
+ #
648
+ # @return self
649
+ #
650
+ # @since 0.5.0
651
+ #
652
+ # @example
653
+ #
654
+ # query.left_join(:users)
655
+ #
656
+ # # => SELECT * FROM `posts` LEFT JOIN `users` ON `posts`.`user_id` = `users`.`id`
657
+ def left_join(collection, options = {})
658
+ _join(collection, options.merge(join: :left))
659
+ end
660
+
661
+ alias_method :left_outer_join, :left_join
662
+
663
+ protected
664
+ # Handles missing methods for query combinations
665
+ #
666
+ # @api private
667
+ # @since 0.1.0
668
+ #
669
+ # @see Hanami::Model::Adapters:Sql::Query#apply
670
+ def method_missing(m, *args, &blk)
671
+ if @context.respond_to?(m)
672
+ apply @context.public_send(m, *args, &blk)
673
+ else
674
+ super
675
+ end
676
+ end
677
+
678
+ private
679
+
680
+ # Specify a JOIN clause. (inner or left)
681
+ #
682
+ # @param collection [String]
683
+ # @param options [Hash]
684
+ # @option key [Symbol] the key
685
+ # @option foreign_key [Symbol] the foreign key
686
+ # @option join [Symbol] the join type
687
+ #
688
+ # @return self
689
+ #
690
+ # @api private
691
+ # @since 0.5.0
692
+ def _join(collection, options = {})
693
+ collection_name = Utils::String.new(collection).singularize
694
+
695
+ foreign_key = options.fetch(:foreign_key) { "#{ @collection.table_name }__#{ collection_name }_id".to_sym }
696
+ key = options.fetch(:key) { @collection.identity.to_sym }
697
+
698
+ conditions.push([:select_all])
699
+ conditions.push([:join_table, options.fetch(:join, :inner), collection, key => foreign_key])
700
+
701
+ self
702
+ end
703
+
704
+ # Returns a new query that is the result of the merge of the current
705
+ # conditions with the ones of the given query.
706
+ #
707
+ # This is used to combine queries together in a Repository.
708
+ #
709
+ # @param query [Hanami::Model::Adapters::Sql::Query] the query to apply
710
+ #
711
+ # @return [Hanami::Model::Adapters::Sql::Query] a new query with the
712
+ # merged conditions
713
+ #
714
+ # @api private
715
+ # @since 0.1.0
716
+ #
717
+ # @example
718
+ # require 'hanami/model'
719
+ #
720
+ # class ArticleRepository
721
+ # include Hanami::Repository
722
+ #
723
+ # def self.by_author(author)
724
+ # query do
725
+ # where(author_id: author.id)
726
+ # end
727
+ # end
728
+ #
729
+ # def self.rank
730
+ # query.reverse_order(:comments_count)
731
+ # end
732
+ #
733
+ # def self.rank_by_author(author)
734
+ # rank.by_author(author)
735
+ # end
736
+ # end
737
+ #
738
+ # # The code above combines two queries: `rank` and `by_author`.
739
+ # #
740
+ # # The first class method `rank` returns a `Sql::Query` instance
741
+ # # which doesn't respond to `by_author`. How to solve this problem?
742
+ # #
743
+ # # 1. When we use `query` to fabricate a `Sql::Query` we pass the
744
+ # # current context (the repository itself) to the query initializer.
745
+ # #
746
+ # # 2. When that query receives the `by_author` message, it's captured
747
+ # # by `method_missing` and dispatched to the repository.
748
+ # #
749
+ # # 3. The class method `by_author` returns a query too.
750
+ # #
751
+ # # 4. We just return a new query that is the result of the current
752
+ # # query's conditions (`rank`) and of the conditions from `by_author`.
753
+ # #
754
+ # # You're welcome ;)
755
+ def apply(query)
756
+ dup.tap do |result|
757
+ result.conditions.push(*query.conditions)
758
+ end
759
+ end
760
+
761
+ # Stores a query condition of a specified type in the conditions array.
762
+ #
763
+ # @param condition_type [Symbol] the condition type. (eg. `:where`, `:or`)
764
+ # @param condition [Hash, Proc] the query condition to be stored.
765
+ #
766
+ # @return [Array<Array>] the conditions array itself.
767
+ #
768
+ # @raise [ArgumentError] if condition is not specified.
769
+ #
770
+ # @api private
771
+ # @since 0.3.1
772
+ def _push_to_conditions(condition_type, condition)
773
+ raise ArgumentError.new('You need to specify a condition.') if condition.nil?
774
+ conditions.push([condition_type, condition])
775
+ end
776
+
777
+ def _order_operator
778
+ if conditions.any? {|c, _| c == :order }
779
+ :order_more
780
+ else
781
+ :order
782
+ end
783
+ end
784
+ end
785
+ end
786
+ end
787
+ end
788
+ end