Skip to content

Commit 8e98954

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

File tree

6 files changed

+218
-10
lines changed

6 files changed

+218
-10
lines changed

lib/jsonapi-resources.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
require 'jsonapi/compiled_json'
66
require 'jsonapi/basic_resource'
77
require 'jsonapi/cross_schema_relationships'
8-
require 'jsonapi/active_relation_resource'
98
require 'jsonapi/active_relation_resource_extensions'
9+
require 'jsonapi/active_relation_resource'
1010
require 'jsonapi/resource'
1111
require 'jsonapi/cached_response_fragment'
1212
require 'jsonapi/response_document'

lib/jsonapi/basic_resource.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,12 @@ def attribute(attribute_name, options = {})
547547
check_reserved_attribute_name(attr)
548548

549549
if (attr == :id) && (options[:format].nil?)
550-
ActiveSupport::Deprecation.warn('Id without format is no longer supported. Please remove ids from attributes, or specify a format.')
550+
message = 'Id without format is no longer supported. Please remove ids from attributes, or specify a format.'
551+
if Rails::VERSION::MAJOR >= 8 && Rails.application && Rails.application.deprecators[:jsonapi_resources]
552+
Rails.application.deprecators[:jsonapi_resources].warn(message)
553+
elsif Rails::VERSION::MAJOR < 8
554+
ActiveSupport::Deprecation.warn(message)
555+
end
551556
end
552557

553558
check_duplicate_attribute_name(attr) if options[:format].nil?

lib/jsonapi/configuration.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,14 @@ def default_processor_klass_name=(default_processor_klass_name)
241241
end
242242

243243
def allow_include=(allow_include)
244-
ActiveSupport::Deprecation.warn('`allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options.')
244+
message = '`allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options.'
245+
if Rails::VERSION::MAJOR >= 8 && Rails.application && Rails.application.deprecators[:jsonapi_resources]
246+
Rails.application.deprecators[:jsonapi_resources].warn(message)
247+
elsif Rails::VERSION::MAJOR < 8
248+
ActiveSupport::Deprecation.warn(message)
249+
else
250+
warn message
251+
end
245252
@default_allow_include_to_one = allow_include
246253
@default_allow_include_to_many = allow_include
247254
end

test/fixtures/active_record.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
end
5858

5959
create_table :posts, force: true do |t|
60-
t.string :title, length: 255
60+
t.string :title, limit: 255
6161
t.text :body
6262
t.integer :author_id
6363
t.integer :parent_post_id
@@ -329,8 +329,13 @@
329329

330330
create_table :related_things, force: true do |t|
331331
t.string :name
332-
t.references :from, references: :thing
333-
t.references :to, references: :thing
332+
if Rails::VERSION::MAJOR >= 8
333+
t.references :from, foreign_key: { to_table: :things }
334+
t.references :to, foreign_key: { to_table: :things }
335+
else
336+
t.references :from, references: :thing
337+
t.references :to, references: :thing
338+
end
334339

335340
t.timestamps null: false
336341
end

test/test_helper.rb

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
ENV['DATABASE_URL'] ||= "sqlite3:test_db"
2424

2525
require 'active_record/railtie'
26-
require 'rails/test_help'
2726
require 'minitest/mock'
2827
require 'jsonapi-resources'
2928
require 'pry'
@@ -60,7 +59,12 @@ class TestApp < Rails::Application
6059
config.action_controller.action_on_unpermitted_parameters = :raise
6160

6261
ActiveRecord::Schema.verbose = false
63-
config.active_record.schema_format = :none
62+
# Rails 8 doesn't support :none schema_format
63+
if Rails::VERSION::MAJOR < 8
64+
config.active_record.schema_format = :none
65+
else
66+
config.active_record.schema_format = :ruby
67+
end
6468
config.active_support.test_order = :random
6569

6670
config.active_support.halt_callback_chains_on_return_false = false
@@ -71,6 +75,12 @@ class TestApp < Rails::Application
7175
end
7276
end
7377

78+
# Initialize the test application before requiring test_help in Rails 8
79+
TestApp.initialize!
80+
81+
# Now require test_help after Rails.application is initialized
82+
require 'rails/test_help'
83+
7484
DatabaseCleaner.allow_remote_database_url = true
7585
DatabaseCleaner.strategy = :transaction
7686

@@ -194,8 +204,6 @@ def show_queries
194204
end
195205
end
196206

197-
TestApp.initialize!
198-
199207
require File.expand_path('../fixtures/active_record', __FILE__)
200208

201209
module Pets
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
require File.expand_path('../../../test_helper', __FILE__)
2+
3+
class CrossSchemaTest < ActiveSupport::TestCase
4+
class Organization < ActiveRecord::Base
5+
self.table_name = 'companies'
6+
has_many :employees, class_name: 'CrossSchemaTest::Employee'
7+
end
8+
9+
class Employee < ActiveRecord::Base
10+
self.table_name = 'people'
11+
belongs_to :organization, class_name: 'CrossSchemaTest::Organization', foreign_key: 'company_id'
12+
end
13+
14+
class OrganizationResource < JSONAPI::ActiveRelationResource
15+
model_name 'CrossSchemaTest::Organization'
16+
attributes :name
17+
18+
has_many :employees
19+
20+
# Define cross-schema relationship using the module method
21+
self._cross_schema_relationships = { employees: { schema: 'hr_schema' } }
22+
end
23+
24+
class EmployeeResource < JSONAPI::Resource
25+
model_name 'CrossSchemaTest::Employee'
26+
attributes :name, :email
27+
28+
has_one :organization
29+
end
30+
31+
def setup
32+
@original_cross_schema = OrganizationResource._cross_schema_relationships
33+
end
34+
35+
def teardown
36+
OrganizationResource._cross_schema_relationships = @original_cross_schema
37+
end
38+
39+
def test_cross_schema_relationship_registration
40+
OrganizationResource._cross_schema_relationships = { test_relation: { schema: 'test_schema' } }
41+
42+
assert_not_nil OrganizationResource._cross_schema_relationships
43+
assert_equal 'test_schema', OrganizationResource._cross_schema_relationships[:test_relation][:schema]
44+
end
45+
46+
def test_cross_schema_relationship_with_custom_table
47+
OrganizationResource._cross_schema_relationships = {
48+
custom_employees: {
49+
schema: 'custom_schema',
50+
table: 'custom_table'
51+
}
52+
}
53+
54+
assert_equal 'custom_schema', OrganizationResource._cross_schema_relationships[:custom_employees][:schema]
55+
assert_equal 'custom_table', OrganizationResource._cross_schema_relationships[:custom_employees][:table]
56+
end
57+
58+
def test_handle_cross_schema_to_one
59+
skip "Requires database setup for cross-schema testing"
60+
# This test would require actual cross-schema database setup
61+
# It's here as documentation of expected behavior
62+
63+
# Setup mock data
64+
org = Organization.create!(name: 'Test Org')
65+
employee = Employee.create!(name: 'Test Employee', company_id: org.id)
66+
67+
# Create resource instance
68+
org_resource = OrganizationResource.new(org, nil)
69+
70+
# Test finding related fragments
71+
source_rids = [JSONAPI::ResourceIdentity.new(OrganizationResource, org.id)]
72+
fragments = OrganizationResource.find_related_fragments(source_rids, :employees, {})
73+
74+
assert_equal 1, fragments.size
75+
assert_equal employee.id, fragments.keys.first.id
76+
end
77+
78+
def test_handle_cross_schema_to_many
79+
skip "Requires database setup for cross-schema testing"
80+
# This test would require actual cross-schema database setup
81+
# It's here as documentation of expected behavior
82+
83+
# Setup mock data
84+
org = Organization.create!(name: 'Test Org')
85+
employee1 = Employee.create!(name: 'Employee 1', company_id: org.id)
86+
employee2 = Employee.create!(name: 'Employee 2', company_id: org.id)
87+
88+
# Create resource instance
89+
org_resource = OrganizationResource.new(org, nil)
90+
91+
# Test finding related fragments
92+
source_rids = [JSONAPI::ResourceIdentity.new(OrganizationResource, org.id)]
93+
fragments = OrganizationResource.find_related_fragments(source_rids, :employees, {})
94+
95+
assert_equal 2, fragments.size
96+
employee_ids = fragments.keys.map(&:id)
97+
assert_includes employee_ids, employee1.id
98+
assert_includes employee_ids, employee2.id
99+
end
100+
101+
def test_cross_schema_included_fragments
102+
skip "Requires database setup for cross-schema testing"
103+
# This test would require actual cross-schema database setup
104+
105+
org = Organization.create!(name: 'Test Org')
106+
employee = Employee.create!(name: 'Test Employee', company_id: org.id)
107+
108+
# Create resource fragments
109+
org_rid = JSONAPI::ResourceIdentity.new(OrganizationResource, org.id)
110+
org_fragment = JSONAPI::ResourceFragment.new(org_rid)
111+
112+
source = { org_rid => org_fragment }
113+
fragments = OrganizationResource.find_included_fragments(source, :employees, {})
114+
115+
assert_equal 1, fragments.size
116+
assert_equal employee.id, fragments.keys.first.id
117+
end
118+
119+
def test_cross_schema_with_filters
120+
skip "Requires database setup for cross-schema testing"
121+
# This test would require actual cross-schema database setup
122+
123+
org = Organization.create!(name: 'Test Org')
124+
employee1 = Employee.create!(name: 'Alice', company_id: org.id)
125+
employee2 = Employee.create!(name: 'Bob', company_id: org.id)
126+
127+
source_rids = [JSONAPI::ResourceIdentity.new(OrganizationResource, org.id)]
128+
129+
# Test with filters
130+
filters = { name: 'Alice' }
131+
fragments = OrganizationResource.find_related_fragments(source_rids, :employees, { filters: filters })
132+
133+
assert_equal 1, fragments.size
134+
assert_equal employee1.id, fragments.keys.first.id
135+
end
136+
137+
def test_cross_schema_sql_injection_protection
138+
# Test that SQL is properly escaped
139+
OrganizationResource._cross_schema_relationships = {
140+
dangerous: { schema: "'; DROP TABLE users; --" }
141+
}
142+
143+
# The schema should be stored but properly escaped when used
144+
assert_equal "'; DROP TABLE users; --", OrganizationResource._cross_schema_relationships[:dangerous][:schema]
145+
146+
# When actually used in queries, it should be properly quoted
147+
# This is handled by ActiveRecord::Base.connection.quote_table_name
148+
end
149+
150+
def test_cross_schema_with_context
151+
skip "Requires database setup for cross-schema testing"
152+
153+
org = Organization.create!(name: 'Test Org')
154+
employee = Employee.create!(name: 'Test Employee', company_id: org.id)
155+
156+
# Test with context
157+
context = { current_user: 'test_user' }
158+
source_rids = [JSONAPI::ResourceIdentity.new(OrganizationResource, org.id)]
159+
fragments = OrganizationResource.find_related_fragments(source_rids, :employees, { context: context })
160+
161+
assert_equal 1, fragments.size
162+
# Verify context was passed through
163+
fragment = fragments.values.first
164+
assert_equal context, fragment.resource.context if fragment.resource
165+
end
166+
167+
def test_module_inclusion
168+
# Test that the module is properly included
169+
assert OrganizationResource.respond_to?(:find_related_fragments)
170+
assert OrganizationResource.respond_to?(:find_included_fragments)
171+
end
172+
173+
def test_fallback_to_normal_relationship
174+
# Test that non-cross-schema relationships still work
175+
OrganizationResource._cross_schema_relationships = nil
176+
177+
# This should not raise an error and should call the original implementation
178+
assert_nothing_raised do
179+
source_rids = []
180+
OrganizationResource.find_related_fragments(source_rids, :employees, {})
181+
end
182+
end
183+
end

0 commit comments

Comments
 (0)