rom-repository 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.travis.yml +5 -5
  4. data/CHANGELOG.md +24 -0
  5. data/Gemfile +21 -5
  6. data/README.md +6 -110
  7. data/lib/rom/repository/changeset/create.rb +26 -0
  8. data/lib/rom/repository/changeset/pipe.rb +40 -0
  9. data/lib/rom/repository/changeset/update.rb +82 -0
  10. data/lib/rom/repository/changeset.rb +99 -0
  11. data/lib/rom/repository/class_interface.rb +142 -0
  12. data/lib/rom/repository/command_compiler.rb +214 -0
  13. data/lib/rom/repository/command_proxy.rb +22 -0
  14. data/lib/rom/repository/header_builder.rb +13 -16
  15. data/lib/rom/repository/mapper_builder.rb +7 -14
  16. data/lib/rom/repository/{loading_proxy → relation_proxy}/wrap.rb +7 -7
  17. data/lib/rom/repository/relation_proxy.rb +225 -0
  18. data/lib/rom/repository/root.rb +110 -0
  19. data/lib/rom/repository/struct_attributes.rb +46 -0
  20. data/lib/rom/repository/struct_builder.rb +31 -14
  21. data/lib/rom/repository/version.rb +1 -1
  22. data/lib/rom/repository.rb +192 -31
  23. data/lib/rom/struct.rb +13 -8
  24. data/rom-repository.gemspec +9 -10
  25. data/spec/integration/changeset_spec.rb +86 -0
  26. data/spec/integration/command_macros_spec.rb +175 -0
  27. data/spec/integration/command_spec.rb +224 -0
  28. data/spec/integration/multi_adapter_spec.rb +3 -3
  29. data/spec/integration/repository_spec.rb +97 -2
  30. data/spec/integration/root_repository_spec.rb +88 -0
  31. data/spec/shared/database.rb +47 -3
  32. data/spec/shared/mappers.rb +35 -0
  33. data/spec/shared/models.rb +41 -0
  34. data/spec/shared/plugins.rb +66 -0
  35. data/spec/shared/relations.rb +76 -0
  36. data/spec/shared/repo.rb +38 -17
  37. data/spec/shared/seeds.rb +19 -0
  38. data/spec/spec_helper.rb +4 -1
  39. data/spec/support/mapper_registry.rb +1 -3
  40. data/spec/unit/changeset_spec.rb +58 -0
  41. data/spec/unit/header_builder_spec.rb +34 -35
  42. data/spec/unit/relation_proxy_spec.rb +170 -0
  43. data/spec/unit/sql/relation_spec.rb +5 -5
  44. data/spec/unit/struct_builder_spec.rb +7 -4
  45. data/spec/unit/struct_spec.rb +22 -0
  46. metadata +38 -41
  47. data/lib/rom/plugins/relation/key_inference.rb +0 -31
  48. data/lib/rom/repository/loading_proxy/combine.rb +0 -158
  49. data/lib/rom/repository/loading_proxy.rb +0 -182
  50. data/spec/unit/loading_proxy_spec.rb +0 -147
@@ -0,0 +1,214 @@
1
+ require 'concurrent/map'
2
+
3
+ require 'rom/commands'
4
+ require 'rom/repository/command_proxy'
5
+
6
+ module ROM
7
+ class Repository
8
+ # Builds commands for relations.
9
+ #
10
+ # This class is used by repositories to automatically create commands for
11
+ # their relations. This is used both by `Repository#command` method and
12
+ # `commands` repository class macros.
13
+ #
14
+ # @api private
15
+ class CommandCompiler
16
+ SUPPORTED_TYPES = %i[create update delete].freeze
17
+
18
+ # Return a specific command type for a given adapter and relation AST
19
+ #
20
+ # This class holds its own registry where all generated commands are being
21
+ # stored
22
+ #
23
+ # CommandProxy is returned for complex command graphs as they expect root
24
+ # relation name to be present in the input, which we don't want to have
25
+ # in repositories. It might be worth looking into removing this requirement
26
+ # from rom core Command::Graph API.
27
+ #
28
+ # @param container [ROM::Container] container where relations are stored
29
+ # @param type [Symbol] The type of command
30
+ # @param adapter [Symbol] The adapter identifier
31
+ # @param ast [Array] The AST representation of a relation
32
+ # @param plugins [Array<Symbol>] A list of optional command plugins that should be used
33
+ #
34
+ # @return [Command, CommandProxy]
35
+ #
36
+ # @api private
37
+ def self.[](*args)
38
+ cache.fetch_or_store(args.hash) do
39
+ container, type, adapter, ast, plugins = args
40
+
41
+ unless SUPPORTED_TYPES.include?(type)
42
+ raise ArgumentError, "#{type.inspect} is not a supported command type"
43
+ end
44
+
45
+ graph_opts = new(type, adapter, container, registry, plugins).visit(ast)
46
+
47
+ command = ROM::Commands::Graph.build(registry, graph_opts)
48
+
49
+ if command.graph?
50
+ CommandProxy.new(command)
51
+ else
52
+ command.unwrap
53
+ end
54
+ end
55
+ end
56
+
57
+ # @api private
58
+ def self.cache
59
+ @__cache__ ||= Concurrent::Map.new
60
+ end
61
+
62
+ # @api private
63
+ def self.registry
64
+ @__registry__ ||= Hash.new { |h, k| h[k] = {} }
65
+ end
66
+
67
+ # @!attribute [r] type
68
+ # @return [Symbol] The command type
69
+ attr_reader :type
70
+
71
+ # @!attribute [r] adapter
72
+ # @return [Symbol] The adapter identifier ie :sql or :http
73
+ attr_reader :adapter
74
+
75
+ # @!attribute [r] container
76
+ # @return [ROM::Container] rom container with relations and gateways
77
+ attr_reader :container
78
+
79
+ # @!attribute [r] registry
80
+ # @return [Hash] local registry where commands will be stored during compilation
81
+ attr_reader :registry
82
+
83
+ # @!attribute [r] plugins
84
+ # @return [Array<Symbol>] a list of optional plugins that will be enabled for commands
85
+ attr_reader :plugins
86
+
87
+ # @api private
88
+ def initialize(type, adapter, container, registry, plugins)
89
+ @type = Commands.const_get(Inflector.classify(type))[adapter]
90
+ @registry = registry
91
+ @container = container
92
+ @plugins = Array(plugins)
93
+ end
94
+
95
+ # @api private
96
+ def visit(ast, *args)
97
+ name, node = ast
98
+ __send__(:"visit_#{name}", node, *args)
99
+ end
100
+
101
+ private
102
+
103
+ # @api private
104
+ def visit_relation(node, parent_relation = nil)
105
+ name, meta, header = node
106
+ other = visit(header, name)
107
+
108
+ mapping =
109
+ if meta[:combine_type] == :many
110
+ name
111
+ else
112
+ { Inflector.singularize(name).to_sym => name }
113
+ end
114
+
115
+ register_command(name, type, meta, parent_relation)
116
+
117
+ if other.size > 0
118
+ [mapping, [type, other]]
119
+ else
120
+ [mapping, type]
121
+ end
122
+ end
123
+
124
+ # @api private
125
+ def visit_header(node, *args)
126
+ node.map { |n| visit(n, *args) }.compact
127
+ end
128
+
129
+ # @api private
130
+ def visit_attribute(*args)
131
+ nil
132
+ end
133
+
134
+ # Build a command object for a specific relation
135
+ #
136
+ # The command will be prepared for handling associations if it's a combined
137
+ # relation. Additional plugins will be enabled if they are configured for
138
+ # this compiler.
139
+ #
140
+ # @param [Symbol] rel_name A relation identifier from the container registry
141
+ # @param [Symbol] type The command type
142
+ # @param [Hash] meta Meta information from relation AST
143
+ # @param [Symbol] parent_relation Optional parent relation identifier
144
+ #
145
+ # @return [ROM::Command]
146
+ #
147
+ # @api private
148
+ def register_command(rel_name, type, meta, parent_relation = nil)
149
+ relation = container.relations[rel_name]
150
+
151
+ type.create_class(rel_name, type) do |klass|
152
+ klass.result(meta.fetch(:combine_type, :one))
153
+
154
+ if meta[:combine_type]
155
+ setup_associates(klass, relation, meta, parent_relation)
156
+ end
157
+
158
+ finalize_command_class(klass, relation)
159
+
160
+ registry[rel_name][type] = klass.build(relation, input: relation.schema_hash)
161
+ end
162
+ end
163
+
164
+ # Sets up `associates` plugin for a given command class and relation
165
+ #
166
+ # @param [Class] klass The command class
167
+ # @param [Relation] relation The relation for the command
168
+ #
169
+ # @api private
170
+ def setup_associates(klass, relation, meta, parent_relation)
171
+ klass.use(:associates)
172
+
173
+ assoc_name =
174
+ if relation.associations.key?(parent_relation)
175
+ parent_relation
176
+ else
177
+ singular_name = Inflector.singularize(parent_relation).to_sym
178
+ singular_name if relation.associations.key?(singular_name)
179
+ end
180
+
181
+ if assoc_name
182
+ klass.associates(assoc_name)
183
+ else
184
+ keys = meta[:keys].invert.to_a.flatten
185
+ klass.associates(parent_relation, key: keys)
186
+ end
187
+ end
188
+
189
+ # Setup a command class for a specific relation
190
+ #
191
+ # Every gateway may provide custom command extensions via
192
+ # `Gateway#extend_command_class`. Furthermore, restrictible commands like
193
+ # `Update` or `Delete` will be extended with relation view methods, so things
194
+ # like `delete_user.by_id(1).call` becomes available.
195
+ #
196
+ # @param [Class] klass The command class
197
+ # @param [Relation] relation The command relation
198
+ #
199
+ # @return [Class]
200
+ #
201
+ # @api private
202
+ def finalize_command_class(klass, relation)
203
+ # TODO: this is a copy-paste from rom's FinalizeCommands, we are missing
204
+ # an interface!
205
+ gateway = container.gateways[relation.class.gateway]
206
+ gateway.extend_command_class(klass, relation.dataset)
207
+
208
+ klass.extend_for_relation(relation) if klass.restrictable
209
+
210
+ plugins.each { |plugin| klass.use(plugin) }
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,22 @@
1
+ module ROM
2
+ class Repository
3
+ # TODO: look into making command graphs work without the root key in the input
4
+ # so that we can get rid of this wrapper
5
+ class CommandProxy
6
+ attr_reader :command, :root
7
+
8
+ def initialize(command)
9
+ @command = command
10
+ @root = Inflector.singularize(command.name.relation).to_sym
11
+ end
12
+
13
+ def call(input)
14
+ command.call(root => input)
15
+ end
16
+
17
+ def >>(other)
18
+ self.class.new(command >> other)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -1,5 +1,4 @@
1
1
  require 'rom/header'
2
-
3
2
  require 'rom/repository/struct_builder'
4
3
 
5
4
  module ROM
@@ -8,12 +7,8 @@ module ROM
8
7
  class HeaderBuilder
9
8
  attr_reader :struct_builder
10
9
 
11
- def self.new(struct_builder = StructBuilder.new)
12
- super
13
- end
14
-
15
- def initialize(struct_builder)
16
- @struct_builder = struct_builder
10
+ def initialize
11
+ @struct_builder = StructBuilder.new
17
12
  end
18
13
 
19
14
  def call(ast)
@@ -23,18 +18,20 @@ module ROM
23
18
 
24
19
  private
25
20
 
26
- def visit(ast, *args)
27
- __send__("visit_#{ast.first}", *(ast[1..ast.size-1] + args))
21
+ def visit(node, *args)
22
+ name, node = node
23
+ __send__("visit_#{name}", node, *args)
28
24
  end
29
25
 
30
- def visit_relation(*args)
31
- name, header, meta = args
26
+ def visit_relation(node, meta = {})
27
+ relation_name, meta, header = node
28
+ name = meta[:combine_name] || relation_name
32
29
 
33
30
  model = meta.fetch(:model) do
34
- struct_builder[meta.fetch(:base_name), header[1].map { |a| a[1] }]
31
+ struct_builder[meta.fetch(:dataset), header]
35
32
  end
36
33
 
37
- options = [visit_header(header[1], meta), model: model]
34
+ options = [visit(header, meta), model: model]
38
35
 
39
36
  if meta[:combine_type]
40
37
  type = meta[:combine_type] == :many ? :array : :hash
@@ -48,13 +45,13 @@ module ROM
48
45
  end
49
46
  end
50
47
 
51
- def visit_header(header, meta = {})
52
- header.map { |attribute| visit(attribute, meta) }
48
+ def visit_header(node, meta = {})
49
+ node.map { |attribute| visit(attribute, meta) }
53
50
  end
54
51
 
55
52
  def visit_attribute(name, meta = {})
56
53
  if meta[:wrap]
57
- [name, from: :"#{meta[:base_name]}_#{name}"]
54
+ [name, from: :"#{meta[:dataset]}_#{name}"]
58
55
  else
59
56
  [name]
60
57
  end
@@ -1,28 +1,21 @@
1
+ require 'rom/support/cache'
2
+ require 'rom/mapper'
1
3
  require 'rom/repository/header_builder'
2
4
 
3
5
  module ROM
4
6
  class Repository
5
7
  # @api private
6
8
  class MapperBuilder
7
- attr_reader :header_builder
8
-
9
- attr_reader :registry
10
-
11
- def self.registry
12
- @__registry__ ||= {}
13
- end
9
+ extend Cache
14
10
 
15
- def self.new(header_builder = HeaderBuilder.new)
16
- super
17
- end
11
+ attr_reader :header_builder
18
12
 
19
- def initialize(header_builder)
20
- @header_builder = header_builder
21
- @registry = self.class.registry
13
+ def initialize
14
+ @header_builder = HeaderBuilder.new
22
15
  end
23
16
 
24
17
  def call(ast)
25
- registry[ast.hash] ||= Mapper.build(header_builder[ast])
18
+ fetch_or_store(ast) { Mapper.build(header_builder[ast]) }
26
19
  end
27
20
  alias_method :[], :call
28
21
  end
@@ -1,6 +1,6 @@
1
1
  module ROM
2
2
  class Repository
3
- class LoadingProxy
3
+ class RelationProxy
4
4
  # Provides convenient methods for producing wrapped relations
5
5
  #
6
6
  # @api public
@@ -12,7 +12,7 @@ module ROM
12
12
  #
13
13
  # @param [Hash] options
14
14
  #
15
- # @return [LoadingProxy]
15
+ # @return [RelationProxy]
16
16
  #
17
17
  # @api public
18
18
  def wrap(options)
@@ -21,7 +21,7 @@ module ROM
21
21
  }
22
22
 
23
23
  relation = wraps.reduce(self) { |a, e|
24
- a.relation.for_wrap(e.meta.fetch(:keys), e.base_name)
24
+ a.relation.for_wrap(e.meta.fetch(:keys), e.base_name.relation)
25
25
  }
26
26
 
27
27
  __new__(relation, meta: { wraps: wraps })
@@ -32,13 +32,13 @@ module ROM
32
32
  # @example
33
33
  # tasks.wrap_parent(owner: users)
34
34
  #
35
- # @return [LoadingProxy]
35
+ # @return [RelationProxy]
36
36
  #
37
37
  # @api public
38
38
  def wrap_parent(options)
39
39
  wrap(
40
40
  options.each_with_object({}) { |(name, parent), h|
41
- h[name] = [parent, combine_keys(parent, :children)]
41
+ h[name] = [parent, combine_keys(parent, relation, :children)]
42
42
  }
43
43
  )
44
44
  end
@@ -48,11 +48,11 @@ module ROM
48
48
  # This will carry meta info used to produce a correct AST from a relation
49
49
  # so that correct mapper can be generated
50
50
  #
51
- # @return [LoadingProxy]
51
+ # @return [RelationProxy]
52
52
  #
53
53
  # @api private
54
54
  def wrapped(name, keys)
55
- with(name: name, meta: { keys: keys, wrap: true })
55
+ with(name: name, meta: { keys: keys, wrap: true, combine_name: name })
56
56
  end
57
57
  end
58
58
  end
@@ -0,0 +1,225 @@
1
+ require 'rom/support/options'
2
+ require 'rom/relation/materializable'
3
+
4
+ require 'rom/repository/relation_proxy/combine'
5
+ require 'rom/repository/relation_proxy/wrap'
6
+
7
+ module ROM
8
+ class Repository
9
+ # RelationProxy decorates a relation and automatically generates mappers that
10
+ # will map raw tuples into rom structs
11
+ #
12
+ # Relation proxies are being registered within repositories so typically there's
13
+ # no need to instantiate them manually.
14
+ #
15
+ # @api public
16
+ class RelationProxy
17
+ include Options
18
+ include Relation::Materializable
19
+
20
+ include RelationProxy::Combine
21
+ include RelationProxy::Wrap
22
+
23
+ option :name, type: Symbol
24
+ option :mappers, reader: true, default: proc { MapperBuilder.new }
25
+ option :meta, reader: true, type: Hash, default: EMPTY_HASH
26
+ option :registry, type: RelationRegistry, default: proc { RelationRegistry.new }, reader: true
27
+
28
+ # @!attribute [r] relation
29
+ # @return [Relation, Relation::Composite, Relation::Graph, Relation::Curried] The decorated relation object
30
+ attr_reader :relation
31
+
32
+ # @!attribute [r] name
33
+ # @return [ROM::Relation::Name] The relation name object
34
+ attr_reader :name
35
+
36
+ # @api private
37
+ def initialize(relation, options = {})
38
+ super
39
+ @relation = relation
40
+ @name = relation.name.with(options[:name])
41
+ end
42
+
43
+ # Materializes wrapped relation and sends it through a mapper
44
+ #
45
+ # For performance reasons a combined relation will skip mapping since
46
+ # we only care about extracting key values for combining
47
+ #
48
+ # @api public
49
+ def call(*args)
50
+ ((combine? || composite?) ? relation : (relation >> mapper)).call(*args)
51
+ end
52
+
53
+ # Maps the wrapped relation with other mappers available in the registry
54
+ #
55
+ # @param *names [Array<Symbol, Class>] Either a list of mapper identifiers
56
+ # or a custom model class
57
+ #
58
+ # @return [RelationProxy] A new relation proxy with pipelined relation
59
+ #
60
+ # @api public
61
+ def map_with(*names)
62
+ if names.size == 1 && names[0].is_a?(Class)
63
+ with(meta: meta.merge(model: names[0]))
64
+ else
65
+ names.reduce(self) { |a, e| a >> relation.mappers[e] }
66
+ end
67
+ end
68
+ alias_method :as, :map_with
69
+
70
+ # Infers a mapper for the wrapped relation
71
+ #
72
+ # @return [ROM::Mapper]
73
+ #
74
+ # @api private
75
+ def mapper
76
+ mappers[to_ast]
77
+ end
78
+
79
+ # Returns a new instance with new options
80
+ #
81
+ # @param new_options [Hash]
82
+ #
83
+ # @return [RelationProxy]
84
+ #
85
+ # @api private
86
+ def with(new_options)
87
+ __new__(relation, new_options)
88
+ end
89
+
90
+ # Returns if this relation is combined aka a relation graph
91
+ #
92
+ # @return [Boolean]
93
+ #
94
+ # @api private
95
+ def combine?
96
+ meta[:combine_type]
97
+ end
98
+
99
+ # Return if this relation is a composite
100
+ #
101
+ # @return [Boolean]
102
+ #
103
+ # @api private
104
+ def composite?
105
+ relation.is_a?(Relation::Composite)
106
+ end
107
+
108
+ # Returns meta info for the wrapped relation
109
+ #
110
+ # @return [Hash]
111
+ #
112
+ # @api private
113
+ def meta
114
+ options[:meta]
115
+ end
116
+
117
+ # @return [Symbol] The wrapped relation's adapter identifier ie :sql or :http
118
+ #
119
+ # @api private
120
+ def adapter
121
+ relation.class.adapter
122
+ end
123
+
124
+ # Returns AST for the wrapped relation
125
+ #
126
+ # @return [Array]
127
+ #
128
+ # @api private
129
+ def to_ast
130
+ @to_ast ||=
131
+ begin
132
+ attr_ast = (attributes - wraps_attributes).map { |name|
133
+ [:attribute, name]
134
+ }
135
+
136
+ meta = options[:meta].merge(dataset: base_name.dataset)
137
+ meta.delete(:wraps)
138
+
139
+ header = attr_ast + nodes_ast + wraps_ast
140
+
141
+ [:relation, [base_name.relation, meta, [:header, header]]]
142
+ end
143
+ end
144
+
145
+ # @api private
146
+ def respond_to_missing?(meth, _include_private = false)
147
+ relation.respond_to?(meth) || super
148
+ end
149
+
150
+ private
151
+
152
+ # @api private
153
+ def base_name
154
+ relation.base_name
155
+ end
156
+
157
+ # @api private
158
+ def wraps_attributes
159
+ @wrap_attributes ||= wraps.flat_map { |wrap|
160
+ prefix = wrap.base_name.dataset
161
+ wrap.attributes.map { |name| :"#{prefix}_#{name}" }
162
+ }
163
+ end
164
+
165
+ # @api private
166
+ def nodes_ast
167
+ @nodes_ast ||= nodes.map(&:to_ast)
168
+ end
169
+
170
+ # @api private
171
+ def wraps_ast
172
+ @wraps_ast ||= wraps.map(&:to_ast)
173
+ end
174
+
175
+ # Return a new instance with another relation and options
176
+ #
177
+ # @return [RelationProxy]
178
+ #
179
+ # @api private
180
+ def __new__(relation, new_options = EMPTY_HASH)
181
+ self.class.new(
182
+ relation, new_options.size > 0 ? options.merge(new_options) : options
183
+ )
184
+ end
185
+
186
+ # Return all nodes that this relation combines
187
+ #
188
+ # @return [Array<RelationProxy>]
189
+ #
190
+ # @api private
191
+ def nodes
192
+ relation.graph? ? relation.nodes : EMPTY_ARRAY
193
+ end
194
+
195
+ # Return all nodes that this relation wraps
196
+ #
197
+ # @return [Array<RelationProxy>]
198
+ #
199
+ # @api private
200
+ def wraps
201
+ meta.fetch(:wraps, [])
202
+ end
203
+
204
+ # Forward to relation and wrap it with proxy if response was a relation too
205
+ #
206
+ # TODO: this will be simplified once ROM::Relation has lazy-features built-in
207
+ # and ROM::Lazy is gone
208
+ #
209
+ # @api private
210
+ def method_missing(meth, *args, &block)
211
+ if relation.respond_to?(meth)
212
+ result = relation.__send__(meth, *args, &block)
213
+
214
+ if result.kind_of?(Relation::Materializable) && !result.is_a?(Relation::Loaded)
215
+ __new__(result)
216
+ else
217
+ result
218
+ end
219
+ else
220
+ super
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end