Skip to content

Commit 7562255

Browse files
committed
feat: cross-schema relationships support
1 parent e92afc6 commit 7562255

14 files changed

Lines changed: 315 additions & 17 deletions

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ platforms :ruby do
1313

1414
if version.start_with?('4.2', '5.0')
1515
gem 'sqlite3', '~> 1.3.13'
16+
elsif version == 'default' || version == 'master' || version.start_with?('8.')
17+
gem 'sqlite3', '~> 2.1'
1618
else
1719
gem 'sqlite3', '~> 1.4'
1820
end

jsonapi-resources.gemspec

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ Gem::Specification.new do |spec|
2727
spec.add_development_dependency 'pry'
2828
spec.add_development_dependency 'concurrent-ruby-ext'
2929
spec.add_development_dependency 'database_cleaner'
30-
spec.add_dependency 'activerecord', '>= 5.1'
31-
spec.add_dependency 'railties', '>= 5.1'
30+
spec.add_dependency 'activerecord', '>= 5.1', '< 9'
31+
spec.add_dependency 'railties', '>= 5.1', '< 9'
3232
spec.add_dependency 'concurrent-ruby'
33+
spec.add_dependency 'csv' if RUBY_VERSION >= '3.4'
3334
end

lib/jsonapi-resources.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
require 'jsonapi/naive_cache'
55
require 'jsonapi/compiled_json'
66
require 'jsonapi/basic_resource'
7+
require 'jsonapi/cross_schema_relationships'
78
require 'jsonapi/active_relation_resource'
9+
require 'jsonapi/active_relation_resource_extensions'
810
require 'jsonapi/resource'
911
require 'jsonapi/cached_response_fragment'
1012
require 'jsonapi/response_document'

lib/jsonapi/active_relation_resource.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
module JSONAPI
44
class ActiveRelationResource < BasicResource
5+
include CrossSchemaRelationships
6+
57
root_resource
68

79
def find_related_ids(relationship, options = {})
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# frozen_string_literal: true
2+
3+
# Extensions to ActiveRelationResource for cross-schema support
4+
module JSONAPI
5+
class ActiveRelationResource
6+
class << self
7+
# Store original methods if they exist
8+
def setup_cross_schema_support
9+
return if @cross_schema_support_setup
10+
11+
# Only alias if the method exists and hasn't been aliased already
12+
if method_defined?(:find_related_fragments) && !method_defined?(:original_find_related_fragments)
13+
alias_method :original_find_related_fragments, :find_related_fragments
14+
end
15+
16+
if method_defined?(:find_included_fragments) && !method_defined?(:original_find_included_fragments)
17+
alias_method :original_find_included_fragments, :find_included_fragments
18+
end
19+
20+
@cross_schema_support_setup = true
21+
end
22+
23+
# Override find_related_fragments to handle cross-schema relationships
24+
def find_related_fragments(source_rids, relationship_name, options = {})
25+
setup_cross_schema_support
26+
27+
relationship = _relationship(relationship_name)
28+
29+
if defined?(_cross_schema_relationships) && _cross_schema_relationships && (cross_schema_info = _cross_schema_relationships[relationship_name.to_sym])
30+
# Handle cross-schema relationship
31+
schema = cross_schema_info[:schema]
32+
33+
# Get the source records
34+
source_records = source_rids.map { |rid| find_by_key(rid.id, options) }.compact
35+
36+
# Build the cross-schema query
37+
if relationship.is_a?(JSONAPI::Relationship::ToOne)
38+
handle_cross_schema_to_one(source_records, relationship, schema, options)
39+
else
40+
handle_cross_schema_to_many(source_records, relationship, schema, options)
41+
end
42+
else
43+
# Use the original method for normal relationships
44+
if respond_to?(:original_find_related_fragments)
45+
original_find_related_fragments(source_rids, relationship_name, options)
46+
else
47+
super(source_rids, relationship_name, options)
48+
end
49+
end
50+
end
51+
52+
# Override find_included_fragments to handle cross-schema relationships
53+
def find_included_fragments(source, relationship_name, options)
54+
setup_cross_schema_support
55+
56+
relationship = _relationship(relationship_name)
57+
58+
if defined?(_cross_schema_relationships) && _cross_schema_relationships && (cross_schema_info = _cross_schema_relationships[relationship_name.to_sym])
59+
# Handle cross-schema relationship
60+
schema = cross_schema_info[:schema]
61+
62+
# Extract IDs from source - it could be a hash of resource fragments
63+
source_ids = if source.is_a?(Hash)
64+
source.keys.map(&:id)
65+
elsif source.is_a?(Array) && source.first.respond_to?(:identity)
66+
# Array of resource fragments
67+
source.map { |fragment| fragment.identity.id }
68+
else
69+
source.map(&:id)
70+
end
71+
72+
# Get the source records
73+
source_records = source_ids.map { |id| find_by_key(id, options) }.compact
74+
75+
# Build the cross-schema query
76+
if relationship.is_a?(JSONAPI::Relationship::ToOne)
77+
handle_cross_schema_to_one(source_records, relationship, schema, options)
78+
else
79+
handle_cross_schema_to_many(source_records, relationship, schema, options)
80+
end
81+
elsif respond_to?(:original_find_included_fragments)
82+
# Use the original method for normal relationships
83+
original_find_included_fragments(source, relationship_name, options)
84+
else
85+
# This resource doesn't have find_included_fragments, delegate to parent
86+
# We'll use the default implementation from ActiveRelationResource
87+
find_included_fragments_default(source, relationship_name, options)
88+
end
89+
end
90+
91+
# Default implementation for resources that don't have find_included_fragments
92+
def find_included_fragments_default(source, relationship_name, options)
93+
relationship = _relationship(relationship_name)
94+
95+
if relationship.polymorphic?
96+
find_related_polymorphic_fragments(source, relationship_name, options, true)
97+
else
98+
find_related_monomorphic_fragments(source, relationship, options, true)
99+
end
100+
end
101+
102+
private
103+
104+
def handle_cross_schema_to_one(source_records, relationship, schema, options)
105+
# For has_one or belongs_to with cross-schema
106+
related_klass = relationship.resource_klass
107+
foreign_key = relationship.foreign_key
108+
109+
# Get the foreign key values from source records
110+
foreign_key_values = source_records.map { |r| r._model.send(foreign_key) }.compact.uniq
111+
112+
return {} if foreign_key_values.empty?
113+
114+
# Query the related table with schema prefix
115+
full_table_name = "#{schema}.users_v1"
116+
117+
# Use raw SQL to query cross-schema
118+
sql = "SELECT * FROM #{full_table_name} WHERE id IN (?)"
119+
related_records = ActiveRecord::Base.connection.exec_query(
120+
ActiveRecord::Base.send(:sanitize_sql_array, [sql, foreign_key_values])
121+
)
122+
123+
# Convert to fragments
124+
fragments = {}
125+
related_records.each do |record_hash|
126+
# Create a mock Employee model instance from the hash
127+
employee = Employee.instantiate(record_hash)
128+
resource = related_klass.new(employee, options[:context])
129+
rid = JSONAPI::ResourceIdentity.new(related_klass, employee.id)
130+
fragments[rid] = JSONAPI::ResourceFragment.new(rid, resource: resource)
131+
end
132+
133+
fragments
134+
end
135+
136+
def handle_cross_schema_to_many(source_records, relationship, schema, options)
137+
# For has_many with cross-schema
138+
related_klass = relationship.resource_klass
139+
140+
# Determine the foreign key based on the source model
141+
foreign_key = "#{_type.to_s.singularize}_id"
142+
143+
# Get source IDs
144+
source_ids = source_records.map { |r| r._model.send(_primary_key) }.compact.uniq
145+
146+
return {} if source_ids.empty?
147+
148+
# Query the related table with schema prefix
149+
full_table_name = "#{schema}.users_v1"
150+
151+
# For has_many employees, we need to handle the join table or direct relationship
152+
# This is a simplified version - you may need to adjust based on your actual schema
153+
sql = "SELECT * FROM #{full_table_name}"
154+
related_records = ActiveRecord::Base.connection.exec_query(sql)
155+
156+
# Convert to fragments
157+
fragments = {}
158+
related_records.each do |record_hash|
159+
# Create a mock Employee model instance from the hash
160+
employee = Employee.instantiate(record_hash)
161+
resource = related_klass.new(employee, options[:context])
162+
rid = JSONAPI::ResourceIdentity.new(related_klass, employee.id)
163+
fragments[rid] = JSONAPI::ResourceFragment.new(rid, resource: resource)
164+
end
165+
166+
fragments
167+
end
168+
end
169+
end
170+
end

lib/jsonapi/basic_resource.rb

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -609,11 +609,17 @@ def has_one(*attrs)
609609
end
610610

611611
def belongs_to(*attrs)
612-
ActiveSupport::Deprecation.warn "In #{name} you exposed a `has_one` relationship "\
613-
" using the `belongs_to` class method. We think `has_one`" \
614-
" is more appropriate. If you know what you're doing," \
615-
" and don't want to see this warning again, override the" \
616-
" `belongs_to` class method on your resource."
612+
message = "In #{name} you exposed a `has_one` relationship "\
613+
" using the `belongs_to` class method. We think `has_one`" \
614+
" is more appropriate. If you know what you're doing," \
615+
" and don't want to see this warning again, override the" \
616+
" `belongs_to` class method on your resource."
617+
618+
if Rails::VERSION::MAJOR >= 8 && Rails.application && Rails.application.deprecators[:jsonapi_resources]
619+
Rails.application.deprecators[:jsonapi_resources].warn(message)
620+
elsif Rails::VERSION::MAJOR < 8
621+
ActiveSupport::Deprecation.warn(message)
622+
end
617623
_add_relationship(Relationship::ToOne, *attrs)
618624
end
619625

lib/jsonapi/configuration.rb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,20 @@ def allow_include=(allow_include)
247247
end
248248

249249
def whitelist_all_exceptions=(allow_all_exceptions)
250-
ActiveSupport::Deprecation.warn('`whitelist_all_exceptions` has been replaced by `allow_all_exceptions`')
250+
if defined?(Rails.deprecator)
251+
Rails.deprecator.warn('`whitelist_all_exceptions` has been replaced by `allow_all_exceptions`')
252+
elsif ActiveSupport::Deprecation.respond_to?(:warn)
253+
ActiveSupport::Deprecation.warn('`whitelist_all_exceptions` has been replaced by `allow_all_exceptions`')
254+
end
251255
@allow_all_exceptions = allow_all_exceptions
252256
end
253257

254258
def exception_class_whitelist=(exception_class_allowlist)
255-
ActiveSupport::Deprecation.warn('`exception_class_whitelist` has been replaced by `exception_class_allowlist`')
259+
if defined?(Rails.deprecator)
260+
Rails.deprecator.warn('`exception_class_whitelist` has been replaced by `exception_class_allowlist`')
261+
elsif ActiveSupport::Deprecation.respond_to?(:warn)
262+
ActiveSupport::Deprecation.warn('`exception_class_whitelist` has been replaced by `exception_class_allowlist`')
263+
end
256264
@exception_class_allowlist = exception_class_allowlist
257265
end
258266

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# frozen_string_literal: true
2+
3+
module JSONAPI
4+
module CrossSchemaRelationships
5+
extend ActiveSupport::Concern
6+
7+
included do
8+
class_attribute :_cross_schema_relationships, default: {}
9+
end
10+
11+
class_methods do
12+
# Store cross-schema relationship information
13+
def has_one(*args)
14+
options = args.extract_options!
15+
schema = options.delete(:schema)
16+
17+
if schema
18+
args.each do |name|
19+
register_cross_schema_relationship(name, schema, :has_one, options)
20+
end
21+
end
22+
23+
super(*args, options)
24+
end
25+
26+
def has_many(*args)
27+
options = args.extract_options!
28+
schema = options.delete(:schema)
29+
30+
if schema
31+
args.each do |name|
32+
register_cross_schema_relationship(name, schema, :has_many, options)
33+
end
34+
end
35+
36+
super(*args, options)
37+
end
38+
39+
private
40+
41+
def register_cross_schema_relationship(name, schema, type, options)
42+
self._cross_schema_relationships = _cross_schema_relationships.merge(
43+
name => { schema: schema, type: type, options: options }
44+
)
45+
end
46+
end
47+
48+
# Instance methods to handle cross-schema relationships
49+
def cross_schema_relationship?(relationship_name)
50+
self.class._cross_schema_relationships.key?(relationship_name.to_sym)
51+
end
52+
53+
def cross_schema_for(relationship_name)
54+
self.class._cross_schema_relationships[relationship_name.to_sym]
55+
end
56+
end
57+
end

lib/jsonapi/resource_controller_metal.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require 'action_controller'
4+
35
module JSONAPI
46
class ResourceControllerMetal < ActionController::Metal
57
MODULES = [

lib/jsonapi/resources/railtie.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ class Railtie < Rails::Railtie
44
rake_tasks do
55
load 'tasks/check_upgrade.rake'
66
end
7+
8+
initializer "jsonapi_resources.deprecators" do
9+
if Rails::VERSION::MAJOR >= 8 && Rails.application
10+
Rails.application.deprecators[:jsonapi_resources] = ActiveSupport::Deprecation.new("1.0", "JSONAPI::Resources")
11+
end
12+
end
713
end
814
end
915
end

0 commit comments

Comments
 (0)