jpie 0.4.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +21 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +35 -110
  5. data/.travis.yml +7 -0
  6. data/Gemfile +21 -0
  7. data/Gemfile.lock +312 -0
  8. data/README.md +2072 -140
  9. data/Rakefile +3 -14
  10. data/bin/console +15 -0
  11. data/bin/setup +8 -0
  12. data/jpie.gemspec +18 -35
  13. data/kiln/app/resources/user_message_resource.rb +2 -0
  14. data/lib/jpie.rb +3 -24
  15. data/lib/json_api/active_storage/deserialization.rb +106 -0
  16. data/lib/json_api/active_storage/detection.rb +74 -0
  17. data/lib/json_api/active_storage/serialization.rb +32 -0
  18. data/lib/json_api/configuration.rb +58 -0
  19. data/lib/json_api/controllers/base_controller.rb +26 -0
  20. data/lib/json_api/controllers/concerns/controller_helpers.rb +223 -0
  21. data/lib/json_api/controllers/concerns/resource_actions.rb +657 -0
  22. data/lib/json_api/controllers/relationships_controller.rb +504 -0
  23. data/lib/json_api/controllers/resources_controller.rb +6 -0
  24. data/lib/json_api/errors/parameter_not_allowed.rb +19 -0
  25. data/lib/json_api/railtie.rb +75 -0
  26. data/lib/json_api/resources/active_storage_blob_resource.rb +11 -0
  27. data/lib/json_api/resources/resource.rb +238 -0
  28. data/lib/json_api/resources/resource_loader.rb +35 -0
  29. data/lib/json_api/routing.rb +72 -0
  30. data/lib/json_api/serialization/deserializer.rb +362 -0
  31. data/lib/json_api/serialization/serializer.rb +320 -0
  32. data/lib/json_api/support/active_storage_support.rb +85 -0
  33. data/lib/json_api/support/collection_query.rb +406 -0
  34. data/lib/json_api/support/instrumentation.rb +42 -0
  35. data/lib/json_api/support/param_helpers.rb +51 -0
  36. data/lib/json_api/support/relationship_guard.rb +16 -0
  37. data/lib/json_api/support/relationship_helpers.rb +74 -0
  38. data/lib/json_api/support/resource_identifier.rb +87 -0
  39. data/lib/json_api/support/responders.rb +100 -0
  40. data/lib/json_api/support/response_helpers.rb +10 -0
  41. data/lib/json_api/support/sort_parsing.rb +21 -0
  42. data/lib/json_api/support/type_conversion.rb +21 -0
  43. data/lib/json_api/testing/test_helper.rb +76 -0
  44. data/lib/json_api/testing.rb +3 -0
  45. data/lib/{jpie → json_api}/version.rb +2 -2
  46. data/lib/json_api.rb +50 -0
  47. data/lib/rubocop/cop/custom/hash_value_omission.rb +53 -0
  48. metadata +50 -169
  49. data/.cursor/rules/dependencies.mdc +0 -19
  50. data/.cursor/rules/examples.mdc +0 -16
  51. data/.cursor/rules/git.mdc +0 -14
  52. data/.cursor/rules/project_structure.mdc +0 -30
  53. data/.cursor/rules/publish_gem.mdc +0 -73
  54. data/.cursor/rules/security.mdc +0 -14
  55. data/.cursor/rules/style.mdc +0 -15
  56. data/.cursor/rules/testing.mdc +0 -16
  57. data/.overcommit.yml +0 -35
  58. data/CHANGELOG.md +0 -164
  59. data/LICENSE.txt +0 -21
  60. data/PUBLISHING.md +0 -111
  61. data/examples/basic_example.md +0 -146
  62. data/examples/including_related_resources.md +0 -491
  63. data/examples/pagination.md +0 -303
  64. data/examples/relationships.md +0 -114
  65. data/examples/resource_attribute_configuration.md +0 -147
  66. data/examples/resource_meta_configuration.md +0 -244
  67. data/examples/rspec_testing.md +0 -130
  68. data/examples/single_table_inheritance.md +0 -160
  69. data/lib/jpie/configuration.rb +0 -12
  70. data/lib/jpie/controller/crud_actions.rb +0 -141
  71. data/lib/jpie/controller/error_handling/handler_setup.rb +0 -124
  72. data/lib/jpie/controller/error_handling/handlers.rb +0 -109
  73. data/lib/jpie/controller/error_handling.rb +0 -23
  74. data/lib/jpie/controller/json_api_validation.rb +0 -193
  75. data/lib/jpie/controller/parameter_parsing.rb +0 -78
  76. data/lib/jpie/controller/related_actions.rb +0 -45
  77. data/lib/jpie/controller/relationship_actions.rb +0 -291
  78. data/lib/jpie/controller/relationship_validation.rb +0 -117
  79. data/lib/jpie/controller/rendering.rb +0 -154
  80. data/lib/jpie/controller.rb +0 -45
  81. data/lib/jpie/deserializer.rb +0 -110
  82. data/lib/jpie/errors.rb +0 -117
  83. data/lib/jpie/generators/resource_generator.rb +0 -116
  84. data/lib/jpie/generators/templates/resource.rb.erb +0 -31
  85. data/lib/jpie/railtie.rb +0 -42
  86. data/lib/jpie/resource/attributable.rb +0 -112
  87. data/lib/jpie/resource/inferrable.rb +0 -43
  88. data/lib/jpie/resource/sortable.rb +0 -93
  89. data/lib/jpie/resource.rb +0 -147
  90. data/lib/jpie/routing.rb +0 -59
  91. data/lib/jpie/serializer.rb +0 -205
@@ -1,110 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JPie
4
- class Deserializer
5
- attr_reader :resource_class, :options
6
-
7
- def initialize(resource_class, options = {})
8
- @resource_class = resource_class
9
- @options = options
10
- end
11
-
12
- def deserialize(json_data, context = {})
13
- data = json_data.is_a?(String) ? JSON.parse(json_data) : json_data
14
-
15
- validate_json_api_structure!(data)
16
-
17
- if data['data'].is_a?(Array)
18
- deserialize_collection(data['data'], context)
19
- else
20
- deserialize_single(data['data'], context)
21
- end
22
- rescue JSON::ParserError => e
23
- raise Errors::BadRequestError.new(detail: "Invalid JSON: #{e.message}")
24
- end
25
-
26
- private
27
-
28
- def deserialize_single(resource_data, context)
29
- validate_resource_data!(resource_data)
30
- extract_attributes(resource_data, context)
31
- end
32
-
33
- def deserialize_collection(resources_data, context)
34
- resources_data.map { deserialize_single(it, context) }
35
- end
36
-
37
- def extract_attributes(resource_data, _context)
38
- attributes = resource_data['attributes'] || {}
39
- type = resource_data['type']
40
- id = resource_data['id']
41
-
42
- validate_type!(type) if type
43
-
44
- model_attributes = transform_attribute_keys(attributes)
45
- filtered_attributes = filter_allowed_attributes(model_attributes)
46
- result = deserialize_attribute_values(filtered_attributes)
47
-
48
- add_id_if_present(result, id)
49
- end
50
-
51
- def transform_attribute_keys(attributes)
52
- attributes.transform_keys { it.to_s.underscore }
53
- end
54
-
55
- def filter_allowed_attributes(model_attributes)
56
- allowed_attributes = resource_class._attributes.map(&:to_s)
57
- model_attributes.slice(*allowed_attributes)
58
- end
59
-
60
- def deserialize_attribute_values(attributes)
61
- attributes.transform_values { deserialize_value(it) }
62
- end
63
-
64
- def add_id_if_present(result, id)
65
- result['id'] = id if id
66
- result.with_indifferent_access
67
- end
68
-
69
- def deserialize_value(value)
70
- case value
71
- when String
72
- # Only try to parse as datetime if it looks like an ISO8601 string
73
- if value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
74
- begin
75
- Time.parse(value)
76
- rescue ArgumentError, TypeError
77
- value
78
- end
79
- else
80
- value
81
- end
82
- else
83
- value
84
- end
85
- end
86
-
87
- def validate_json_api_structure!(data)
88
- return if data.is_a?(Hash) && data.key?('data')
89
-
90
- raise Errors::BadRequestError.new(detail: 'Invalid JSON:API structure. Missing "data" key.')
91
- end
92
-
93
- def validate_resource_data!(resource_data)
94
- raise Errors::BadRequestError.new(detail: 'Invalid resource data structure.') unless resource_data.is_a?(Hash)
95
-
96
- return if resource_data.key?('type')
97
-
98
- raise Errors::BadRequestError.new(detail: 'Resource data must include "type".')
99
- end
100
-
101
- def validate_type!(type)
102
- expected_type = resource_class.type
103
- return if type == expected_type
104
-
105
- raise Errors::BadRequestError.new(
106
- detail: "Expected type '#{expected_type}', got '#{type}'"
107
- )
108
- end
109
- end
110
- end
data/lib/jpie/errors.rb DELETED
@@ -1,117 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JPie
4
- module Errors
5
- class Error < StandardError
6
- attr_reader :status, :code, :title, :detail, :source
7
-
8
- def initialize(status:, code: nil, title: nil, detail: nil, source: nil)
9
- @status = status
10
- @code = code
11
- @title = title
12
- @detail = detail
13
- @source = source
14
- super(detail || title || 'An error occurred')
15
- end
16
-
17
- def to_hash
18
- {
19
- status: status.to_s,
20
- code:,
21
- title:,
22
- detail:,
23
- source:
24
- }.compact
25
- end
26
- end
27
-
28
- class ValidationError < Error
29
- def initialize(detail:, source: nil)
30
- super(status: 422, title: 'Validation Error', detail:, source:)
31
- end
32
- end
33
-
34
- class NotFoundError < Error
35
- def initialize(detail: 'Resource not found')
36
- super(status: 404, title: 'Not Found', detail:)
37
- end
38
- end
39
-
40
- class BadRequestError < Error
41
- def initialize(detail: 'Bad Request')
42
- super(status: 400, title: 'Bad Request', detail:)
43
- end
44
- end
45
-
46
- class UnauthorizedError < Error
47
- def initialize(detail: 'Unauthorized')
48
- super(status: 401, title: 'Unauthorized', detail:)
49
- end
50
- end
51
-
52
- class ForbiddenError < Error
53
- def initialize(detail: 'Forbidden')
54
- super(status: 403, title: 'Forbidden', detail:)
55
- end
56
- end
57
-
58
- class UnsupportedMediaTypeError < Error
59
- def initialize(detail: 'Unsupported Media Type')
60
- super(status: 415, title: 'Unsupported Media Type', detail:)
61
- end
62
- end
63
-
64
- class InternalServerError < Error
65
- def initialize(detail: 'Internal Server Error')
66
- super(status: 500, title: 'Internal Server Error', detail:)
67
- end
68
- end
69
-
70
- class ResourceError < Error
71
- def initialize(detail:)
72
- super(status: 500, title: 'Resource Error', detail:)
73
- end
74
- end
75
-
76
- # JSON:API Compliance Errors
77
- class InvalidJsonApiRequestError < BadRequestError
78
- def initialize(detail: 'Request is not JSON:API compliant')
79
- super
80
- end
81
- end
82
-
83
- class UnsupportedIncludeError < BadRequestError
84
- def initialize(include_path:, supported_includes: [])
85
- detail = if supported_includes.any?
86
- "Unsupported include '#{include_path}'. Supported includes: #{supported_includes.join(', ')}"
87
- else
88
- "Unsupported include '#{include_path}'. No includes are supported for this resource"
89
- end
90
- super(detail: detail)
91
- end
92
- end
93
-
94
- class UnsupportedSortFieldError < BadRequestError
95
- def initialize(sort_field:, supported_fields: [])
96
- detail = if supported_fields.any?
97
- "Unsupported sort field '#{sort_field}'. Supported fields: #{supported_fields.join(', ')}"
98
- else
99
- "Unsupported sort field '#{sort_field}'. No sorting is supported for this resource"
100
- end
101
- super(detail: detail)
102
- end
103
- end
104
-
105
- class InvalidSortParameterError < BadRequestError
106
- def initialize(detail: 'Invalid sort parameter format')
107
- super
108
- end
109
- end
110
-
111
- class InvalidIncludeParameterError < BadRequestError
112
- def initialize(detail: 'Invalid include parameter format')
113
- super
114
- end
115
- end
116
- end
117
- end
@@ -1,116 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'rails/generators/base'
4
-
5
- module JPie
6
- module Generators
7
- class ResourceGenerator < Rails::Generators::NamedBase
8
- source_root File.expand_path('templates', __dir__)
9
-
10
- desc 'Generate a JPie resource class with semantic field definitions'
11
-
12
- argument :field_definitions, type: :array, default: [],
13
- banner: 'attribute:field meta:field relationship:type:field'
14
-
15
- class_option :model, type: :string,
16
- desc: 'Model class to associate with this resource (defaults to inferred model)'
17
- class_option :skip_model, type: :boolean, default: false,
18
- desc: 'Skip explicit model declaration (use automatic inference)'
19
-
20
- def create_resource_file
21
- template 'resource.rb.erb', File.join('app/resources', "#{file_name}_resource.rb")
22
- end
23
-
24
- private
25
-
26
- def model_class_name
27
- options[:model] || class_name
28
- end
29
-
30
- def needs_explicit_model?
31
- # Only need explicit model declaration if:
32
- # 1. User explicitly provided a different model name, OR
33
- # 2. User didn't use --skip-model flag AND the model name differs from the inferred name
34
- return false if options[:skip_model]
35
- return true if options[:model] && options[:model] != class_name
36
-
37
- # For standard naming (UserResource -> User), we can skip the explicit declaration
38
- false
39
- end
40
-
41
- def resource_attributes
42
- parse_field_definitions.fetch(:attributes, [])
43
- end
44
-
45
- def meta_attributes_list
46
- parse_field_definitions.fetch(:meta_attributes, [])
47
- end
48
-
49
- def relationships_list
50
- parse_field_definitions.fetch(:relationships, [])
51
- end
52
-
53
- def parse_relationships
54
- relationships_list.map do |rel|
55
- # rel is already a hash with :type and :name from parse_field_definitions
56
- rel
57
- end
58
- end
59
-
60
- def parse_field_definitions
61
- return @parsed_definitions if @parsed_definitions
62
-
63
- @parsed_definitions = { attributes: [], meta_attributes: [], relationships: [] }
64
-
65
- field_definitions.each do |definition|
66
- process_field_definition(definition)
67
- end
68
-
69
- @parsed_definitions
70
- end
71
-
72
- def process_field_definition(definition)
73
- case definition
74
- when /^attribute:(.+)$/
75
- @parsed_definitions[:attributes] << ::Regexp.last_match(1)
76
- when /^meta:(.+)$/
77
- @parsed_definitions[:meta_attributes] << ::Regexp.last_match(1)
78
- when /^relationship:(.+):(.+)$/, /^(has_many|has_one|belongs_to):(.+)$/
79
- add_relationship(::Regexp.last_match(1), ::Regexp.last_match(2))
80
- when /^(.+):(.+)$/
81
- process_legacy_field(::Regexp.last_match(1))
82
- else
83
- process_plain_field(definition)
84
- end
85
- end
86
-
87
- def add_relationship(type, name)
88
- @parsed_definitions[:relationships] << { type: type, name: name }
89
- end
90
-
91
- def process_legacy_field(field_name)
92
- # Legacy support: field:type format - treat as attribute and ignore type
93
- if meta_attribute_name?(field_name)
94
- @parsed_definitions[:meta_attributes] << field_name
95
- else
96
- @parsed_definitions[:attributes] << field_name
97
- end
98
- end
99
-
100
- def process_plain_field(field_name)
101
- # Plain field name - treat as attribute
102
- if meta_attribute_name?(field_name)
103
- @parsed_definitions[:meta_attributes] << field_name
104
- else
105
- @parsed_definitions[:attributes] << field_name
106
- end
107
- end
108
-
109
- def meta_attribute_name?(name)
110
- # Check if field name suggests it's a meta attribute
111
- meta_names = %w[created_at updated_at deleted_at published_at archived_at]
112
- meta_names.include?(name)
113
- end
114
- end
115
- end
116
- end
@@ -1,31 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class <%= class_name %>Resource < JPie::Resource
4
- <% if needs_explicit_model? -%>
5
- model <%= model_class_name %>
6
- <% end -%>
7
-
8
- <% if resource_attributes.any? -%>
9
- attributes <%= resource_attributes.map { |attr| ":#{attr}" }.join(', ') %>
10
- <% else -%>
11
- # Define your attributes here:
12
- # attributes :name, :email, :title
13
- <% end -%>
14
-
15
- <% if meta_attributes_list.any? -%>
16
- meta_attributes <%= meta_attributes_list.map { |attr| ":#{attr}" }.join(', ') %>
17
- <% else -%>
18
- # Define your meta attributes here:
19
- # meta_attributes :created_at, :updated_at
20
- <% end -%>
21
-
22
- <% if parse_relationships.any? -%>
23
- <% parse_relationships.each do |rel| -%>
24
- <%= rel[:type] %> :<%= rel[:name] %>
25
- <% end -%>
26
- <% else -%>
27
- # Define your relationships here:
28
- # has_many :posts
29
- # has_one :user
30
- <% end -%>
31
- end
data/lib/jpie/railtie.rb DELETED
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'rails/railtie'
4
-
5
- module JPie
6
- class Railtie < Rails::Railtie
7
- railtie_name :jpie
8
-
9
- config.jpie = ActiveSupport::OrderedOptions.new
10
-
11
- # Configure Rails inflections to preserve JPie casing
12
- initializer 'jpie.inflections' do
13
- ActiveSupport::Inflector.inflections(:en) do |inflect|
14
- inflect.acronym 'JPie'
15
- end
16
- end
17
-
18
- initializer 'jpie.configure' do |app|
19
- JPie.configure do |config|
20
- app.config.jpie.each do |key, value|
21
- config.public_send("#{key}=", value) if config.respond_to?("#{key}=")
22
- end
23
- end
24
- end
25
-
26
- initializer 'jpie.action_controller' do
27
- ActiveSupport.on_load(:action_controller) do
28
- extend JPie::Controller::ClassMethods if defined?(JPie::Controller::ClassMethods)
29
- end
30
- end
31
-
32
- initializer 'jpie.routing' do
33
- ActiveSupport.on_load(:action_dispatch) do
34
- ActionDispatch::Routing::Mapper.include JPie::Routing
35
- end
36
- end
37
-
38
- generators do
39
- require 'jpie/generators/resource_generator'
40
- end
41
- end
42
- end
@@ -1,112 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JPie
4
- class Resource
5
- module Attributable
6
- extend ActiveSupport::Concern
7
-
8
- included do
9
- class_attribute :_attributes, :_relationships, :_meta_attributes
10
- self._attributes = []
11
- self._relationships = {}
12
- self._meta_attributes = []
13
- end
14
-
15
- class_methods do
16
- def attribute(name, options = {}, &)
17
- name = name.to_sym
18
- _attributes << name unless _attributes.include?(name)
19
- define_attribute_method(name, options, &)
20
- end
21
-
22
- def attributes(*names)
23
- names.each { attribute(it) }
24
- end
25
-
26
- def meta_attribute(name, options = {}, &)
27
- name = name.to_sym
28
- _meta_attributes << name unless _meta_attributes.include?(name)
29
- define_attribute_method(name, options, &)
30
- end
31
-
32
- def meta_attributes(*names)
33
- names.each { meta_attribute(it) }
34
- end
35
-
36
- # More concise aliases for modern Rails style
37
- alias_method :meta, :meta_attribute
38
- alias_method :metas, :meta_attributes
39
-
40
- def relationship(name, options = {})
41
- name = name.to_sym
42
- _relationships[name] = options
43
-
44
- # Check if method is already defined (public or private) to allow custom implementations
45
- return if method_defined?(name) || private_method_defined?(name)
46
-
47
- define_method(name) do
48
- # Handle through associations
49
- if options[:through]
50
- handle_through_association(name, options)
51
- else
52
- # Standard association handling
53
- attr_name = options[:attr] || name
54
- @object.public_send(attr_name)
55
- end
56
- end
57
- end
58
-
59
- def has_many(name, options = {})
60
- name = name.to_sym
61
- resource_class_name = options[:resource] || infer_resource_class_name(name)
62
- relationship(name, { type: :has_many, resource: resource_class_name }.merge(options))
63
- end
64
-
65
- def has_one(name, options = {})
66
- name = name.to_sym
67
- resource_class_name = options[:resource] || infer_resource_class_name(name)
68
- relationship(name, { type: :has_one, resource: resource_class_name }.merge(options))
69
- end
70
-
71
- private
72
-
73
- def define_attribute_method(name, options, &)
74
- # If a block is provided, use it (existing behavior)
75
- if block_given?
76
- define_method(name) do
77
- instance_exec(&)
78
- end
79
- # If options[:block] is provided, use it (existing behavior)
80
- elsif options[:block]
81
- define_method(name) do
82
- instance_exec(&options[:block])
83
- end
84
- # If method is not already defined on the resource (public or private), define the default implementation
85
- elsif !method_defined?(name) && !private_method_defined?(name)
86
- define_method(name) do
87
- attr_name = options[:attr] || name
88
- @object.public_send(attr_name)
89
- end
90
- end
91
- # If method is already defined, don't override it - let the custom method handle it
92
- end
93
-
94
- def infer_resource_class_name(relationship_name)
95
- # Convert relationship name to resource class name
96
- # :posts -> "PostResource"
97
- # :user -> "UserResource"
98
- singularized_name = relationship_name.to_s.singularize
99
- "#{singularized_name.camelize}Resource"
100
- end
101
- end
102
-
103
- private
104
-
105
- # Handle through associations by calling the appropriate association method
106
- def handle_through_association(name, options)
107
- attr_name = options[:attr] || name
108
- @object.public_send(attr_name)
109
- end
110
- end
111
- end
112
- end
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JPie
4
- class Resource
5
- module Inferrable
6
- extend ActiveSupport::Concern
7
-
8
- included do
9
- class_attribute :_type, :_model_class
10
- end
11
-
12
- class_methods do
13
- def model(model_class = nil)
14
- if model_class
15
- self._model_class = model_class
16
- else
17
- _model_class || infer_model_class
18
- end
19
- end
20
-
21
- def type(type_name = nil)
22
- if type_name
23
- self._type = type_name.to_s
24
- else
25
- _type || infer_type_name
26
- end
27
- end
28
-
29
- private
30
-
31
- def infer_model_class
32
- name.chomp('Resource').constantize
33
- rescue NameError
34
- nil
35
- end
36
-
37
- def infer_type_name
38
- model&.model_name&.plural || name.chomp('Resource').underscore.pluralize
39
- end
40
- end
41
- end
42
- end
43
- end
@@ -1,93 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JPie
4
- class Resource
5
- module Sortable
6
- extend ActiveSupport::Concern
7
-
8
- included do
9
- class_attribute :_sortable_fields
10
- self._sortable_fields = {}
11
- end
12
-
13
- class_methods do
14
- # Define a sortable field with optional custom sorting logic
15
- # Example:
16
- # sortable_by :name
17
- # sortable_by :created_at, :created_at_desc
18
- # sortable_by :popularity do |direction|
19
- # if direction == :asc
20
- # model.order(:likes_count)
21
- # else
22
- # model.order(likes_count: :desc)
23
- # end
24
- # end
25
- def sortable_by(field, column = nil, &block)
26
- field = field.to_sym
27
- _sortable_fields[field] = block || column || field
28
- end
29
-
30
- # More concise alias for modern Rails style
31
- alias_method :sortable, :sortable_by
32
-
33
- # Apply sorting to a query based on sort parameters
34
- # sort_fields: array of sort field strings (e.g., ['name', '-created_at'])
35
- def sort(query, sort_fields)
36
- return query if sort_fields.blank?
37
-
38
- sort_fields.each do |sort_field|
39
- # Parse direction (- prefix means descending)
40
- if sort_field.start_with?('-')
41
- field = sort_field[1..].to_sym
42
- direction = :desc
43
- else
44
- field = sort_field.to_sym
45
- direction = :asc
46
- end
47
-
48
- # Check if field is sortable
49
- unless sortable_field?(field)
50
- raise JPie::Errors::BadRequestError.new(
51
- detail: "Invalid sort field: #{field}. Sortable fields are: #{sortable_fields.join(', ')}"
52
- )
53
- end
54
-
55
- # Apply sorting
56
- query = apply_sort(query, field, direction)
57
- end
58
-
59
- query
60
- end
61
-
62
- # Get list of all sortable fields (attributes + explicitly defined sortable fields)
63
- def sortable_fields
64
- (_attributes + _sortable_fields.keys).uniq.map(&:to_s)
65
- end
66
-
67
- # Check if a field is sortable
68
- def sortable_field?(field)
69
- field = field.to_sym
70
- _attributes.include?(field) || _sortable_fields.key?(field)
71
- end
72
-
73
- private
74
-
75
- # Apply a single sort to the query
76
- def apply_sort(query, field, direction)
77
- sortable_config = _sortable_fields[field]
78
-
79
- if sortable_config.is_a?(Proc)
80
- # Custom sorting block
81
- instance_exec(query, direction, &sortable_config)
82
- elsif sortable_config.is_a?(Symbol)
83
- # Custom column name
84
- query.order(sortable_config => direction)
85
- else
86
- # Default sorting by attribute name
87
- query.order(field => direction)
88
- end
89
- end
90
- end
91
- end
92
- end
93
- end