diff --git a/.rubocop.yml b/.rubocop.yml index 4499390ed..5efa9df51 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -297,6 +297,7 @@ Metrics/MethodLength: - 'packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/collection.rb' - 'packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc.rb' - 'packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/Utils/sse_client.rb' + - 'packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb' - 'packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/routes/sse.rb' - 'packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/http/router.rb' diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/composite_datasource.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/composite_datasource.rb index 053e73663..a5a1c9608 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/composite_datasource.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/composite_datasource.rb @@ -1,5 +1,7 @@ module ForestAdminDatasourceCustomizer class CompositeDatasource + attr_reader :datasources + def initialize @datasources = [] end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/datasource_customizer.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/datasource_customizer.rb index 62bcb0610..4ab2fd862 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/datasource_customizer.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/datasource_customizer.rb @@ -1,7 +1,7 @@ module ForestAdminDatasourceCustomizer class DatasourceCustomizer include DSL::DatasourceHelpers - attr_reader :stack, :datasources + attr_reader :stack, :composite_datasource def initialize(_db_config = {}) @composite_datasource = ForestAdminDatasourceCustomizer::CompositeDatasource.new diff --git a/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc.rb b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc.rb index ea6b5e813..054cf4970 100644 --- a/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc.rb +++ b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc.rb @@ -55,6 +55,7 @@ def self.build(options) 'Fatal: Unable to build RPC datasource - no introspection schema was provided and schema fetch failed' end + options.delete(:introspection) ForestAdminDatasourceRpc::Datasource.new(options, schema, schema_polling) end diff --git a/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/datasource.rb b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/datasource.rb index 0feff5981..8e9668a19 100644 --- a/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/datasource.rb +++ b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/datasource.rb @@ -2,7 +2,7 @@ module ForestAdminDatasourceRpc class Datasource < ForestAdminDatasourceToolkit::Datasource include ForestAdminDatasourceRpc::Utils - attr_reader :shared_rpc_client + attr_reader :shared_rpc_client, :rpc_relations def initialize(options, introspection, schema_polling_client = nil) super() diff --git a/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/reconciliate_rpc.rb b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/reconciliate_rpc.rb new file mode 100644 index 000000000..750de72f2 --- /dev/null +++ b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/reconciliate_rpc.rb @@ -0,0 +1,71 @@ +module ForestAdminDatasourceRpc + class ReconciliateRpc < ForestAdminDatasourceCustomizer::Plugins::Plugin + def run(datasource_customizer, _collection_customizer = nil, options = {}) + datasource_customizer.composite_datasource.datasources.each do |datasource| + real_datasource = get_datasource(datasource) + next unless real_datasource.is_a?(ForestAdminDatasourceRpc::Datasource) + + # Disable search for non-searchable collections + real_datasource.collections.each_value do |collection| + unless collection.schema[:searchable] + cz = datasource_customizer.get_collection(get_collection_name(options[:rename], collection.name)) + cz.disable_search + end + end + + # Add relations from rpc_relations + (real_datasource.rpc_relations || {}).each do |collection_name, relations| + collection_name = get_collection_name(options[:rename], collection_name) + cz = datasource_customizer.get_collection(collection_name) + + relations.each do |relation_name, relation_definition| + add_relation(cz, options[:rename], relation_name.to_s, relation_definition) + end + end + end + end + + private + + def get_datasource(datasource) + # can be publication -> rename deco or a custom one + while datasource.is_a?(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator) + datasource = datasource.child_datasource + end + + datasource + end + + def get_collection_name(renames, collection_name) + name = collection_name + + if renames.is_a?(Proc) + name = renames.call(collection_name) + elsif renames.is_a?(Hash) && renames.key?(collection_name.to_s) + name = renames[collection_name.to_s] + end + + name + end + + def add_relation(collection_customizer, renames, relation_name, relation_definition) + relation = relation_definition.transform_keys(&:to_sym) + foreign_collection = get_collection_name(renames, relation[:foreign_collection]) + options = relation.except(:type, :foreign_collection, :through_collection) + + case relation[:type] + when 'ManyToMany' + through_collection = get_collection_name(renames, relation[:through_collection]) + collection_customizer.add_many_to_many_relation(relation_name, foreign_collection, through_collection, options) + when 'OneToMany' + collection_customizer.add_one_to_many_relation(relation_name, foreign_collection, options) + when 'OneToOne' + collection_customizer.add_one_to_one_relation(relation_name, foreign_collection, options) + when 'ManyToOne' + collection_customizer.add_many_to_one_relation(relation_name, foreign_collection, options) + else + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, "Unsupported relation type: #{relation[:type]}" + end + end + end +end diff --git a/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/reconciliate_rpc_spec.rb b/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/reconciliate_rpc_spec.rb new file mode 100644 index 000000000..775f6397b --- /dev/null +++ b/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/reconciliate_rpc_spec.rb @@ -0,0 +1,387 @@ +require 'spec_helper' + +module ForestAdminDatasourceRpc + describe ReconciliateRpc do + let(:plugin) { described_class.new } + let(:collection_customizer) { instance_spy(ForestAdminDatasourceCustomizer::CollectionCustomizer) } + + describe '#run' do + let(:datasource_customizer) { double('DatasourceCustomizer') } # rubocop:disable RSpec/VerifiedDoubles + let(:composite_datasource) { double('CompositeDatasource') } # rubocop:disable RSpec/VerifiedDoubles + let(:rpc_datasource) { double('RpcDatasource') } # rubocop:disable RSpec/VerifiedDoubles + let(:rpc_collection) { double('RpcCollection') } # rubocop:disable RSpec/VerifiedDoubles + + before do + allow(datasource_customizer).to receive_messages(composite_datasource: composite_datasource, get_collection: collection_customizer) + end + + context 'when datasource is not an RPC datasource' do + let(:other_datasource) { instance_double(ForestAdminDatasourceToolkit::Datasource) } + + it 'skips non-RPC datasources' do + allow(composite_datasource).to receive(:datasources).and_return([other_datasource]) + + plugin.run(datasource_customizer) + + expect(datasource_customizer).not_to have_received(:get_collection) + end + end + + context 'when datasource is wrapped in decorators' do + let(:decorator) { double('DatasourceDecorator') } # rubocop:disable RSpec/VerifiedDoubles + + before do + allow(decorator).to receive(:is_a?).with(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator).and_return(true) + allow(decorator).to receive(:child_datasource).and_return(rpc_datasource) + allow(rpc_datasource).to receive(:is_a?).with(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator).and_return(false) + allow(rpc_datasource).to receive(:is_a?).with(ForestAdminDatasourceRpc::Datasource).and_return(true) + allow(rpc_datasource).to receive_messages(collections: {}, rpc_relations: nil) + allow(composite_datasource).to receive(:datasources).and_return([decorator]) + end + + it 'unwraps decorators to find RPC datasource' do + plugin.run(datasource_customizer) + + expect(decorator).to have_received(:child_datasource) + end + end + + context 'when collection is not searchable' do + before do + allow(rpc_datasource).to receive(:is_a?).with(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator).and_return(false) + allow(rpc_datasource).to receive(:is_a?).with(ForestAdminDatasourceRpc::Datasource).and_return(true) + allow(rpc_collection).to receive_messages(name: 'Product', schema: { searchable: false }) + allow(rpc_datasource).to receive_messages(collections: { 'Product' => rpc_collection }, rpc_relations: nil) + allow(composite_datasource).to receive(:datasources).and_return([rpc_datasource]) + end + + it 'disables search on non-searchable collections' do + plugin.run(datasource_customizer) + + expect(collection_customizer).to have_received(:disable_search) + end + end + + context 'when collection is searchable' do + before do + allow(rpc_datasource).to receive(:is_a?).with(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator).and_return(false) + allow(rpc_datasource).to receive(:is_a?).with(ForestAdminDatasourceRpc::Datasource).and_return(true) + allow(rpc_collection).to receive_messages(name: 'Product', schema: { searchable: true }) + allow(rpc_datasource).to receive_messages(collections: { 'Product' => rpc_collection }, rpc_relations: nil) + allow(composite_datasource).to receive(:datasources).and_return([rpc_datasource]) + end + + it 'does not disable search on searchable collections' do + plugin.run(datasource_customizer) + + expect(collection_customizer).not_to have_received(:disable_search) + end + end + + context 'when rpc_relations exist' do + before do + allow(rpc_datasource).to receive(:is_a?).with(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator).and_return(false) + allow(rpc_datasource).to receive(:is_a?).with(ForestAdminDatasourceRpc::Datasource).and_return(true) + allow(rpc_datasource).to receive_messages( + collections: {}, + rpc_relations: { + 'Product' => { + 'manufacturer' => { type: 'ManyToOne', foreign_collection: 'Manufacturer', foreign_key: 'manufacturer_id', foreign_key_target: 'id' } + } + } + ) + allow(composite_datasource).to receive(:datasources).and_return([rpc_datasource]) + end + + it 'adds relations from rpc_relations' do + plugin.run(datasource_customizer) + + expect(collection_customizer).to have_received(:add_many_to_one_relation).with( + 'manufacturer', + 'Manufacturer', + { foreign_key: 'manufacturer_id', foreign_key_target: 'id' } + ) + end + end + + context 'with rename option' do + before do + allow(rpc_datasource).to receive(:is_a?).with(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator).and_return(false) + allow(rpc_datasource).to receive(:is_a?).with(ForestAdminDatasourceRpc::Datasource).and_return(true) + allow(rpc_collection).to receive_messages(name: 'Product', schema: { searchable: false }) + allow(rpc_datasource).to receive_messages(collections: { 'Product' => rpc_collection }, rpc_relations: nil) + allow(composite_datasource).to receive(:datasources).and_return([rpc_datasource]) + end + + it 'uses renamed collection name with Hash' do + plugin.run(datasource_customizer, nil, { rename: { 'Product' => 'RenamedProduct' } }) + + expect(datasource_customizer).to have_received(:get_collection).with('RenamedProduct') + end + + it 'uses renamed collection name with Proc' do + plugin.run(datasource_customizer, nil, { rename: ->(name) { "Prefix_#{name}" } }) + + expect(datasource_customizer).to have_received(:get_collection).with('Prefix_Product') + end + end + end + + describe '#get_collection_name' do + it 'returns original name when renames is nil' do + result = plugin.send(:get_collection_name, nil, 'Product') + + expect(result).to eq('Product') + end + + it 'returns original name when renames Hash does not contain the key' do + result = plugin.send(:get_collection_name, { 'Other' => 'RenamedOther' }, 'Product') + + expect(result).to eq('Product') + end + + it 'returns renamed name when renames Hash contains the key' do + result = plugin.send(:get_collection_name, { 'Product' => 'RenamedProduct' }, 'Product') + + expect(result).to eq('RenamedProduct') + end + + it 'returns renamed name when renames is a Proc' do + result = plugin.send(:get_collection_name, ->(name) { "Prefix_#{name}" }, 'Product') + + expect(result).to eq('Prefix_Product') + end + + it 'handles symbol collection name with string Hash key' do + result = plugin.send(:get_collection_name, { 'Product' => 'RenamedProduct' }, :Product) + + expect(result).to eq('RenamedProduct') + end + end + + describe '#get_datasource' do + it 'returns datasource as-is when not a decorator' do + datasource = double('Datasource') # rubocop:disable RSpec/VerifiedDoubles + allow(datasource).to receive(:is_a?).with(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator).and_return(false) + + result = plugin.send(:get_datasource, datasource) + + expect(result).to eq(datasource) + end + + it 'unwraps single decorator' do + inner_datasource = double('InnerDatasource') # rubocop:disable RSpec/VerifiedDoubles + decorator = double('Decorator') # rubocop:disable RSpec/VerifiedDoubles + + allow(decorator).to receive(:is_a?).with(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator).and_return(true) + allow(decorator).to receive(:child_datasource).and_return(inner_datasource) + allow(inner_datasource).to receive(:is_a?).with(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator).and_return(false) + + result = plugin.send(:get_datasource, decorator) + + expect(result).to eq(inner_datasource) + end + + it 'unwraps nested decorators' do + inner_datasource = double('InnerDatasource') # rubocop:disable RSpec/VerifiedDoubles + inner_decorator = double('InnerDecorator') # rubocop:disable RSpec/VerifiedDoubles + outer_decorator = double('OuterDecorator') # rubocop:disable RSpec/VerifiedDoubles + + allow(outer_decorator).to receive(:is_a?).with(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator).and_return(true) + allow(outer_decorator).to receive(:child_datasource).and_return(inner_decorator) + allow(inner_decorator).to receive(:is_a?).with(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator).and_return(true) + allow(inner_decorator).to receive(:child_datasource).and_return(inner_datasource) + allow(inner_datasource).to receive(:is_a?).with(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator).and_return(false) + + result = plugin.send(:get_datasource, outer_decorator) + + expect(result).to eq(inner_datasource) + end + end + + describe '#add_relation' do + context 'with ManyToOne relation' do + it 'calls add_many_to_one_relation with correct options' do + relation_definition = { + type: 'ManyToOne', + foreign_collection: 'Manufacturer', + foreign_key: 'manufacturer_id', + foreign_key_target: 'id' + } + + plugin.send(:add_relation, collection_customizer, nil, 'manufacturer', relation_definition) + + expect(collection_customizer).to have_received(:add_many_to_one_relation).with( + 'manufacturer', + 'Manufacturer', + { foreign_key: 'manufacturer_id', foreign_key_target: 'id' } + ) + end + + it 'works with string keys' do + relation_definition = { + 'type' => 'ManyToOne', + 'foreign_collection' => 'Manufacturer', + 'foreign_key' => 'manufacturer_id', + 'foreign_key_target' => 'id' + } + + plugin.send(:add_relation, collection_customizer, nil, 'manufacturer', relation_definition) + + expect(collection_customizer).to have_received(:add_many_to_one_relation).with( + 'manufacturer', + 'Manufacturer', + { foreign_key: 'manufacturer_id', foreign_key_target: 'id' } + ) + end + end + + context 'with OneToMany relation' do + it 'calls add_one_to_many_relation with correct options' do + relation_definition = { + type: 'OneToMany', + foreign_collection: 'Product', + origin_key: 'manufacturer_id', + origin_key_target: 'id' + } + + plugin.send(:add_relation, collection_customizer, nil, 'products', relation_definition) + + expect(collection_customizer).to have_received(:add_one_to_many_relation).with( + 'products', + 'Product', + { origin_key: 'manufacturer_id', origin_key_target: 'id' } + ) + end + end + + context 'with OneToOne relation' do + it 'calls add_one_to_one_relation with correct options' do + relation_definition = { + type: 'OneToOne', + foreign_collection: 'Profile', + origin_key: 'user_id', + origin_key_target: 'id' + } + + plugin.send(:add_relation, collection_customizer, nil, 'profile', relation_definition) + + expect(collection_customizer).to have_received(:add_one_to_one_relation).with( + 'profile', + 'Profile', + { origin_key: 'user_id', origin_key_target: 'id' } + ) + end + end + + context 'with ManyToMany relation' do + it 'calls add_many_to_many_relation with correct options' do + relation_definition = { + type: 'ManyToMany', + foreign_collection: 'Tag', + through_collection: 'ProductTag', + foreign_key: 'tag_id', + foreign_key_target: 'id', + origin_key: 'product_id', + origin_key_target: 'id' + } + + plugin.send(:add_relation, collection_customizer, nil, 'tags', relation_definition) + + expect(collection_customizer).to have_received(:add_many_to_many_relation).with( + 'tags', + 'Tag', + 'ProductTag', + { + foreign_key: 'tag_id', + foreign_key_target: 'id', + origin_key: 'product_id', + origin_key_target: 'id' + } + ) + end + end + + context 'with unsupported relation type' do + it 'raises an error' do + relation_definition = { + type: 'InvalidType', + foreign_collection: 'Something' + } + + expect do + plugin.send(:add_relation, collection_customizer, nil, 'invalid', relation_definition) + end.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + 'Unsupported relation type: InvalidType' + ) + end + end + + context 'with rename option' do + it 'renames foreign_collection using Hash' do + relation_definition = { + type: 'ManyToOne', + foreign_collection: 'Manufacturer', + foreign_key: 'manufacturer_id', + foreign_key_target: 'id' + } + renames = { 'Manufacturer' => 'RenamedManufacturer' } + + plugin.send(:add_relation, collection_customizer, renames, 'manufacturer', relation_definition) + + expect(collection_customizer).to have_received(:add_many_to_one_relation).with( + 'manufacturer', + 'RenamedManufacturer', + { foreign_key: 'manufacturer_id', foreign_key_target: 'id' } + ) + end + + it 'renames foreign_collection using Proc' do + relation_definition = { + type: 'ManyToOne', + foreign_collection: 'Manufacturer', + foreign_key: 'manufacturer_id', + foreign_key_target: 'id' + } + renames = ->(name) { "Prefix_#{name}" } + + plugin.send(:add_relation, collection_customizer, renames, 'manufacturer', relation_definition) + + expect(collection_customizer).to have_received(:add_many_to_one_relation).with( + 'manufacturer', + 'Prefix_Manufacturer', + { foreign_key: 'manufacturer_id', foreign_key_target: 'id' } + ) + end + + it 'renames through_collection for ManyToMany' do + relation_definition = { + type: 'ManyToMany', + foreign_collection: 'Tag', + through_collection: 'ProductTag', + foreign_key: 'tag_id', + foreign_key_target: 'id', + origin_key: 'product_id', + origin_key_target: 'id' + } + renames = { 'Tag' => 'RenamedTag', 'ProductTag' => 'RenamedProductTag' } + + plugin.send(:add_relation, collection_customizer, renames, 'tags', relation_definition) + + expect(collection_customizer).to have_received(:add_many_to_many_relation).with( + 'tags', + 'RenamedTag', + 'RenamedProductTag', + { + foreign_key: 'tag_id', + foreign_key_target: 'id', + origin_key: 'product_id', + origin_key_target: 'id' + } + ) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/decorators/datasource_decorator.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/decorators/datasource_decorator.rb index 9042fc1ea..ebb8cd72e 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/decorators/datasource_decorator.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/decorators/datasource_decorator.rb @@ -1,6 +1,8 @@ module ForestAdminDatasourceToolkit module Decorators class DatasourceDecorator + attr_reader :child_datasource + def initialize(child_datasource, collection_decorator_class) @child_datasource = child_datasource @collection_decorator_class = collection_decorator_class diff --git a/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb b/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb index 022d95ad3..a99eccced 100644 --- a/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb +++ b/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb @@ -45,15 +45,6 @@ def mark_collections_as_rpc(*names) self end - # Returns the cached schema for the /rpc-schema route - # Falls back to building schema from datasource if not cached - def rpc_schema - return @cached_schema if @cached_schema - - build_and_cache_rpc_schema_from_datasource - @cached_schema - end - # Check if provided hash matches the cached schema hash def schema_hash_matches?(provided_hash) return false unless @cached_schema_hash && provided_hash @@ -84,19 +75,49 @@ def write_schema_file_for_reference ) end - def build_and_cache_rpc_schema_from_datasource - datasource = @container.resolve(:datasource) - - @cached_schema = build_rpc_schema_from_datasource(datasource) - compute_and_cache_hash - end - def build_rpc_schema_from_datasource(datasource) schema = customizer.schema - schema[:collections] = datasource.collections - .map { |_name, collection| serialize_collection_schema(collection) } - .sort_by { |c| c[:name] } + rpc_relations = {} + collections = [] + + datasource.collections.each_value do |collection| + relations = {} + + if @rpc_collections.include?(collection.name) + # RPC collection → extract relations to non-RPC collections + collection.schema[:fields].each do |field_name, field| + next if field.type == 'Column' + next if @rpc_collections.include?(field.foreign_collection) + + relations[field_name] = field + end + else + fields = {} + + collection.schema[:fields].each do |field_name, field| + if field.type != 'Column' && @rpc_collections.include?(field.foreign_collection) + relations[field_name] = field + else + if field.type == 'Column' + field.filter_operators = ForestAdminAgent::Utils::Schema::FrontendFilterable.sort_operators( + field.filter_operators + ) + end + + fields[field_name] = field + end + end + + # Normal collection → include in schema + collections << collection.schema.merge({ name: collection.name, fields: fields }) + end + + rpc_relations[collection.name] = relations unless relations.empty? + end + + schema[:collections] = collections.sort_by { |c| c[:name] } + schema[:rpc_relations] = rpc_relations schema[:native_query_connections] = datasource.live_query_connections.keys .map { |connection_name| { name: connection_name } } @@ -104,31 +125,6 @@ def build_rpc_schema_from_datasource(datasource) schema end - # Serialize collection schema, converting field objects to plain hashes - def serialize_collection_schema(collection) - schema = collection.schema.dup - schema[:name] = collection.name - schema[:fields] = schema[:fields].transform_values { |field| object_to_hash(field) } - schema - end - - # Convert any object to a hash using its instance variables - def object_to_hash(obj) - return obj if obj.is_a?(Hash) || obj.is_a?(Array) || obj.is_a?(String) || obj.is_a?(Numeric) || obj.nil? - - hash = obj.instance_variables.to_h do |var| - [var.to_s.delete('@').to_sym, obj.instance_variable_get(var)] - end - - if hash[:filter_operators].is_a?(Array) - hash[:filter_operators] = ForestAdminAgent::Utils::Schema::FrontendFilterable.sort_operators( - hash[:filter_operators] - ) - end - - hash - end - def compute_and_cache_hash return unless @cached_schema diff --git a/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/routes/schema.rb b/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/routes/schema.rb index ee1b3c3ca..1bf4a7141 100644 --- a/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/routes/schema.rb +++ b/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/routes/schema.rb @@ -27,7 +27,7 @@ def handle_request(args) end # Get schema from cache (or build from datasource if not cached) - schema = agent.rpc_schema + schema = agent.cached_schema etag = agent.cached_schema_hash # Return schema with ETag header diff --git a/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/agent_spec.rb b/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/agent_spec.rb index dec5fc8e2..f79322aec 100644 --- a/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/agent_spec.rb +++ b/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/agent_spec.rb @@ -200,5 +200,194 @@ module ForestAdminRpcAgent expect(result).to eq(instance) end end + + describe '#schema_hash_matches?' do + let(:customizer) { instance_double(ForestAdminDatasourceCustomizer::DatasourceCustomizer) } + + before do + instance.container.register(:datasource, datasource, replace: true) + allow(instance).to receive(:customizer).and_return(customizer) + allow(customizer).to receive(:schema).and_return({}) + allow(datasource).to receive_messages(collections: {}, live_query_connections: {}) + allow(ForestAdminRpcAgent::Facades::Container).to receive(:cache) do |key| + { skip_schema_update: false, schema_path: '/tmp/test-schema.json', is_production: true }[key] + end + end + + it 'returns false when cached_schema_hash is nil' do + expect(instance.schema_hash_matches?('some_hash')).to be false + end + + it 'returns false when provided_hash is nil' do + instance.send_schema + expect(instance.schema_hash_matches?(nil)).to be false + end + + it 'returns true when hashes match' do + instance.send_schema + cached_hash = instance.cached_schema_hash + + expect(instance.schema_hash_matches?(cached_hash)).to be true + end + + it 'returns false when hashes do not match' do + instance.send_schema + + expect(instance.schema_hash_matches?('wrong_hash')).to be false + end + end + + describe '#build_rpc_schema_from_datasource' do + let(:customizer) { instance_double(ForestAdminDatasourceCustomizer::DatasourceCustomizer) } + + before do + instance.container.register(:datasource, datasource, replace: true) + allow(instance).to receive(:customizer).and_return(customizer) + allow(customizer).to receive(:schema).and_return({}) + allow(ForestAdminRpcAgent::Facades::Container).to receive(:cache) do |key| + { skip_schema_update: false, schema_path: '/tmp/test-schema.json', is_production: true }[key] + end + end + + it 'excludes RPC collections from schema collections' do + rpc_collection = instance_double(ForestAdminDatasourceToolkit::Collection) + normal_collection = instance_double(ForestAdminDatasourceToolkit::Collection) + + allow(rpc_collection).to receive_messages(name: 'RpcCollection', schema: { fields: {} }) + allow(normal_collection).to receive_messages(name: 'NormalCollection', schema: { fields: {} }) + allow(datasource).to receive_messages( + collections: { 'RpcCollection' => rpc_collection, 'NormalCollection' => normal_collection }, + live_query_connections: {} + ) + + instance.mark_collections_as_rpc('RpcCollection') + instance.send_schema + + collection_names = instance.cached_schema[:collections].map { |c| c[:name] } + expect(collection_names).to include('NormalCollection') + expect(collection_names).not_to include('RpcCollection') + end + + it 'extracts relations from RPC collections to non-RPC collections into rpc_relations' do + rpc_collection = instance_double(ForestAdminDatasourceToolkit::Collection) + normal_collection = instance_double(ForestAdminDatasourceToolkit::Collection) + + relation_field = instance_double(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + allow(relation_field).to receive_messages( + type: 'ManyToOne', + foreign_collection: 'NormalCollection' + ) + + allow(rpc_collection).to receive_messages( + name: 'RpcCollection', + schema: { fields: { 'normal' => relation_field } } + ) + allow(normal_collection).to receive_messages(name: 'NormalCollection', schema: { fields: {} }) + allow(datasource).to receive_messages( + collections: { 'RpcCollection' => rpc_collection, 'NormalCollection' => normal_collection }, + live_query_connections: {} + ) + + instance.mark_collections_as_rpc('RpcCollection') + instance.send_schema + + expect(instance.cached_schema[:rpc_relations]).to have_key('RpcCollection') + expect(instance.cached_schema[:rpc_relations]['RpcCollection']).to have_key('normal') + end + + it 'extracts relations from normal collections to RPC collections into rpc_relations' do + rpc_collection = instance_double(ForestAdminDatasourceToolkit::Collection) + normal_collection = instance_double(ForestAdminDatasourceToolkit::Collection) + + relation_field = instance_double(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + allow(relation_field).to receive_messages( + type: 'ManyToOne', + foreign_collection: 'RpcCollection' + ) + + allow(rpc_collection).to receive_messages(name: 'RpcCollection', schema: { fields: {} }) + allow(normal_collection).to receive_messages( + name: 'NormalCollection', + schema: { fields: { 'rpc_ref' => relation_field } } + ) + allow(datasource).to receive_messages( + collections: { 'RpcCollection' => rpc_collection, 'NormalCollection' => normal_collection }, + live_query_connections: {} + ) + + instance.mark_collections_as_rpc('RpcCollection') + instance.send_schema + + expect(instance.cached_schema[:rpc_relations]).to have_key('NormalCollection') + expect(instance.cached_schema[:rpc_relations]['NormalCollection']).to have_key('rpc_ref') + end + + it 'does not extract relations between RPC collections' do + rpc_collection1 = instance_double(ForestAdminDatasourceToolkit::Collection) + rpc_collection2 = instance_double(ForestAdminDatasourceToolkit::Collection) + + relation_field = instance_double(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + allow(relation_field).to receive_messages( + type: 'ManyToOne', + foreign_collection: 'RpcCollection2' + ) + + allow(rpc_collection1).to receive_messages( + name: 'RpcCollection1', + schema: { fields: { 'other_rpc' => relation_field } } + ) + allow(rpc_collection2).to receive_messages(name: 'RpcCollection2', schema: { fields: {} }) + allow(datasource).to receive_messages( + collections: { 'RpcCollection1' => rpc_collection1, 'RpcCollection2' => rpc_collection2 }, + live_query_connections: {} + ) + + instance.mark_collections_as_rpc('RpcCollection1', 'RpcCollection2') + instance.send_schema + + rpc_relations_for_rpc1 = instance.cached_schema[:rpc_relations]['RpcCollection1'] || {} + expect(rpc_relations_for_rpc1).not_to have_key('other_rpc') + end + + it 'sorts filter_operators for Column fields' do + normal_collection = instance_double(ForestAdminDatasourceToolkit::Collection) + column_field = instance_double(ForestAdminDatasourceToolkit::Schema::ColumnSchema) + + allow(column_field).to receive_messages(type: 'Column') + allow(column_field).to receive(:filter_operators=) + allow(column_field).to receive(:filter_operators).and_return(%w[equal greater_than less_than]) + allow(ForestAdminAgent::Utils::Schema::FrontendFilterable).to receive(:sort_operators) + .and_return(%w[equal greater_than less_than]) + + allow(normal_collection).to receive_messages( + name: 'NormalCollection', + schema: { fields: { 'id' => column_field } } + ) + allow(datasource).to receive_messages( + collections: { 'NormalCollection' => normal_collection }, + live_query_connections: {} + ) + + instance.send_schema + + expect(ForestAdminAgent::Utils::Schema::FrontendFilterable).to have_received(:sort_operators) + end + + it 'includes native_query_connections in schema' do + normal_collection = instance_double(ForestAdminDatasourceToolkit::Collection) + allow(normal_collection).to receive_messages(name: 'NormalCollection', schema: { fields: {} }) + allow(datasource).to receive_messages( + collections: { 'NormalCollection' => normal_collection }, + live_query_connections: { 'main' => {}, 'secondary' => {} } + ) + + instance.send_schema + + expect(instance.cached_schema[:native_query_connections]).to contain_exactly( + { name: 'main' }, + { name: 'secondary' } + ) + end + end end end diff --git a/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/routes/schema_spec.rb b/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/routes/schema_spec.rb index d502daf61..eddd125e5 100644 --- a/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/routes/schema_spec.rb +++ b/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/routes/schema_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'rack' module ForestAdminRpcAgent module Routes @@ -26,7 +27,7 @@ module Routes allow(ForestAdminRpcAgent::Agent).to receive(:instance).and_return(agent) allow(ForestAdminRpcAgent::Facades::Container).to receive(:logger).and_return(logger) allow(logger).to receive(:log) - allow(agent).to receive_messages(rpc_schema: cached_schema, + allow(agent).to receive_messages(cached_schema: cached_schema, cached_schema_hash: cached_hash, schema_hash_matches?: false) end @@ -42,7 +43,7 @@ module Routes context 'when client provides matching If-None-Match header' do let(:mock_request) do - instance_double(Rack::Request, get_header: %("#{cached_hash}")) + instance_double(::Rack::Request, get_header: %("#{cached_hash}")) end before do @@ -66,7 +67,7 @@ module Routes context 'when client provides non-matching If-None-Match header' do let(:mock_request) do - instance_double(Rack::Request, get_header: '"different_hash"') + instance_double(::Rack::Request, get_header: '"different_hash"') end it 'returns the full schema with ETag header' do @@ -80,7 +81,7 @@ module Routes context 'when client does not provide If-None-Match header' do let(:mock_request) do - instance_double(Rack::Request, get_header: nil) + instance_double(::Rack::Request, get_header: nil) end it 'returns the full schema' do