From b15df3e4369d8036e38fd5308d620ffbe325b6dd Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Tue, 9 Dec 2025 16:50:15 +0100 Subject: [PATCH 01/13] feat: handle rpc relations --- .../lib/forest_admin_rpc_agent/agent.rb | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) 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..00878fdad 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 @@ -94,9 +94,31 @@ def build_and_cache_rpc_schema_from_datasource 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] } + schema = agent.customizer.schema + rpc_collections = agent.rpc_collections || [] + + rpc_relations = {} + collections = [] + + datasource.collections.each do |_name, collection| + if rpc_collections.include?(collection.name) + # RPC collection → extract relations to non-RPC collections + relations = {} + 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 + rpc_relations[collection.name] = relations unless relations.empty? + else + # Normal collection → include in schema + collections << collection.schema.merge({ name: collection.name }) + end + 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 } } From 95ec2b6f4620cf1fe875519a14d8a10c4897ffe0 Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Thu, 11 Dec 2025 10:11:46 +0100 Subject: [PATCH 02/13] fix: add econciliate plugin --- .../reconciliate_rpc.rb | 76 +++++++++++++++++++ .../lib/forest_admin_rpc_agent/agent.rb | 7 +- 2 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/reconciliate_rpc.rb 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..6de64d797 --- /dev/null +++ b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/reconciliate_rpc.rb @@ -0,0 +1,76 @@ +module ForestAdminDatasourceRpc + class ReconciliateRpc < ForestAdminDatasourceCustomizer::Plugins::Plugin + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + datasource_customizer.datasources.each do |datasource| + next unless datasource.is_a?(ForestAdminDatasourceRpc::Datasource) + + # Disable search for non-searchable collections + datasource.collections.each do |_name, collection| + unless collection.schema[:searchable] + cz = datasource_customizer.get_collection(collection.name) + cz.disable_search + end + end + + # Add relations from rpc_relations + (datasource.rpc_relations || {}).each do |collection_name, relations| + cz = datasource_customizer.get_collection(collection_name) + + relations.each do |relation_name, relation_definition| + add_relation(cz, relation_name, relation_definition) + end + end + end + end + + private + + def add_relation(collection_customizer, relation_name, relation_definition) + type = relation_definition[:type] || relation_definition['type'] + foreign_collection = relation_definition[:foreign_collection] || relation_definition['foreign_collection'] + + case type + when 'ManyToMany' + through_collection = relation_definition[:through_collection] || relation_definition['through_collection'] + collection_customizer.add_many_to_many_relation( + relation_name, + foreign_collection, + through_collection, + { + foreign_key: relation_definition[:foreign_key] || relation_definition['foreign_key'], + foreign_key_target: relation_definition[:foreign_key_target] || relation_definition['foreign_key_target'], + origin_key: relation_definition[:origin_key] || relation_definition['origin_key'], + origin_key_target: relation_definition[:origin_key_target] || relation_definition['origin_key_target'] + } + ) + when 'OneToMany' + collection_customizer.add_one_to_many_relation( + relation_name, + foreign_collection, + { + origin_key: relation_definition[:origin_key] || relation_definition['origin_key'], + origin_key_target: relation_definition[:origin_key_target] || relation_definition['origin_key_target'] + } + ) + when 'OneToOne' + collection_customizer.add_one_to_one_relation( + relation_name, + foreign_collection, + { + origin_key: relation_definition[:origin_key] || relation_definition['origin_key'], + origin_key_target: relation_definition[:origin_key_target] || relation_definition['origin_key_target'] + } + ) + else # ManyToOne + collection_customizer.add_many_to_one_relation( + relation_name, + foreign_collection, + { + foreign_key: relation_definition[:foreign_key] || relation_definition['foreign_key'], + foreign_key_target: relation_definition[:foreign_key_target] || relation_definition['foreign_key_target'] + } + ) + end + end + end +end 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 00878fdad..4ff56d86d 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 @@ -94,19 +94,16 @@ def build_and_cache_rpc_schema_from_datasource def build_rpc_schema_from_datasource(datasource) schema = customizer.schema - schema = agent.customizer.schema - rpc_collections = agent.rpc_collections || [] - rpc_relations = {} collections = [] datasource.collections.each do |_name, collection| - if rpc_collections.include?(collection.name) + if @rpc_collections.include?(collection.name) # RPC collection → extract relations to non-RPC collections relations = {} collection.schema[:fields].each do |field_name, field| next if field.type == 'Column' - next if rpc_collections.include?(field.foreign_collection) + next if @rpc_collections.include?(field.foreign_collection) relations[field_name] = field end From 18acf8571de3a26918b01d995a250800da419e9a Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Tue, 23 Dec 2025 14:38:13 +0100 Subject: [PATCH 03/13] fix: access datasource --- .../forest_admin_datasource_customizer/composite_datasource.rb | 2 ++ .../forest_admin_datasource_customizer/datasource_customizer.rb | 2 +- .../lib/forest_admin_datasource_rpc/reconciliate_rpc.rb | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) 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/reconciliate_rpc.rb b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/reconciliate_rpc.rb index 6de64d797..d32a68341 100644 --- 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 @@ -1,7 +1,7 @@ module ForestAdminDatasourceRpc class ReconciliateRpc < ForestAdminDatasourceCustomizer::Plugins::Plugin def run(datasource_customizer, _collection_customizer = nil, _options = {}) - datasource_customizer.datasources.each do |datasource| + datasource_customizer.composite_datasource.datasources.each do |datasource| next unless datasource.is_a?(ForestAdminDatasourceRpc::Datasource) # Disable search for non-searchable collections From af19ddc44f09a9f0a41ca5dff1b3412fb7837fcf Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Tue, 23 Dec 2025 16:26:15 +0100 Subject: [PATCH 04/13] chore: fix perf after introspection --- .../lib/forest_admin_datasource_rpc.rb | 1 + 1 file changed, 1 insertion(+) 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 From 307f80850b98ffd8acf1d4624e3d72ed2913c2bb Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Wed, 24 Dec 2025 11:39:14 +0100 Subject: [PATCH 05/13] fix: attribute access --- .../lib/forest_admin_datasource_rpc/datasource.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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() From c5d0d43ff15ca404c9c6d629648873836f75047b Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Wed, 24 Dec 2025 14:49:37 +0100 Subject: [PATCH 06/13] chore: improve rel --- .../lib/forest_admin_rpc_agent/agent.rb | 65 ++++++------------- .../forest_admin_rpc_agent/routes/schema.rb | 2 +- 2 files changed, 22 insertions(+), 45 deletions(-) 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 4ff56d86d..9ee6edd90 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,13 +75,6 @@ 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 @@ -98,20 +82,38 @@ def build_rpc_schema_from_datasource(datasource) collections = [] datasource.collections.each do |_name, collection| + relations = {} + if @rpc_collections.include?(collection.name) # RPC collection → extract relations to non-RPC collections - relations = {} 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 - rpc_relations[collection.name] = relations unless relations.empty? 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 }) + 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] } @@ -123,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 From 189c74fcdfebe0d7462450621d88e74ab2a12962 Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Wed, 24 Dec 2025 17:08:52 +0100 Subject: [PATCH 07/13] fix: add rename options to reconciliate pluggin --- .../reconciliate_rpc.rb | 41 +++++++++++++++---- .../decorators/datasource_decorator.rb | 2 + 2 files changed, 34 insertions(+), 9 deletions(-) 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 index d32a68341..6493a0f87 100644 --- 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 @@ -1,23 +1,25 @@ module ForestAdminDatasourceRpc class ReconciliateRpc < ForestAdminDatasourceCustomizer::Plugins::Plugin - def run(datasource_customizer, _collection_customizer = nil, _options = {}) + def run(datasource_customizer, _collection_customizer = nil, options = {}) datasource_customizer.composite_datasource.datasources.each do |datasource| - next unless datasource.is_a?(ForestAdminDatasourceRpc::Datasource) + real_datasource = get_datasource(datasource) + next unless real_datasource.is_a?(ForestAdminDatasourceRpc::Datasource) # Disable search for non-searchable collections - datasource.collections.each do |_name, collection| + real_datasource.collections.each do |_name, collection| unless collection.schema[:searchable] - cz = datasource_customizer.get_collection(collection.name) + cz = datasource_customizer.get_collection(get_collection_name(options[:rename], collection.name)) cz.disable_search end end # Add relations from rpc_relations - (datasource.rpc_relations || {}).each do |collection_name, 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, relation_name, relation_definition) + add_relation(cz, options[:rename], relation_name.to_s, relation_definition) end end end @@ -25,13 +27,34 @@ def run(datasource_customizer, _collection_customizer = nil, _options = {}) private - def add_relation(collection_customizer, relation_name, relation_definition) + def get_datasource(datasource) + # can be publication -> rename deco or a custom one + while datasource.is_a?(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator) do + 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) + else 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) type = relation_definition[:type] || relation_definition['type'] - foreign_collection = relation_definition[:foreign_collection] || relation_definition['foreign_collection'] + foreign_collection = get_collection_name(renames, relation_definition[:foreign_collection] || relation_definition['foreign_collection']) case type when 'ManyToMany' - through_collection = relation_definition[:through_collection] || relation_definition['through_collection'] + through_collection = get_collection_name(renames, relation_definition[:through_collection] || relation_definition['through_collection']) collection_customizer.add_many_to_many_relation( relation_name, foreign_collection, 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 From cc6b9fb2a1e7280f568efa58d7e880e31a709201 Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Wed, 24 Dec 2025 18:06:34 +0100 Subject: [PATCH 08/13] fix: lint --- .rubocop.yml | 1 + .../forest_admin_datasource_rpc/reconciliate_rpc.rb | 12 +++++++----- .../lib/forest_admin_rpc_agent/agent.rb | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) 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_rpc/lib/forest_admin_datasource_rpc/reconciliate_rpc.rb b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/reconciliate_rpc.rb index 6493a0f87..d033e1d08 100644 --- 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 @@ -6,7 +6,7 @@ def run(datasource_customizer, _collection_customizer = nil, options = {}) next unless real_datasource.is_a?(ForestAdminDatasourceRpc::Datasource) # Disable search for non-searchable collections - real_datasource.collections.each do |_name, collection| + 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 @@ -29,7 +29,7 @@ def run(datasource_customizer, _collection_customizer = nil, options = {}) def get_datasource(datasource) # can be publication -> rename deco or a custom one - while datasource.is_a?(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator) do + while datasource.is_a?(ForestAdminDatasourceToolkit::Decorators::DatasourceDecorator) datasource = datasource.child_datasource end @@ -41,7 +41,7 @@ def get_collection_name(renames, collection_name) if renames.is_a?(Proc) name = renames.call(collection_name) - else renames.is_a?(Hash) && renames.key?(collection_name.to_s) + elsif renames.is_a?(Hash) && renames.key?(collection_name.to_s) name = renames[collection_name.to_s] end @@ -50,11 +50,13 @@ def get_collection_name(renames, collection_name) def add_relation(collection_customizer, renames, relation_name, relation_definition) type = relation_definition[:type] || relation_definition['type'] - foreign_collection = get_collection_name(renames, relation_definition[:foreign_collection] || relation_definition['foreign_collection']) + foreign_collection_name = relation_definition[:foreign_collection] || relation_definition['foreign_collection'] + foreign_collection = get_collection_name(renames, foreign_collection_name) case type when 'ManyToMany' - through_collection = get_collection_name(renames, relation_definition[:through_collection] || relation_definition['through_collection']) + through_collection_name = relation_definition[:through_collection] || relation_definition['through_collection'] + through_collection = get_collection_name(renames, through_collection_name) collection_customizer.add_many_to_many_relation( relation_name, foreign_collection, 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 9ee6edd90..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 @@ -81,7 +81,7 @@ def build_rpc_schema_from_datasource(datasource) rpc_relations = {} collections = [] - datasource.collections.each do |_name, collection| + datasource.collections.each_value do |collection| relations = {} if @rpc_collections.include?(collection.name) @@ -99,7 +99,7 @@ def build_rpc_schema_from_datasource(datasource) if field.type != 'Column' && @rpc_collections.include?(field.foreign_collection) relations[field_name] = field else - if (field.type == 'Column') + if field.type == 'Column' field.filter_operators = ForestAdminAgent::Utils::Schema::FrontendFilterable.sort_operators( field.filter_operators ) From ab8202ce4bccdf10cf7df6eacb48e1a8621e6ad9 Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Mon, 5 Jan 2026 15:58:06 +0100 Subject: [PATCH 09/13] test: fix test --- .../lib/forest_admin_rpc_agent/routes/schema_spec.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 From 70b3ab0eafc25475847fb8f63049a964abb7d62f Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Tue, 6 Jan 2026 15:37:25 +0100 Subject: [PATCH 10/13] chore: refactore add_relation plugin --- .../reconciliate_rpc.rb | 54 +++++-------------- 1 file changed, 12 insertions(+), 42 deletions(-) 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 index d033e1d08..750de72f2 100644 --- 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 @@ -49,52 +49,22 @@ def get_collection_name(renames, collection_name) end def add_relation(collection_customizer, renames, relation_name, relation_definition) - type = relation_definition[:type] || relation_definition['type'] - foreign_collection_name = relation_definition[:foreign_collection] || relation_definition['foreign_collection'] - foreign_collection = get_collection_name(renames, foreign_collection_name) + 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 type + case relation[:type] when 'ManyToMany' - through_collection_name = relation_definition[:through_collection] || relation_definition['through_collection'] - through_collection = get_collection_name(renames, through_collection_name) - collection_customizer.add_many_to_many_relation( - relation_name, - foreign_collection, - through_collection, - { - foreign_key: relation_definition[:foreign_key] || relation_definition['foreign_key'], - foreign_key_target: relation_definition[:foreign_key_target] || relation_definition['foreign_key_target'], - origin_key: relation_definition[:origin_key] || relation_definition['origin_key'], - origin_key_target: relation_definition[:origin_key_target] || relation_definition['origin_key_target'] - } - ) + 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, - { - origin_key: relation_definition[:origin_key] || relation_definition['origin_key'], - origin_key_target: relation_definition[:origin_key_target] || relation_definition['origin_key_target'] - } - ) + 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, - { - origin_key: relation_definition[:origin_key] || relation_definition['origin_key'], - origin_key_target: relation_definition[:origin_key_target] || relation_definition['origin_key_target'] - } - ) - else # ManyToOne - collection_customizer.add_many_to_one_relation( - relation_name, - foreign_collection, - { - foreign_key: relation_definition[:foreign_key] || relation_definition['foreign_key'], - foreign_key_target: relation_definition[:foreign_key_target] || relation_definition['foreign_key_target'] - } - ) + 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 From 0997e277276d1f9e9f301f07b128c7f1d6feae8d Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Tue, 6 Jan 2026 16:19:36 +0100 Subject: [PATCH 11/13] test: add test --- .../reconciliate_rpc_spec.rb | 193 ++++++++++++++++++ .../lib/forest_admin_rpc_agent/agent_spec.rb | 189 +++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/reconciliate_rpc_spec.rb 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..2cbc1d252 --- /dev/null +++ b/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/reconciliate_rpc_spec.rb @@ -0,0 +1,193 @@ +require 'spec_helper' + +module ForestAdminDatasourceRpc + describe ReconciliateRpc do + let(:plugin) { described_class.new } + let(:collection_customizer) { instance_double(ForestAdminDatasourceCustomizer::CollectionCustomizer) } + + 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' + } + + expect(collection_customizer).to receive(:add_many_to_one_relation).with( + 'manufacturer', + 'Manufacturer', + { foreign_key: 'manufacturer_id', foreign_key_target: 'id' } + ) + + plugin.send(:add_relation, collection_customizer, nil, 'manufacturer', relation_definition) + end + + it 'works with string keys' do + relation_definition = { + 'type' => 'ManyToOne', + 'foreign_collection' => 'Manufacturer', + 'foreign_key' => 'manufacturer_id', + 'foreign_key_target' => 'id' + } + + expect(collection_customizer).to receive(:add_many_to_one_relation).with( + 'manufacturer', + 'Manufacturer', + { foreign_key: 'manufacturer_id', foreign_key_target: 'id' } + ) + + plugin.send(:add_relation, collection_customizer, nil, 'manufacturer', relation_definition) + 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' + } + + expect(collection_customizer).to receive(:add_one_to_many_relation).with( + 'products', + 'Product', + { origin_key: 'manufacturer_id', origin_key_target: 'id' } + ) + + plugin.send(:add_relation, collection_customizer, nil, 'products', relation_definition) + 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' + } + + expect(collection_customizer).to receive(:add_one_to_one_relation).with( + 'profile', + 'Profile', + { origin_key: 'user_id', origin_key_target: 'id' } + ) + + plugin.send(:add_relation, collection_customizer, nil, 'profile', relation_definition) + 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' + } + + expect(collection_customizer).to receive(: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' + } + ) + + plugin.send(:add_relation, collection_customizer, nil, 'tags', relation_definition) + 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' } + + expect(collection_customizer).to receive(:add_many_to_one_relation).with( + 'manufacturer', + 'RenamedManufacturer', + { foreign_key: 'manufacturer_id', foreign_key_target: 'id' } + ) + + plugin.send(:add_relation, collection_customizer, renames, 'manufacturer', relation_definition) + 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}" } + + expect(collection_customizer).to receive(:add_many_to_one_relation).with( + 'manufacturer', + 'Prefix_Manufacturer', + { foreign_key: 'manufacturer_id', foreign_key_target: 'id' } + ) + + plugin.send(:add_relation, collection_customizer, renames, 'manufacturer', relation_definition) + 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' } + + expect(collection_customizer).to receive(: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' + } + ) + + plugin.send(:add_relation, collection_customizer, renames, 'tags', relation_definition) + end + end + end + end +end 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 From 29fcdcd597cec8089041e71bae06a98fa8706b5a Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Tue, 6 Jan 2026 16:42:34 +0100 Subject: [PATCH 12/13] test: fix test --- .../reconciliate_rpc_spec.rb | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) 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 index 2cbc1d252..281aeea9f 100644 --- 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 @@ -3,7 +3,7 @@ module ForestAdminDatasourceRpc describe ReconciliateRpc do let(:plugin) { described_class.new } - let(:collection_customizer) { instance_double(ForestAdminDatasourceCustomizer::CollectionCustomizer) } + let(:collection_customizer) { instance_spy(ForestAdminDatasourceCustomizer::CollectionCustomizer) } describe '#add_relation' do context 'with ManyToOne relation' do @@ -15,13 +15,13 @@ module ForestAdminDatasourceRpc foreign_key_target: 'id' } - expect(collection_customizer).to receive(:add_many_to_one_relation).with( + 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' } ) - - plugin.send(:add_relation, collection_customizer, nil, 'manufacturer', relation_definition) end it 'works with string keys' do @@ -32,13 +32,13 @@ module ForestAdminDatasourceRpc 'foreign_key_target' => 'id' } - expect(collection_customizer).to receive(:add_many_to_one_relation).with( + 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' } ) - - plugin.send(:add_relation, collection_customizer, nil, 'manufacturer', relation_definition) end end @@ -51,13 +51,13 @@ module ForestAdminDatasourceRpc origin_key_target: 'id' } - expect(collection_customizer).to receive(:add_one_to_many_relation).with( + 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' } ) - - plugin.send(:add_relation, collection_customizer, nil, 'products', relation_definition) end end @@ -70,13 +70,13 @@ module ForestAdminDatasourceRpc origin_key_target: 'id' } - expect(collection_customizer).to receive(:add_one_to_one_relation).with( + 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' } ) - - plugin.send(:add_relation, collection_customizer, nil, 'profile', relation_definition) end end @@ -92,7 +92,9 @@ module ForestAdminDatasourceRpc origin_key_target: 'id' } - expect(collection_customizer).to receive(:add_many_to_many_relation).with( + 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', @@ -103,8 +105,6 @@ module ForestAdminDatasourceRpc origin_key_target: 'id' } ) - - plugin.send(:add_relation, collection_customizer, nil, 'tags', relation_definition) end end @@ -134,13 +134,13 @@ module ForestAdminDatasourceRpc } renames = { 'Manufacturer' => 'RenamedManufacturer' } - expect(collection_customizer).to receive(:add_many_to_one_relation).with( + 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' } ) - - plugin.send(:add_relation, collection_customizer, renames, 'manufacturer', relation_definition) end it 'renames foreign_collection using Proc' do @@ -152,13 +152,13 @@ module ForestAdminDatasourceRpc } renames = ->(name) { "Prefix_#{name}" } - expect(collection_customizer).to receive(:add_many_to_one_relation).with( + 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' } ) - - plugin.send(:add_relation, collection_customizer, renames, 'manufacturer', relation_definition) end it 'renames through_collection for ManyToMany' do @@ -173,7 +173,9 @@ module ForestAdminDatasourceRpc } renames = { 'Tag' => 'RenamedTag', 'ProductTag' => 'RenamedProductTag' } - expect(collection_customizer).to receive(:add_many_to_many_relation).with( + 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', @@ -184,8 +186,6 @@ module ForestAdminDatasourceRpc origin_key_target: 'id' } ) - - plugin.send(:add_relation, collection_customizer, renames, 'tags', relation_definition) end end end From 73984d19797a87251c013ee2e0cc74249d26e689 Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Tue, 6 Jan 2026 16:52:31 +0100 Subject: [PATCH 13/13] test: add test --- .../reconciliate_rpc_spec.rb | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) 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 index 281aeea9f..775f6397b 100644 --- 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 @@ -5,6 +5,200 @@ module ForestAdminDatasourceRpc 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