Skip to content

Commit a8b698e

Browse files
committed
Merge pull request #494 from kinesisptyltd/master
[#490] Add support for sorting relationship fields.
2 parents 1470c27 + 1610ad8 commit a8b698e

8 files changed

Lines changed: 183 additions & 31 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,32 @@ class PostResource < JSONAPI::Resource
287287
end
288288
```
289289

290+
JR also supports sorting primary resources by fields on relationships.
291+
292+
Here's an example of sorting books by the author name:
293+
294+
```ruby
295+
class Book < ActiveRecord::Base
296+
belongs_to :author
297+
end
298+
299+
class Author < ActiveRecord::Base
300+
has_many :books
301+
end
302+
303+
class BookResource < JSONAPI::Resource
304+
attributes :title, :body
305+
306+
def self.sortable_fields(context)
307+
super(context) << :"author.name"
308+
end
309+
end
310+
```
311+
The request will look something like:
312+
```
313+
GET /books?include=author&sort=author.name
314+
```
315+
290316
##### Attribute Formatting
291317

292318
Attributes can have a `Format`. By default all attributes use the default formatter. If an attribute has the `format`

lib/jsonapi/resource.rb

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -530,10 +530,46 @@ def apply_pagination(records, paginator, order_options)
530530

531531
def apply_sort(records, order_options, _context = {})
532532
if order_options.any?
533-
records.order(order_options)
534-
else
535-
records
533+
order_options.each_pair do |field, direction|
534+
if field.to_s.include?(".")
535+
*model_names, column_name = field.split(".")
536+
537+
associations = _lookup_association_chain([records.model.to_s, *model_names])
538+
joins_query = _build_joins([records.model, *associations])
539+
540+
# _sorting is appended to avoid name clashes with manual joins eg. overriden filters
541+
order_by_query = "#{associations.last.name}_sorting.#{column_name} #{direction}"
542+
records = records.joins(joins_query).order(order_by_query)
543+
else
544+
records = records.order(field => direction)
545+
end
546+
end
547+
end
548+
549+
records
550+
end
551+
552+
def _lookup_association_chain(model_names)
553+
associations = []
554+
model_names.inject do |prev, current|
555+
association = prev.classify.constantize.reflect_on_all_associations.detect do |assoc|
556+
assoc.name.to_s.downcase == current.downcase
557+
end
558+
associations << association
559+
association.class_name
560+
end
561+
562+
associations
563+
end
564+
565+
def _build_joins(associations)
566+
joins = []
567+
568+
associations.inject do |prev, current|
569+
joins << "LEFT JOIN #{current.table_name} AS #{current.name}_sorting ON #{current.name}_sorting.id = #{prev.table_name}.#{current.foreign_key}"
570+
current
536571
end
572+
joins.join("\n")
537573
end
538574

539575
def apply_filter(records, filter, value, options = {})

lib/jsonapi/resource_serializer.rb

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ def initialize(primary_resource_klass, options = {})
3131

3232
# Converts a single resource, or an array of resources to a hash, conforming to the JSONAPI structure
3333
def serialize_to_hash(source)
34+
@top_level_sources = Set.new([source].flatten.compact.map {|s| top_level_source_key(s) })
35+
3436
is_resource_collection = source.respond_to?(:to_ary)
3537

3638
@included_objects = {}
@@ -174,6 +176,14 @@ def custom_links_hash(source)
174176
(custom_links.is_a?(Hash) && custom_links) || {}
175177
end
176178

179+
def top_level_source_key(source)
180+
"#{source.class}_#{source.id}"
181+
end
182+
183+
def self_referential_and_already_in_source(resource)
184+
resource && @top_level_sources.include?(top_level_source_key(resource))
185+
end
186+
177187
def relationships_hash(source, include_directives)
178188
relationships = source.class._relationships
179189
requested = requested_fields(source.class)
@@ -192,39 +202,24 @@ def relationships_hash(source, include_directives)
192202

193203
include_linkage = ia && ia[:include]
194204
include_linked_children = ia && !ia[:include_related].empty?
205+
resources = (include_linkage || include_linked_children) && [source.public_send(name)].flatten.compact
195206

196207
if field_set.include?(name)
197208
hash[format_key(name)] = link_object(source, relationship, include_linkage)
198209
end
199210

200-
type = relationship.type
201-
202211
# If the object has been serialized once it will be in the related objects list,
203212
# but it's possible all children won't have been captured. So we must still go
204213
# through the relationships.
205214
if include_linkage || include_linked_children
206-
if relationship.is_a?(JSONAPI::Relationship::ToOne)
207-
resource = source.public_send(name)
208-
if resource
209-
id = resource.id
210-
type = relationship.type_for_source(source)
211-
relationships_only = already_serialized?(type, id)
212-
if include_linkage && !relationships_only
213-
add_included_object(id, object_hash(resource, ia))
214-
elsif include_linked_children || relationships_only
215-
relationships_hash(resource, ia)
216-
end
217-
end
218-
elsif relationship.is_a?(JSONAPI::Relationship::ToMany)
219-
resources = source.public_send(name)
220-
resources.each do |resource|
221-
id = resource.id
222-
relationships_only = already_serialized?(type, id)
223-
if include_linkage && !relationships_only
224-
add_included_object(id, object_hash(resource, ia))
225-
elsif include_linked_children || relationships_only
226-
relationships_hash(resource, ia)
227-
end
215+
resources.each do |resource|
216+
next if self_referential_and_already_in_source(resource)
217+
id = resource.id
218+
relationships_only = already_serialized?(relationship.type, id)
219+
if include_linkage && !relationships_only
220+
add_included_object(id, object_hash(resource, ia))
221+
elsif include_linked_children || relationships_only
222+
relationships_hash(resource, ia)
228223
end
229224
end
230225
end

test/controllers/controller_test.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,31 @@ def test_sorting_by_multiple_fields
302302
assert_equal '14', json_response['data'][0]['id']
303303
end
304304

305+
def create_alphabetically_first_user_and_post
306+
author = Person.create(name: "Aardvark", date_joined: Time.now)
307+
author.posts.create(title: "My first post", body: "Hello World")
308+
end
309+
310+
def test_sorting_by_relationship_field
311+
post = create_alphabetically_first_user_and_post
312+
get :index, params: {sort: 'author.name'}
313+
314+
assert_response :success
315+
assert json_response['data'].length > 10, 'there are enough recordsto show sort'
316+
assert_equal '17', json_response['data'][0]['id'], 'nil is at the top'
317+
assert_equal post.id.to_s, json_response['data'][1]['id'], 'alphabetically first user is second'
318+
end
319+
320+
def test_desc_sorting_by_relationship_field
321+
post = create_alphabetically_first_user_and_post
322+
get :index, {sort: '-author.name'}
323+
324+
assert_response :success
325+
assert json_response['data'].length > 10, 'there are enough records to show sort'
326+
assert_equal '17', json_response['data'][-1]['id'], 'nil is at the bottom'
327+
assert_equal post.id.to_s, json_response['data'][-2]['id'], 'alphabetically first user is second last'
328+
end
329+
305330
def test_invalid_sort_param
306331
get :index, params: {sort: 'asdfg'}
307332

test/fixtures/active_record.rb

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
t.string :title
2929
t.text :body
3030
t.integer :author_id
31+
t.integer :parent_post_id
3132
t.belongs_to :section, index: true
3233
t.timestamps null: false
3334
end
@@ -279,6 +280,7 @@ class Post < ActiveRecord::Base
279280
has_many :special_post_tags, source: :tag
280281
has_many :special_tags, through: :special_post_tags, source: :tag
281282
belongs_to :section
283+
has_one :parent_post, class_name: 'Post', foreign_key: 'parent_post_id'
282284

283285
validates :author, presence: true
284286
validates :title, length: { maximum: 35 }
@@ -885,6 +887,14 @@ class SectionResource < JSONAPI::Resource
885887
attributes 'name'
886888
end
887889

890+
module ParentApi
891+
class PostResource < JSONAPI::Resource
892+
model_name 'Post'
893+
attributes :title
894+
has_one :parent_post
895+
end
896+
end
897+
888898
class PostResource < JSONAPI::Resource
889899
attribute :title
890900
attribute :body
@@ -895,7 +905,6 @@ class PostResource < JSONAPI::Resource
895905
has_many :tags, acts_as_set: true
896906
has_many :comments, acts_as_set: false
897907

898-
899908
# Not needed - just for testing
900909
primary_key :id
901910

@@ -973,9 +982,9 @@ def self.creatable_fields(context)
973982
end
974983

975984
def self.sortable_fields(context)
976-
super(context) - [:id]
985+
super(context) - [:id] + [:"author.name"]
977986
end
978-
987+
979988
def self.verify_key(key, context = nil)
980989
super(key)
981990
raise JSONAPI::Exceptions::RecordNotFound.new(key) unless find_by_key(key, context: context)
@@ -1227,7 +1236,6 @@ def custom_links(options)
12271236
end
12281237
end
12291238

1230-
12311239
module Api
12321240
module V1
12331241
class WriterResource < JSONAPI::Resource

test/unit/jsonapi_request/jsonapi_request_test.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ class CatResource < JSONAPI::Resource
88
has_one :father, class_name: 'Cat'
99

1010
filters :name
11+
12+
def self.sortable_fields(context)
13+
super(context) << :"mother.name"
14+
end
1115
end
1216

1317
class JSONAPIRequestTest < ActiveSupport::TestCase
@@ -190,6 +194,22 @@ def test_parse_filters_with_invalid_filters_param
190194
assert_equal(@request.errors.first.title, "Invalid filters syntax")
191195
end
192196

197+
def test_parse_sort_with_valid_sorts
198+
setup_request
199+
@request.parse_sort_criteria("-name")
200+
assert_equal(@request.filters, {})
201+
assert_equal(@request.errors, [])
202+
assert_equal(@request.sort_criteria, [{:field=>"name", :direction=>:desc}])
203+
end
204+
205+
def test_parse_sort_with_relationships
206+
setup_request
207+
@request.parse_sort_criteria("-mother.name")
208+
assert_equal(@request.filters, {})
209+
assert_equal(@request.errors, [])
210+
assert_equal(@request.sort_criteria, [{:field=>"mother.name", :direction=>:desc}])
211+
end
212+
193213
private
194214

195215
def setup_request

test/unit/resource/resource_test.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,31 @@ def apply_sort(records, criteria, context = {})
342342
end
343343
end
344344

345+
def test_lookup_association_chain
346+
model_names = %w(person posts parent_post)
347+
result = PostResource._lookup_association_chain(model_names)
348+
assert_equal 2, result.length
349+
350+
posts_reflection, parent_post_reflection = result
351+
assert_equal :posts, posts_reflection.name
352+
assert_equal :parent_post, parent_post_reflection.name
353+
354+
assert_equal "posts", posts_reflection.table_name
355+
assert_equal "posts", parent_post_reflection.table_name
356+
357+
assert_equal "author_id", posts_reflection.foreign_key
358+
assert_equal "parent_post_id", parent_post_reflection.foreign_key
359+
end
360+
361+
def test_build_joins
362+
model_names = %w(person posts parent_post author)
363+
associations = PostResource._lookup_association_chain(model_names)
364+
result = PostResource._build_joins(associations)
365+
366+
assert_equal "LEFT JOIN posts AS parent_post_sorting ON parent_post_sorting.id = posts.parent_post_id
367+
LEFT JOIN people AS author_sorting ON author_sorting.id = posts.author_id", result
368+
end
369+
345370
def test_to_many_relationship_pagination
346371
post_resource = PostResource.new(Post.find(1), nil)
347372
comments = post_resource.comments

test/unit/serializer/serializer_test.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,23 @@ def test_serializer_include_sub_objects
529529
)
530530
end
531531

532+
def test_serializer_keeps_sorted_order_of_objects_with_self_referential_relationships
533+
post1, post2, post3 = Post.find(1), Post.find(2), Post.find(3)
534+
post1.parent_post = post3
535+
ordered_posts = [post1, post2, post3]
536+
serialized_data = JSONAPI::ResourceSerializer.new(
537+
ParentApi::PostResource,
538+
include: ['parent_post'],
539+
base_url: 'http://example.com').serialize_to_hash(ordered_posts.map {|p| ParentApi::PostResource.new(p, nil)}
540+
)[:data]
541+
542+
assert_equal(3, serialized_data.length)
543+
assert_equal("1", serialized_data[0]["id"])
544+
assert_equal("2", serialized_data[1]["id"])
545+
assert_equal("3", serialized_data[2]["id"])
546+
end
547+
548+
532549
def test_serializer_different_foreign_key
533550
serialized = JSONAPI::ResourceSerializer.new(
534551
PersonResource,

0 commit comments

Comments
 (0)