rom-repository 0.2.0 → 0.3.0

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