pakyow-data 1.0.0.rc1

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