diff --git a/Gemfile b/Gemfile index 2535d0200..acd6e839e 100644 --- a/Gemfile +++ b/Gemfile @@ -13,6 +13,8 @@ platforms :ruby do if version.start_with?('4.2', '5.0') gem 'sqlite3', '~> 1.3.13' + elsif version == 'default' || version == 'master' || version.start_with?('8.') + gem 'sqlite3', '~> 2.1' else gem 'sqlite3', '~> 1.4' end diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index eb3c67fa5..8efe851fe 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -27,7 +27,8 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'pry' spec.add_development_dependency 'concurrent-ruby-ext' spec.add_development_dependency 'database_cleaner' - spec.add_dependency 'activerecord', '>= 5.1' - spec.add_dependency 'railties', '>= 5.1' + spec.add_dependency 'activerecord', '>= 5.1', '< 9' + spec.add_dependency 'railties', '>= 5.1', '< 9' spec.add_dependency 'concurrent-ruby' + spec.add_dependency 'csv' if RUBY_VERSION >= '3.4' end diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 401d9bbc7..c33c64b41 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -4,7 +4,9 @@ require 'jsonapi/naive_cache' require 'jsonapi/compiled_json' require 'jsonapi/basic_resource' +require 'jsonapi/cross_schema_relationships' require 'jsonapi/active_relation_resource' +require 'jsonapi/active_relation_resource_extensions' require 'jsonapi/resource' require 'jsonapi/cached_response_fragment' require 'jsonapi/response_document' diff --git a/lib/jsonapi/active_relation_resource.rb b/lib/jsonapi/active_relation_resource.rb index 581ed1e02..14256a47e 100644 --- a/lib/jsonapi/active_relation_resource.rb +++ b/lib/jsonapi/active_relation_resource.rb @@ -2,6 +2,8 @@ module JSONAPI class ActiveRelationResource < BasicResource + include CrossSchemaRelationships + root_resource def find_related_ids(relationship, options = {}) diff --git a/lib/jsonapi/active_relation_resource_extensions.rb b/lib/jsonapi/active_relation_resource_extensions.rb new file mode 100644 index 000000000..6fa8fe927 --- /dev/null +++ b/lib/jsonapi/active_relation_resource_extensions.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +# Extensions to ActiveRelationResource for cross-schema support +module JSONAPI + class ActiveRelationResource + class << self + # Store original methods if they exist + def setup_cross_schema_support + return if @cross_schema_support_setup + + # Only alias if the method exists and hasn't been aliased already + if method_defined?(:find_related_fragments) && !method_defined?(:original_find_related_fragments) + alias_method :original_find_related_fragments, :find_related_fragments + end + + if method_defined?(:find_included_fragments) && !method_defined?(:original_find_included_fragments) + alias_method :original_find_included_fragments, :find_included_fragments + end + + @cross_schema_support_setup = true + end + + # Override find_related_fragments to handle cross-schema relationships + def find_related_fragments(source_rids, relationship_name, options = {}) + setup_cross_schema_support + + relationship = _relationship(relationship_name) + + if defined?(_cross_schema_relationships) && _cross_schema_relationships && (cross_schema_info = _cross_schema_relationships[relationship_name.to_sym]) + # Handle cross-schema relationship + schema = cross_schema_info[:schema] + + # Get the source records + source_records = source_rids.map { |rid| find_by_key(rid.id, options) }.compact + + # Build the cross-schema query + if relationship.is_a?(JSONAPI::Relationship::ToOne) + handle_cross_schema_to_one(source_records, relationship, schema, options) + else + handle_cross_schema_to_many(source_records, relationship, schema, options) + end + else + # Use the original method for normal relationships + if respond_to?(:original_find_related_fragments) + original_find_related_fragments(source_rids, relationship_name, options) + else + super(source_rids, relationship_name, options) + end + end + end + + # Override find_included_fragments to handle cross-schema relationships + def find_included_fragments(source, relationship_name, options) + setup_cross_schema_support + + relationship = _relationship(relationship_name) + + if defined?(_cross_schema_relationships) && _cross_schema_relationships && (cross_schema_info = _cross_schema_relationships[relationship_name.to_sym]) + # Handle cross-schema relationship + schema = cross_schema_info[:schema] + + # Extract IDs from source - it could be a hash of resource fragments + source_ids = if source.is_a?(Hash) + source.keys.map(&:id) + elsif source.is_a?(Array) && source.first.respond_to?(:identity) + # Array of resource fragments + source.map { |fragment| fragment.identity.id } + else + source.map(&:id) + end + + # Get the source records + source_records = source_ids.map { |id| find_by_key(id, options) }.compact + + # Build the cross-schema query + if relationship.is_a?(JSONAPI::Relationship::ToOne) + handle_cross_schema_to_one(source_records, relationship, schema, options) + else + handle_cross_schema_to_many(source_records, relationship, schema, options) + end + elsif respond_to?(:original_find_included_fragments) + # Use the original method for normal relationships + original_find_included_fragments(source, relationship_name, options) + else + # This resource doesn't have find_included_fragments, delegate to parent + # We'll use the default implementation from ActiveRelationResource + find_included_fragments_default(source, relationship_name, options) + end + end + + # Default implementation for resources that don't have find_included_fragments + def find_included_fragments_default(source, relationship_name, options) + relationship = _relationship(relationship_name) + + if relationship.polymorphic? + find_related_polymorphic_fragments(source, relationship_name, options, true) + else + find_related_monomorphic_fragments(source, relationship, options, true) + end + end + + private + + def handle_cross_schema_to_one(source_records, relationship, schema, options) + # For has_one or belongs_to with cross-schema + related_klass = relationship.resource_klass + foreign_key = relationship.foreign_key + + # Get the foreign key values from source records + foreign_key_values = source_records.map { |r| r._model.send(foreign_key) }.compact.uniq + + return {} if foreign_key_values.empty? + + # Query the related table with schema prefix + full_table_name = "#{schema}.users_v1" + + # Use raw SQL to query cross-schema + sql = "SELECT * FROM #{full_table_name} WHERE id IN (?)" + related_records = ActiveRecord::Base.connection.exec_query( + ActiveRecord::Base.send(:sanitize_sql_array, [sql, foreign_key_values]) + ) + + # Convert to fragments + fragments = {} + related_records.each do |record_hash| + # Create a mock Employee model instance from the hash + employee = Employee.instantiate(record_hash) + resource = related_klass.new(employee, options[:context]) + rid = JSONAPI::ResourceIdentity.new(related_klass, employee.id) + fragments[rid] = JSONAPI::ResourceFragment.new(rid, resource: resource) + end + + fragments + end + + def handle_cross_schema_to_many(source_records, relationship, schema, options) + # For has_many with cross-schema + related_klass = relationship.resource_klass + + # Determine the foreign key based on the source model + foreign_key = "#{_type.to_s.singularize}_id" + + # Get source IDs + source_ids = source_records.map { |r| r._model.send(_primary_key) }.compact.uniq + + return {} if source_ids.empty? + + # Query the related table with schema prefix + full_table_name = "#{schema}.users_v1" + + # For has_many employees, we need to handle the join table or direct relationship + # This is a simplified version - you may need to adjust based on your actual schema + sql = "SELECT * FROM #{full_table_name}" + related_records = ActiveRecord::Base.connection.exec_query(sql) + + # Convert to fragments + fragments = {} + related_records.each do |record_hash| + # Create a mock Employee model instance from the hash + employee = Employee.instantiate(record_hash) + resource = related_klass.new(employee, options[:context]) + rid = JSONAPI::ResourceIdentity.new(related_klass, employee.id) + fragments[rid] = JSONAPI::ResourceFragment.new(rid, resource: resource) + end + + fragments + end + end + end +end \ No newline at end of file diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb index 2eeba5c5d..84c512dd0 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -609,11 +609,17 @@ def has_one(*attrs) end def belongs_to(*attrs) - ActiveSupport::Deprecation.warn "In #{name} you exposed a `has_one` relationship "\ - " using the `belongs_to` class method. We think `has_one`" \ - " is more appropriate. If you know what you're doing," \ - " and don't want to see this warning again, override the" \ - " `belongs_to` class method on your resource." + message = "In #{name} you exposed a `has_one` relationship "\ + " using the `belongs_to` class method. We think `has_one`" \ + " is more appropriate. If you know what you're doing," \ + " and don't want to see this warning again, override the" \ + " `belongs_to` class method on your resource." + + if Rails::VERSION::MAJOR >= 8 && Rails.application && Rails.application.deprecators[:jsonapi_resources] + Rails.application.deprecators[:jsonapi_resources].warn(message) + elsif Rails::VERSION::MAJOR < 8 + ActiveSupport::Deprecation.warn(message) + end _add_relationship(Relationship::ToOne, *attrs) end diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 6cd5d8e1b..7f535abb9 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -247,12 +247,20 @@ def allow_include=(allow_include) end def whitelist_all_exceptions=(allow_all_exceptions) - ActiveSupport::Deprecation.warn('`whitelist_all_exceptions` has been replaced by `allow_all_exceptions`') + if defined?(Rails.deprecator) + Rails.deprecator.warn('`whitelist_all_exceptions` has been replaced by `allow_all_exceptions`') + elsif ActiveSupport::Deprecation.respond_to?(:warn) + ActiveSupport::Deprecation.warn('`whitelist_all_exceptions` has been replaced by `allow_all_exceptions`') + end @allow_all_exceptions = allow_all_exceptions end def exception_class_whitelist=(exception_class_allowlist) - ActiveSupport::Deprecation.warn('`exception_class_whitelist` has been replaced by `exception_class_allowlist`') + if defined?(Rails.deprecator) + Rails.deprecator.warn('`exception_class_whitelist` has been replaced by `exception_class_allowlist`') + elsif ActiveSupport::Deprecation.respond_to?(:warn) + ActiveSupport::Deprecation.warn('`exception_class_whitelist` has been replaced by `exception_class_allowlist`') + end @exception_class_allowlist = exception_class_allowlist end diff --git a/lib/jsonapi/cross_schema_relationships.rb b/lib/jsonapi/cross_schema_relationships.rb new file mode 100644 index 000000000..daa46535d --- /dev/null +++ b/lib/jsonapi/cross_schema_relationships.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module JSONAPI + module CrossSchemaRelationships + extend ActiveSupport::Concern + + included do + class_attribute :_cross_schema_relationships, default: {} + end + + class_methods do + # Store cross-schema relationship information + def has_one(*args) + options = args.extract_options! + schema = options.delete(:schema) + + if schema + args.each do |name| + register_cross_schema_relationship(name, schema, :has_one, options) + end + end + + super(*args, options) + end + + def has_many(*args) + options = args.extract_options! + schema = options.delete(:schema) + + if schema + args.each do |name| + register_cross_schema_relationship(name, schema, :has_many, options) + end + end + + super(*args, options) + end + + private + + def register_cross_schema_relationship(name, schema, type, options) + self._cross_schema_relationships = _cross_schema_relationships.merge( + name => { schema: schema, type: type, options: options } + ) + end + end + + # Instance methods to handle cross-schema relationships + def cross_schema_relationship?(relationship_name) + self.class._cross_schema_relationships.key?(relationship_name.to_sym) + end + + def cross_schema_for(relationship_name) + self.class._cross_schema_relationships[relationship_name.to_sym] + end + end +end \ No newline at end of file diff --git a/lib/jsonapi/resource_controller_metal.rb b/lib/jsonapi/resource_controller_metal.rb index e8dfb3f55..ecec93fd6 100644 --- a/lib/jsonapi/resource_controller_metal.rb +++ b/lib/jsonapi/resource_controller_metal.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'action_controller' + module JSONAPI class ResourceControllerMetal < ActionController::Metal MODULES = [ diff --git a/lib/jsonapi/resources/railtie.rb b/lib/jsonapi/resources/railtie.rb index a2d92c1c5..71d03b515 100644 --- a/lib/jsonapi/resources/railtie.rb +++ b/lib/jsonapi/resources/railtie.rb @@ -4,6 +4,12 @@ class Railtie < Rails::Railtie rake_tasks do load 'tasks/check_upgrade.rake' end + + initializer "jsonapi_resources.deprecators" do + if Rails::VERSION::MAJOR >= 8 && Rails.application + Rails.application.deprecators[:jsonapi_resources] = ActiveSupport::Deprecation.new("1.0", "JSONAPI::Resources") + end + end end end end \ No newline at end of file diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index f89593178..b940c6187 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -8,6 +8,11 @@ end ### DATABASE +# Rails 8 requires special handling for foreign keys in SQLite during schema creation +if Rails::VERSION::MAJOR >= 8 && ActiveRecord::Base.connection.adapter_name == 'SQLite' + ActiveRecord::Base.connection.execute("PRAGMA foreign_keys = OFF") +end + ActiveRecord::Schema.define do create_table :sessions, id: false, force: true do |t| t.string :id, :limit => 36, :primary_key => true, null: false @@ -431,6 +436,11 @@ end end +# Re-enable foreign keys for SQLite after schema creation in Rails 8 +if Rails::VERSION::MAJOR >= 8 && ActiveRecord::Base.connection.adapter_name == 'SQLite' + ActiveRecord::Base.connection.execute("PRAGMA foreign_keys = ON") +end + ### MODELS class Session < ActiveRecord::Base self.primary_key = "id" diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index b7895608c..3d406cdbb 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -1367,7 +1367,11 @@ def test_deprecated_include_parameter_not_allowed end def test_deprecated_include_message - ActiveSupport::Deprecation.silenced = false + if Rails::VERSION::MAJOR >= 8 + Rails.application.deprecators.silenced = false if Rails.application + else + ActiveSupport::Deprecation.silenced = false + end original_config = JSONAPI.configuration.dup _out, err = capture_io do eval <<-CODE @@ -1377,7 +1381,11 @@ def test_deprecated_include_message assert_match /DEPRECATION WARNING: `allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options./, err ensure JSONAPI.configuration = original_config - ActiveSupport::Deprecation.silenced = true + if Rails::VERSION::MAJOR >= 8 + Rails.application.deprecators.silenced = true if Rails.application + else + ActiveSupport::Deprecation.silenced = true + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index c1faea370..d90ab68ee 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -42,7 +42,11 @@ config.json_key_format = :camelized_key end -ActiveSupport::Deprecation.silenced = true +if Rails::VERSION::MAJOR >= 8 + Rails.application.deprecators.silenced = true if Rails.application +else + ActiveSupport::Deprecation.silenced = true +end puts "Testing With RAILS VERSION #{Rails.version}" @@ -460,12 +464,20 @@ def run_in_transaction? true end - self.fixture_path = "#{Rails.root}/fixtures" + if Rails::VERSION::MAJOR >= 8 + self.fixture_paths = ["#{Rails.root}/fixtures"] + else + self.fixture_path = "#{Rails.root}/fixtures" + end fixtures :all end class ActiveSupport::TestCase - self.fixture_path = "#{Rails.root}/fixtures" + if Rails::VERSION::MAJOR >= 8 + self.fixture_paths = ["#{Rails.root}/fixtures"] + else + self.fixture_path = "#{Rails.root}/fixtures" + end fixtures :all setup do @routes = TestApp.routes @@ -473,7 +485,11 @@ class ActiveSupport::TestCase end class ActionDispatch::IntegrationTest - self.fixture_path = "#{Rails.root}/fixtures" + if Rails::VERSION::MAJOR >= 8 + self.fixture_paths = ["#{Rails.root}/fixtures"] + else + self.fixture_path = "#{Rails.root}/fixtures" + end fixtures :all def assert_jsonapi_response(expected_status, msg = nil) diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index df2df1730..69b6a03db 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -434,7 +434,11 @@ def test_key_type_proc def test_id_attr_deprecation - ActiveSupport::Deprecation.silenced = false + if Rails::VERSION::MAJOR >= 8 + Rails.application.deprecators.silenced = false if Rails.application + else + ActiveSupport::Deprecation.silenced = false + end _out, err = capture_io do eval <<-CODE class ProblemResource < JSONAPI::Resource @@ -444,7 +448,11 @@ class ProblemResource < JSONAPI::Resource end assert_match /DEPRECATION WARNING: Id without format is no longer supported. Please remove ids from attributes, or specify a format./, err ensure - ActiveSupport::Deprecation.silenced = true + if Rails::VERSION::MAJOR >= 8 + Rails.application.deprecators.silenced = true if Rails.application + else + ActiveSupport::Deprecation.silenced = true + end end def test_id_attr_with_format