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,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/inflector"
4
+
5
+ require "pakyow/data/types"
6
+
7
+ module Pakyow
8
+ module Data
9
+ class Container
10
+ attr_reader :connection, :sources
11
+
12
+ def initialize(connection:, sources:, objects:)
13
+ @connection, @sources = connection, sources
14
+
15
+ @object_map = objects.each_with_object({}) { |object, map|
16
+ map[object.__object_name.name] = object
17
+ }
18
+ end
19
+
20
+ def source(source_name)
21
+ plural_source_name = Support.inflector.pluralize(source_name).to_sym
22
+
23
+ if found_source = sources.find { |source|
24
+ source.plural_name == plural_source_name
25
+ }
26
+
27
+ found_source.new(
28
+ @connection.dataset_for_source(found_source)
29
+ )
30
+ end
31
+ end
32
+
33
+ def object(object_name)
34
+ @object_map.fetch(object_name, Object)
35
+ end
36
+
37
+ def finalize_associations!(other_containers)
38
+ @sources.each do |source|
39
+ discover_has_and_belongs_to!(source, other_containers)
40
+ end
41
+
42
+ @sources.each do |source|
43
+ set_container_for_source!(source)
44
+ define_reciprocal_associations!(source, other_containers)
45
+ end
46
+ end
47
+
48
+ def finalize_sources!(other_containers)
49
+ @sources.each do |source|
50
+ mixin_commands!(source)
51
+ mixin_dataset_methods!(source)
52
+ define_attributes_for_associations!(source, other_containers)
53
+ define_queries_for_attributes!(source)
54
+ wrap_defined_queries!(source)
55
+ define_methods_for_associations!(source)
56
+ define_methods_for_objects!(source)
57
+ finalize_source_types!(source)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def adapter
64
+ @connection.adapter
65
+ end
66
+
67
+ def discover_has_and_belongs_to!(source, other_containers)
68
+ source.associations.values.flatten.select { |association|
69
+ # Only look for has_* associations that aren't already setup through another source.
70
+ #
71
+ association.type == :has
72
+ }.each do |association|
73
+ reciprocal_association = nil
74
+ reciprocal_source = (@sources + other_containers.flat_map(&:sources)).reject { |potentially_reciprocal_source|
75
+ potentially_reciprocal_source == source
76
+ }.find { |potentially_reciprocal_source|
77
+ reciprocal_association = potentially_reciprocal_source.associations.values.flatten.find { |potentially_reciprocal_association|
78
+ potentially_reciprocal_association.specific_type == association.specific_type &&
79
+ potentially_reciprocal_association.associated_source_name == source.plural_name &&
80
+ Support.inflector.pluralize(potentially_reciprocal_association.name) == Support.inflector.pluralize(association.associated_name) &&
81
+ Support.inflector.pluralize(potentially_reciprocal_association.associated_name) == Support.inflector.pluralize(association.name)
82
+ }
83
+ }
84
+
85
+ if reciprocal_source
86
+ joining_source_name = [source.plural_name, reciprocal_source.plural_name].sort.join("_")
87
+ joining_source = (@sources + other_containers.flat_map(&:sources)).find { |potentially_joining_source|
88
+ potentially_joining_source.plural_name == joining_source_name
89
+ }
90
+
91
+ unless joining_source
92
+ joining_source = source.ancestors.find { |ancestor|
93
+ ancestor != source && ancestor.ancestors.include?(Sources::Abstract)
94
+ }.make(
95
+ joining_source_name,
96
+ adapter: source.adapter,
97
+ connection: source.connection,
98
+ within: source.__object_name.namespace
99
+ )
100
+
101
+ @sources << joining_source
102
+ end
103
+
104
+ # Modify both sides of the association to be through the joining source.
105
+ #
106
+ source.setup_as_through(association, through: joining_source_name).internal!
107
+ reciprocal_source.setup_as_through(reciprocal_association, through: joining_source_name).internal!
108
+ end
109
+ end
110
+ end
111
+
112
+ def set_container_for_source!(source)
113
+ source.container = self
114
+ end
115
+
116
+ def mixin_commands!(source)
117
+ source.include adapter.class.const_get("Commands")
118
+ end
119
+
120
+ def mixin_dataset_methods!(source)
121
+ source.extend adapter.class.const_get("DatasetMethods")
122
+ end
123
+
124
+ def define_attributes_for_associations!(source, other_containers)
125
+ source.associations.values.flatten.each do |association|
126
+ associated_source = (@sources + other_containers.flat_map(&:sources)).find { |potentially_associated_source|
127
+ potentially_associated_source.plural_name == association.associated_source_name
128
+ }
129
+
130
+ if associated_source
131
+ association.associated_source = associated_source
132
+
133
+ if association.type == :through
134
+ association.joining_source = (@sources + other_containers.flat_map(&:sources)).find { |potentially_joining_source|
135
+ potentially_joining_source.plural_name == association.joining_source_name
136
+ }
137
+ end
138
+
139
+ if association.type == :belongs
140
+ # Define an attribute for the foreign key.
141
+ #
142
+ source.attribute(
143
+ association.foreign_key_field,
144
+ association.foreign_key_type,
145
+ foreign_key: association.associated_source_name
146
+ )
147
+ end
148
+ end
149
+ end
150
+ end
151
+
152
+ def define_reciprocal_associations!(source, other_containers)
153
+ (source.associations[:has_many] + source.associations[:has_one]).each do |association|
154
+ associated_source = (@sources + other_containers.flat_map(&:sources)).find { |potentially_associated_source|
155
+ potentially_associated_source.plural_name == association.associated_source_name
156
+ }
157
+
158
+ if associated_source
159
+ if association.type == :through
160
+ joining_source = (@sources + other_containers.flat_map(&:sources)).find { |potential_joining_source|
161
+ potential_joining_source.plural_name == association.joining_source_name
162
+ }
163
+
164
+ if joining_source
165
+ unless joining_source.associations[:belongs_to].any? { |current_association| current_association.name == association.left_name }
166
+ joining_source.belongs_to(association.left_name, source: associated_source.plural_name)
167
+ end
168
+
169
+ unless joining_source.associations[:belongs_to].any? { |current_association| current_association.name == association.right_name }
170
+ joining_source.belongs_to(association.right_name, source: source.plural_name)
171
+ end
172
+
173
+ unless association.internal?
174
+ unless associated_source.associations[association.specific_type].any? { |current_association| current_association.joining_source_name == association.joining_source_name }
175
+ associated_source.send(association.specific_type, association.associated_name, source: source.plural_name, as: association.left_name, through: association.joining_source_name, dependent: association.dependent)
176
+ end
177
+
178
+ unless source.associations[association.specific_type].any? { |current_association| current_association.associated_source_name == association.joining_source_name }
179
+ source.send(association.specific_type, association.joining_source_name, source: joining_source.plural_name, as: Support.inflector.singularize(association.associated_name), dependent: association.dependent)
180
+ end
181
+ end
182
+ end
183
+ else
184
+ unless associated_source.associations[:belongs_to].any? { |current_association| current_association.name == Support.inflector.singularize(association.associated_name).to_sym }
185
+ associated_source.belongs_to(association.associated_name, source: source.plural_name)
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
191
+
192
+ def define_queries_for_attributes!(source)
193
+ source.attributes.keys.each do |attribute|
194
+ method_name = :"by_#{attribute}"
195
+ unless source.instance_methods.include?(method_name)
196
+ source.class_eval do
197
+ define_method method_name do |value|
198
+ self.class.container.connection.adapter.result_for_attribute_value(attribute, value, self)
199
+ end
200
+
201
+ # Qualify the query.
202
+ #
203
+ subscribe :"by_#{attribute}", attribute => :__arg0__
204
+ end
205
+ end
206
+ end
207
+ end
208
+
209
+ def define_methods_for_associations!(source)
210
+ source.associations.values.flatten.each do |association|
211
+ method_name = :"with_#{association.name}"
212
+ unless source.instance_methods.include?(method_name)
213
+ source.class_eval do
214
+ define_method method_name do
215
+ including(association.name)
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
221
+
222
+ def define_methods_for_objects!(source)
223
+ @object_map.keys.each do |object_name|
224
+ method_name = :"as_#{object_name}"
225
+ unless source.instance_methods.include?(method_name)
226
+ source.class_eval do
227
+ define_method method_name do
228
+ as(object_name)
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
234
+
235
+ # Override queries with methods that update the source with a new dataset.
236
+ #
237
+ def wrap_defined_queries!(source)
238
+ local_queries = source.queries
239
+ source.prepend(
240
+ Module.new do
241
+ local_queries.each do |query|
242
+ define_method query do |*args, &block|
243
+ tap do
244
+ result = super(*args, &block)
245
+ case result
246
+ when self.class
247
+ result
248
+ else
249
+ __setobj__(result)
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
255
+ )
256
+ end
257
+
258
+ def finalize_source_types!(source)
259
+ source.attributes.each do |attribute_name, attribute_info|
260
+ if attribute_info.is_a?(Hash)
261
+ type = Types.type_for(attribute_info[:type], connection.types)
262
+
263
+ if attribute_name == source.primary_key_field
264
+ type = type.meta(primary_key: true)
265
+ end
266
+
267
+ source.attributes[attribute_name] = type.meta(attribute_info[:options])
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/error"
4
+
5
+ module Pakyow
6
+ module Data
7
+ class Error < Pakyow::Error
8
+ end
9
+
10
+ class ConnectionError < Error
11
+ def contextual_message
12
+ String.new(
13
+ <<~MESSAGE
14
+ Connection for #{@context.type}.#{@context.name} could not be established.
15
+ MESSAGE
16
+ )
17
+ end
18
+ end
19
+
20
+ class ConstraintViolation < Error
21
+ class_state :messages, default: {
22
+ associate_many_missing: "can't associate results as `{association}' because at least one value could not be found",
23
+ associate_missing: "can't find associated {source} with {field} of `{value}'",
24
+ associate_multiple: "can't associate multiple results as `{association}'",
25
+ dependent_delete: "can't delete {source} because of {count} dependent {dependent}"
26
+ }.freeze
27
+ end
28
+
29
+ class MissingAdapter < Error
30
+ end
31
+
32
+ class NotNullViolation < Error
33
+ class_state :messages, default: {
34
+ default: "`{attribute}' is a required attribute"
35
+ }.freeze
36
+ end
37
+
38
+ class QueryError < Error
39
+ end
40
+
41
+ class Rollback < Error
42
+ end
43
+
44
+ class TypeMismatch < Error
45
+ class_state :messages, default: {
46
+ default: "can't convert `{type}' into {mapping}",
47
+ associate_many_not_object: "can't associate results as `{association}' because at least one value is not a Pakyow::Data::Object",
48
+ associate_many_wrong_source: "can't associate results as `{association}' because at least one value did not originate from {source}",
49
+ associate_unknown_object: "can't associate an object with an unknown source as `{association}'",
50
+ associate_wrong_object: "can't associate an object from {source} as `{association}'",
51
+ associate_wrong_source: "can't associate {source} as `{association}'",
52
+ associate_wrong_type: "can't associate {type} as `{association}'"
53
+ }.freeze
54
+ end
55
+
56
+ class UniqueViolation < Error
57
+ end
58
+
59
+ class UnknownAdapter < Error
60
+ class_state :messages, default: {
61
+ default: "`{type}' is not a known adapter"
62
+ }.freeze
63
+ end
64
+
65
+ class UnknownAttribute < Error
66
+ class_state :messages, default: {
67
+ default: "`{attribute}' is not a known attribute for {source}"
68
+ }.freeze
69
+ end
70
+
71
+ class UnknownAssociation < Error
72
+ def contextual_message
73
+ if associations.any?
74
+ String.new(
75
+ <<~MESSAGE
76
+ The following associations exist for #{@context.__object_name.name}:
77
+ MESSAGE
78
+ ).tap do |message|
79
+ associations.each do |association|
80
+ message << " * #{association.name}"
81
+ end
82
+ end
83
+ else
84
+ String.new(
85
+ <<~MESSAGE
86
+ No associations exist for #{@context.__object_name.name}.
87
+ MESSAGE
88
+ )
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def associations
95
+ @context.associations.values.flatten
96
+ end
97
+ end
98
+
99
+ class UnknownCommand < Error
100
+ class_state :messages, default: {
101
+ default: "`{command}' is not a known command"
102
+ }.freeze
103
+
104
+ def contextual_message
105
+ if commands.any?
106
+ String.new(
107
+ <<~MESSAGE
108
+ The following commands are defined for #{@context.__object_name.name}:
109
+ MESSAGE
110
+ ).tap do |message|
111
+ commands.keys.each do |command|
112
+ message << " * #{command}\n"
113
+ end
114
+ end
115
+ else
116
+ String.new(
117
+ <<~MESSAGE
118
+ No commands are defined for #{@context.__object_name.name}.
119
+ MESSAGE
120
+ )
121
+ end
122
+ end
123
+
124
+ private
125
+
126
+ def commands
127
+ @context.commands
128
+ end
129
+ end
130
+
131
+ class UnknownSource < Error
132
+ class_state :messages, default: {
133
+ default: "unknown source `{association_source}' for association: {source} {association_type} {association_name}"
134
+ }.freeze
135
+
136
+ def contextual_message
137
+ if sources.any?
138
+ String.new(
139
+ <<~MESSAGE
140
+ The following sources are defined:
141
+ MESSAGE
142
+ ).tap do |message|
143
+ sources.keys.each do |source|
144
+ message << " * #{source}\n"
145
+ end
146
+ end
147
+ else
148
+ String.new(
149
+ <<~MESSAGE
150
+ No sources are defined.
151
+ MESSAGE
152
+ )
153
+ end
154
+ end
155
+
156
+ private
157
+
158
+ def sources
159
+ @context.sources
160
+ end
161
+ end
162
+
163
+ class UnknownSubscriberAdapter < Error
164
+ class_state :messages, default: {
165
+ default: "Failed to load subscriber adapter named `{adapter}'"
166
+ }.freeze
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/framework"
4
+
5
+ require "pakyow/data/object"
6
+ require "pakyow/data/helpers"
7
+
8
+ require "pakyow/data/behavior/config"
9
+ require "pakyow/data/behavior/lookup"
10
+ require "pakyow/data/behavior/serialization"
11
+
12
+ require "pakyow/data/sources/relational"
13
+
14
+ module Pakyow
15
+ module Data
16
+ class Framework < Pakyow::Framework(:data)
17
+ def boot
18
+ object.class_eval do
19
+ isolate Sources::Relational
20
+ isolate Object
21
+
22
+ stateful :source, isolated(:Relational)
23
+ stateful :object, isolated(:Object)
24
+
25
+ # Autoload sources from the `sources` directory.
26
+ #
27
+ aspect :sources
28
+
29
+ # Autoload objects from the `objects` directory.
30
+ #
31
+ aspect :objects
32
+
33
+ register_helper :active, Helpers
34
+
35
+ include Behavior::Config
36
+ include Behavior::Lookup
37
+ include Behavior::Serialization
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pakyow
4
+ module Data
5
+ module Helpers
6
+ def data
7
+ @connection.app.data
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/data/errors"
4
+ require "pakyow/data/proxy"
5
+ require "pakyow/data/sources/ephemeral"
6
+
7
+ module Pakyow
8
+ module Data
9
+ class Lookup
10
+ # @api private
11
+ attr_reader :subscribers, :sources, :containers
12
+
13
+ def initialize(containers:, subscribers:, app:)
14
+ @subscribers = subscribers
15
+ @subscribers.lookup = self
16
+ @app = app
17
+
18
+ @sources = {}
19
+ @containers = containers
20
+ @containers.each do |container|
21
+ container.sources.each do |source|
22
+ @sources[source.__object_name.name] = source
23
+ define_singleton_method source.__object_name.name do
24
+ Proxy.new(
25
+ container.source(
26
+ source.__object_name.name
27
+ ),
28
+
29
+ @subscribers, @app
30
+ )
31
+ end
32
+ end
33
+ end
34
+
35
+ validate!
36
+ end
37
+
38
+ def ephemeral(type, **qualifications)
39
+ Proxy.new(
40
+ Sources::Ephemeral.new(type, **qualifications),
41
+ @subscribers, @app
42
+ )
43
+ end
44
+
45
+ def unsubscribe(subscriber)
46
+ @subscribers.unsubscribe(subscriber)
47
+ end
48
+
49
+ def expire(subscriber, seconds)
50
+ @subscribers.expire(subscriber, seconds)
51
+ end
52
+
53
+ def persist(subscriber)
54
+ @subscribers.persist(subscriber)
55
+ end
56
+
57
+ private
58
+
59
+ def validate!
60
+ validate_associated_sources!
61
+ end
62
+
63
+ def validate_associated_sources!
64
+ @sources.values.each do |source|
65
+ source.associations.values.flatten.each do |association|
66
+ association.dependent_source_names.compact.each do |source_name|
67
+ unless @sources.key?(source_name)
68
+ raise(
69
+ UnknownSource.new_with_message(
70
+ source: source.__object_name.name,
71
+ association_source: source_name,
72
+ association_type: association.specific_type,
73
+ association_name: association.name
74
+ ).tap do |error|
75
+ error.context = self
76
+ end
77
+ )
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end