diff --git a/lib/rom/components/core.rb b/lib/rom/components/core.rb index 9936d762e..476f82d7c 100644 --- a/lib/rom/components/core.rb +++ b/lib/rom/components/core.rb @@ -38,7 +38,7 @@ def self.inherited(klass) option :config, type: Types.Instance(Dry::Configurable::Config) # @!attribute [r] gateway - # @return [Proc] Optional dataset evaluation block + # @return [Proc] Optional component evaluation block option :block, type: Types.Interface(:to_proc), optional: true # @api public diff --git a/lib/rom/components/dsl.rb b/lib/rom/components/dsl.rb index 6ad7ba616..e42dfe2df 100644 --- a/lib/rom/components/dsl.rb +++ b/lib/rom/components/dsl.rb @@ -6,6 +6,7 @@ require "rom/components/dsl/dataset" require "rom/components/dsl/schema" require "rom/components/dsl/relation" +require "rom/components/dsl/view" require "rom/components/dsl/association" require "rom/components/dsl/command" require "rom/components/dsl/mapper" @@ -71,6 +72,58 @@ def relation(id, dataset: id, **options, &block) __dsl__(DSL::Relation, id: id, dataset: dataset, **options, &block) end + # Define a relation view with a specific schema + # + # This method should only be used in cases where a given adapter doesn't + # support automatic schema projection at run-time. + # + # @overload view(name, schema, &block) + # @example View with the canonical schema + # class Users < ROM::Relation[:sql] + # view(:listing, schema) do + # order(:name) + # end + # end + # + # @example View with a projected schema + # class Users < ROM::Relation[:sql] + # view(:listing, schema.project(:id, :name)) do + # order(:name) + # end + # end + # + # @overload view(name, &block) + # @example View with the canonical schema and arguments + # class Users < ROM::Relation[:sql] + # view(:by_name) do |name| + # where(name: name) + # end + # end + # + # @example View with projected schema and arguments + # class Users < ROM::Relation[:sql] + # view(:by_name) do + # schema { project(:id, :name) } + # relation { |name| where(name: name) } + # end + # end + # + # @example View with a schema extended with foreign attributes + # class Users < ROM::Relation[:sql] + # view(:index) do + # schema { append(relations[:tasks][:title]) } + # relation { |name| where(name: name) } + # end + # end + # + # @return [Symbol] view method name + # + # @api public + def view(id, *args, &block) + __dsl__(DSL::View, id: id, args: args, &block) + id + end + # Define associations for a relation # # @example diff --git a/lib/rom/components/dsl/schema.rb b/lib/rom/components/dsl/schema.rb index 6233cfd77..0abecb8c6 100644 --- a/lib/rom/components/dsl/schema.rb +++ b/lib/rom/components/dsl/schema.rb @@ -50,7 +50,8 @@ def call plugin.enable(self) unless plugin.enabled? end - instance_eval(&block) if block + # Evaluate block only if it's not a schema defined by Relation.view DSL + instance_eval(&block) if block && !config.view # Apply plugin defaults plugins.each do |plugin| @@ -59,7 +60,7 @@ def call configure - components.add(key, config: config) + components.add(key, config: config, block: config.view ? block : nil) end # @api private diff --git a/lib/rom/components/dsl/view.rb b/lib/rom/components/dsl/view.rb new file mode 100644 index 000000000..d9a1a46a6 --- /dev/null +++ b/lib/rom/components/dsl/view.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require_relative "core" + +module ROM + module Components + module DSL + # @api private + class View < Core + key :views + + # @api private + attr_reader :schema_block + + # @api private + attr_reader :relation_block + + # @see Components::DSL#view + # + # @api public + def schema(&block) + @schema_block = block + self + end + + # @see Components::DSL#view + # + # @api public + def relation(&block) + @relation_block = block + self + end + + # @api private + def call + # Nest view under relation ns + config.join!({namespace: relation_id}, :right) + + if args.empty? && block.arity.positive? + raise ArgumentError, "schema attribute names must be provided as the second argument" + end + + # Capture schema and relation blocks if there are no args + # otherwise assume args is a list of attributes to project + if args.empty? && block + instance_eval(&block) + else + schema { schema.project(*args.first) } + end + + provider.schema( + id: config.id, + namespace: relation_id, + relation: relation_id, + view: true, + &schema_block + ) + + components.add( + key, + config: config, + relation_id: relation_id, + # Default to the block because we assume the schema was set based on args + relation_block: relation_block || block + ) + end + + private + + # @api private + def args + config.args + end + + # @api private + def relation_id + provider.config.component.id + end + end + end + end +end diff --git a/lib/rom/components/relation.rb b/lib/rom/components/relation.rb index 01a3430dc..9dbe6de12 100644 --- a/lib/rom/components/relation.rb +++ b/lib/rom/components/relation.rb @@ -16,6 +16,11 @@ class Relation < Core def build constant.use(:registry_reader, relations: resolver.relation_ids) + # Define view methods if there are any registered view components for this relation + local_components.views(relation_id: id).each do |view| + view.define(constant) + end + trigger("relations.class.ready", relation: constant, adapter: adapter) apply_plugins diff --git a/lib/rom/components/view.rb b/lib/rom/components/view.rb new file mode 100644 index 000000000..a5a09bbe4 --- /dev/null +++ b/lib/rom/components/view.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require_relative "core" + +module ROM + module Components + # @api public + class View < Core + # @!attribute [r] relation_id + # @return [Symbol] Relation runtime identifier + # @api private + option :relation_id + + # @!attribute [r] relation_block + # @return [Proc] Block used for view method definition + # @api private + option :relation_block + + # @return [ROM::Relation] + # + # @api private + def build + resolver.relations[relation_id].public_send(config.id) + end + + # @return [Symbol] + # + # @api private + def define(constant) + _name = config.id + _relation_block = relation_block + + if relation_block&.arity&.positive? + constant.class_eval do + auto_curry_guard do + define_method(_name, &_relation_block) + + auto_curry(_name) do + schemas[_name].(self) + end + end + end + else + constant.class_eval do + define_method(_name) do + schemas[_name].(instance_eval(&_relation_block)) + end + end + end + + _name + end + end + end +end diff --git a/lib/rom/constants.rb b/lib/rom/constants.rb index 4e855830f..7ea9f567f 100644 --- a/lib/rom/constants.rb +++ b/lib/rom/constants.rb @@ -11,6 +11,7 @@ module ROM datasets schemas relations + views associations mappers commands diff --git a/lib/rom/core.rb b/lib/rom/core.rb index 47b228cc6..3a3862d02 100644 --- a/lib/rom/core.rb +++ b/lib/rom/core.rb @@ -11,6 +11,7 @@ require_relative "components/dataset" require_relative "components/schema" require_relative "components/relation" +require_relative "components/view" require_relative "components/association" require_relative "components/command" require_relative "components/mapper" @@ -23,7 +24,8 @@ # @api public module ROM extend Global - extend self + + module_function # Global component setup # @@ -69,6 +71,7 @@ def plugins(*args, &block) register :dataset, Components::Dataset register :schema, Components::Schema register :relation, Components::Relation + register :view, Components::View register :association, Components::Association register :command, Components::Command register :mapper, Components::Mapper diff --git a/lib/rom/relation.rb b/lib/rom/relation.rb index 6186c758d..2e390612e 100644 --- a/lib/rom/relation.rb +++ b/lib/rom/relation.rb @@ -40,7 +40,7 @@ module ROM # # @api public class Relation - extend ROM::Provider(:dataset, :schema, :association, type: :relation) + extend ROM::Provider(:dataset, :schema, :view, :association, type: :relation) extend Initializer extend ClassInterface diff --git a/lib/rom/relation/class_interface.rb b/lib/rom/relation/class_interface.rb index 833bc96fc..3199c01f2 100644 --- a/lib/rom/relation/class_interface.rb +++ b/lib/rom/relation/class_interface.rb @@ -9,7 +9,6 @@ require "rom/constants" require "rom/relation/name" -require "rom/relation/view_dsl" require "rom/schema" module ROM @@ -35,98 +34,6 @@ def [](adapter) raise AdapterNotPresentError.new(adapter, :relation) end - # Define a relation view with a specific schema - # - # This method should only be used in cases where a given adapter doesn't - # support automatic schema projection at run-time. - # - # **It's not needed in rom-sql** - # - # @overload view(name, schema, &block) - # @example View with the canonical schema - # class Users < ROM::Relation[:sql] - # view(:listing, schema) do - # order(:name) - # end - # end - # - # @example View with a projected schema - # class Users < ROM::Relation[:sql] - # view(:listing, schema.project(:id, :name)) do - # order(:name) - # end - # end - # - # @overload view(name, &block) - # @example View with the canonical schema and arguments - # class Users < ROM::Relation[:sql] - # view(:by_name) do |name| - # where(name: name) - # end - # end - # - # @example View with projected schema and arguments - # class Users < ROM::Relation[:sql] - # view(:by_name) do - # schema { project(:id, :name) } - # relation { |name| where(name: name) } - # end - # end - # - # @example View with a schema extended with foreign attributes - # class Users < ROM::Relation[:sql] - # view(:index) do - # schema { append(relations[:tasks][:title]) } - # relation { |name| where(name: name) } - # end - # end - # - # @return [Symbol] view method name - # - # @api public - # - # rubocop:disable Metrics/AbcSize - # rubocop:disable Metrics/PerceivedComplexity - def view(*args, &block) - if args.size == 1 && block.arity.positive? - raise ArgumentError, "schema attribute names must be provided as the second argument" - end - - name, schema_block, relation_block = - if args.size == 1 - ViewDSL.new(*args, &block).call - else - [*args, block] - end - - block = - if args.size == 2 - -> _ { schema.project(*args[1]) } - else - schema_block - end - - schema(id: name, relation: config.component.id, view: true, &block) - - if relation_block.arity.positive? - auto_curry_guard do - define_method(name, &relation_block) - - auto_curry(name) do - schemas[name].(self) - end - end - else - define_method(name) do - schemas[name].(instance_exec(&relation_block)) - end - end - - name - end - # rubocop:enable Metrics/AbcSize - # rubocop:enable Metrics/PerceivedComplexity - # Dynamically define a method that will forward to the dataset and wrap # response in the relation itself # diff --git a/lib/rom/relation/view_dsl.rb b/lib/rom/relation/view_dsl.rb deleted file mode 100644 index c5c667b4d..000000000 --- a/lib/rom/relation/view_dsl.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -module ROM - class Relation - # ViewDSL is exposed in `Relation.view` method - # - # This is used to establish pre-defined relation views with explicit schemas. - # Such views can be used to compose relations together, even from multiple - # adapters. In advanced adapters like rom-sql using view DSL is not required though, - # as relation schemas are dynamic and they always represent current tuple structure. - # - # @api public - class ViewDSL - # @!attribute [r] name - # @return [Symbol] The view name (relation method) - attr_reader :name - - # @!attribute [r] relation_block - # @return [Proc] The relation block that will be evaluated by the view method - attr_reader :relation_block - - # @!attribute [r] new_schema - # @return [Proc] The schema proc returned by the schema DSL - attr_reader :schema_block - - # @api private - def initialize(name, &block) - @name = name - @schema_block = nil - @relation_block = nil - instance_eval(&block) - end - - # Define a schema for a relation view - # - # @return [Proc] - # - # @see Relation::ClassInterface.view - # - # @api public - def schema(&block) - @schema_block = block - end - - # Define a relation block for a relation view - # - # @return [Proc] - # - # @see Relation::ClassInterface.view - # - # @api public - def relation(&block) - @relation_block = block - end - - # Return procs captured by the DSL - # - # @return [Array] - # - # @api private - def call - [name, schema_block, relation_block] - end - end - end -end diff --git a/lib/rom/settings.rb b/lib/rom/settings.rb index ba0dbff0b..dcece87b2 100644 --- a/lib/rom/settings.rb +++ b/lib/rom/settings.rb @@ -71,6 +71,14 @@ module ROM setting :plugins, default: EMPTY_ARRAY, inherit: true end + # Relation view defaults + setting :view do + setting :type, default: :view + setting :id + setting :namespace, default: "views", join: true + setting :args, default: [].freeze + end + # Association defaults setting :association do setting :type, default: :association diff --git a/spec/suite/rom/relation/class_interface/view_spec.rb b/spec/suite/rom/relation/class_interface/view_spec.rb index c9c1f6890..80b6530f5 100644 --- a/spec/suite/rom/relation/class_interface/view_spec.rb +++ b/spec/suite/rom/relation/class_interface/view_spec.rb @@ -25,15 +25,13 @@ end it "returns view method name" do - pending "TODO: rework `view` DSL" - klass = Class.new(ROM::Relation[:memory]) { config.component.id = :users schema { attribute :id, ROM::Types::Integer } } - name = klass.view(:by_id, []) { self } + name = klass.view(:by_id) { self } expect(name).to be(:by_id) end @@ -42,17 +40,21 @@ klass = Class.new(ROM::Relation[:memory]) expect { klass.view(:broken) { |r| r } } - .to raise_error(ArgumentError, "schema attribute names must be provided as the second argument") + .to raise_error( + ArgumentError, "schema attribute names must be provided as the second argument" + ) end shared_context "relation with views" do before do - pending "TODO: rework `view` DSL" - relation << {id: 1, name: "Joe"} relation << {id: 2, name: "Jane"} end + it "registers view objects" do + expect(rom["views.users.names"]).to eql(relation.names) + end + it "appends foreign attributes" do expect(relation.schemas[:foreign_attributes].map(&:name)).to eql(%i[id name title]) end @@ -131,6 +133,8 @@ } Class.new(ROM::Memory::Relation) do + config.component.id = :users + config.schema.inferrer = ROM::Schema::DEFAULT_INFERRER.with( attributes_inferrer: attributes_inferrer ) diff --git a/spec/suite/rom/repository/typed_structs_spec.rb b/spec/suite/rom/repository/typed_structs_spec.rb index 30df2503a..cebcd5c87 100644 --- a/spec/suite/rom/repository/typed_structs_spec.rb +++ b/spec/suite/rom/repository/typed_structs_spec.rb @@ -12,8 +12,6 @@ context "typed projections" do before do - pending "TODO: rework `view` DSL" - configuration.relation(:books) do schema(:books, infer: true) @@ -54,12 +52,12 @@ end configuration.commands(:books) do - define(:create) { result(:one) } + define(:create) { config.result = :one } end end # FIXME: this is flaky - xit "loads typed structs" do + it "loads typed structs" do created_book = repo.create(title: :'Hello World', created_at: Time.now) expect(created_book).to be_kind_of(Dry::Struct)