Skip to content

Commit 67957b0

Browse files
authored
Merge pull request #15 from speee/fix/quote-method-mysql-compatibility
Fix quote method to use database adapter for MySQL compatibility
2 parents 6facf3b + fd06792 commit 67957b0

8 files changed

Lines changed: 418 additions & 2 deletions

File tree

Dockerfile.ruby3.1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ FROM ruby:3.1.5
44

55
# Install dependencies
66
RUN apt-get update -qq && \
7-
apt-get install -y build-essential libpq-dev nodejs && \
7+
apt-get install -y build-essential libpq-dev default-libmysqlclient-dev nodejs && \
88
apt-get clean && \
99
rm -rf /var/lib/apt/lists/*
1010

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ version = ENV['RAILS_VERSION'] || 'default'
1010

1111
platforms :ruby do
1212
gem 'pg'
13+
gem 'mysql2'
1314

1415
if version.start_with?('4.2', '5.0')
1516
gem 'sqlite3', '~> 1.3.13'

docker-compose.yml

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,91 @@ services:
115115
- RAILS_VERSION=${RAILS_VERSION:-8.1.2}
116116
command: /bin/bash
117117

118+
# Database services
119+
mysql:
120+
image: mysql:8.0
121+
container_name: jsonapi-mysql
122+
environment:
123+
MYSQL_ROOT_PASSWORD: root
124+
MYSQL_DATABASE: jsonapi_test
125+
ports:
126+
- "3306:3306"
127+
volumes:
128+
- mysql-data:/var/lib/mysql
129+
healthcheck:
130+
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
131+
interval: 10s
132+
timeout: 5s
133+
retries: 5
134+
135+
postgres:
136+
image: postgres:14
137+
container_name: jsonapi-postgres
138+
environment:
139+
POSTGRES_USER: postgres
140+
POSTGRES_PASSWORD: postgres
141+
POSTGRES_DB: jsonapi_test
142+
ports:
143+
- "5432:5432"
144+
volumes:
145+
- postgres-data:/var/lib/postgresql/data
146+
healthcheck:
147+
test: ["CMD-SHELL", "pg_isready -U postgres"]
148+
interval: 10s
149+
timeout: 5s
150+
retries: 5
151+
152+
# Rails 6.1 with MySQL
153+
rails-6.1-mysql:
154+
<<: *test-base
155+
container_name: jsonapi-rails-6.1-mysql
156+
depends_on:
157+
mysql:
158+
condition: service_healthy
159+
environment:
160+
- RAILS_VERSION=6.1.7.10
161+
- DATABASE_URL=mysql2://root:root@mysql:3306/jsonapi_test
162+
command: bash -c "rm -f Gemfile.lock && /usr/local/bin/bundler _2.4.14_ install && /usr/local/bin/bundler _2.4.14_ exec rake test"
163+
164+
# Rails 6.1 with PostgreSQL
165+
rails-6.1-postgres:
166+
<<: *test-base
167+
container_name: jsonapi-rails-6.1-postgres
168+
depends_on:
169+
postgres:
170+
condition: service_healthy
171+
environment:
172+
- RAILS_VERSION=6.1.7.10
173+
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/jsonapi_test
174+
command: bash -c "rm -f Gemfile.lock && /usr/local/bin/bundler _2.4.14_ install && /usr/local/bin/bundler _2.4.14_ exec rake test"
175+
176+
# Rails 7.0 with MySQL
177+
rails-7.0-mysql:
178+
<<: *test-base
179+
container_name: jsonapi-rails-7.0-mysql
180+
depends_on:
181+
mysql:
182+
condition: service_healthy
183+
environment:
184+
- RAILS_VERSION=7.0.10
185+
- DATABASE_URL=mysql2://root:root@mysql:3306/jsonapi_test
186+
command: bash -c "rm -f Gemfile.lock && /usr/local/bin/bundler _2.4.14_ install && /usr/local/bin/bundler _2.4.14_ exec rake test"
187+
188+
# Rails 7.0 with PostgreSQL
189+
rails-7.0-postgres:
190+
<<: *test-base
191+
container_name: jsonapi-rails-7.0-postgres
192+
depends_on:
193+
postgres:
194+
condition: service_healthy
195+
environment:
196+
- RAILS_VERSION=7.0.10
197+
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/jsonapi_test
198+
command: bash -c "rm -f Gemfile.lock && /usr/local/bin/bundler _2.4.14_ install && /usr/local/bin/bundler _2.4.14_ exec rake test"
199+
118200
volumes:
119201
bundle-cache-ruby31:
120202
bundle-cache-ruby27:
121203
bundle-cache-ruby34:
204+
mysql-data:
205+
postgres-data:

lib/jsonapi/active_relation_resource.rb

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ def find_to_populate_by_keys(keys, options = {})
9696
# the ResourceInstances matching the filters, sorting, and pagination rules along with any request
9797
# additional_field values
9898
def find_fragments(filters, options = {})
99+
# PORO compatibility: if _model_class doesn't respond to :all, it's not ActiveRecord
100+
# Fall back to find_by_key-based implementation for 0.9.x compatibility
101+
unless _model_class.respond_to?(:all)
102+
return find_fragments_for_non_active_record(filters, options)
103+
end
104+
99105
include_directives = options.fetch(:include_directives, {})
100106
resource_klass = self
101107

@@ -368,6 +374,22 @@ def join_relationship(records:, relationship:, resource_type: nil, join_type: :i
368374
records.merge(relationship_records)
369375
end
370376

377+
# Fallback implementation of find_fragments for non-ActiveRecord models (PORO)
378+
# This provides 0.9.x compatibility by using find_by_key instead of records.pluck
379+
def find_fragments_for_non_active_record(filters, options)
380+
context = options[:context]
381+
primary_keys = options.fetch(:primary_keys, nil) || filters[_primary_key]
382+
return {} if primary_keys.blank?
383+
384+
fragments = {}
385+
Array(primary_keys).each do |key|
386+
resource = find_by_key(key, context: context)
387+
identity = JSONAPI::ResourceIdentity.new(self, key)
388+
fragments[identity] = JSONAPI::ResourceFragment.new(identity, resource: resource)
389+
end
390+
fragments
391+
end
392+
371393
protected
372394

373395
def to_one_relationships_for_linkage(include_related)
@@ -847,7 +869,7 @@ def alias_table_field(table, field, quoted = false)
847869
end
848870

849871
def quote(field)
850-
"\"#{field.to_s}\""
872+
_model_class.connection.quote_column_name(field.to_s)
851873
end
852874

853875
def apply_filters(records, filters, options = {})
@@ -859,6 +881,7 @@ def apply_filters(records, filters, options = {})
859881

860882
records
861883
end
884+
public :apply_filters # Public for 0.9.x backward compatibility
862885

863886
def get_aliased_field(path_with_field, join_manager)
864887
path = JSONAPI::Path.new(resource_klass: self, path_string: path_with_field)

lib/jsonapi/operation_result.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,35 @@ def to_hash(serializer = nil)
2121
end
2222
end
2323

24+
# Backward compatibility module for 0.9.x result.resource access
25+
# In 0.9.x, OperationResult had a `resource` accessor that returned the single resource.
26+
# This module provides the same interface for code that relied on `result.resource`.
27+
module ResourceSetResultMethods
28+
# Returns the first resource from the resource_set
29+
def resource
30+
each_resource { |r| return r }
31+
nil
32+
end
33+
34+
# Returns all resources from the resource_set
35+
def resources
36+
result = []
37+
each_resource { |r| result << r }
38+
result
39+
end
40+
41+
private
42+
43+
def each_resource
44+
return unless resource_set&.resource_klasses
45+
resource_set.resource_klasses.each_value do |identities|
46+
identities.each_value do |data|
47+
yield data[:resource] if data[:resource]
48+
end
49+
end
50+
end
51+
end
52+
2453
class ErrorsOperationResult < OperationResult
2554
attr_accessor :errors
2655

@@ -41,6 +70,8 @@ def to_hash(serializer = nil)
4170
end
4271

4372
class ResourceSetOperationResult < OperationResult
73+
include ResourceSetResultMethods
74+
4475
attr_accessor :resource_set, :pagination_params
4576

4677
def initialize(code, resource_set, options = {})
@@ -61,6 +92,8 @@ def to_hash(serializer)
6192
end
6293

6394
class ResourcesSetOperationResult < OperationResult
95+
include ResourceSetResultMethods
96+
6497
attr_accessor :resource_set, :pagination_params, :record_count, :page_count
6598

6699
def initialize(code, resource_set, options = {})
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
require File.expand_path('../../../test_helper', __FILE__)
2+
3+
# Tests for apply_filters public accessibility (0.9.x compatibility)
4+
class ApplyFiltersAccessibilityTest < ActiveSupport::TestCase
5+
def test_apply_filters_is_publicly_accessible
6+
# In 0.9.x, apply_filters was public and could be called from outside
7+
# Verify it's accessible as a public method
8+
assert PostResource.respond_to?(:apply_filters, false),
9+
"apply_filters should be a public class method for 0.9.x compatibility"
10+
end
11+
12+
def test_apply_filters_can_be_called_externally
13+
# Verify apply_filters can be called from outside the class
14+
records = Post.all
15+
filters = {}
16+
17+
# This should not raise NoMethodError
18+
result = PostResource.apply_filters(records, filters)
19+
20+
assert_kind_of ActiveRecord::Relation, result
21+
end
22+
end
23+
24+
# Tests for ResourceSetOperationResult backward compatibility
25+
class ResourceSetOperationResultBackwardCompatibilityTest < ActiveSupport::TestCase
26+
def setup
27+
# Create a helper to build ResourceSet with resources
28+
end
29+
30+
def test_resource_method_returns_first_resource
31+
# Create a ResourceSet with a single resource using the proper API
32+
post = Post.find(1)
33+
resource = PostResource.new(post, nil)
34+
35+
# Use the ResourceSet with a resource (not nil)
36+
resource_set = JSONAPI::ResourceSet.new(resource)
37+
resource_set.mark_populated!
38+
39+
result = JSONAPI::ResourceSetOperationResult.new(:ok, resource_set)
40+
41+
# 0.9.x style: result.resource
42+
assert result.respond_to?(:resource), "ResourceSetOperationResult should respond to :resource for 0.9.x compatibility"
43+
assert_equal resource, result.resource
44+
end
45+
46+
def test_resource_method_returns_nil_for_empty_resource_set
47+
# Create an empty resource set by passing an empty array
48+
resource_set = JSONAPI::ResourceSet.new([])
49+
result = JSONAPI::ResourceSetOperationResult.new(:ok, resource_set)
50+
51+
assert_nil result.resource
52+
end
53+
54+
def test_resources_method_returns_all_resources
55+
# Create a ResourceSet with multiple resources
56+
post1 = Post.find(1)
57+
post2 = Post.find(2)
58+
resource1 = PostResource.new(post1, nil)
59+
resource2 = PostResource.new(post2, nil)
60+
61+
# Use the ResourceSet with an array of resources
62+
resource_set = JSONAPI::ResourceSet.new([resource1, resource2])
63+
resource_set.mark_populated!
64+
65+
result = JSONAPI::ResourceSetOperationResult.new(:ok, resource_set)
66+
67+
# 0.9.x style: result.resources (for collections)
68+
assert result.respond_to?(:resources), "ResourceSetOperationResult should respond to :resources for 0.9.x compatibility"
69+
assert_equal 2, result.resources.length
70+
assert_includes result.resources, resource1
71+
assert_includes result.resources, resource2
72+
end
73+
end
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
require File.expand_path('../../../test_helper', __FILE__)
2+
3+
# PORO (Plain Old Ruby Object) for testing
4+
class PoroModel
5+
attr_accessor :id, :name, :created_at
6+
7+
def initialize(id, name)
8+
@id = id
9+
@name = name
10+
@created_at = Time.now
11+
end
12+
13+
# Note: No .all method - this is a PORO, not ActiveRecord
14+
end
15+
16+
# Resource for PORO model
17+
class PoroModelResource < JSONAPI::Resource
18+
model_name 'PoroModel'
19+
attributes :name
20+
21+
class << self
22+
def find_by_key(key, options = {})
23+
context = options[:context]
24+
model = PoroModel.new(key, "PORO Item #{key}")
25+
new(model, context)
26+
end
27+
28+
def create(context)
29+
model = PoroModel.new(SecureRandom.uuid, "New PORO")
30+
new(model, context)
31+
end
32+
end
33+
end
34+
35+
class PoroResourceTest < ActiveSupport::TestCase
36+
def test_poro_model_does_not_respond_to_all
37+
# Verify our test PORO doesn't have .all method
38+
refute PoroModel.respond_to?(:all),
39+
"PORO model should not respond to :all"
40+
end
41+
42+
def test_find_fragments_works_with_poro
43+
# find_fragments should work with PORO by falling back to find_by_key
44+
filters = {}
45+
options = {
46+
context: {},
47+
primary_keys: ['key1', 'key2']
48+
}
49+
50+
fragments = PoroModelResource.find_fragments(filters, options)
51+
52+
assert_equal 2, fragments.length, "Should return 2 fragments"
53+
54+
# Verify fragments have correct structure
55+
fragments.each do |identity, fragment|
56+
assert_kind_of JSONAPI::ResourceIdentity, identity
57+
assert_kind_of JSONAPI::ResourceFragment, fragment
58+
assert_not_nil fragment.resource, "Fragment should have resource for PORO"
59+
end
60+
end
61+
62+
def test_find_fragments_returns_empty_for_poro_without_keys
63+
filters = {}
64+
options = {
65+
context: {},
66+
primary_keys: nil
67+
}
68+
69+
fragments = PoroModelResource.find_fragments(filters, options)
70+
71+
assert_equal({}, fragments, "Should return empty hash when no keys")
72+
end
73+
74+
def test_find_fragments_with_single_key_for_poro
75+
filters = {}
76+
options = {
77+
context: {},
78+
primary_keys: 'single_key'
79+
}
80+
81+
fragments = PoroModelResource.find_fragments(filters, options)
82+
83+
assert_equal 1, fragments.length, "Should return 1 fragment"
84+
end
85+
86+
def test_active_record_resource_still_uses_original_find_fragments
87+
# Ensure ActiveRecord resources still use the optimized pluck-based implementation
88+
# This test verifies we didn't break existing behavior
89+
filters = { PostResource._primary_key => [1, 2] }
90+
options = { context: {} }
91+
92+
# PostResource uses ActiveRecord, should work normally
93+
fragments = PostResource.find_fragments(filters, options)
94+
95+
assert_equal 2, fragments.length
96+
fragments.each do |identity, fragment|
97+
assert_kind_of JSONAPI::ResourceIdentity, identity
98+
assert_kind_of JSONAPI::ResourceFragment, fragment
99+
end
100+
end
101+
end

0 commit comments

Comments
 (0)