pakyow-data 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +0 -0
  3. data/LICENSE +4 -0
  4. data/README.md +29 -0
  5. data/lib/pakyow/data/adapters/abstract.rb +58 -0
  6. data/lib/pakyow/data/adapters/sql/commands.rb +58 -0
  7. data/lib/pakyow/data/adapters/sql/dataset_methods.rb +29 -0
  8. data/lib/pakyow/data/adapters/sql/differ.rb +76 -0
  9. data/lib/pakyow/data/adapters/sql/migrator/adapter_methods.rb +95 -0
  10. data/lib/pakyow/data/adapters/sql/migrator.rb +181 -0
  11. data/lib/pakyow/data/adapters/sql/migrators/automator.rb +49 -0
  12. data/lib/pakyow/data/adapters/sql/migrators/finalizer.rb +96 -0
  13. data/lib/pakyow/data/adapters/sql/runner.rb +49 -0
  14. data/lib/pakyow/data/adapters/sql/source_extension.rb +31 -0
  15. data/lib/pakyow/data/adapters/sql/types.rb +50 -0
  16. data/lib/pakyow/data/adapters/sql.rb +247 -0
  17. data/lib/pakyow/data/behavior/config.rb +28 -0
  18. data/lib/pakyow/data/behavior/lookup.rb +75 -0
  19. data/lib/pakyow/data/behavior/serialization.rb +40 -0
  20. data/lib/pakyow/data/connection.rb +103 -0
  21. data/lib/pakyow/data/container.rb +273 -0
  22. data/lib/pakyow/data/errors.rb +169 -0
  23. data/lib/pakyow/data/framework.rb +42 -0
  24. data/lib/pakyow/data/helpers.rb +11 -0
  25. data/lib/pakyow/data/lookup.rb +85 -0
  26. data/lib/pakyow/data/migrator.rb +182 -0
  27. data/lib/pakyow/data/object.rb +98 -0
  28. data/lib/pakyow/data/proxy.rb +262 -0
  29. data/lib/pakyow/data/result.rb +53 -0
  30. data/lib/pakyow/data/sources/abstract.rb +82 -0
  31. data/lib/pakyow/data/sources/ephemeral.rb +72 -0
  32. data/lib/pakyow/data/sources/relational/association.rb +43 -0
  33. data/lib/pakyow/data/sources/relational/associations/belongs_to.rb +47 -0
  34. data/lib/pakyow/data/sources/relational/associations/has_many.rb +54 -0
  35. data/lib/pakyow/data/sources/relational/associations/has_one.rb +54 -0
  36. data/lib/pakyow/data/sources/relational/associations/through.rb +67 -0
  37. data/lib/pakyow/data/sources/relational/command.rb +531 -0
  38. data/lib/pakyow/data/sources/relational/migrator.rb +101 -0
  39. data/lib/pakyow/data/sources/relational.rb +587 -0
  40. data/lib/pakyow/data/subscribers/adapters/memory.rb +153 -0
  41. data/lib/pakyow/data/subscribers/adapters/redis/pipeliner.rb +45 -0
  42. data/lib/pakyow/data/subscribers/adapters/redis/scripts/_shared.lua +73 -0
  43. data/lib/pakyow/data/subscribers/adapters/redis/scripts/expire.lua +16 -0
  44. data/lib/pakyow/data/subscribers/adapters/redis/scripts/persist.lua +15 -0
  45. data/lib/pakyow/data/subscribers/adapters/redis/scripts/register.lua +37 -0
  46. data/lib/pakyow/data/subscribers/adapters/redis.rb +209 -0
  47. data/lib/pakyow/data/subscribers.rb +148 -0
  48. data/lib/pakyow/data/tasks/bootstrap.rake +18 -0
  49. data/lib/pakyow/data/tasks/create.rake +22 -0
  50. data/lib/pakyow/data/tasks/drop.rake +32 -0
  51. data/lib/pakyow/data/tasks/finalize.rake +56 -0
  52. data/lib/pakyow/data/tasks/migrate.rake +24 -0
  53. data/lib/pakyow/data/tasks/reset.rake +18 -0
  54. data/lib/pakyow/data/types.rb +37 -0
  55. data/lib/pakyow/data.rb +27 -0
  56. data/lib/pakyow/environment/data/auto_migrate.rb +31 -0
  57. data/lib/pakyow/environment/data/config.rb +54 -0
  58. data/lib/pakyow/environment/data/connections.rb +76 -0
  59. data/lib/pakyow/environment/data/memory_db.rb +23 -0
  60. data/lib/pakyow/validations/unique.rb +26 -0
  61. metadata +186 -0
@@ -0,0 +1,587 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/makeable"
4
+ require "pakyow/support/class_state"
5
+ require "pakyow/support/inflector"
6
+
7
+ require "pakyow/data/sources/abstract"
8
+
9
+ module Pakyow
10
+ module Data
11
+ module Sources
12
+ # A relational data source through which you interact with a persistence
13
+ # layer such as a sql database, redis, or http. Defines the schema, queries,
14
+ # and other adapter-specific metadata (e.g. sql table).
15
+ #
16
+ # Each adapter provides its own interface for interacting with the underlying
17
+ # persistence layer. For example, the sql adapter exposes +Sequel::Dataset+
18
+ # provided by the *fantastic* Sequel gem.
19
+ #
20
+ # In normal use, the underlying dataset is inaccessible from outside of the
21
+ # source. Instead, access to the dataset occurs through queries defined on
22
+ # the source that interact with the dataset and return a result.
23
+ #
24
+ # Results are always returned as a new source instance (or when used from
25
+ # the app, a {Pakyow::Data::Proxy} object). Access to the underlying value
26
+ # is provided through methods such as +one+, +to_a+, and +each+.
27
+ # (@see Pakyow::Data::Container#wrap_defined_queries!)
28
+ #
29
+ # Mutations occur through commands. Commands do not implement validation
30
+ # other than checking for required attributes and checking that the given
31
+ # attributes are defined on the source. Use the input verifier pattern to
32
+ # verify and validate input before passing it to a command
33
+ # (@see Pakyow::Verifier).
34
+ #
35
+ # @example
36
+ # source :posts, adapter: :sql, connection: :default do
37
+ # table :posts
38
+ #
39
+ # primary_id
40
+ # timestamps
41
+ #
42
+ # attribute :title, :string
43
+ #
44
+ # command :create do |params|
45
+ # insert(params)
46
+ # end
47
+ #
48
+ # def by_id(id)
49
+ # where(id: id)
50
+ # end
51
+ # end
52
+ #
53
+ # data.posts.create(title: "foo")
54
+ # data.posts.by_id(1).first
55
+ # => #<Pakyow::Data::Object @values={:id => 1, :title => "foo", :created_at => "2018-11-30 10:55:05 -0800", :updated_at => "2018-11-30 10:55:05 -0800"}>
56
+ #
57
+ class Relational < Sources::Abstract
58
+ require "pakyow/data/sources/relational/associations/belongs_to"
59
+ require "pakyow/data/sources/relational/associations/has_many"
60
+ require "pakyow/data/sources/relational/associations/has_one"
61
+ require "pakyow/data/sources/relational/associations/through"
62
+
63
+ require "pakyow/data/sources/relational/command"
64
+ require "pakyow/data/sources/relational/migrator"
65
+
66
+ # @api private
67
+ attr_reader :included
68
+
69
+ def initialize(*)
70
+ super
71
+
72
+ @wrap_as = self.class.singular_name
73
+ @included = []
74
+
75
+ if default_query = self.class.__default_query
76
+ result = if default_query.is_a?(Proc)
77
+ instance_exec(&default_query)
78
+ else
79
+ public_send(self.class.__default_query)
80
+ end
81
+
82
+ result = case result
83
+ when self.class
84
+ result.__getobj__
85
+ else
86
+ result
87
+ end
88
+
89
+ __setobj__(result)
90
+ end
91
+ end
92
+
93
+ def including(association_name, &block)
94
+ tap do
95
+ association_name = association_name.to_sym
96
+
97
+ association_to_include = self.class.associations.values.flatten.find { |association|
98
+ association.name == association_name
99
+ } || raise(UnknownAssociation.new("unknown association `#{association_name}'").tap { |error| error.context = self.class })
100
+
101
+ included_source = association_to_include.associated_source.instance
102
+
103
+ if association_to_include.query
104
+ included_source = included_source.send(association_to_include.query)
105
+ end
106
+
107
+ final_source = if block_given?
108
+ included_source.instance_exec(&block) || included_source
109
+ else
110
+ included_source
111
+ end
112
+
113
+ @included << [association_to_include, final_source]
114
+ end
115
+ end
116
+
117
+ def as(object)
118
+ tap do
119
+ @wrap_as = object
120
+ end
121
+ end
122
+
123
+ def limit(count)
124
+ __setobj__(__getobj__.limit(count)); self
125
+ end
126
+
127
+ def order(*ordering)
128
+ __setobj__(
129
+ __getobj__.order(
130
+ *ordering.flat_map { |order|
131
+ case order
132
+ when Array
133
+ Sequel.public_send(order[1].to_sym, order[0].to_sym)
134
+ when Hash
135
+ order.each_pair.map { |key, value|
136
+ Sequel.public_send(value.to_sym, key.to_sym)
137
+ }
138
+ else
139
+ Sequel.asc(order.to_s.to_sym)
140
+ end
141
+ }
142
+ )
143
+ ); self
144
+ end
145
+
146
+ def to_a
147
+ return @results if instance_variable_defined?(:@results)
148
+ @results = self.class.to_a(__getobj__)
149
+ include_results!(@results)
150
+ @results.map! { |result|
151
+ finalize(result)
152
+ }
153
+ end
154
+ alias all to_a
155
+
156
+ def one
157
+ return @results.first if instance_variable_defined?(:@results)
158
+ return @result if instance_variable_defined?(:@result)
159
+
160
+ if result = self.class.one(__getobj__)
161
+ include_results!([result])
162
+ @result = finalize(result)
163
+ else
164
+ nil
165
+ end
166
+ end
167
+
168
+ def transaction(&block)
169
+ self.class.container.connection.transaction(&block)
170
+ end
171
+
172
+ def transaction?
173
+ self.class.container.connection.adapter.connection.in_transaction?
174
+ end
175
+
176
+ def on_commit(&block)
177
+ self.class.container.connection.adapter.connection.after_commit(&block)
178
+ end
179
+
180
+ def on_rollback(&block)
181
+ self.class.container.connection.adapter.connection.after_rollback(&block)
182
+ end
183
+
184
+ def command(command_name)
185
+ if command = self.class.commands[command_name]
186
+ Command.new(
187
+ command_name,
188
+ block: command[:block],
189
+ source: self,
190
+ provides_dataset: command[:provides_dataset],
191
+ performs_create: command[:performs_create],
192
+ performs_update: command[:performs_update],
193
+ performs_delete: command[:performs_delete]
194
+ )
195
+ else
196
+ raise(
197
+ UnknownCommand.new_with_message(command: command_name).tap do |error|
198
+ error.context = self.class
199
+ end
200
+ )
201
+ end
202
+ end
203
+
204
+ def count
205
+ if self.class.respond_to?(:count)
206
+ self.class.count(__getobj__)
207
+ else
208
+ super
209
+ end
210
+ end
211
+
212
+ # @api private
213
+ IVARS_TO_RELOAD = %i(
214
+ @results @result
215
+ )
216
+
217
+ def reload
218
+ IVARS_TO_RELOAD.select { |ivar|
219
+ instance_variable_defined?(ivar)
220
+ }.each do |ivar|
221
+ remove_instance_variable(ivar)
222
+ end
223
+
224
+ self
225
+ end
226
+
227
+ def to_json(*)
228
+ to_a.to_json
229
+ end
230
+
231
+ # @api private
232
+ def source_name
233
+ self.class.__object_name.name
234
+ end
235
+
236
+ # @api private
237
+ def command?(maybe_command_name)
238
+ self.class.commands.include?(maybe_command_name)
239
+ end
240
+
241
+ # @api private
242
+ def query?(maybe_query_name)
243
+ self.class.queries.include?(maybe_query_name)
244
+ end
245
+
246
+ # @api private
247
+ MODIFIER_METHODS = %i(as including limit order).freeze
248
+ # @api private
249
+ def modifier?(maybe_modifier_name)
250
+ MODIFIER_METHODS.include?(maybe_modifier_name)
251
+ end
252
+
253
+ # @api private
254
+ NESTED_METHODS = %i(including).freeze
255
+ # @api private
256
+ def block_for_nested_source?(maybe_nested_name)
257
+ NESTED_METHODS.include?(maybe_nested_name)
258
+ end
259
+
260
+ private
261
+
262
+ def finalize(result)
263
+ wrap(typecast(result))
264
+ end
265
+
266
+ def typecast(result)
267
+ result.each do |key, value|
268
+ unless value.nil? || !self.class.attributes.include?(key)
269
+ result[key] = self.class.attributes[key][value]
270
+ end
271
+ end
272
+
273
+ result
274
+ end
275
+
276
+ def wrap(result)
277
+ wrapped_result = if @wrap_as.is_a?(Class)
278
+ @wrap_as.new(result)
279
+ else
280
+ self.class.container.object(@wrap_as).new(result)
281
+ end
282
+
283
+ if wrapped_result.is_a?(Object)
284
+ wrapped_result.originating_source = self.class
285
+ end
286
+
287
+ wrapped_result
288
+ end
289
+
290
+ def include_results!(results)
291
+ @included.each do |association, combined_source|
292
+ group_by_key, assign_by_key, remove_keys = if association.type == :through
293
+ joining_source = association.joining_source.instance
294
+
295
+ if combined_source.class == association.joining_source
296
+ combined_source.__setobj__(
297
+ combined_source.class.container.connection.adapter.result_for_attribute_value(
298
+ combined_source.class.container.connection.adapter.qualify_attribute(
299
+ association.right_foreign_key_field, combined_source
300
+ ),
301
+ results.map { |result| result[association.associated_query_field] },
302
+ combined_source
303
+ )
304
+ )
305
+ else
306
+ aliased = SecureRandom.hex(4).to_sym
307
+
308
+ if joining_source.class.container.connection == combined_source.class.container.connection
309
+ # Optimize with joins.
310
+ #
311
+ combined_source.__setobj__(
312
+ combined_source.class.container.connection.adapter.restrict_to_source(
313
+ combined_source,
314
+ combined_source.class.container.connection.adapter.result_for_attribute_value(
315
+ combined_source.class.container.connection.adapter.qualify_attribute(
316
+ association.right_foreign_key_field, joining_source
317
+ ),
318
+ joining_source.class.container.connection.adapter.restrict_to_attribute(
319
+ association.query_field, source_from_self(__getobj__.dup)
320
+ ),
321
+ combined_source.class.container.connection.adapter.merge_results(
322
+ association.left_foreign_key_field,
323
+ association.associated_source.primary_key_field,
324
+ joining_source,
325
+ combined_source
326
+ )
327
+ ),
328
+ combined_source.class.container.connection.adapter.alias_attribute(
329
+ combined_source.class.container.connection.adapter.qualify_attribute(
330
+ association.right_foreign_key_field, joining_source
331
+ ), aliased
332
+ )
333
+ )
334
+ )
335
+ else
336
+ # Manually join.
337
+ #
338
+ self_ids = self.class.container.connection.adapter.restrict_to_attribute(
339
+ self.class.primary_key_field, self
340
+ ).map { |result|
341
+ result[self.class.primary_key_field]
342
+ }
343
+
344
+ joined_results = joining_source.class.container.connection.adapter.restrict_to_attribute(
345
+ [association.right_foreign_key_field, association.left_foreign_key_field],
346
+ joining_source.class.container.connection.adapter.result_for_attribute_value(
347
+ association.right_foreign_key_field, self_ids, joining_source
348
+ )
349
+ )
350
+
351
+ combined_results = combined_source.class.container.connection.adapter.result_for_attribute_value(
352
+ combined_source.class.primary_key_field, joined_results.map { |result| result[association.left_foreign_key_field] }, combined_source
353
+ )
354
+
355
+ combined_results = joined_results.map { |joined_result|
356
+ combined_results.find { |result|
357
+ result[combined_source.class.primary_key_field] == joined_result[association.left_foreign_key_field]
358
+ }.dup.tap do |combined_result|
359
+ combined_result[aliased] = joined_result[association.right_foreign_key_field]
360
+ end
361
+ }
362
+ end
363
+ end
364
+
365
+ [aliased, association.name, [aliased]]
366
+ else
367
+ combined_source.__setobj__(
368
+ combined_source.class.container.connection.adapter.result_for_attribute_value(
369
+ association.associated_query_field,
370
+ results.map { |result| result[association.query_field] },
371
+ combined_source
372
+ )
373
+ )
374
+
375
+ [association.associated_query_field, association.name, []]
376
+ end
377
+
378
+ # Group the raw results by associated column value.
379
+ #
380
+ combined_results = (combined_results || combined_source).to_a.group_by { |combined_result|
381
+ combined_result[group_by_key]
382
+ }
383
+
384
+ # Add each result group to its associated object.
385
+ #
386
+ results.map! { |result|
387
+ combined_results_for_result = combined_results[result[association.query_field]].to_a.map! { |combined_result|
388
+ if combined_result.is_a?(Pakyow::Data::Object)
389
+ combined_result = combined_result.values.dup
390
+ end
391
+
392
+ # Remove any keys, such as temporary values used for grouping.
393
+ #
394
+ remove_keys.each do |remove_key|
395
+ combined_result.delete(remove_key)
396
+ end
397
+
398
+ # Wrap the result into the appropriate data object.
399
+ #
400
+ combined_source.send(:wrap, combined_result)
401
+ }
402
+
403
+ result[assign_by_key] = if association.result_type == :one
404
+ combined_results_for_result[0]
405
+ else
406
+ combined_results_for_result
407
+ end
408
+
409
+ result
410
+ }
411
+ end
412
+ end
413
+
414
+ extend Support::Makeable
415
+ extend Support::ClassState
416
+
417
+ class_state :__default_query
418
+ class_state :timestamp_fields
419
+ class_state :primary_key_field
420
+ class_state :attributes, default: {}
421
+ class_state :qualifications, default: {}, getter: false
422
+ class_state :associations, default: { belongs_to: [], has_many: [], has_one: [] }
423
+ class_state :commands, default: {}
424
+
425
+ class << self
426
+ attr_reader :name, :adapter, :connection
427
+
428
+ def command(command_name, provides_dataset: true, performs_create: false, performs_update: false, performs_delete: false, &block)
429
+ @commands[command_name] = {
430
+ block: block,
431
+ provides_dataset: provides_dataset,
432
+ performs_create: performs_create,
433
+ performs_update: performs_update,
434
+ performs_delete: performs_delete
435
+ }
436
+ end
437
+
438
+ def queries
439
+ instance_methods - superclass.instance_methods
440
+ end
441
+
442
+ def query(query_name = nil, &block)
443
+ @__default_query = query_name || block
444
+ end
445
+
446
+ def timestamps(create: :created_at, update: :updated_at)
447
+ @timestamp_fields = {
448
+ create: create,
449
+ update: update
450
+ }
451
+
452
+ attribute create, :datetime
453
+ attribute update, :datetime
454
+ end
455
+
456
+ def primary_id
457
+ primary_key :id
458
+ attribute :id, default_primary_key_type
459
+ end
460
+
461
+ def primary_key(field)
462
+ @primary_key_field = field
463
+ end
464
+
465
+ def primary_key_type
466
+ case primary_key_attribute
467
+ when Hash
468
+ primary_key_attribute[:type]
469
+ else
470
+ primary_key_attribute.meta[:mapping]
471
+ end
472
+ end
473
+
474
+ def primary_key_attribute
475
+ attributes[@primary_key_field]
476
+ end
477
+
478
+ def default_primary_key_type
479
+ :integer
480
+ end
481
+
482
+ def attribute(name, type = :string, **options)
483
+ attributes[name.to_sym] = {
484
+ type: type,
485
+ options: options
486
+ }
487
+ end
488
+
489
+ def subscribe(query_name, qualifications)
490
+ @qualifications[query_name] = qualifications
491
+ end
492
+
493
+ def qualifications(query_name)
494
+ @qualifications.dig(query_name) || {}
495
+ end
496
+
497
+ def belongs_to(association_name, query: nil, source: association_name)
498
+ Associations::BelongsTo.new(
499
+ name: association_name, query: query, source: self, associated_source_name: source
500
+ ).tap do |association|
501
+ @associations[:belongs_to] << association
502
+ end
503
+ end
504
+
505
+ # rubocop:disable Naming/PredicateName
506
+ def has_many(association_name, query: nil, source: association_name, as: singular_name, through: nil, dependent: :raise)
507
+ Associations::HasMany.new(
508
+ name: association_name, query: query, source: self, associated_source_name: source, as: as, dependent: dependent
509
+ ).tap do |association|
510
+ @associations[:has_many] << association
511
+
512
+ if through
513
+ setup_as_through(association, through: through)
514
+ end
515
+ end
516
+ end
517
+ # rubocop:enable Naming/PredicateName
518
+
519
+ # rubocop:disable Naming/PredicateName
520
+ def has_one(association_name, query: nil, source: association_name, as: singular_name, through: nil, dependent: :raise)
521
+ Associations::HasOne.new(
522
+ name: association_name, query: query, source: self, associated_source_name: source, as: as, dependent: dependent
523
+ ).tap do |association|
524
+ @associations[:has_one] << association
525
+
526
+ if through
527
+ setup_as_through(association, through: through)
528
+ end
529
+ end
530
+ end
531
+ # rubocop:enable Naming/PredicateName
532
+
533
+ def setup_as_through(association, through:)
534
+ Associations::Through.new(association, joining_source_name: through).tap do |through_association|
535
+ associations[association.specific_type][
536
+ associations[association.specific_type].index(association)
537
+ ] = through_association
538
+ end
539
+ end
540
+
541
+ def make(name, adapter: Pakyow.config.data.default_adapter, connection: Pakyow.config.data.default_connection, state: nil, parent: nil, primary_id: true, timestamps: true, **kwargs, &block)
542
+ super(name, state: state, parent: parent, adapter: adapter, connection: connection, attributes: {}, **kwargs) do
543
+ adapter_class = Connection.adapter(adapter)
544
+
545
+ if adapter_class.const_defined?("SourceExtension")
546
+ # Extend the source with any adapter-specific behavior.
547
+ #
548
+ extension_module = adapter_class.const_get("SourceExtension")
549
+ unless ancestors.include?(extension_module)
550
+ include(extension_module)
551
+ end
552
+
553
+ # Define default fields
554
+ #
555
+ self.primary_id if primary_id
556
+ self.timestamps if timestamps
557
+ end
558
+
559
+ # Call the original block.
560
+ #
561
+ class_eval(&block) if block_given?
562
+ end
563
+ end
564
+
565
+ # @api private
566
+ def source_from_source(*)
567
+ super.tap(&:reload)
568
+ end
569
+
570
+ # @api private
571
+ def find_association_to_source(source)
572
+ associations.values.flatten.find { |association|
573
+ association.associated_source == source.class
574
+ }
575
+ end
576
+
577
+ # @api private
578
+ def association_with_name?(name)
579
+ associations.values.flatten.find { |association|
580
+ association.name == name
581
+ }
582
+ end
583
+ end
584
+ end
585
+ end
586
+ end
587
+ end