Skip to content

Commit 486f985

Browse files
committed
feat: cross-schema relationships support
1 parent 8e98954 commit 486f985

File tree

4 files changed

+118
-115
lines changed

4 files changed

+118
-115
lines changed

lib/jsonapi/active_relation_resource.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
module JSONAPI
44
class ActiveRelationResource < BasicResource
55
include CrossSchemaRelationships
6+
include ActiveRelationResourceExtensions
67

78
root_resource
89

@@ -523,6 +524,11 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options,
523524
filters = options.fetch(:filters, {})
524525
source_ids = source_fragments.collect {|item| item.identity.id}
525526

527+
# Handle case where relationship is passed as a symbol/string instead of a Relationship object
528+
if relationship.is_a?(Symbol) || relationship.is_a?(String)
529+
relationship = _relationship(relationship.to_sym)
530+
end
531+
526532
resource_klass = relationship.resource_klass
527533
include_directives = options.fetch(:include_directives, {})
528534

lib/jsonapi/active_relation_resource_extensions.rb

Lines changed: 88 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,38 @@
22

33
# Extensions to ActiveRelationResource for cross-schema support
44
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
5+
module ActiveRelationResourceExtensions
6+
def self.included(base)
7+
base.class_eval do
8+
# Override find_related_fragments to handle cross-schema relationships
9+
def self.find_related_fragments(source_rids, relationship_name, options = {})
10+
relationship = _relationship(relationship_name)
11+
12+
if defined?(_cross_schema_relationships) && _cross_schema_relationships && (cross_schema_info = _cross_schema_relationships[relationship_name.to_sym])
13+
# Handle cross-schema relationship
14+
handle_cross_schema_relationship(source_rids, relationship, cross_schema_info, options)
15+
else
16+
# Call the original implementation
17+
super(source_rids, relationship_name, options)
18+
end
1819
end
1920

20-
@cross_schema_support_setup = true
21-
end
21+
# Override find_included_fragments to handle cross-schema relationships
22+
def self.find_included_fragments(source, relationship_name, options)
23+
relationship = _relationship(relationship_name)
2224

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
25+
if defined?(_cross_schema_relationships) && _cross_schema_relationships && (cross_schema_info = _cross_schema_relationships[relationship_name.to_sym])
26+
# Handle cross-schema relationship
27+
handle_cross_schema_included(source, relationship, cross_schema_info, options)
28+
else
29+
# Call the original implementation
30+
super(source, relationship_name, options)
31+
end
32+
end
2633

27-
relationship = _relationship(relationship_name)
34+
private
2835

29-
if defined?(_cross_schema_relationships) && _cross_schema_relationships && (cross_schema_info = _cross_schema_relationships[relationship_name.to_sym])
30-
# Handle cross-schema relationship
36+
def self.handle_cross_schema_relationship(source_rids, relationship, cross_schema_info, options)
3137
schema = cross_schema_info[:schema]
3238

3339
# Get the source records
@@ -39,24 +45,9 @@ def find_related_fragments(source_rids, relationship_name, options = {})
3945
else
4046
handle_cross_schema_to_many(source_records, relationship, schema, options)
4147
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
4948
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
5549

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
50+
def self.handle_cross_schema_included(source, relationship, cross_schema_info, options)
6051
schema = cross_schema_info[:schema]
6152

6253
# Extract IDs from source - it could be a hash of resource fragments
@@ -78,92 +69,80 @@ def find_included_fragments(source, relationship_name, options)
7869
else
7970
handle_cross_schema_to_many(source_records, relationship, schema, options)
8071
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)
8872
end
89-
end
9073

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)
74+
def self.handle_cross_schema_to_one(source_records, relationship, schema, options)
75+
# For has_one or belongs_to with cross-schema
76+
related_klass = relationship.resource_klass
77+
foreign_key = relationship.foreign_key
78+
79+
# Get the foreign key values from source records
80+
foreign_key_values = source_records.map { |r| r._model.send(foreign_key) }.compact.uniq
81+
82+
return {} if foreign_key_values.empty?
83+
84+
# Query the related table with schema prefix
85+
# This should be configured based on the actual schema and table
86+
full_table_name = "#{schema}.#{relationship.table_name || related_klass._table_name}"
87+
88+
# Use ActiveRecord to query cross-schema with proper connection
89+
connection = ActiveRecord::Base.connection
90+
quoted_table = connection.quote_table_name(full_table_name)
91+
quoted_ids = foreign_key_values.map { |id| connection.quote(id) }.join(',')
92+
93+
sql = "SELECT * FROM #{quoted_table} WHERE id IN (#{quoted_ids})"
94+
related_records = connection.exec_query(sql)
95+
96+
# Convert to fragments
97+
fragments = {}
98+
related_records.each do |record_hash|
99+
# Create a model instance from the hash
100+
model_class = related_klass._model_class
101+
instance = model_class.instantiate(record_hash)
102+
resource = related_klass.new(instance, options[:context])
103+
rid = JSONAPI::ResourceIdentity.new(related_klass, instance.id)
104+
fragments[rid] = JSONAPI::ResourceFragment.new(rid, resource: resource)
105+
end
94106

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)
107+
fragments
99108
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
111109

112-
return {} if foreign_key_values.empty?
110+
def self.handle_cross_schema_to_many(source_records, relationship, schema, options)
111+
# For has_many with cross-schema
112+
related_klass = relationship.resource_klass
113113

114-
# Query the related table with schema prefix
115-
full_table_name = "#{schema}.users_v1"
114+
# Determine the foreign key based on the source model
115+
foreign_key = relationship.foreign_key || "#{_type.to_s.singularize}_id"
116116

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-
)
117+
# Get source IDs
118+
source_ids = source_records.map { |r| r._model.send(_primary_key) }.compact.uniq
122119

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
120+
return {} if source_ids.empty?
139121

140-
# Determine the foreign key based on the source model
141-
foreign_key = "#{_type.to_s.singularize}_id"
122+
# Query the related table with schema prefix
123+
full_table_name = "#{schema}.#{relationship.table_name || related_klass._table_name}"
142124

143-
# Get source IDs
144-
source_ids = source_records.map { |r| r._model.send(_primary_key) }.compact.uniq
125+
connection = ActiveRecord::Base.connection
126+
quoted_table = connection.quote_table_name(full_table_name)
127+
quoted_key = connection.quote_column_name(foreign_key)
128+
quoted_ids = source_ids.map { |id| connection.quote(id) }.join(',')
145129

146-
return {} if source_ids.empty?
130+
sql = "SELECT * FROM #{quoted_table} WHERE #{quoted_key} IN (#{quoted_ids})"
131+
related_records = connection.exec_query(sql)
147132

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)
133+
# Convert to fragments
134+
fragments = {}
135+
related_records.each do |record_hash|
136+
# Create a model instance from the hash
137+
model_class = related_klass._model_class
138+
instance = model_class.instantiate(record_hash)
139+
resource = related_klass.new(instance, options[:context])
140+
rid = JSONAPI::ResourceIdentity.new(related_klass, instance.id)
141+
fragments[rid] = JSONAPI::ResourceFragment.new(rid, resource: resource)
142+
end
155143

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)
144+
fragments
164145
end
165-
166-
fragments
167146
end
168147
end
169148
end

lib/jsonapi/error.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ def initialize(options = {})
1717
@source = options[:source]
1818
@links = options[:links]
1919

20-
@status = Rack::Utils::SYMBOL_TO_STATUS_CODE[options[:status]].to_s
20+
@status = if options[:status]
21+
code = Rack::Utils::SYMBOL_TO_STATUS_CODE[options[:status]]
22+
code ? code.to_s : options[:status].to_s
23+
else
24+
nil
25+
end
2126
@meta = options[:meta]
2227
end
2328

@@ -48,7 +53,8 @@ def update_with_overrides(error_object_overrides)
4853

4954
if error_object_overrides[:status]
5055
# :nocov:
51-
@status = Rack::Utils::SYMBOL_TO_STATUS_CODE[error_object_overrides[:status]].to_s
56+
code = Rack::Utils::SYMBOL_TO_STATUS_CODE[error_object_overrides[:status]]
57+
@status = code ? code.to_s : error_object_overrides[:status].to_s
5258
# :nocov:
5359
end
5460
@meta = error_object_overrides[:meta] || @meta

lib/jsonapi/response_document.rb

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,26 @@ def contents
6161
def status
6262
status_codes = if has_errors?
6363
@global_errors.collect do |error|
64-
error.status.to_i
64+
# Handle both numeric and symbol/string statuses
65+
if error.status.is_a?(String) && error.status.match(/^\d+$/)
66+
error.status.to_i
67+
elsif error.status.is_a?(String) || error.status.is_a?(Symbol)
68+
Rack::Utils::SYMBOL_TO_STATUS_CODE[error.status.to_sym] || 422
69+
else
70+
error.status.to_i
71+
end
6572
end
6673
else
6774
@result_codes
6875
end
6976

70-
# Count the unique status codes
71-
counts = status_codes.each_with_object(Hash.new(0)) { |code, counts| counts[code] += 1 }
77+
# Count the unique status codes, filtering out invalid codes
78+
valid_codes = status_codes.select { |code| code.to_i > 0 }
79+
80+
# If no valid status codes, default to 200
81+
return 200 if valid_codes.empty?
82+
83+
counts = valid_codes.each_with_object(Hash.new(0)) { |code, counts| counts[code] += 1 }
7284

7385
# if there is only one status code we can return that
7486
return counts.keys[0].to_i if counts.length == 1
@@ -77,7 +89,7 @@ def status
7789

7890
# if there are many we should return the highest general code, 200, 400, 500 etc.
7991
max_status = 0
80-
status_codes.each do |status|
92+
valid_codes.each do |status|
8193
code = status.to_i
8294
max_status = code if max_status < code
8395
end

0 commit comments

Comments
 (0)