diff --git a/.gitignore b/.gitignore index 6cc125d63..b15de95a8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,9 @@ .bundle .config .yardoc +.ruby-gemset .ruby-version +.byebug_history Gemfile.lock InstalledFiles _yardoc diff --git a/Rakefile b/Rakefile index 7c629c8a6..c18491c1a 100644 --- a/Rakefile +++ b/Rakefile @@ -15,4 +15,9 @@ namespace :test do Rake::TestTask.new(:benchmark) do |t| t.pattern = 'test/benchmark/*_benchmark.rb' end + + desc "Refresh dump.sql from fixtures and schema." + task :refresh_dump do + require_relative 'test/support/database/generator' + end end diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index 3f031d173..557883627 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -25,8 +25,14 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'minitest-spec-rails' spec.add_development_dependency 'simplecov' spec.add_development_dependency 'pry' + spec.add_development_dependency 'pry-byebug' spec.add_development_dependency 'concurrent-ruby-ext' - spec.add_dependency 'activerecord', '>= 4.1' + spec.add_development_dependency 'sequel' + spec.add_development_dependency 'sequel-rails' + spec.add_development_dependency 'sequel_polymorphic' + spec.add_development_dependency 'fixture_dependencies' + spec.add_development_dependency 'activerecord', '>= 4.1' + spec.add_dependency 'activesupport', '>= 4.1' spec.add_dependency 'railties', '>= 4.1' spec.add_dependency 'concurrent-ruby' end diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index e16022ff6..aedecdcf5 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -25,4 +25,5 @@ require 'jsonapi/callbacks' require 'jsonapi/link_builder' require 'jsonapi/record_accessor' -require 'jsonapi/active_record_accessor' +require 'jsonapi/active_record_record_accessor' +require 'jsonapi/sequel_record_accessor' \ No newline at end of file diff --git a/lib/jsonapi/active_record_accessor.rb b/lib/jsonapi/active_record_accessor.rb deleted file mode 100644 index 18ae2abaf..000000000 --- a/lib/jsonapi/active_record_accessor.rb +++ /dev/null @@ -1,499 +0,0 @@ -require 'jsonapi/record_accessor' - -module JSONAPI - class ActiveRecordAccessor < RecordAccessor - - # RecordAccessor methods - - def find_resource(filters, options = {}) - if options[:caching] && options[:caching][:cache_serializer_output] - find_serialized_with_caching(filters, options[:caching][:serializer], options) - else - _resource_klass.resources_for(find_records(filters, options), options[:context]) - end - end - - def find_resource_by_key(key, options = {}) - if options[:caching] && options[:caching][:cache_serializer_output] - find_by_key_serialized_with_caching(key, options[:caching][:serializer], options) - else - records = find_records({ _resource_klass._primary_key => key }, options.except(:paginator, :sort_criteria)) - model = records.first - fail JSONAPI::Exceptions::RecordNotFound.new(key) if model.nil? - _resource_klass.resource_for(model, options[:context]) - end - end - - def find_resources_by_keys(keys, options = {}) - records = records(options) - records = apply_includes(records, options) - records = records.where({ _resource_klass._primary_key => keys }) - - _resource_klass.resources_for(records, options[:context]) - end - - def find_count(filters, options = {}) - count_records(filter_records(filters, options)) - end - - def related_resource(resource, relationship_name, options = {}) - relationship = resource.class._relationships[relationship_name.to_sym] - - if relationship.polymorphic? - associated_model = records_for_relationship(resource, relationship_name, options) - resource_klass = resource.class.resource_klass_for_model(associated_model) if associated_model - return resource_klass.new(associated_model, resource.context) if resource_klass && associated_model - else - resource_klass = relationship.resource_klass - if resource_klass - associated_model = records_for_relationship(resource, relationship_name, options) - return associated_model ? resource_klass.new(associated_model, resource.context) : nil - end - end - end - - def related_resources(resource, relationship_name, options = {}) - relationship = resource.class._relationships[relationship_name.to_sym] - relationship_resource_klass = relationship.resource_klass - - if options[:caching] && options[:caching][:cache_serializer_output] - scope = relationship_resource_klass._record_accessor.records_for_relationship(resource, relationship_name, options) - relationship_resource_klass._record_accessor.find_serialized_with_caching(scope, options[:caching][:serializer], options) - else - records = records_for_relationship(resource, relationship_name, options) - return records.collect do |record| - klass = relationship.polymorphic? ? resource.class.resource_klass_for_model(record) : relationship_resource_klass - klass.new(record, resource.context) - end - end - end - - def count_for_relationship(resource, relationship_name, options = {}) - relationship = resource.class._relationships[relationship_name.to_sym] - - context = resource.context - - relation_name = relationship.relation_name(context: context) - records = records_for(resource, relation_name) - - resource_klass = relationship.resource_klass - - filters = options.fetch(:filters, {}) - unless filters.nil? || filters.empty? - records = resource_klass._record_accessor.apply_filters(records, filters, options) - end - - records.count(:all) - end - - def foreign_key(resource, relationship_name, options = {}) - relationship = resource.class._relationships[relationship_name.to_sym] - - if relationship.belongs_to? - resource._model.method(relationship.foreign_key).call - else - records = records_for_relationship(resource, relationship_name, options) - return nil if records.nil? - records.public_send(relationship.resource_klass._primary_key) - end - end - - def foreign_keys(resource, relationship_name, options = {}) - relationship = resource.class._relationships[relationship_name.to_sym] - - records = records_for_relationship(resource, relationship_name, options) - records.collect do |record| - record.public_send(relationship.resource_klass._primary_key) - end - end - - # protected-ish methods left public for tests and what not - - def find_serialized_with_caching(filters_or_source, serializer, options = {}) - if filters_or_source.is_a?(ActiveRecord::Relation) - return cached_resources_for(filters_or_source, serializer, options) - elsif _resource_klass._model_class.respond_to?(:all) && _resource_klass._model_class.respond_to?(:arel_table) - records = find_records(filters_or_source, options.except(:include_directives)) - return cached_resources_for(records, serializer, options) - else - # :nocov: - warn('Caching enabled on model that does not support ActiveRelation') - # :nocov: - end - end - - def find_by_key_serialized_with_caching(key, serializer, options = {}) - if _resource_klass._model_class.respond_to?(:all) && _resource_klass._model_class.respond_to?(:arel_table) - results = find_serialized_with_caching({ _resource_klass._primary_key => key }, serializer, options) - result = results.first - fail JSONAPI::Exceptions::RecordNotFound.new(key) if result.nil? - return result - else - # :nocov: - warn('Caching enabled on model that does not support ActiveRelation') - # :nocov: - end - end - - def records_for_relationship(resource, relationship_name, options = {}) - relationship = resource.class._relationships[relationship_name.to_sym] - - context = resource.context - - relation_name = relationship.relation_name(context: context) - records = records_for(resource, relation_name) - - resource_klass = relationship.resource_klass - - filters = options.fetch(:filters, {}) - unless filters.nil? || filters.empty? - records = resource_klass._record_accessor.apply_filters(records, filters, options) - end - - sort_criteria = options.fetch(:sort_criteria, {}) - order_options = relationship.resource_klass.construct_order_options(sort_criteria) - records = apply_sort(records, order_options, context) - - paginator = options[:paginator] - if paginator - records = apply_pagination(records, paginator, order_options) - end - - records - end - - # Implement self.records on the resource if you want to customize the relation for - # finder methods (find, find_by_key, find_serialized_with_caching) - def records(_options = {}) - if defined?(_resource_klass.records) - _resource_klass.records(_options) - else - _resource_klass._model_class.all - end - end - - # Implement records_for on the resource to customize how the associated records - # are fetched for a model. Particularly helpful for authorization. - def records_for(resource, relation_name) - if resource.respond_to?(:records_for) - return resource.records_for(relation_name) - end - - relationship = resource.class._relationships[relation_name] - - if relationship.is_a?(JSONAPI::Relationship::ToMany) - if resource.respond_to?(:"records_for_#{relation_name}") - return resource.method(:"records_for_#{relation_name}").call - end - else - if resource.respond_to?(:"record_for_#{relation_name}") - return resource.method(:"record_for_#{relation_name}").call - end - end - - resource._model.public_send(relation_name) - end - - def apply_includes(records, options = {}) - include_directives = options[:include_directives] - if include_directives - model_includes = resolve_relationship_names_to_relations(_resource_klass, include_directives.model_includes, options) - records = records.includes(model_includes) - end - - records - end - - def apply_pagination(records, paginator, order_options) - records = paginator.apply(records, order_options) if paginator - records - end - - def apply_sort(records, order_options, context = {}) - if defined?(_resource_klass.apply_sort) - _resource_klass.apply_sort(records, order_options, context) - else - if order_options.any? - order_options.each_pair do |field, direction| - if field.to_s.include?(".") - *model_names, column_name = field.split(".") - - associations = _lookup_association_chain([records.model.to_s, *model_names]) - joins_query = _build_joins([records.model, *associations]) - - # _sorting is appended to avoid name clashes with manual joins eg. overridden filters - order_by_query = "#{associations.last.name}_sorting.#{column_name} #{direction}" - records = records.joins(joins_query).order(order_by_query) - else - records = records.order(field => direction) - end - end - end - - records - end - end - - def _lookup_association_chain(model_names) - associations = [] - model_names.inject do |prev, current| - association = prev.classify.constantize.reflect_on_all_associations.detect do |assoc| - assoc.name.to_s.downcase == current.downcase - end - associations << association - association.class_name - end - - associations - end - - def _build_joins(associations) - joins = [] - - associations.inject do |prev, current| - joins << "LEFT JOIN #{current.table_name} AS #{current.name}_sorting ON #{current.name}_sorting.id = #{prev.table_name}.#{current.foreign_key}" - current - end - joins.join("\n") - end - - def apply_filter(records, filter, value, options = {}) - strategy = _resource_klass._allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] - - if strategy - if strategy.is_a?(Symbol) || strategy.is_a?(String) - _resource_klass.send(strategy, records, value, options) - else - strategy.call(records, value, options) - end - else - records.where(filter => value) - end - end - - # Assumes ActiveRecord's counting. Override if you need a different counting method - def count_records(records) - records.count(:all) - end - - def resolve_relationship_names_to_relations(resource_klass, model_includes, options = {}) - case model_includes - when Array - return model_includes.map do |value| - resolve_relationship_names_to_relations(resource_klass, value, options) - end - when Hash - model_includes.keys.each do |key| - relationship = resource_klass._relationships[key] - value = model_includes[key] - model_includes.delete(key) - model_includes[relationship.relation_name(options)] = resolve_relationship_names_to_relations(relationship.resource_klass, value, options) - end - return model_includes - when Symbol - relationship = resource_klass._relationships[model_includes] - return relationship.relation_name(options) - end - end - - def apply_filters(records, filters, options = {}) - required_includes = [] - - if filters - filters.each do |filter, value| - if _resource_klass._relationships.include?(filter) - if _resource_klass._relationships[filter].belongs_to? - records = apply_filter(records, _resource_klass._relationships[filter].foreign_key, value, options) - else - required_includes.push(filter.to_s) - records = apply_filter(records, "#{_resource_klass._relationships[filter].table_name}.#{_resource_klass._relationships[filter].primary_key}", value, options) - end - else - records = apply_filter(records, filter, value, options) - end - end - end - - if required_includes.any? - records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(_resource_klass, required_includes, force_eager_load: true))) - end - - records - end - - def filter_records(filters, options, records = records(options)) - records = apply_filters(records, filters, options) - apply_includes(records, options) - end - - def sort_records(records, order_options, context = {}) - apply_sort(records, order_options, context) - end - - def cached_resources_for(records, serializer, options) - if _resource_klass.caching? - t = _resource_klass._model_class.arel_table - cache_ids = pluck_arel_attributes(records, t[_resource_klass._primary_key], t[_resource_klass._cache_field]) - resources = CachedResourceFragment.fetch_fragments(_resource_klass, serializer, options[:context], cache_ids) - else - resources = _resource_klass.resources_for(records, options[:context]).map { |r| [r.id, r] }.to_h - end - - preload_included_fragments(resources, records, serializer, options) - - resources.values - end - - def find_records(filters, options = {}) - if defined?(_resource_klass.find_records) - ActiveSupport::Deprecation.warn "In #{_resource_klass.name} you overrode `find_records`. "\ - "`find_records` has been deprecated in favor of using `apply` "\ - "and `verify` callables on the filter." - - _resource_klass.find_records(filters, options) - else - context = options[:context] - - records = filter_records(filters, options) - - sort_criteria = options.fetch(:sort_criteria) { [] } - order_options = _resource_klass.construct_order_options(sort_criteria) - records = sort_records(records, order_options, context) - - records = apply_pagination(records, options[:paginator], order_options) - - records - end - end - - def preload_included_fragments(resources, records, serializer, options) - return if resources.empty? - res_ids = resources.keys - - include_directives = options[:include_directives] - return unless include_directives - - context = options[:context] - - # For each association, including indirect associations, find the target record ids. - # Even if a target class doesn't have caching enabled, we still have to look up - # and match the target ids here, because we can't use ActiveRecord#includes. - # - # Note that `paths` returns partial paths before complete paths, so e.g. the partial - # fragments for posts.comments will exist before we start working with posts.comments.author - target_resources = {} - include_directives.paths.each do |path| - # If path is [:posts, :comments, :author], then... - pluck_attrs = [] # ...will be [posts.id, comments.id, authors.id, authors.updated_at] - pluck_attrs << _resource_klass._model_class.arel_table[_resource_klass._primary_key] - - relation = records - .except(:limit, :offset, :order) - .where({ _resource_klass._primary_key => res_ids }) - - # These are updated as we iterate through the association path; afterwards they will - # refer to the final resource on the path, i.e. the actual resource to find in the cache. - # So e.g. if path is [:posts, :comments, :author], then after iteration... - parent_klass = nil # Comment - klass = _resource_klass # Person - relationship = nil # JSONAPI::Relationship::ToOne for CommentResource.author - table = nil # people - assocs_path = [] # [ :posts, :approved_comments, :author ] - ar_hash = nil # { :posts => { :approved_comments => :author } } - - # For each step on the path, figure out what the actual table name/alias in the join - # will be, and include the primary key of that table in our list of fields to select - non_polymorphic = true - path.each do |elem| - relationship = klass._relationships[elem] - if relationship.polymorphic - # Can't preload through a polymorphic belongs_to association, ResourceSerializer - # will just have to bypass the cache and load the real Resource. - non_polymorphic = false - break - end - assocs_path << relationship.relation_name(options).to_sym - # Converts [:a, :b, :c] to Rails-style { :a => { :b => :c }} - ar_hash = assocs_path.reverse.reduce { |memo, step| { step => memo } } - # We can't just look up the table name from the resource class, because Arel could - # have used a table alias if the relation includes a self-reference. - join_source = relation.joins(ar_hash).arel.source.right.reverse.find do |arel_node| - arel_node.is_a?(Arel::Nodes::InnerJoin) - end - table = join_source.left - parent_klass = klass - klass = relationship.resource_klass - pluck_attrs << table[klass._primary_key] - end - next unless non_polymorphic - - # Pre-fill empty hashes for each resource up to the end of the path. - # This allows us to later distinguish between a preload that returned nothing - # vs. a preload that never ran. - prefilling_resources = resources.values - path.each do |rel_name| - rel_name = serializer.key_formatter.format(rel_name) - prefilling_resources.map! do |res| - res.preloaded_fragments[rel_name] ||= {} - res.preloaded_fragments[rel_name].values - end - prefilling_resources.flatten!(1) - end - - pluck_attrs << table[klass._cache_field] if klass.caching? - relation = relation.joins(ar_hash) - if relationship.is_a?(JSONAPI::Relationship::ToMany) - # Rails doesn't include order clauses in `joins`, so we have to add that manually here. - # FIXME Should find a better way to reflect on relationship ordering. :-( - relation = relation.order(parent_klass._model_class.new.send(assocs_path.last).arel.orders) - end - - # [[post id, comment id, author id, author updated_at], ...] - id_rows = pluck_arel_attributes(relation.joins(ar_hash), *pluck_attrs) - - target_resources[klass.name] ||= {} - - if klass.caching? - sub_cache_ids = id_rows - .map { |row| row.last(2) } - .reject { |row| target_resources[klass.name].has_key?(row.first) } - .uniq - target_resources[klass.name].merge! CachedResourceFragment.fetch_fragments( - klass, serializer, context, sub_cache_ids - ) - else - sub_res_ids = id_rows - .map(&:last) - .reject { |id| target_resources[klass.name].has_key?(id) } - .uniq - found = klass.find({ klass._primary_key => sub_res_ids }, context: options[:context]) - target_resources[klass.name].merge! found.map { |r| [r.id, r] }.to_h - end - - id_rows.each do |row| - res = resources[row.first] - path.each_with_index do |rel_name, index| - rel_name = serializer.key_formatter.format(rel_name) - rel_id = row[index+1] - assoc_rels = res.preloaded_fragments[rel_name] - if index == path.length - 1 - assoc_rels[rel_id] = target_resources[klass.name].fetch(rel_id) - else - res = assoc_rels[rel_id] - end - end - end - end - end - - def pluck_arel_attributes(relation, *attrs) - conn = relation.connection - quoted_attrs = attrs.map do |attr| - quoted_table = conn.quote_table_name(attr.relation.table_alias || attr.relation.name) - quoted_column = conn.quote_column_name(attr.name) - "#{quoted_table}.#{quoted_column}" - end - relation.pluck(*quoted_attrs) - end - end -end diff --git a/lib/jsonapi/active_record_record_accessor.rb b/lib/jsonapi/active_record_record_accessor.rb new file mode 100644 index 000000000..520c012e0 --- /dev/null +++ b/lib/jsonapi/active_record_record_accessor.rb @@ -0,0 +1,321 @@ +require 'jsonapi/record_accessor' + +module JSONAPI + class ActiveRecordRecordAccessor < RecordAccessor + # RecordAccessor methods + + class << self + + def transaction + ActiveRecord::Base.transaction do + yield + end + end + + def rollback_transaction + fail ActiveRecord::Rollback + end + + def model_error_messages(model) + model.errors.messages + end + + def valid?(model, validation_context) + model.valid?(validation_context) + end + + def save(model, options={}) + method = options[:raise_on_failure] ? :save! : :save + model.public_send(method, options.slice(:validate)) + end + + def destroy(model, options={}) + model.destroy + end + + def delete_relationship(model, relationship_name, id) + model.public_send(relationship_name).delete(id) + end + + def reload(model) + model.reload + end + + def model_base_class + ActiveRecord::Base + end + + def delete_restriction_error_class + ActiveRecord::DeleteRestrictionError + end + + def record_not_found_error_class + ActiveRecord::RecordNotFound + end + + def find_in_association(model, association_name, ids) + primary_key = model.class.reflections[association_name.to_s].klass.primary_key + model.public_send(association_name).where(primary_key => ids) + end + + def add_to_association(model, association_name, association_model) + model.public_send(association_name) << association_model + end + + def association_model_class_name(from_model, relationship_name) + (reflect = from_model.reflect_on_association(relationship_name)) && reflect.class_name + end + + def set_primary_keys(model, relationship, value) + model.method("#{relationship.foreign_key}=").call(value) + end + + end + + + # In AR, the .all command will return a chainable relation in which you can attach limit, offset, where, etc. + def model_class_relation + _resource_klass._model_class.all + end + + # protected-ish methods left public for tests and what not + + def find_serialized_with_caching(filters_or_source, serializer, options = {}) + if filters_or_source.is_a?(ActiveRecord::Relation) + return cached_resources_for(filters_or_source, serializer, options) + # TODO - if we are already in ActiveRecordRecordAccessor, then _resource_klass._model_class + # will essentially always support ActiveRelation, right? Maybe we should get rid of this check. + elsif _resource_klass._model_class.respond_to?(:all) && _resource_klass._model_class.respond_to?(:arel_table) + records = find_records(filters_or_source, options.except(:include_directives)) + return cached_resources_for(records, serializer, options) + else + # :nocov: + warn('Caching enabled on model that does not support ActiveRelation') + # :nocov: + end + end + + def apply_sort(records, order_options, context = {}) + if defined?(_resource_klass.apply_sort) + _resource_klass.apply_sort(records, order_options, context) + else + if order_options.any? + order_options.each_pair do |field, direction| + if field.to_s.include?(".") + *model_names, column_name = field.split(".") + + associations = _lookup_association_chain([records.model.to_s, *model_names]) + joins_query = _build_joins([records.model, *associations]) + + # _sorting is appended to avoid name clashes with manual joins eg. overridden filters + order_by_query = "#{associations.last.name}_sorting.#{column_name} #{direction}" + records = records.joins(joins_query).order(order_by_query) + else + records = records.order(field => direction) + end + end + end + records + end + end + + def _lookup_association_chain(model_names) + associations = [] + model_names.inject do |prev, current| + association = prev.classify.constantize.reflect_on_all_associations.detect do |assoc| + assoc.name.to_s.downcase == current.downcase + end + associations << association + association.class_name + end + + associations + end + + def _build_joins(associations) + joins = [] + + associations.inject do |prev, current| + joins << "LEFT JOIN #{current.table_name} AS #{current.name}_sorting ON #{current.name}_sorting.id = #{prev.table_name}.#{current.foreign_key}" + current + end + joins.join("\n") + end + + def apply_filter(records, filter, value, options = {}) + strategy = _resource_klass._allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] + + if strategy + if strategy.is_a?(Symbol) || strategy.is_a?(String) + _resource_klass.send(strategy, records, value, options) + else + strategy.call(records, value, options) + end + else + records.where(filter => value) + end + end + + def apply_filters_to_many_relationships(records, to_many_filters, options) + required_includes = [] + to_many_filters.each do |filter, value| + relationship = _resource_klass._relationships[filter] + required_includes << filter.to_s + records = apply_filter(records, "#{relationship.table_name}.#{relationship.primary_key}", value, options) + end + + records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(_resource_klass, required_includes, force_eager_load: true))) + + records + end + + # ActiveRecord requires :all to be specified + def count_records(records) + records.count(:all) + end + + def cached_resources_for(records, serializer, options) + if _resource_klass.caching? + t = _resource_klass._model_class.arel_table + cache_ids = pluck_attributes(records, t[_resource_klass._primary_key], t[_resource_klass._cache_field]) + resources = CachedResourceFragment.fetch_fragments(_resource_klass, serializer, options[:context], cache_ids) + else + resources = resources_for(records, options[:context]).map { |r| [r.id, r] }.to_h + end + + preload_included_fragments(resources, records, serializer, options) + + resources.values + end + + def preload_included_fragments(resources, records, serializer, options) + return if resources.empty? + res_ids = resources.keys + + include_directives = options[:include_directives] + return unless include_directives + + context = options[:context] + + # For each association, including indirect associations, find the target record ids. + # Even if a target class doesn't have caching enabled, we still have to look up + # and match the target ids here, because we can't use ActiveRecord#includes. + # + # Note that `paths` returns partial paths before complete paths, so e.g. the partial + # fragments for posts.comments will exist before we start working with posts.comments.author + target_resources = {} + include_directives.paths.each do |path| + # If path is [:posts, :comments, :author], then... + pluck_attrs = [] # ...will be [posts.id, comments.id, authors.id, authors.updated_at] + pluck_attrs << _resource_klass._model_class.arel_table[_resource_klass._primary_key] + + relation = records + .except(:limit, :offset, :order) + .where({ _resource_klass._primary_key => res_ids }) + + # These are updated as we iterate through the association path; afterwards they will + # refer to the final resource on the path, i.e. the actual resource to find in the cache. + # So e.g. if path is [:posts, :comments, :author], then after iteration... + parent_klass = nil # Comment + klass = _resource_klass # Person + relationship = nil # JSONAPI::Relationship::ToOne for CommentResource.author + table = nil # people + assocs_path = [] # [ :posts, :approved_comments, :author ] + joins_hash = nil # { :posts => { :approved_comments => :author } } + + # For each step on the path, figure out what the actual table name/alias in the join + # will be, and include the primary key of that table in our list of fields to select + non_polymorphic = true + path.each do |elem| + relationship = klass._relationships[elem] + if relationship.polymorphic + # Can't preload through a polymorphic belongs_to association, ResourceSerializer + # will just have to bypass the cache and load the real Resource. + non_polymorphic = false + break + end + assocs_path << relationship.relation_name(options).to_sym + # Converts [:a, :b, :c] to Rails-style { :a => { :b => :c }} + joins_hash = assocs_path.reverse.reduce { |memo, step| { step => memo } } + # We can't just look up the table name from the resource class, because Arel could + # have used a table alias if the relation includes a self-reference. + join_source = relation.joins(joins_hash).arel.source.right.reverse.find do |arel_node| + arel_node.is_a?(Arel::Nodes::InnerJoin) + end + table = join_source.left + parent_klass = klass + klass = relationship.resource_klass + pluck_attrs << table[klass._primary_key] + end + next unless non_polymorphic + + # Pre-fill empty hashes for each resource up to the end of the path. + # This allows us to later distinguish between a preload that returned nothing + # vs. a preload that never ran. + prefilling_resources = resources.values + path.each do |rel_name| + rel_name = serializer.key_formatter.format(rel_name) + prefilling_resources.map! do |res| + res.preloaded_fragments[rel_name] ||= {} + res.preloaded_fragments[rel_name].values + end + prefilling_resources.flatten!(1) + end + + pluck_attrs << table[klass._cache_field] if klass.caching? + relation = relation.joins(joins_hash) + if relationship.is_a?(JSONAPI::Relationship::ToMany) + # Rails doesn't include order clauses in `joins`, so we have to add that manually here. + # FIXME Should find a better way to reflect on relationship ordering. :-( + relation = relation.order(parent_klass._model_class.new.send(assocs_path.last).arel.orders) + end + + # [[post id, comment id, author id, author updated_at], ...] + id_rows = pluck_attributes(relation.joins(joins_hash), *pluck_attrs) + + target_resources[klass.name] ||= {} + if klass.caching? + sub_cache_ids = id_rows + .map { |row| row.last(2) } + .reject { |row| target_resources[klass.name].has_key?(row.first) } + .uniq + target_resources[klass.name].merge! CachedResourceFragment.fetch_fragments( + klass, serializer, context, sub_cache_ids + ) + else + sub_res_ids = id_rows + .map(&:last) + .reject { |id| target_resources[klass.name].has_key?(id) } + .uniq + found = klass.find({ klass._primary_key => sub_res_ids }, context: options[:context]) + target_resources[klass.name].merge! found.map { |r| [r.id, r] }.to_h + end + + id_rows.each do |row| + res = resources[row.first] + path.each_with_index do |rel_name, index| + rel_name = serializer.key_formatter.format(rel_name) + rel_id = row[index+1] + assoc_rels = res.preloaded_fragments[rel_name] + if index == path.length - 1 + assoc_rels[rel_id] = target_resources[klass.name].fetch(rel_id) + else + res = assoc_rels[rel_id] + end + end + end + + end + end + + def pluck_attributes(relation, *attrs) + conn = relation.connection + quoted_attrs = attrs.map do |attr| + quoted_table = conn.quote_table_name(attr.relation.table_alias || attr.relation.name) + quoted_column = conn.quote_column_name(attr.name) + "#{quoted_table}.#{quoted_column}" + end + relation.pluck(*quoted_attrs) + end + end +end diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index 830109df5..90ac7bfa9 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -96,6 +96,7 @@ def process_request process_operation(op) end rescue => e + debugger if ENV["RAISE"] handle_exceptions(e) end end @@ -114,7 +115,7 @@ def process_request def run_in_transaction(transactional) if transactional run_callbacks :transaction do - transaction do + resource_klass._record_accessor_klass.transaction do yield end end @@ -126,7 +127,7 @@ def run_in_transaction(transactional) def rollback_transaction(transactional) if transactional run_callbacks :rollback do - rollback + resource_klass._record_accessor_klass.rollback_transaction end end end @@ -136,16 +137,6 @@ def process_operation(operation) response_document.add_result(result, operation) end - def transaction - ActiveRecord::Base.transaction do - yield - end - end - - def rollback - fail ActiveRecord::Rollback - end - private def resource_klass diff --git a/lib/jsonapi/cached_resource_fragment.rb b/lib/jsonapi/cached_resource_fragment.rb index a8ed301b2..495dc45a4 100644 --- a/lib/jsonapi/cached_resource_fragment.rb +++ b/lib/jsonapi/cached_resource_fragment.rb @@ -8,11 +8,10 @@ def self.fetch_fragments(resource_klass, serializer, context, cache_ids) results = self.lookup(resource_klass, serializer_config_key, context, context_key, cache_ids) - miss_ids = results.select{|k,v| v.nil? }.keys + miss_ids = results.select{ |k,v| v.nil? }.keys unless miss_ids.empty? find_filters = {resource_klass._primary_key => miss_ids.uniq} - find_options = {context: context} - resource_klass.find(find_filters, find_options).each do |resource| + resource_klass.find(find_filters, context: context).each do |resource| (id, cr) = write(resource_klass, resource, serializer, serializer_config_key, context, context_key) results[id] = cr end diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 52c34ab81..15b8edc6a 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -1,7 +1,6 @@ require 'jsonapi/formatter' require 'jsonapi/processor' -require 'jsonapi/record_accessor' -require 'jsonapi/active_record_accessor' +require 'jsonapi/active_record_record_accessor' require 'concurrent' module JSONAPI @@ -96,10 +95,10 @@ def initialize self.always_include_to_many_linkage_data = false # Record Accessor - # The default Record Accessor is the ActiveRecordAccessor which provides + # The default Record Accessor is the JSONAPI::ActiveRecordRecordAccessor which provides # caching access to ActiveRecord backed models. Custom Accessors can be specified # in order to support other models. - self.default_record_accessor_klass = JSONAPI::ActiveRecordAccessor + self.default_record_accessor_klass = JSONAPI::ActiveRecordRecordAccessor # The default Operation Processor to use if one is not defined specifically # for a Resource. diff --git a/lib/jsonapi/record_accessor.rb b/lib/jsonapi/record_accessor.rb index 3cf39ee99..4ab847839 100644 --- a/lib/jsonapi/record_accessor.rb +++ b/lib/jsonapi/record_accessor.rb @@ -2,65 +2,428 @@ module JSONAPI class RecordAccessor attr_reader :_resource_klass + # Note: model_base_class, delete_restriction_error_class, record_not_found_error_class could be defined as + # class attributes but currently all the library files are loaded using 'require', so if we have something like + # self.model_base_class = ActiveRecord::Base, then ActiveRecord would be required as a dependency. Leaving these + # as instance methods means we can load in these files at load-time and use them if they so choose. + def initialize(resource_klass) @_resource_klass = resource_klass end - # Resource records - def find_resource(_filters, _options = {}) - # :nocov: - raise 'Abstract method called' - # :nocov: + class << self + def transaction + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def rollback_transaction + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + # Should return an enumerable with the key being the attribute name and value being an array of error messages. + def model_error_messages(model) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def valid?(model, validation_context) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + # Must save without raising an error as well. + # +options+ can include :validate and :raise_on_failure as options. + def save(model, options={}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def destroy(model, options={}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def delete_relationship(model, relationship_name, id) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def reload(model) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def model_base_class + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def delete_restriction_error_class + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def record_not_found_error_class + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def find_in_association(model, association_name, ids) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def add_to_association(model, association_name, association_model) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def association_model_class_name(from_model, relationship_name) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def set_primary_keys(model, relationship, value) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + end - def find_resource_by_key(_key, options = {}) - # :nocov: - raise 'Abstract method called' - # :nocov: + def find_records(filters, options = {}) + if defined?(_resource_klass.find_records) + ActiveSupport::Deprecation.warn "In #{_resource_klass.name} you overrode `find_records`. "\ + "`find_records` has been deprecated in favor of using `apply` "\ + "and `verify` callables on the filter." + + _resource_klass.find_records(filters, options) + else + context = options[:context] + + records = filter_records(filters, options) + + sort_criteria = options.fetch(:sort_criteria) { [] } + order_options = _resource_klass.construct_order_options(sort_criteria) + records = sort_records(records, order_options, context) + + records = apply_pagination(records, options[:paginator], order_options) + + records + end end - def find_resources_by_keys(_keys, options = {}) - # :nocov: - raise 'Abstract method called' - # :nocov: + def filter_records(filters, options, records = records(options)) + records = apply_filters(records, filters, options) + apply_includes(records, options) end - def find_count(_filters, _options = {}) - # :nocov: - raise 'Abstract method called' - # :nocov: + def apply_includes(records, options = {}) + # TODO: See if we can delete these keys out of options since they should really only be for #apply_includes + include_as_join = options[:include_as_join] + include_directives = options[:include_directives] + if include_directives + model_includes = resolve_relationship_names_to_relations(_resource_klass, include_directives.model_includes, options) + records = records_with_includes(records, model_includes, include_as_join) + end + records end - # Relationship records - def related_resource(_resource, _relationship_name, _options = {}) - # :nocov: - raise 'Abstract method called' - # :nocov: + def sort_records(records, order_options, context = {}) + apply_sort(records, order_options, context) end - def related_resources(_resource, _relationship_name, _options = {}) - # :nocov: - raise 'Abstract method called' - # :nocov: + # Converts a chainable relation to an actual Array of records. + # Overwrite if subclass ORM has different implementation. + def get_all(records) + records.all end - def count_for_relationship(_resource, _relationship_name, _options = {}) + # Overwrite if subclass ORM has different implementation. + def count_records(records) + records.count + end + + # Eager load the has of includes onto the record and return a chainable relation. + # Overwrite if subclass ORM has different implementation. + # + # +include_as_join+ signifies that the includes should create a join table. Some ORMs will do includes as seperate + # foreign key lookups to avoid huge cascading joins. + def records_with_includes(records, includes, include_as_join=false) + records.includes(includes) + end + + # Overwrite if subclass ORM has different implementation. + def association_relation(model, relation_name) + model.public_send(relation_name) + end + + # Returns a chainable relation from the model class. + def model_class_relation # :nocov: raise 'Abstract method called' # :nocov: end - # Keys - def foreign_key(_resource, _relationship_name, options = {}) + # Implement self.records on the resource if you want to customize the relation for + # finder methods (find, find_by_key, find_serialized_with_caching) + def records(_options = {}) + if defined?(_resource_klass.records) + _resource_klass.records(_options) + else + model_class_relation + end + end + + def apply_pagination(records, paginator, order_options) + records = paginator.apply(records, order_options) if paginator + records + end + + def find_by_key_serialized_with_caching(key, serializer, options = {}) + if _resource_klass.model_class_compatible_with_record_accessor? + results = find_serialized_with_caching({ _resource_klass._primary_key => key }, serializer, options) + result = results.first + fail JSONAPI::Exceptions::RecordNotFound.new(key) if result.nil? + return result + else + # :nocov: + warn('Caching enabled on model that does not support ActiveRelation') + # :nocov: + end + end + + def find_resource(filters, options = {}) + if options[:caching] && options[:caching][:cache_serializer_output] + find_serialized_with_caching(filters, options[:caching][:serializer], options) + else + resources_for(find_records(filters, options), options[:context]) + end + end + + # Gets all the given resources for a +records+ relation and calls #get_all to ensure all the records are + # queried and returned to the Resource.resources_for function. + def resources_for(records, context) + _resource_klass.resources_for(get_all(records), context) + end + + def find_resources_by_keys(keys, options = {}) + records = records(options) + records = apply_includes(records, options) + records = records.where({ _resource_klass._primary_key => keys }) + + resources_for(records, options[:context]) + end + + def find_resource_by_key(key, options = {}) + if options[:caching] && options[:caching][:cache_serializer_output] + find_by_key_serialized_with_caching(key, options[:caching][:serializer], options) + else + records = find_records({ _resource_klass._primary_key => key }, options.except(:paginator, :sort_criteria)) + model = records.first + fail JSONAPI::Exceptions::RecordNotFound.new(key) if model.nil? + _resource_klass.resource_for(model, options[:context]) + end + end + + def find_count(filters, options = {}) + count_records(filter_records(filters, options)) + end + + def count_for_relationship(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + + context = resource.context + + relation_name = relationship.relation_name(context: context) + records = records_for(resource, relation_name) + + resource_klass = relationship.resource_klass + + filters = options.fetch(:filters, {}) + unless filters.nil? || filters.empty? + records = resource_klass._record_accessor.apply_filters(records, filters, options) + end + + count_records(records) + end + + def records_for_relationship(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + + context = resource.context + + relation_name = relationship.relation_name(context: context) + records = records_for(resource, relation_name) + + resource_klass = relationship.resource_klass + + filters = options.fetch(:filters, {}) + unless filters.nil? || filters.empty? + records = resource_klass._record_accessor.apply_filters(records, filters, options) + end + + sort_criteria = options.fetch(:sort_criteria, {}) + order_options = relationship.resource_klass.construct_order_options(sort_criteria) + records = apply_sort(records, order_options, context) + + paginator = options[:paginator] + if paginator + records = apply_pagination(records, paginator, order_options) + end + + records + end + + def foreign_key(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + + if relationship.belongs_to? + resource._model.method(relationship.foreign_key).call + else + records = records_for_relationship(resource, relationship_name, options) + return nil if records.nil? + records.public_send(relationship.resource_klass._primary_key) + end + end + + def foreign_keys(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + + records = records_for_relationship(resource, relationship_name, options) + records.collect do |record| + record.public_send(relationship.resource_klass._primary_key) + end + end + + def related_resource(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + + if relationship.polymorphic? + associated_model = records_for_relationship(resource, relationship_name, options) + resource_klass = resource.class.resource_klass_for_model(associated_model) if associated_model + return resource_klass.new(associated_model, resource.context) if resource_klass && associated_model + else + resource_klass = relationship.resource_klass + if resource_klass + associated_model = records_for_relationship(resource, relationship_name, options) + return associated_model ? resource_klass.new(associated_model, resource.context) : nil + end + end + end + + def related_resources(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + relationship_resource_klass = relationship.resource_klass + + if options[:caching] && options[:caching][:cache_serializer_output] + scope = relationship_resource_klass._record_accessor.records_for_relationship(resource, relationship_name, options) + relationship_resource_klass._record_accessor.find_serialized_with_caching(scope, options[:caching][:serializer], options) + else + records = records_for_relationship(resource, relationship_name, options) + return records.collect do |record| + klass = relationship.polymorphic? ? resource.class.resource_klass_for_model(record) : relationship_resource_klass + klass.new(record, resource.context) + end + end + end + + def resolve_relationship_names_to_relations(resource_klass, model_includes, options = {}) + case model_includes + when Array + return model_includes.map do |value| + resolve_relationship_names_to_relations(resource_klass, value, options) + end + when Hash + model_includes.keys.each do |key| + relationship = resource_klass._relationships[key] + value = model_includes[key] + model_includes.delete(key) + model_includes[relationship.relation_name(options)] = resolve_relationship_names_to_relations(relationship.resource_klass, value, options) + end + return model_includes + when Symbol + relationship = resource_klass._relationships[model_includes] + return relationship.relation_name(options) + end + end + + # Implement records_for on the resource to customize how the associated records + # are fetched for a model. Particularly helpful for authorization. + def records_for(resource, relation_name) + if resource.respond_to?(:records_for) + return resource.records_for(relation_name) + end + + relationship = resource.class._relationships[relation_name] + + if relationship.is_a?(JSONAPI::Relationship::ToMany) + if resource.respond_to?(:"records_for_#{relation_name}") + return resource.method(:"records_for_#{relation_name}").call + end + else + if resource.respond_to?(:"record_for_#{relation_name}") + return resource.method(:"record_for_#{relation_name}").call + end + end + + association_relation(resource._model, relation_name) + end + + def apply_filters(records, filters, options = {}) + to_many_filters = [] + + if filters + filters.each do |filter, value| + if _resource_klass._relationships.include?(filter) + if _resource_klass._relationships[filter].belongs_to? + records = apply_filter(records, _resource_klass._relationships[filter].foreign_key, value, options) + else + to_many_filters << [filter, value] + end + else + records = apply_filter(records, filter, value, options) + end + end + end + + if to_many_filters.any? + records = apply_filters_to_many_relationships(records, to_many_filters, options) + end + + records + end + + # Apply an array of "to many" relationships to a set of records. + # + # Returns a collection of +records+. + def apply_filters_to_many_relationships(records, to_many_filters, options) # :nocov: raise 'Abstract method called' # :nocov: end - def foreign_keys(_resource, _relationship_name, _options = {}) + def pluck_attributes(relation, model_class, *attrs) # :nocov: raise 'Abstract method called' # :nocov: end + end end \ No newline at end of file diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index a9667bb1e..e49022b53 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -118,7 +118,7 @@ def fetchable_fields end def model_error_messages - _model.errors.messages + self.class._record_accessor_klass.model_error_messages(_model) end # Add metadata to validation error objects. @@ -193,12 +193,12 @@ def save # end # ``` def _save(validation_context = nil) - unless @model.valid?(validation_context) + unless self.class._record_accessor_klass.valid?(@model, validation_context) fail JSONAPI::Exceptions::ValidationErrors.new(self) end - if defined? @model.save - saved = @model.save(validate: false) + if defined?(@model.save) + saved = self.class._record_accessor_klass.save(@model, validate: false, raise_on_failure: false) unless saved if @model.errors.present? @@ -210,7 +210,7 @@ def _save(validation_context = nil) else saved = true end - @model.reload if @reload_needed + self.class._record_accessor_klass.reload(@model) if @reload_needed @reload_needed = false @save_needed = !saved @@ -219,12 +219,12 @@ def _save(validation_context = nil) end def _remove - unless @model.destroy + unless self.class._record_accessor_klass.destroy(@model, raise_on_failure: false) fail JSONAPI::Exceptions::ValidationErrors.new(self) end :completed - rescue ActiveRecord::DeleteRestrictionError => e + rescue self.class._record_accessor_klass.delete_restriction_error_class => e fail JSONAPI::Exceptions::RecordLocked.new(e.message) end @@ -245,14 +245,14 @@ def _create_to_many_links(relationship_type, relationship_key_values, options) # check if relationship_key_values are already members of this relationship relation_name = relationship.relation_name(context: @context) - existing_relations = @model.public_send(relation_name).where(relationship.primary_key => relationship_key_values) + existing_relations = self.class._record_accessor_klass.find_in_association(@model, relation_name, relationship_key_values) if existing_relations.count > 0 # todo: obscure id so not to leak info fail JSONAPI::Exceptions::HasManyRelationExists.new(existing_relations.first.id) end if options[:reflected_source] - @model.public_send(relation_name) << options[:reflected_source]._model + self.class._record_accessor_klass.add_to_association(@model, relation_name, options[:reflected_source]._model) return :completed end @@ -277,7 +277,7 @@ def _create_to_many_links(relationship_type, relationship_key_values, options) end @reload_needed = true else - @model.public_send(relation_name) << related_resource._model + self.class._record_accessor_klass.add_to_association(@model, relation_name, related_resource._model) end end @@ -349,14 +349,13 @@ def _remove_to_many_link(relationship_type, key, options) @reload_needed = true else - @model.public_send(relationship.relation_name(context: @context)).delete(key) + self.class._record_accessor_klass.delete_relationship(@model, relationship.relation_name(context: @context).to_s, key) end :completed - - rescue ActiveRecord::DeleteRestrictionError => e + rescue self.class._record_accessor_klass.delete_restriction_error_class => e fail JSONAPI::Exceptions::RecordLocked.new(e.message) - rescue ActiveRecord::RecordNotFound + rescue self.class._record_accessor_klass.record_not_found_error_class fail JSONAPI::Exceptions::RecordNotFound.new(key) end @@ -517,13 +516,19 @@ def attribute(attr, options = {}) @_attributes ||= {} @_attributes[attr] = options - define_method attr do - @model.public_send(options[:delegate] ? options[:delegate].to_sym : attr) - end unless method_defined?(attr) - define_method "#{attr}=" do |value| - @model.public_send("#{options[:delegate] ? options[:delegate].to_sym : attr}=", value) - end unless method_defined?("#{attr}=") + delegate = lambda do |model| + if options[:delegate] + options[:delegate].to_sym + elsif attr.to_sym == :id + model.class.primary_key + else + attr + end + end + + define_method(attr) { @model.public_send(delegate[@model]) } unless method_defined?(attr) + define_method("#{attr}=") { |value| @model.public_send("#{delegate[@model]}=", value) } unless method_defined?("#{attr}=") end def default_attribute_options @@ -809,7 +814,7 @@ def paginator(paginator) end def _record_accessor - @_record_accessor = _record_accessor_klass.new(self) + @_record_accessor ||= _record_accessor_klass.new(self) end def record_accessor=(record_accessor_klass) @@ -872,6 +877,10 @@ def _model_class @model_class end + def model_class_compatible_with_record_accessor? + _model_class && _model_class < _record_accessor_klass.model_base_class + end + def _allowed_filter?(filter) !_allowed_filters[filter].nil? end @@ -913,17 +922,16 @@ def _add_relationship(klass, *attrs) # ResourceBuilder methods def define_relationship_methods(relationship_name, relationship_klass, options) - # Initialize from an ActiveRecord model's properties - if _model_class && _model_class.ancestors.collect { |ancestor| ancestor.name }.include?('ActiveRecord::Base') - model_association = _model_class.reflect_on_association(relationship_name) - if model_association - options = options.reverse_merge(class_name: model_association.class_name) - end + # Initialize from an ORM model's properties + if model_class_compatible_with_record_accessor? && + (association_model_class_name = _record_accessor_klass.association_model_class_name(_model_class, relationship_name)) + + options = options.reverse_merge(class_name: association_model_class_name) end relationship = register_relationship( - relationship_name, - relationship_klass.new(relationship_name, options) + relationship_name, + relationship_klass.new(relationship_name, options) ) define_foreign_key_setter(relationship) @@ -942,7 +950,7 @@ def define_relationship_methods(relationship_name, relationship_klass, options) def define_foreign_key_setter(relationship) define_on_resource "#{relationship.foreign_key}=" do |value| - _model.method("#{relationship.foreign_key}=").call(value) + self.class._record_accessor_klass.set_primary_keys(_model, relationship, value) end end diff --git a/lib/jsonapi/sequel_record_accessor.rb b/lib/jsonapi/sequel_record_accessor.rb new file mode 100644 index 000000000..645e8255f --- /dev/null +++ b/lib/jsonapi/sequel_record_accessor.rb @@ -0,0 +1,377 @@ +require 'jsonapi/record_accessor' +require 'sequel/plugins/association_pks' + +module JSONAPI + class SequelRecordAccessor < RecordAccessor + + class << self + def transaction + ::Sequel.transaction(::Sequel::DATABASES) do + yield + end + end + + def rollback_transaction + fail ::Sequel::Rollback + end + + def model_error_messages(model) + model.errors + end + + def valid?(model, validation_context) + raise("Sequel does not support validation contexts") if validation_context + model.valid? + end + + def save(model, options={}) + model.save(options) + end + + def destroy(model, options={}) + model.destroy(options.slice(:raise_on_failure)) + end + + def delete_relationship(model, relationship_name, id) + model.public_send("remove_#{relationship_name.singularize}", id) + end + + def reload(model) + model.reload + end + + def model_base_class + Sequel::Model + end + + def delete_restriction_error_class + Class.new(Exception) + end + + def record_not_found_error_class + Class.new(Exception) + end + + def find_in_association(model, association_name, ids) + klass = model.class.association_reflection(association_name).associated_class + model.send("#{association_name}_dataset").where(:"#{klass.table_name}__#{klass.primary_key}" => ids) + end + + def add_to_association(model, association_name, association_model) + model.public_send("add_#{association_name.to_s.singularize}", association_model) + end + + def association_model_class_name(from_model, association_name) + (reflect = from_model.association_reflection(association_name)) && + reflect[:class_name] && reflect[:class_name].gsub(/^::/, '') # Sequel puts "::" in the beginning + end + + def set_primary_keys(model, relationship, value) + unless model.class.plugins.include?(Sequel::Plugins::AssociationPks) + raise("Please include the Sequel::Plugins::AssociationPks plugin into the #{model.class} model.") + end + + setter_method = relationship.is_a?(Relationship::ToMany) ? + "#{relationship.name.to_s.singularize}_pks=" : + "#{relationship.foreign_key}=" + + model.public_send(setter_method, value) + end + + end + + def model_class_relation + _resource_klass._model_class.dataset + end + + # protected-ish methods left public for tests and what not + + def find_serialized_with_caching(filters_or_source, serializer, options = {}) + if filters_or_source.is_a?(Sequel::SQLite::Dataset) + cached_resources_for(filters_or_source, serializer, options) + else + records = find_records(filters_or_source, options.except(:include_directives)) + cached_resources_for(records, serializer, options) + end + end + + + # Implement self.records on the resource if you want to customize the relation for + # finder methods (find, find_by_key, find_serialized_with_caching) + # def records(_options = {}) + # if defined?(_resource_klass.records) + # _resource_klass.records(_options) + # else + # _resource_klass._model_class.all + # end + # end + # + # def association_relation(model, relation_name) + # relationship = _resource_klass._relationships[relation_name] + # method = relationship.is_a?(JSONAPI::Relationship::ToMany) ? "#{relation_name}_dataset" : relation_name + # model.public_send(method) + # end + + def association_relation(model, relation_name) + # Sequel Reflection classes returning a collection end in "ToMany", so match against that. + method = model.class.association_reflections[relation_name].class.to_s =~ /ToMany/ ? + "#{relation_name}_dataset" : relation_name + + # Aryk: Leave off point. + model.public_send(method) + end + + def records_with_includes(records, includes, include_as_join=false) + method = include_as_join ? :eager_graph : :eager + records.public_send(method, includes) + end + + def apply_sort(records, order_options, context = {}) + if defined?(_resource_klass.apply_sort) + _resource_klass.apply_sort(records, order_options, context) + else + if order_options.any? + columns = [] + order_options.each_pair do |field, direction| + table_name = extract_model_class(records).table_name + if field.to_s.include?(".") + *association_names, column_name = field.split(".") + association_graph = association_names. + reverse. + inject(nil) {|hash, assoc| hash ? {assoc.to_sym => hash} : assoc.to_sym } + + records = records.select_all(table_name).association_left_join(association_graph) + + # associations = _lookup_association_chain([records.model.to_s, *model_names]) + # joins_query = _build_joins([records.model, *associations]) + + # _sorting is appended to avoid name clashes with manual joins eg. overridden filters + # debugger + columns << Sequel.send(direction, :"#{records.opts[:join].last.table_expr.aliaz}__#{column_name}") + # records = records.association_join(joins_query).select_all(table_name) + else + # DB[:items].order(Sequel.desc(:name)) # SELECT * FROM items ORDER BY name DESC + columns << Sequel.send(direction, :"#{table_name}__#{field}") + end + end + records = records.order(*columns) + end + records + end + end + + def _lookup_association_chain(model_names) + associations = [] + model_names.inject do |prev, current| + association = prev.classify.constantize.all_association_reflections.detect do |assoc| + assoc.association_method.to_s.downcase == current.downcase + end + associations << association + association.associated_class.to_s + end + + associations + end + + def _build_joins(associations) + joins = [] + + associations.inject do |prev, current| + joins << "LEFT JOIN #{current.table_name} AS #{current.name}_sorting ON #{current.name}_sorting.id = #{prev.table_name}.#{current.foreign_key}" + current + end + joins.join("\n") + end + + def apply_filter(records, filter, value, options = {}) + strategy = _resource_klass._allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] + + if strategy + if strategy.is_a?(Symbol) || strategy.is_a?(String) + _resource_klass.send(strategy, records, value, options) + else + strategy.call(records, value, options) + end + else + # Prefix primary key lookups with the table name to avoid conflicts (Sequel does not do this automatically) + prefixed_filter = _resource_klass._primary_key == filter ? :"#{_resource_klass._model_class.table_name}__#{filter}" : filter + records.where(prefixed_filter => value) + end + end + + def apply_filters_to_many_relationships(records, to_many_filters, options) + required_includes = to_many_filters.map { |filter, _| filter.to_s } + records = apply_includes(records, options.merge( + include_as_join: true, + include_directives: IncludeDirectives.new(_resource_klass, required_includes, force_eager_load: true)), + ) + + to_many_filters.each do |filter, value| + table_name = records.opts[:join].map(&:table_expr).detect { |t| t.expression == filter }.aliaz + relationship = _resource_klass._relationships[filter] + records = apply_filter(records, :"#{table_name}__#{relationship.primary_key}", value, options) + end + + records + end + + def cached_resources_for(records, serializer, options) + if _resource_klass.caching? + table = _resource_klass._model_class.table_name + cache_ids = pluck_attributes(records, [table, _resource_klass._primary_key], [table, _resource_klass._cache_field]) + resources = CachedResourceFragment.fetch_fragments(_resource_klass, serializer, options[:context], cache_ids) + else + resources = resources_for(records, options[:context]).map { |r| [r.id, r] }.to_h + end + + preload_included_fragments(resources, records, serializer, options) + + resources.values + end + + def preload_included_fragments(resources, records, serializer, options) + return if resources.empty? + res_ids = resources.keys + + include_directives = options[:include_directives] + return unless include_directives + + context = options[:context] + + # For each association, including indirect associations, find the target record ids. + # Even if a target class doesn't have caching enabled, we still have to look up + # and match the target ids here, because we can't use ActiveRecord#includes. + # + # Note that `paths` returns partial paths before complete paths, so e.g. the partial + # fragments for posts.comments will exist before we start working with posts.comments.author + target_resources = {} + include_directives.paths.each do |path| + # If path is [:posts, :comments, :author], then... + pluck_attrs = [] # ...will be [posts.id, comments.id, authors.id, authors.updated_at] + pluck_attrs << [_resource_klass._model_class.table_name, _resource_klass._primary_key] + + relation = records.clone + .tap { |dataset| [:limit, :offset, :order, :where, :join].each { |x| dataset.opts.delete(x) }} + .where({ Sequel[_resource_klass._model_class.table_name][_resource_klass._primary_key] => res_ids }) + + # These are updated as we iterate through the association path; afterwards they will + # refer to the final resource on the path, i.e. the actual resource to find in the cache. + # So e.g. if path is [:posts, :comments, :author], then after iteration... + parent_klass = nil # Comment + klass = _resource_klass # Person + relationship = nil # JSONAPI::Relationship::ToOne for CommentResource.author + table = nil # people + assocs_path = [] # [ :posts, :approved_comments, :author ] + joins_hash = nil # { :posts => { :approved_comments => :author } } + + # For each step on the path, figure out what the actual table name/alias in the join + # will be, and include the primary key of that table in our list of fields to select + non_polymorphic = true + path.each do |elem| + relationship = klass._relationships[elem] + if relationship.polymorphic + # Can't preload through a polymorphic belongs_to association, ResourceSerializer + # will just have to bypass the cache and load the real Resource. + non_polymorphic = false + break + end + assocs_path << relationship.relation_name(options).to_sym + # Converts [:a, :b, :c] to Rails-style { :a => { :b => :c }} + joins_hash = assocs_path.reverse.reduce { |memo, step| { step => memo } } + + # Sequel::Model::Associations::ManyToManyAssociationReflection + # join_source = relation.join(ar_hash).arel.source.right.reverse.find do |arel_node| + # arel_node.is_a?(Arel::Nodes::InnerJoin) + # end + # table = join_source.left + + parent_klass = klass + klass = relationship.resource_klass + + # We can't just look up the table name from the resource class, because Sequel could + # have used a table alias if the relation includes a self-reference. + table = relation.association_join(joins_hash).opts[:join].last.table_expr.alias + # pluck_attrs << :"#{_resource_klass._model_class}__#{_resource_klass._primary_key}" + # pluck_attrs << table[klass._primary_key] + pluck_attrs << [table, klass._primary_key] + end + next unless non_polymorphic + + # Pre-fill empty hashes for each resource up to the end of the path. + # This allows us to later distinguish between a preload that returned nothing + # vs. a preload that never ran. + prefilling_resources = resources.values + path.each do |rel_name| + rel_name = serializer.key_formatter.format(rel_name) + prefilling_resources.map! do |res| + res.preloaded_fragments[rel_name] ||= {} + res.preloaded_fragments[rel_name].values + end + prefilling_resources.flatten!(1) + end + + pluck_attrs << [table, klass._cache_field] if klass.caching? + relation = relation.association_join(joins_hash).select_all(_resource_klass._model_class.table_name) + # debugger + if relationship.is_a?(JSONAPI::Relationship::ToMany) + relation = relation.order(parent_klass._model_class.association_reflection(assocs_path.last)[:order]) + end + + # [[post id, comment id, author id, author updated_at], ...] + id_rows = pluck_attributes(relation, *pluck_attrs) + + target_resources[klass.name] ||= {} + + if klass.caching? + sub_cache_ids = id_rows + .map { |row| row.last(2) } + .reject { |row| target_resources[klass.name].has_key?(row.first) } + .uniq + target_resources[klass.name].merge! CachedResourceFragment.fetch_fragments( + klass, serializer, context, sub_cache_ids + ) + else + sub_res_ids = id_rows + .map(&:last) + .reject { |id| target_resources[klass.name].has_key?(id) } + .uniq + # debugger + found = klass.find({ :"#{klass._model_class.table_name}__#{klass._primary_key}" => sub_res_ids }, context: options[:context]) + target_resources[klass.name].merge! found.map { |r| [r.id, r] }.to_h + end + + id_rows.each do |row| + res = resources[row.first] + path.each_with_index do |rel_name, index| + rel_name = serializer.key_formatter.format(rel_name) + rel_id = row[index+1] + assoc_rels = res.preloaded_fragments[rel_name] + if index == path.length - 1 + assoc_rels[rel_id] = target_resources[klass.name].fetch(rel_id) + else + res = assoc_rels[rel_id] + end + end + end + end + end + + def pluck_attributes(relation, *attrs) + # Use Sequel's Symbol table aliasing convention. + relation.select_map(attrs.map { |table, column| :"#{table}__#{column}___#{table}#{column}" }) + end + + private + + def extract_model_class(records) + if records.is_a?(Sequel::Dataset) + records.opts[:model] + elsif records.is_a?(Class) && records < self.class.model_base_class + records + else + raise("Cannot extract table name from #{records.inspect}") + end + end + + end +end \ No newline at end of file diff --git a/test/benchmark/reflect_create_and_delete_benchmark.rb b/test/benchmark/reflect_create_and_delete_benchmark.rb index 0efd6b181..22f145bad 100644 --- a/test/benchmark/reflect_create_and_delete_benchmark.rb +++ b/test/benchmark/reflect_create_and_delete_benchmark.rb @@ -2,7 +2,7 @@ class ReflectCreateAndDeleteBenchmark < IntegrationBenchmark def setup - $test_user = Person.find(1) + $test_user = find_first(Person, 1) end def create_and_delete_comments @@ -19,7 +19,7 @@ def create_and_delete_comments 'Accept' => JSONAPI::MEDIA_TYPE } assert_response :no_content - post_object = Post.find(15) + post_object = find_first(Post, 15) assert_equal 3, post_object.comments.collect { |comment| comment.id }.length delete '/posts/15/relationships/comments', params: @@ -35,7 +35,7 @@ def create_and_delete_comments 'Accept' => JSONAPI::MEDIA_TYPE } assert_response :no_content - post_object = Post.find(15) + post_object = find_first(Post, 15) assert_equal 0, post_object.comments.collect { |comment| comment.id }.length end diff --git a/test/benchmark/reflect_update_relationships_benchmark.rb b/test/benchmark/reflect_update_relationships_benchmark.rb index 0eb61f171..bff31c286 100644 --- a/test/benchmark/reflect_update_relationships_benchmark.rb +++ b/test/benchmark/reflect_update_relationships_benchmark.rb @@ -2,7 +2,7 @@ class ReflectUpdateRelationshipsBenchmark < IntegrationBenchmark def setup - $test_user = Person.find(1) + $test_user = find_first(Person, 1) end def replace_tags @@ -16,7 +16,7 @@ def replace_tags 'Accept' => JSONAPI::MEDIA_TYPE } assert_response :no_content - post_object = Post.find(15) + post_object = find_first(Post, 15) assert_equal 5, post_object.tags.collect { |tag| tag.id }.length put '/posts/15/relationships/tags', params: @@ -29,7 +29,7 @@ def replace_tags 'Accept' => JSONAPI::MEDIA_TYPE } assert_response :no_content - post_object = Post.find(15) + post_object = find_first(Post, 15) assert_equal 3, post_object.tags.collect { |tag| tag.id }.length end diff --git a/test/benchmark/request_benchmark.rb b/test/benchmark/request_benchmark.rb index d42d3a695..0607e89c7 100644 --- a/test/benchmark/request_benchmark.rb +++ b/test/benchmark/request_benchmark.rb @@ -3,7 +3,7 @@ class RequestBenchmark < IntegrationBenchmark def setup super - $test_user = Person.find(1) + $test_user = find_first(Person, 1) end def bench_large_index_request_uncached diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 20fa914f6..bc532bce1 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -430,7 +430,7 @@ def test_sorting_by_multiple_fields def create_alphabetically_first_user_and_post author = Person.create(name: "Aardvark", date_joined: Time.now) - author.posts.create(title: "My first post", body: "Hello World") + Post.create(title: "My first post", body: "Hello World", author_id: author.id) end def test_sorting_by_relationship_field @@ -720,8 +720,8 @@ def test_create_with_invalid_data assert_equal "author - can't be blank", json_response['errors'][0]['detail'] assert_equal "/data/attributes/title", json_response['errors'][1]['source']['pointer'] - assert_equal "is too long (maximum is 35 characters)", json_response['errors'][1]['title'] - assert_equal "title - is too long (maximum is 35 characters)", json_response['errors'][1]['detail'] + assert_match /is too long( \(maximum is 35 characters\))?/, json_response['errors'][1]['title'] + assert_match /title - is too long( \(maximum is 35 characters\))?/, json_response['errors'][1]['detail'] assert_nil response.location end @@ -958,7 +958,7 @@ def test_create_with_links_include_and_fields def test_update_with_links set_content_type_header! - javascript = Section.find_by(name: 'javascript') + javascript = find_first(Section, name: 'javascript') put :update, params: { @@ -989,7 +989,7 @@ def test_update_with_links def test_update_with_internal_server_error set_content_type_header! - post_object = Post.find(3) + post_object = find_first(Post, 3) title = post_object.title put :update, params: @@ -1005,7 +1005,7 @@ def test_update_with_internal_server_error } assert_response 500 - post_object = Post.find(3) + post_object = find_first(Post, 3) assert_equal title, post_object.title end @@ -1013,7 +1013,7 @@ def test_update_with_links_allow_extra_params JSONAPI.configuration.raise_if_parameters_not_allowed = false set_content_type_header! - javascript = Section.find_by(name: 'javascript') + javascript = find_first(Section, name: 'javascript') put :update, params: { @@ -1110,27 +1110,27 @@ def test_update_remove_links def test_update_relationship_to_one set_content_type_header! - ruby = Section.find_by(name: 'ruby') - post_object = Post.find(4) + ruby = find_first(Section, name: 'ruby') + post_object = find_first(Post, 4) assert_not_equal ruby.id, post_object.section_id put :update_relationship, params: {post_id: 4, relationship: 'section', data: {type: 'sections', id: "#{ruby.id}"}} assert_response :no_content - post_object = Post.find(4) + post_object = find_first(Post, 4) assert_equal ruby.id, post_object.section_id end def test_update_relationship_to_one_nil set_content_type_header! - ruby = Section.find_by(name: 'ruby') - post_object = Post.find(4) + ruby = find_first(Section, name: 'ruby') + post_object = find_first(Post, 4) assert_not_equal ruby.id, post_object.section_id put :update_relationship, params: {post_id: 4, relationship: 'section', data: nil} assert_response :no_content - post_object = Post.find(4) + post_object = find_first(Post, 4) assert_nil post_object.section_id end @@ -1240,10 +1240,10 @@ def test_update_other_to_many_links_data_nil def test_update_relationship_to_one_singular_param_id_nil set_content_type_header! - ruby = Section.find_by(name: 'ruby') - post_object = Post.find(3) + ruby = find_first(Section, name: 'ruby') + post_object = find_first(Post, 3) post_object.section = ruby - post_object.save! + save!(post_object) put :update_relationship, params: {post_id: 3, relationship: 'section', data: {type: 'sections', id: nil}} @@ -1253,10 +1253,10 @@ def test_update_relationship_to_one_singular_param_id_nil def test_update_relationship_to_one_data_nil set_content_type_header! - ruby = Section.find_by(name: 'ruby') - post_object = Post.find(3) + ruby = find_first(Section, name: 'ruby') + post_object = find_first(Post, 3) post_object.section = ruby - post_object.save! + save!(post_object) put :update_relationship, params: {post_id: 3, relationship: 'section', data: nil} @@ -1266,29 +1266,29 @@ def test_update_relationship_to_one_data_nil def test_remove_relationship_to_one set_content_type_header! - ruby = Section.find_by(name: 'ruby') - post_object = Post.find(3) + ruby = find_first(Section, name: 'ruby') + post_object = find_first(Post, 3) post_object.section_id = ruby.id - post_object.save! + save!(post_object) put :destroy_relationship, params: {post_id: 3, relationship: 'section'} assert_response :no_content - post_object = Post.find(3) + post_object = find_first(Post, 3) assert_nil post_object.section_id end def test_update_relationship_to_one_singular_param set_content_type_header! - ruby = Section.find_by(name: 'ruby') - post_object = Post.find(3) + ruby = find_first(Section, name: 'ruby') + post_object = find_first(Post, 3) post_object.section_id = nil - post_object.save! + save!(post_object) put :update_relationship, params: {post_id: 3, relationship: 'section', data: {type: 'sections', id: "#{ruby.id}"}} assert_response :no_content - post_object = Post.find(3) + post_object = find_first(Post, 3) assert_equal ruby.id, post_object.section_id end @@ -1297,19 +1297,19 @@ def test_update_relationship_to_many_join_table_single put :update_relationship, params: {post_id: 3, relationship: 'tags', data: []} assert_response :no_content - post_object = Post.find(3) + post_object = find_first(Post, 3) assert_equal 0, post_object.tags.length put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 2}]} assert_response :no_content - post_object = Post.find(3) + post_object = find_first(Post, 3) assert_equal 1, post_object.tags.length put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 5}]} assert_response :no_content - post_object = Post.find(3) + post_object = find_first(Post, 3) tags = post_object.tags.collect { |tag| tag.id } assert_equal 1, tags.length assert matches_array? [5], tags @@ -1320,7 +1320,7 @@ def test_update_relationship_to_many put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}]} assert_response :no_content - post_object = Post.find(3) + post_object = find_first(Post, 3) assert_equal 2, post_object.tags.collect { |tag| tag.id }.length assert matches_array? [2, 3], post_object.tags.collect { |tag| tag.id } end @@ -1330,14 +1330,14 @@ def test_create_relationship_to_many_join_table put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}]} assert_response :no_content - post_object = Post.find(3) + post_object = find_first(Post, 3) assert_equal 2, post_object.tags.collect { |tag| tag.id }.length assert matches_array? [2, 3], post_object.tags.collect { |tag| tag.id } post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 5}]} assert_response :no_content - post_object = Post.find(3) + post_object = find_first(Post, 3) assert_equal 3, post_object.tags.collect { |tag| tag.id }.length assert matches_array? [2, 3, 5], post_object.tags.collect { |tag| tag.id } end @@ -1345,13 +1345,13 @@ def test_create_relationship_to_many_join_table def test_create_relationship_to_many_join_table_reflect JSONAPI.configuration.use_relationship_reflection = true set_content_type_header! - post_object = Post.find(15) + post_object = find_first(Post, 15) assert_equal 5, post_object.tags.collect { |tag| tag.id }.length put :update_relationship, params: {post_id: 15, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}, {type: 'tags', id: 4}]} assert_response :no_content - post_object = Post.find(15) + post_object = find_first(Post, 15) assert_equal 3, post_object.tags.collect { |tag| tag.id }.length assert matches_array? [2, 3, 4], post_object.tags.collect { |tag| tag.id } ensure @@ -1393,7 +1393,7 @@ def test_create_relationship_to_many_missing_data def test_create_relationship_to_many_join_table_no_reflection JSONAPI.configuration.use_relationship_reflection = false set_content_type_header! - p = Post.find(4) + p = find_first(Post, 4) assert_equal [], p.tag_ids post :create_relationship, params: {post_id: 4, relationship: 'tags', data: [{type: 'tags', id: 1}, {type: 'tags', id: 2}, {type: 'tags', id: 3}]} @@ -1408,7 +1408,7 @@ def test_create_relationship_to_many_join_table_no_reflection def test_create_relationship_to_many_join_table_reflection JSONAPI.configuration.use_relationship_reflection = true set_content_type_header! - p = Post.find(4) + p = find_first(Post, 4) assert_equal [], p.tag_ids post :create_relationship, params: {post_id: 4, relationship: 'tags', data: [{type: 'tags', id: 1}, {type: 'tags', id: 2}, {type: 'tags', id: 3}]} @@ -1423,7 +1423,7 @@ def test_create_relationship_to_many_join_table_reflection def test_create_relationship_to_many_no_reflection JSONAPI.configuration.use_relationship_reflection = false set_content_type_header! - p = Post.find(4) + p = find_first(Post, 4) assert_equal [], p.comment_ids post :create_relationship, params: {post_id: 4, relationship: 'comments', data: [{type: 'comments', id: 7}, {type: 'comments', id: 8}]} @@ -1438,7 +1438,7 @@ def test_create_relationship_to_many_no_reflection def test_create_relationship_to_many_reflection JSONAPI.configuration.use_relationship_reflection = true set_content_type_header! - p = Post.find(4) + p = find_first(Post, 4) assert_equal [], p.comment_ids post :create_relationship, params: {post_id: 4, relationship: 'comments', data: [{type: 'comments', id: 7}, {type: 'comments', id: 8}]} @@ -1455,7 +1455,7 @@ def test_create_relationship_to_many_join_table_record_exists put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}]} assert_response :no_content - post_object = Post.find(3) + post_object = find_first(Post, 3) assert_equal 2, post_object.tags.collect { |tag| tag.id }.length assert matches_array? [2, 3], post_object.tags.collect { |tag| tag.id } @@ -1487,7 +1487,7 @@ def test_delete_relationship_to_many } assert_response :no_content - p = Post.find(14) + p = find_first(Post, 14) assert_equal [2, 3, 4], p.tag_ids delete :destroy_relationship, @@ -1512,14 +1512,14 @@ def test_delete_relationship_to_many_with_relationship_url_not_matching_type post :create_relationship, params: {post_id: 14, relationship: 'special_tags', data: [{type: 'tags', id: 2}]} #check the relationship was created successfully - assert_equal 1, Post.find(14).special_tags.count - before_tags = Post.find(14).tags.count + assert_equal 1, find_first(Post, 14).special_tags.count + before_tags = find_first(Post, 14).tags.count delete :destroy_relationship, params: {post_id: 14, relationship: 'special_tags', data: [{type: 'tags', id: 2}]} - assert_equal 0, Post.find(14).special_tags.count, "Relationship that matches URL relationship not destroyed" + assert_equal 0, find_first(Post, 14).special_tags.count, "Relationship that matches URL relationship not destroyed" #check that the tag association is not affected - assert_equal Post.find(14).tags.count, before_tags + assert_equal find_first(Post, 14).tags.count, before_tags ensure PostResource.instance_variable_get(:@_relationships).delete(:special_tags) end @@ -1528,7 +1528,7 @@ def test_delete_relationship_to_many_does_not_exist set_content_type_header! put :update_relationship, params: {post_id: 14, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}]} assert_response :no_content - p = Post.find(14) + p = find_first(Post, 14) assert_equal [2, 3], p.tag_ids delete :destroy_relationship, params: {post_id: 14, relationship: 'tags', data: [{type: 'tags', id: 4}]} @@ -1542,7 +1542,7 @@ def test_delete_relationship_to_many_with_empty_data set_content_type_header! put :update_relationship, params: {post_id: 14, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}]} assert_response :no_content - p = Post.find(14) + p = find_first(Post, 14) assert_equal [2, 3], p.tag_ids put :update_relationship, params: {post_id: 14, relationship: 'tags', data: [] } @@ -1554,7 +1554,7 @@ def test_delete_relationship_to_many_with_empty_data def test_update_mismatch_single_key set_content_type_header! - javascript = Section.find_by(name: 'javascript') + javascript = find_first(Section, name: 'javascript') put :update, params: { @@ -1578,7 +1578,7 @@ def test_update_mismatch_single_key def test_update_extra_param set_content_type_header! - javascript = Section.find_by(name: 'javascript') + javascript = find_first(Section, name: 'javascript') put :update, params: { @@ -1603,7 +1603,7 @@ def test_update_extra_param def test_update_extra_param_in_links set_content_type_header! - javascript = Section.find_by(name: 'javascript') + javascript = find_first(Section, name: 'javascript') put :update, params: { @@ -1631,7 +1631,6 @@ def test_update_extra_param_in_links_allow_extra_params JSONAPI.configuration.use_text_errors = true set_content_type_header! - javascript = Section.find_by(name: 'javascript') put :update, params: { @@ -1660,7 +1659,7 @@ def test_update_extra_param_in_links_allow_extra_params def test_update_missing_param set_content_type_header! - javascript = Section.find_by(name: 'javascript') + javascript = find_first(Section, name: 'javascript') put :update, params: { @@ -1701,7 +1700,7 @@ def test_update_missing_key def test_update_missing_type set_content_type_header! - javascript = Section.find_by(name: 'javascript') + javascript = find_first(Section, name: 'javascript') put :update, params: { @@ -1725,7 +1724,7 @@ def test_update_missing_type def test_update_unknown_key set_content_type_header! - javascript = Section.find_by(name: 'javascript') + javascript = find_first(Section, name: 'javascript') put :update, params: { @@ -1750,7 +1749,7 @@ def test_update_unknown_key def test_update_multiple_ids set_content_type_header! - javascript = Section.find_by(name: 'javascript') + javascript = find_first(Section, name: 'javascript') put :update, params: { id: '3,16', @@ -1774,7 +1773,7 @@ def test_update_multiple_ids def test_update_multiple_array set_content_type_header! - javascript = Section.find_by(name: 'javascript') + javascript = find_first(Section, name: 'javascript') put :update, params: { @@ -1843,7 +1842,8 @@ def test_update_bad_attributes end def test_delete_with_validation_error - post = Post.create!(title: "can't destroy me", author: Person.first) + post = Post.new(title: "can't destroy me", author: Person.first) + save!(post) delete :destroy, params: { id: post.id } assert_equal "can't destroy me", json_response['errors'][0]['title'] @@ -2514,7 +2514,7 @@ def test_get_related_resource_nil class BooksControllerTest < ActionController::TestCase def test_books_include_correct_type - $test_user = Person.find(1) + $test_user = find_first(Person, 1) assert_cacheable_get :index, params: {filter: {id: '1'}, include: 'authors'} assert_response :success assert_equal 'authors', json_response['included'][0]['type'] @@ -2523,23 +2523,22 @@ def test_books_include_correct_type def test_destroy_relationship_has_and_belongs_to_many JSONAPI.configuration.use_relationship_reflection = false - assert_equal 2, Book.find(2).authors.count + assert_equal 2, find_first(Book, 2).authors.count delete :destroy_relationship, params: {book_id: 2, relationship: 'authors', data: [{type: 'authors', id: 1}]} assert_response :no_content - assert_equal 1, Book.find(2).authors.count + assert_equal 1, find_first(Book, 2).authors.count ensure JSONAPI.configuration.use_relationship_reflection = false end def test_destroy_relationship_has_and_belongs_to_many_reflect JSONAPI.configuration.use_relationship_reflection = true - - assert_equal 2, Book.find(2).authors.count + assert_equal 2, find_first(Book, 2).authors.count delete :destroy_relationship, params: {book_id: 2, relationship: 'authors', data: [{type: 'authors', id: 1}]} assert_response :no_content - assert_equal 1, Book.find(2).authors.count + assert_equal 1, find_first(Book, 2).authors.count ensure JSONAPI.configuration.use_relationship_reflection = false @@ -2885,7 +2884,7 @@ def test_create_with_invalid_data class Api::V2::BooksControllerTest < ActionController::TestCase def setup JSONAPI.configuration.json_key_format = :dasherized_key - $test_user = Person.find(1) + $test_user = find_first(Person, 1) end def after_teardown @@ -2979,7 +2978,6 @@ def test_books_offset_pagination_no_params_includes_query_count_two_levels def test_books_offset_pagination Api::V2::BookResource.paginator :offset - assert_cacheable_get :index, params: {page: {offset: 50, limit: 12}} assert_response :success assert_equal 12, json_response['data'].size @@ -3106,7 +3104,7 @@ def test_books_included_paged end def test_books_banned_non_book_admin - $test_user = Person.find(1) + $test_user = find_first(Person, 1) Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true assert_query_count(2) do @@ -3121,7 +3119,7 @@ def test_books_banned_non_book_admin end def test_books_banned_non_book_admin_includes_switched - $test_user = Person.find(1) + $test_user = find_first(Person, 1) Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true assert_query_count(3) do @@ -3140,7 +3138,7 @@ def test_books_banned_non_book_admin_includes_switched end def test_books_banned_non_book_admin_includes_nested_includes - $test_user = Person.find(1) + $test_user = find_first(Person, 1) JSONAPI.configuration.top_level_meta_include_record_count = true Api::V2::BookResource.paginator :offset assert_query_count(4) do @@ -3156,7 +3154,7 @@ def test_books_banned_non_book_admin_includes_nested_includes end def test_books_banned_admin - $test_user = Person.find(5) + $test_user = find_first(Person, 5) Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true assert_query_count(2) do @@ -3171,7 +3169,7 @@ def test_books_banned_admin end def test_books_not_banned_admin - $test_user = Person.find(5) + $test_user = find_first(Person, 5) Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true assert_query_count(2) do @@ -3186,7 +3184,7 @@ def test_books_not_banned_admin end def test_books_banned_non_book_admin_overlapped - $test_user = Person.find(1) + $test_user = find_first(Person, 1) Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true assert_query_count(2) do @@ -3201,7 +3199,7 @@ def test_books_banned_non_book_admin_overlapped end def test_books_included_exclude_unapproved - $test_user = Person.find(1) + $test_user = find_first(Person, 1) Api::V2::BookResource.paginator :none assert_query_count(2) do @@ -3215,7 +3213,7 @@ def test_books_included_exclude_unapproved end def test_books_included_all_comments_for_admin - $test_user = Person.find(5) + $test_user = find_first(Person, 5) Api::V2::BookResource.paginator :none assert_cacheable_get :index, params: {filter: {id: '0,1,2,3,4'}, include: 'book-comments'} @@ -3227,14 +3225,14 @@ def test_books_included_all_comments_for_admin end def test_books_filter_by_book_comment_id_limited_user - $test_user = Person.find(1) + $test_user = find_first(Person, 1) assert_cacheable_get :index, params: {filter: {book_comments: '0,52' }} assert_response :success assert_equal 1, json_response['data'].size end def test_books_filter_by_book_comment_id_admin_user - $test_user = Person.find(5) + $test_user = find_first(Person, 5) assert_cacheable_get :index, params: {filter: {book_comments: '0,52' }} assert_response :success assert_equal 2, json_response['data'].size @@ -3242,7 +3240,7 @@ def test_books_filter_by_book_comment_id_admin_user def test_books_create_unapproved_comment_limited_user_using_relation_name set_content_type_header! - $test_user = Person.find(1) + $test_user = find_first(Person, 1) book_comment = BookComment.create(body: 'Not Approved dummy comment', approved: false) post :create_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} @@ -3256,7 +3254,7 @@ def test_books_create_unapproved_comment_limited_user_using_relation_name def test_books_create_approved_comment_limited_user_using_relation_name set_content_type_header! - $test_user = Person.find(1) + $test_user = find_first(Person, 1) book_comment = BookComment.create(body: 'Approved dummy comment', approved: true) post :create_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} @@ -3267,7 +3265,7 @@ def test_books_create_approved_comment_limited_user_using_relation_name end def test_books_delete_unapproved_comment_limited_user_using_relation_name - $test_user = Person.find(1) + $test_user = find_first(Person, 1) book_comment = BookComment.create(book_id: 1, body: 'Not Approved dummy comment', approved: false) delete :destroy_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} @@ -3278,7 +3276,7 @@ def test_books_delete_unapproved_comment_limited_user_using_relation_name end def test_books_delete_approved_comment_limited_user_using_relation_name - $test_user = Person.find(1) + $test_user = find_first(Person, 1) book_comment = BookComment.create(book_id: 1, body: 'Approved dummy comment', approved: true) delete :destroy_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} @@ -3290,7 +3288,7 @@ def test_books_delete_approved_comment_limited_user_using_relation_name def test_books_delete_approved_comment_limited_user_using_relation_name_reflected JSONAPI.configuration.use_relationship_reflection = true - $test_user = Person.find(1) + $test_user = find_first(Person, 1) book_comment = BookComment.create(book_id: 1, body: 'Approved dummy comment', approved: true) delete :destroy_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} @@ -3306,11 +3304,11 @@ class Api::V2::BookCommentsControllerTest < ActionController::TestCase def setup JSONAPI.configuration.json_key_format = :dasherized_key Api::V2::BookCommentResource.paginator :none - $test_user = Person.find(1) + $test_user = find_first(Person, 1) end def test_book_comments_all_for_admin - $test_user = Person.find(5) + $test_user = find_first(Person, 5) assert_query_count(1) do assert_cacheable_get :index end @@ -3319,7 +3317,7 @@ def test_book_comments_all_for_admin end def test_book_comments_unapproved_context_based - $test_user = Person.find(5) + $test_user = find_first(Person, 5) assert_query_count(1) do assert_cacheable_get :index, params: {filter: {approved: 'false'}} end @@ -3328,7 +3326,7 @@ def test_book_comments_unapproved_context_based end def test_book_comments_exclude_unapproved_context_based - $test_user = Person.find(1) + $test_user = find_first(Person, 1) assert_query_count(1) do assert_cacheable_get :index end @@ -3485,7 +3483,7 @@ def test_get_related_resources end def test_get_related_resources_filtered - $test_user = Person.find(1) + $test_user = find_first(Person, 1) get :get_related_resources, params: {moon_id: '1', relationship: 'craters', source: "api/v1/moons", filter: {description: 'Small crater'}} assert_response :success assert_hash_equals({ @@ -3636,6 +3634,7 @@ def test_whitelisted_error_in_controller end end +# These specs have ORM-specific implementations, please see support//app_config.rb class Api::V6::PostsControllerTest < ActionController::TestCase def test_caching_with_join_from_resource_with_sql_fragment assert_cacheable_get :index, params: {include: 'section'} diff --git a/test/helpers/configuration_helpers.rb b/test/helpers/configuration_helpers.rb index ed7169700..ef7b2eb78 100644 --- a/test/helpers/configuration_helpers.rb +++ b/test/helpers/configuration_helpers.rb @@ -30,9 +30,8 @@ def with_resource_caching(cache, classes = :all) resource_classes = ObjectSpace.each_object(Class).select do |klass| if klass < JSONAPI::Resource # Not using Resource#_model_class to avoid tripping the warning early, which could - # cause ResourceTest#test_nil_model_class to fail. - model_class = klass._model_name.to_s.safe_constantize - if model_class && model_class.respond_to?(:arel_table) + # cause ResourceTest#test_nil_model_class to fail, so we check first with safe_constantize. + if klass._model_name.to_s.safe_constantize && klass.model_class_compatible_with_record_accessor? next true end end diff --git a/test/helpers/record_accessor_helpers.rb b/test/helpers/record_accessor_helpers.rb new file mode 100644 index 000000000..c9ee5100c --- /dev/null +++ b/test/helpers/record_accessor_helpers.rb @@ -0,0 +1,21 @@ +module Helpers +# Test specific methods needed by each ORM for test cases to work. + module RecordAccessorHelpers + def find_first(model_class, id) + find_all(model_class, id).first + end + + # Written to be ORM agnostic so long as the orm implements a #where method which responds to #all + # or #first, which many of them do. If needed this, can be abstracted out into the RecordAccessor, but + # since they are only used for tests, I didn't want to add test-only logic into the library. + def find_all(model_class, *ids) + model_class.where(ids.first.is_a?(Hash) ? ids.first : {id: ids}) + end + + def save!(model) + JSONAPI.configuration.default_record_accessor_klass.save(model, raise_on_failure: true) + end + + end + +end \ No newline at end of file diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 8a27de962..cc98723aa 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -5,7 +5,7 @@ def setup JSONAPI.configuration.json_key_format = :underscored_key JSONAPI.configuration.route_format = :underscored_route Api::V2::BookResource.paginator :offset - $test_user = Person.find(1) + $test_user = find_first(Person, 1) end def after_teardown @@ -259,14 +259,14 @@ def test_post_single_minimal_invalid end def test_update_relationship_without_content_type - ruby = Section.find_by(name: 'ruby') + ruby = find_first(Section, name: 'ruby') patch '/posts/3/relationships/section', params: { 'data' => {'type' => 'sections', 'id' => ruby.id.to_s }}.to_json assert_equal 415, status end def test_patch_update_relationship_to_one - ruby = Section.find_by(name: 'ruby') + ruby = find_first(Section, name: 'ruby') patch '/posts/3/relationships/section', params: { 'data' => {'type' => 'sections', 'id' => ruby.id.to_s }}.to_json, headers: { @@ -278,7 +278,7 @@ def test_patch_update_relationship_to_one end def test_put_update_relationship_to_one - ruby = Section.find_by(name: 'ruby') + ruby = find_first(Section, name: 'ruby') put '/posts/3/relationships/section', params: { 'data' => {'type' => 'sections', 'id' => ruby.id.to_s }}.to_json, headers: { 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, @@ -291,7 +291,7 @@ def test_put_update_relationship_to_one def test_patch_update_relationship_to_many_acts_as_set # Comments are acts_as_set=false so PUT/PATCH should respond with 403 - rogue = Comment.find_by(body: 'Rogue Comment Here') + rogue = find_first(Comment, body: 'Rogue Comment Here') patch '/posts/5/relationships/comments', params: { 'data' => [{'type' => 'comments', 'id' => rogue.id.to_s }]}.to_json, headers: { 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, @@ -302,7 +302,7 @@ def test_patch_update_relationship_to_many_acts_as_set end def test_post_update_relationship_to_many - rogue = Comment.find_by(body: 'Rogue Comment Here') + rogue = find_first(Comment, body: 'Rogue Comment Here') post '/posts/5/relationships/comments', params: { 'data' => [{'type' => 'comments', 'id' => rogue.id.to_s }]}.to_json, headers: { 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, @@ -315,7 +315,7 @@ def test_post_update_relationship_to_many def test_put_update_relationship_to_many_acts_as_set # Comments are acts_as_set=false so PUT/PATCH should respond with 403. Note: JR currently treats PUT and PATCH as equivalent - rogue = Comment.find_by(body: 'Rogue Comment Here') + rogue = find_first(Comment, body: 'Rogue Comment Here') put '/posts/5/relationships/comments', params: { 'data' => [{'type' => 'comments', 'id' => rogue.id.to_s }]}.to_json, headers: { 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, @@ -896,7 +896,7 @@ def test_patch_formatted_dasherized_replace_to_many def test_patch_formatted_dasherized_replace_to_many_computed_relation $original_test_user = $test_user - $test_user = Person.find(5) + $test_user = find_first(Person, 5) original_config = JSONAPI.configuration.dup JSONAPI.configuration.route_format = :dasherized_route JSONAPI.configuration.json_key_format = :dasherized_key @@ -955,7 +955,7 @@ def test_post_to_many_link def test_post_computed_relation_to_many $original_test_user = $test_user - $test_user = Person.find(5) + $test_user = find_first(Person, 5) original_config = JSONAPI.configuration.dup JSONAPI.configuration.route_format = :dasherized_route JSONAPI.configuration.json_key_format = :dasherized_key @@ -1000,7 +1000,7 @@ def test_patch_to_many_link def test_patch_to_many_link_computed_relation $original_test_user = $test_user - $test_user = Person.find(5) + $test_user = find_first(Person, 5) original_config = JSONAPI.configuration.dup JSONAPI.configuration.route_format = :dasherized_route JSONAPI.configuration.json_key_format = :dasherized_key diff --git a/test/support/active_record/app_config.rb b/test/support/active_record/app_config.rb new file mode 100644 index 000000000..1ace5e634 --- /dev/null +++ b/test/support/active_record/app_config.rb @@ -0,0 +1,12 @@ + +JSONAPI.configuration.default_record_accessor_klass = JSONAPI::ActiveRecordRecordAccessor + +TestApp.class_eval do + config.active_record.schema_format = :none + + if Rails::VERSION::MAJOR >= 5 + config.active_support.halt_callback_chains_on_return_false = false + config.active_record.time_zone_aware_types = [:time, :datetime] + config.active_record.belongs_to_required_by_default = false + end +end \ No newline at end of file diff --git a/test/support/active_record/import_schema.rb b/test/support/active_record/import_schema.rb new file mode 100644 index 000000000..d33fb244e --- /dev/null +++ b/test/support/active_record/import_schema.rb @@ -0,0 +1,11 @@ +require 'active_record' + +connection = ActiveRecord::Base.connection + +sql = File.read(File.expand_path('../../database/dump.sql', __FILE__)) +statements = sql.split(/;$/) +statements.pop # the last empty statement + +statements.each do |statement| + connection.execute(statement) +end \ No newline at end of file diff --git a/test/support/active_record/initialize.rb b/test/support/active_record/initialize.rb new file mode 100644 index 000000000..1f7ec46c0 --- /dev/null +++ b/test/support/active_record/initialize.rb @@ -0,0 +1 @@ +require 'active_record/railtie' \ No newline at end of file diff --git a/test/support/active_record/models.rb b/test/support/active_record/models.rb new file mode 100644 index 000000000..577bdaf58 --- /dev/null +++ b/test/support/active_record/models.rb @@ -0,0 +1,372 @@ +class Person < ActiveRecord::Base + has_many :posts, foreign_key: 'author_id' + has_many :comments, foreign_key: 'author_id' + has_many :expense_entries, foreign_key: 'employee_id', dependent: :restrict_with_exception + has_many :vehicles + belongs_to :preferences + belongs_to :hair_cut + has_one :author_detail + + has_and_belongs_to_many :books, join_table: :book_authors + + has_many :even_posts, -> { where('posts.id % 2 = 0') }, class_name: 'Post', foreign_key: 'author_id' + has_many :odd_posts, -> { where('posts.id % 2 = 1') }, class_name: 'Post', foreign_key: 'author_id' + + ### Validations + validates :name, presence: true + validates :date_joined, presence: true +end + +class AuthorDetail < ActiveRecord::Base + belongs_to :author, class_name: 'Person', foreign_key: 'person_id' +end + +class Post < ActiveRecord::Base + belongs_to :author, class_name: 'Person', foreign_key: 'author_id' + belongs_to :writer, class_name: 'Person', foreign_key: 'author_id' + has_many :comments + has_and_belongs_to_many :tags, join_table: :posts_tags + has_many :special_post_tags, source: :tag + has_many :special_tags, through: :special_post_tags, source: :tag + belongs_to :section + has_one :parent_post, class_name: 'Post', foreign_key: 'parent_post_id' + + validates :author, presence: true + validates :title, length: { maximum: 35 } + + before_destroy :destroy_callback + + def destroy_callback + if title == "can't destroy me" + errors.add(:title, "can't destroy me") + + # :nocov: + if Rails::VERSION::MAJOR >= 5 + throw(:abort) + else + return false + end + # :nocov: + end + end +end + +class PostWithBadAfterSave < ActiveRecord::Base + self.table_name = 'posts' + after_save :do_some_after_save_stuff + + def do_some_after_save_stuff + errors[:base] << 'Boom! Error added in after_save callback.' + raise ActiveRecord::RecordInvalid.new(self) + end +end + +class PostWithCustomValidationContext < ActiveRecord::Base + self.table_name = 'posts' + validate :api_specific_check, on: :json_api_create + + def api_specific_check + errors[:base] << 'Record is invalid' + end +end + +module Legacy + class FlatPost < ActiveRecord::Base + self.table_name = "posts" + end +end + +class SpecialPostTag < ActiveRecord::Base + belongs_to :tag + belongs_to :post +end + +class Comment < ActiveRecord::Base + belongs_to :author, class_name: 'Person', foreign_key: 'author_id' + belongs_to :post + has_and_belongs_to_many :tags, join_table: :comments_tags +end + +class Company < ActiveRecord::Base +end + +class Firm < Company +end + +class Tag < ActiveRecord::Base + has_and_belongs_to_many :posts, join_table: :posts_tags + has_and_belongs_to_many :planets, join_table: :planets_tags +end + +class Section < ActiveRecord::Base + has_many :posts +end + +class HairCut < ActiveRecord::Base + has_many :people +end + +class Property < ActiveRecord::Base +end + +class Customer < ActiveRecord::Base +end + +class BadlyNamedAttributes < ActiveRecord::Base +end + +class Cat < ActiveRecord::Base +end + +class IsoCurrency < ActiveRecord::Base + self.primary_key = :code + # has_many :expense_entries, foreign_key: 'currency_code' +end + +class ExpenseEntry < ActiveRecord::Base + belongs_to :employee, class_name: 'Person', foreign_key: 'employee_id' + belongs_to :iso_currency, foreign_key: 'currency_code' +end + +class Planet < ActiveRecord::Base + has_many :moons + belongs_to :planet_type + + has_and_belongs_to_many :tags, join_table: :planets_tags + + # Test model callback cancelling save + before_save :check_not_pluto + + def check_not_pluto + # Pluto can't be a planet, so cancel the save + if name.downcase == 'pluto' + # :nocov: + if Rails::VERSION::MAJOR >= 5 + throw(:abort) + else + return false + end + # :nocov: + end + end +end + +class PlanetType < ActiveRecord::Base + has_many :planets +end + +class Moon < ActiveRecord::Base + belongs_to :planet + + has_many :craters +end + +class Crater < ActiveRecord::Base + self.primary_key = :code + + belongs_to :moon +end + +class Preferences < ActiveRecord::Base + has_one :author, class_name: 'Person', :inverse_of => 'preferences' +end + +class Fact < ActiveRecord::Base + validates :spouse_name, :bio, presence: true +end + +class Like < ActiveRecord::Base +end + +class Breed + + def initialize(id = nil, name = nil) + if id.nil? + @id = $breed_data.new_id + $breed_data.add(self) + else + @id = id + end + @name = name + @errors = ActiveModel::Errors.new(self) + end + + attr_accessor :id, :name + + def destroy + $breed_data.remove(@id) + end + + def valid?(context = nil) + @errors.clear + if name.is_a?(String) && name.length > 0 + return true + else + @errors.add(:name, "can't be blank") + return false + end + end + + def errors + @errors + end +end + +class Book < ActiveRecord::Base + has_many :book_comments + has_many :approved_book_comments, -> { where(approved: true) }, class_name: "BookComment" + + has_and_belongs_to_many :authors, join_table: :book_authors, class_name: "Person" +end + +class BookComment < ActiveRecord::Base + belongs_to :author, class_name: 'Person', foreign_key: 'author_id' + belongs_to :book + + def self.for_user(current_user) + records = self + # Hide the unapproved comments from people who are not book admins + unless current_user && current_user.book_admin + records = records.where(approved: true) + end + records + end +end + +class BreedData + def initialize + @breeds = {} + end + + def breeds + @breeds + end + + def new_id + @breeds.keys.max + 1 + end + + def add(breed) + @breeds[breed.id] = breed + end + + def remove(id) + @breeds.delete(id) + end +end + +class Customer < ActiveRecord::Base + has_many :purchase_orders +end + +class PurchaseOrder < ActiveRecord::Base + belongs_to :customer + has_many :line_items + has_many :admin_line_items, class_name: 'LineItem', foreign_key: 'purchase_order_id' + + has_and_belongs_to_many :order_flags, join_table: :purchase_orders_order_flags + + has_and_belongs_to_many :admin_order_flags, join_table: :purchase_orders_order_flags, class_name: 'OrderFlag' +end + +class OrderFlag < ActiveRecord::Base + has_and_belongs_to_many :purchase_orders, join_table: :purchase_orders_order_flags +end + +class LineItem < ActiveRecord::Base + belongs_to :purchase_order +end + +class NumeroTelefone < ActiveRecord::Base +end + +class Category < ActiveRecord::Base +end + +class Picture < ActiveRecord::Base + belongs_to :imageable, polymorphic: true +end + +class Vehicle < ActiveRecord::Base + belongs_to :person +end + +class Car < Vehicle +end + +class Boat < Vehicle +end + +class Document < ActiveRecord::Base + has_many :pictures, as: :imageable +end + +class Document::Topic < Document +end + +class Product < ActiveRecord::Base + has_one :picture, as: :imageable +end + +class Make < ActiveRecord::Base +end + +class WebPage < ActiveRecord::Base +end + +class Box < ActiveRecord::Base + has_many :things +end + +class User < ActiveRecord::Base + has_many :things +end + +class Thing < ActiveRecord::Base + belongs_to :box + belongs_to :user + + has_many :related_things, foreign_key: :from_id + has_many :things, through: :related_things, source: :to +end + +class RelatedThing < ActiveRecord::Base + belongs_to :from, class_name: Thing, foreign_key: :from_id + belongs_to :to, class_name: Thing, foreign_key: :to_id +end + +class Question < ActiveRecord::Base + has_one :answer + + def respondent + answer.try(:respondent) + end +end + +class Answer < ActiveRecord::Base + belongs_to :question + belongs_to :respondent, polymorphic: true +end + +class Patient < ActiveRecord::Base +end + +class Doctor < ActiveRecord::Base +end + +module Api + module V7 + class Client < Customer + end + + class Customer < Customer + end + end +end + +### PORO Data - don't do this in a production app +$breed_data = BreedData.new +$breed_data.add(Breed.new(0, 'persian')) +$breed_data.add(Breed.new(1, 'siamese')) +$breed_data.add(Breed.new(2, 'sphinx')) +$breed_data.add(Breed.new(3, 'to_delete')) diff --git a/test/support/active_record/rollback.rb b/test/support/active_record/rollback.rb new file mode 100644 index 000000000..b386ad42b --- /dev/null +++ b/test/support/active_record/rollback.rb @@ -0,0 +1,21 @@ +module Minitest + module Rollback + + def before_setup + ActiveRecord::Base.connection.begin_transaction joinable: false + super + end + + def after_teardown + super + conn = ActiveRecord::Base.connection + conn.rollback_transaction if conn.transaction_open? + ActiveRecord::Base.clear_active_connections! + end + + end + + class Test + include Rollback + end +end \ No newline at end of file diff --git a/test/fixtures/active_record.rb b/test/support/controllers_resources_processors.rb similarity index 64% rename from test/fixtures/active_record.rb rename to test/support/controllers_resources_processors.rb index 62326f732..8d113d1f3 100644 --- a/test/fixtures/active_record.rb +++ b/test/support/controllers_resources_processors.rb @@ -1,659 +1,4 @@ -require 'active_record' -require 'jsonapi-resources' - -ActiveSupport::Inflector.inflections(:en) do |inflect| - inflect.uncountable 'preferences' - inflect.irregular 'numero_telefone', 'numeros_telefone' -end - -### DATABASE -ActiveRecord::Schema.define do - create_table :people, force: true do |t| - t.string :name - t.string :email - t.datetime :date_joined - t.belongs_to :preferences - t.integer :hair_cut_id, index: true - t.boolean :book_admin, default: false - t.boolean :special, default: false - t.timestamps null: false - end - - create_table :author_details, force: true do |t| - t.integer :person_id - t.string :author_stuff - end - - create_table :posts, force: true do |t| - t.string :title, length: 255 - t.text :body - t.integer :author_id - t.integer :parent_post_id - t.belongs_to :section, index: true - t.timestamps null: false - end - - create_table :comments, force: true do |t| - t.text :body - t.belongs_to :post, index: true - t.integer :author_id - t.timestamps null: false - end - - create_table :companies, force: true do |t| - t.string :type - t.string :name - t.string :address - t.timestamps null: false - end - - create_table :tags, force: true do |t| - t.string :name - t.timestamps null: false - end - - create_table :sections, force: true do |t| - t.string :name - t.timestamps null: false - end - - create_table :posts_tags, force: true do |t| - t.references :post, :tag, index: true - end - add_index :posts_tags, [:post_id, :tag_id], unique: true - - create_table :special_post_tags, force: true do |t| - t.references :post, :tag, index: true - end - add_index :special_post_tags, [:post_id, :tag_id], unique: true - - create_table :comments_tags, force: true do |t| - t.references :comment, :tag, index: true - end - - create_table :iso_currencies, id: false, force: true do |t| - t.string :code, limit: 3, null: false - t.string :name - t.string :country_name - t.string :minor_unit - t.timestamps null: false - end - add_index :iso_currencies, :code, unique: true - - create_table :expense_entries, force: true do |t| - t.string :currency_code, limit: 3, null: false - t.integer :employee_id, null: false - t.decimal :cost, precision: 12, scale: 4, null: false - t.date :transaction_date - t.timestamps null: false - end - - create_table :planets, force: true do |t| - t.string :name - t.string :description - t.integer :planet_type_id - end - - create_table :planets_tags, force: true do |t| - t.references :planet, :tag, index: true - end - add_index :planets_tags, [:planet_id, :tag_id], unique: true - - create_table :planet_types, force: true do |t| - t.string :name - end - - create_table :moons, force: true do |t| - t.string :name - t.string :description - t.integer :planet_id - t.timestamps null: false - end - - create_table :craters, id: false, force: true do |t| - t.string :code - t.string :description - t.integer :moon_id - t.timestamps null: false - end - - create_table :preferences, force: true do |t| - t.integer :person_id - t.boolean :advanced_mode, default: false - t.timestamps null: false - end - - create_table :facts, force: true do |t| - t.integer :person_id - t.string :spouse_name - t.text :bio - t.float :quality_rating - t.decimal :salary, precision: 12, scale: 2 - t.datetime :date_time_joined - t.date :birthday - t.time :bedtime - t.binary :photo, limit: 1.kilobyte - t.boolean :cool - t.timestamps null: false - end - - create_table :books, force: true do |t| - t.string :title - t.string :isbn - t.boolean :banned, default: false - t.timestamps null: false - end - - create_table :book_authors, force: true do |t| - t.integer :book_id - t.integer :person_id - end - - create_table :book_comments, force: true do |t| - t.text :body - t.belongs_to :book, index: true - t.integer :author_id - t.boolean :approved, default: true - t.timestamps null: false - end - - create_table :customers, force: true do |t| - t.string :name - t.timestamps null: false - end - - create_table :purchase_orders, force: true do |t| - t.date :order_date - t.date :requested_delivery_date - t.date :delivery_date - t.integer :customer_id - t.string :delivery_name - t.string :delivery_address_1 - t.string :delivery_address_2 - t.string :delivery_city - t.string :delivery_state - t.string :delivery_postal_code - t.float :delivery_fee - t.float :tax - t.float :total - t.timestamps null: false - end - - create_table :order_flags, force: true do |t| - t.string :name - end - - create_table :purchase_orders_order_flags, force: true do |t| - t.references :purchase_order, :order_flag, index: true - end - add_index :purchase_orders_order_flags, [:purchase_order_id, :order_flag_id], unique: true, name: "po_flags_idx" - - create_table :line_items, force: true do |t| - t.integer :purchase_order_id - t.string :part_number - t.string :quantity - t.float :item_cost - t.timestamps null: false - end - - create_table :hair_cuts, force: true do |t| - t.string :style - end - - create_table :numeros_telefone, force: true do |t| - t.string :numero_telefone - t.timestamps null: false - end - - create_table :categories, force: true do |t| - t.string :name - t.string :status, limit: 10 - t.timestamps null: false - end - - create_table :pictures, force: true do |t| - t.string :name - t.integer :imageable_id - t.string :imageable_type - t.timestamps null: false - end - - create_table :documents, force: true do |t| - t.string :name - t.timestamps null: false - end - - create_table :products, force: true do |t| - t.string :name - t.timestamps null: false - end - - create_table :vehicles, force: true do |t| - t.string :type - t.string :make - t.string :model - t.string :length_at_water_line - t.string :drive_layout - t.string :serial_number - t.integer :person_id - t.timestamps null: false - end - - create_table :makes, force: true do |t| - t.string :model - t.timestamps null: false - end - - # special cases - fields that look like they should be reserved names - create_table :hrefs, force: true do |t| - t.string :name - t.timestamps null: false - end - - create_table :links, force: true do |t| - t.string :name - t.timestamps null: false - end - - create_table :web_pages, force: true do |t| - t.string :href - t.string :link - t.timestamps null: false - end - - create_table :questionables, force: true do |t| - t.timestamps null: false - end - - create_table :boxes, force: true do |t| - t.string :name - t.timestamps null: false - end - - create_table :things, force: true do |t| - t.string :name - t.references :user - t.references :box - - t.timestamps null: false - end - - create_table :users, force: true do |t| - t.string :name - t.timestamps null: false - end - - create_table :related_things, force: true do |t| - t.string :name - t.references :from, references: :thing - t.references :to, references: :thing - - t.timestamps null: false - end - - create_table :questions, force: true do |t| - t.string :text - end - - create_table :answers, force: true do |t| - t.references :question - t.integer :respondent_id - t.string :respondent_type - t.string :text - end - - create_table :patients, force: true do |t| - t.string :name - end - - create_table :doctors, force: true do |t| - t.string :name - end - - # special cases -end - -### MODELS -class Person < ActiveRecord::Base - has_many :posts, foreign_key: 'author_id' - has_many :comments, foreign_key: 'author_id' - has_many :expense_entries, foreign_key: 'employee_id', dependent: :restrict_with_exception - has_many :vehicles - belongs_to :preferences - belongs_to :hair_cut - has_one :author_detail - - has_and_belongs_to_many :books, join_table: :book_authors - - has_many :even_posts, -> { where('posts.id % 2 = 0') }, class_name: 'Post', foreign_key: 'author_id' - has_many :odd_posts, -> { where('posts.id % 2 = 1') }, class_name: 'Post', foreign_key: 'author_id' - - ### Validations - validates :name, presence: true - validates :date_joined, presence: true -end - -class AuthorDetail < ActiveRecord::Base - belongs_to :author, class_name: 'Person', foreign_key: 'person_id' -end - -class Post < ActiveRecord::Base - belongs_to :author, class_name: 'Person', foreign_key: 'author_id' - belongs_to :writer, class_name: 'Person', foreign_key: 'author_id' - has_many :comments - has_and_belongs_to_many :tags, join_table: :posts_tags - has_many :special_post_tags, source: :tag - has_many :special_tags, through: :special_post_tags, source: :tag - belongs_to :section - has_one :parent_post, class_name: 'Post', foreign_key: 'parent_post_id' - - validates :author, presence: true - validates :title, length: { maximum: 35 } - - before_destroy :destroy_callback - - def destroy_callback - if title == "can't destroy me" - errors.add(:title, "can't destroy me") - - # :nocov: - if Rails::VERSION::MAJOR >= 5 - throw(:abort) - else - return false - end - # :nocov: - end - end -end - -class SpecialPostTag < ActiveRecord::Base - belongs_to :tag - belongs_to :post -end - -class Comment < ActiveRecord::Base - belongs_to :author, class_name: 'Person', foreign_key: 'author_id' - belongs_to :post - has_and_belongs_to_many :tags, join_table: :comments_tags -end - -class Company < ActiveRecord::Base -end - -class Firm < Company -end - -class Tag < ActiveRecord::Base - has_and_belongs_to_many :posts, join_table: :posts_tags - has_and_belongs_to_many :planets, join_table: :planets_tags -end - -class Section < ActiveRecord::Base - has_many :posts -end - -class HairCut < ActiveRecord::Base - has_many :people -end - -class Property < ActiveRecord::Base -end - -class Customer < ActiveRecord::Base -end - -class BadlyNamedAttributes < ActiveRecord::Base -end - -class Cat < ActiveRecord::Base -end - -class IsoCurrency < ActiveRecord::Base - self.primary_key = :code - # has_many :expense_entries, foreign_key: 'currency_code' -end - -class ExpenseEntry < ActiveRecord::Base - belongs_to :employee, class_name: 'Person', foreign_key: 'employee_id' - belongs_to :iso_currency, foreign_key: 'currency_code' -end - -class Planet < ActiveRecord::Base - has_many :moons - belongs_to :planet_type - - has_and_belongs_to_many :tags, join_table: :planets_tags - - # Test model callback cancelling save - before_save :check_not_pluto - - def check_not_pluto - # Pluto can't be a planet, so cancel the save - if name.downcase == 'pluto' - # :nocov: - if Rails::VERSION::MAJOR >= 5 - throw(:abort) - else - return false - end - # :nocov: - end - end -end - -class PlanetType < ActiveRecord::Base - has_many :planets -end - -class Moon < ActiveRecord::Base - belongs_to :planet - - has_many :craters -end - -class Crater < ActiveRecord::Base - self.primary_key = :code - - belongs_to :moon -end - -class Preferences < ActiveRecord::Base - has_one :author, class_name: 'Person', :inverse_of => 'preferences' -end - -class Fact < ActiveRecord::Base - validates :spouse_name, :bio, presence: true -end - -class Like < ActiveRecord::Base -end - -class Breed - - def initialize(id = nil, name = nil) - if id.nil? - @id = $breed_data.new_id - $breed_data.add(self) - else - @id = id - end - @name = name - @errors = ActiveModel::Errors.new(self) - end - - attr_accessor :id, :name - - def destroy - $breed_data.remove(@id) - end - - def valid?(context = nil) - @errors.clear - if name.is_a?(String) && name.length > 0 - return true - else - @errors.add(:name, "can't be blank") - return false - end - end - - def errors - @errors - end -end - -class Book < ActiveRecord::Base - has_many :book_comments - has_many :approved_book_comments, -> { where(approved: true) }, class_name: "BookComment" - - has_and_belongs_to_many :authors, join_table: :book_authors, class_name: "Person" -end - -class BookComment < ActiveRecord::Base - belongs_to :author, class_name: 'Person', foreign_key: 'author_id' - belongs_to :book - - def self.for_user(current_user) - records = self - # Hide the unapproved comments from people who are not book admins - unless current_user && current_user.book_admin - records = records.where(approved: true) - end - records - end -end - -class BreedData - def initialize - @breeds = {} - end - - def breeds - @breeds - end - - def new_id - @breeds.keys.max + 1 - end - - def add(breed) - @breeds[breed.id] = breed - end - - def remove(id) - @breeds.delete(id) - end -end - -class Customer < ActiveRecord::Base - has_many :purchase_orders -end - -class PurchaseOrder < ActiveRecord::Base - belongs_to :customer - has_many :line_items - has_many :admin_line_items, class_name: 'LineItem', foreign_key: 'purchase_order_id' - - has_and_belongs_to_many :order_flags, join_table: :purchase_orders_order_flags - - has_and_belongs_to_many :admin_order_flags, join_table: :purchase_orders_order_flags, class_name: 'OrderFlag' -end - -class OrderFlag < ActiveRecord::Base - has_and_belongs_to_many :purchase_orders, join_table: :purchase_orders_order_flags -end - -class LineItem < ActiveRecord::Base - belongs_to :purchase_order -end - -class NumeroTelefone < ActiveRecord::Base -end - -class Category < ActiveRecord::Base -end - -class Picture < ActiveRecord::Base - belongs_to :imageable, polymorphic: true -end - -class Vehicle < ActiveRecord::Base - belongs_to :person -end - -class Car < Vehicle -end - -class Boat < Vehicle -end - -class Document < ActiveRecord::Base - has_many :pictures, as: :imageable -end - -class Document::Topic < Document -end - -class Product < ActiveRecord::Base - has_one :picture, as: :imageable -end - -class Make < ActiveRecord::Base -end - -class WebPage < ActiveRecord::Base -end - -class Box < ActiveRecord::Base - has_many :things -end - -class User < ActiveRecord::Base - has_many :things -end - -class Thing < ActiveRecord::Base - belongs_to :box - belongs_to :user - - has_many :related_things, foreign_key: :from_id - has_many :things, through: :related_things, source: :to -end - -class RelatedThing < ActiveRecord::Base - belongs_to :from, class_name: Thing, foreign_key: :from_id - belongs_to :to, class_name: Thing, foreign_key: :to_id -end - -class Question < ActiveRecord::Base - has_one :answer - - def respondent - answer.try(:respondent) - end -end - -class Answer < ActiveRecord::Base - belongs_to :question - belongs_to :respondent, polymorphic: true -end - -class Patient < ActiveRecord::Base -end - -class Doctor < ActiveRecord::Base -end - -module Api - module V7 - class Client < Customer - end - - class Customer < Customer - end - end -end +# Controllers, Resources, and Processors for specs. ### CONTROLLERS class AuthorsController < JSONAPI::ResourceControllerMetal @@ -1471,12 +816,8 @@ class BookResource < JSONAPI::Resource filter :banned, apply: :apply_filter_banned class << self - def books - Book.arel_table - end - def not_banned_books - books[:banned].eq(false) + {banned: false} end def records(options = {}) @@ -1521,12 +862,12 @@ class BookCommentResource < JSONAPI::Resource } class << self - def book_comments - BookComment.arel_table - end + # def book_comments + # BookComment.arel_table + # end def approved_comments(approved = true) - book_comments[:approved].eq(approved) + {approved: approved} end def records(options = {}) @@ -1617,8 +958,17 @@ class CommentResource < CommentResource; end class PostResource < PostResource # Test caching with SQL fragments + # --- + # This is the only resource in the test cases that has an ORM specific implementation + # Rather then extracting this out, let's just keep the logic here until we have more ORM-specific + # resources and then we can move this PostResource to a "resources.rb" for each ORM type. + # That seems like overkill and too much indirection for now, so keeping all resources in one spot. def self.records(options = {}) - _model_class.all.joins('INNER JOIN people on people.id = author_id') + if _model_class.respond_to?(:with_sql) + _model_class.association_join(:author).select_all(:posts) + else + _model_class.all.joins('INNER JOIN people on people.id = author_id') + end end end @@ -1759,12 +1109,6 @@ class PersonResource < JSONAPI::Resource end end -module Legacy - class FlatPost < ActiveRecord::Base - self.table_name = "posts" - end -end - class FlatPostResource < JSONAPI::Resource model_name "Legacy::FlatPost", add_model_hint: false diff --git a/test/config/database.yml b/test/support/database/config.yml similarity index 100% rename from test/config/database.yml rename to test/support/database/config.yml diff --git a/test/fixtures/answers.yml b/test/support/database/fixtures/answers.yml similarity index 100% rename from test/fixtures/answers.yml rename to test/support/database/fixtures/answers.yml diff --git a/test/fixtures/author_details.yml b/test/support/database/fixtures/author_details.yml similarity index 100% rename from test/fixtures/author_details.yml rename to test/support/database/fixtures/author_details.yml diff --git a/test/fixtures/book_authors.yml b/test/support/database/fixtures/book_authors.yml similarity index 100% rename from test/fixtures/book_authors.yml rename to test/support/database/fixtures/book_authors.yml diff --git a/test/fixtures/book_comments.yml b/test/support/database/fixtures/book_comments.yml similarity index 100% rename from test/fixtures/book_comments.yml rename to test/support/database/fixtures/book_comments.yml diff --git a/test/fixtures/books.yml b/test/support/database/fixtures/books.yml similarity index 100% rename from test/fixtures/books.yml rename to test/support/database/fixtures/books.yml diff --git a/test/fixtures/boxes.yml b/test/support/database/fixtures/boxes.yml similarity index 100% rename from test/fixtures/boxes.yml rename to test/support/database/fixtures/boxes.yml diff --git a/test/fixtures/categories.yml b/test/support/database/fixtures/categories.yml similarity index 100% rename from test/fixtures/categories.yml rename to test/support/database/fixtures/categories.yml diff --git a/test/fixtures/comments.yml b/test/support/database/fixtures/comments.yml similarity index 100% rename from test/fixtures/comments.yml rename to test/support/database/fixtures/comments.yml diff --git a/test/fixtures/comments_tags.yml b/test/support/database/fixtures/comments_tags.yml similarity index 100% rename from test/fixtures/comments_tags.yml rename to test/support/database/fixtures/comments_tags.yml diff --git a/test/fixtures/companies.yml b/test/support/database/fixtures/companies.yml similarity index 100% rename from test/fixtures/companies.yml rename to test/support/database/fixtures/companies.yml diff --git a/test/fixtures/craters.yml b/test/support/database/fixtures/craters.yml similarity index 100% rename from test/fixtures/craters.yml rename to test/support/database/fixtures/craters.yml diff --git a/test/fixtures/customers.yml b/test/support/database/fixtures/customers.yml similarity index 100% rename from test/fixtures/customers.yml rename to test/support/database/fixtures/customers.yml diff --git a/test/fixtures/doctors.yml b/test/support/database/fixtures/doctors.yml similarity index 100% rename from test/fixtures/doctors.yml rename to test/support/database/fixtures/doctors.yml diff --git a/test/fixtures/documents.yml b/test/support/database/fixtures/documents.yml similarity index 100% rename from test/fixtures/documents.yml rename to test/support/database/fixtures/documents.yml diff --git a/test/fixtures/expense_entries.yml b/test/support/database/fixtures/expense_entries.yml similarity index 100% rename from test/fixtures/expense_entries.yml rename to test/support/database/fixtures/expense_entries.yml diff --git a/test/fixtures/facts.yml b/test/support/database/fixtures/facts.yml similarity index 100% rename from test/fixtures/facts.yml rename to test/support/database/fixtures/facts.yml diff --git a/test/fixtures/hair_cuts.yml b/test/support/database/fixtures/hair_cuts.yml similarity index 100% rename from test/fixtures/hair_cuts.yml rename to test/support/database/fixtures/hair_cuts.yml diff --git a/test/fixtures/iso_currencies.yml b/test/support/database/fixtures/iso_currencies.yml similarity index 100% rename from test/fixtures/iso_currencies.yml rename to test/support/database/fixtures/iso_currencies.yml diff --git a/test/fixtures/line_items.yml b/test/support/database/fixtures/line_items.yml similarity index 100% rename from test/fixtures/line_items.yml rename to test/support/database/fixtures/line_items.yml diff --git a/test/fixtures/makes.yml b/test/support/database/fixtures/makes.yml similarity index 100% rename from test/fixtures/makes.yml rename to test/support/database/fixtures/makes.yml diff --git a/test/fixtures/moons.yml b/test/support/database/fixtures/moons.yml similarity index 100% rename from test/fixtures/moons.yml rename to test/support/database/fixtures/moons.yml diff --git a/test/fixtures/numeros_telefone.yml b/test/support/database/fixtures/numeros_telefone.yml similarity index 100% rename from test/fixtures/numeros_telefone.yml rename to test/support/database/fixtures/numeros_telefone.yml diff --git a/test/fixtures/order_flags.yml b/test/support/database/fixtures/order_flags.yml similarity index 100% rename from test/fixtures/order_flags.yml rename to test/support/database/fixtures/order_flags.yml diff --git a/test/fixtures/patients.yml b/test/support/database/fixtures/patients.yml similarity index 100% rename from test/fixtures/patients.yml rename to test/support/database/fixtures/patients.yml diff --git a/test/fixtures/people.yml b/test/support/database/fixtures/people.yml similarity index 100% rename from test/fixtures/people.yml rename to test/support/database/fixtures/people.yml diff --git a/test/fixtures/pictures.yml b/test/support/database/fixtures/pictures.yml similarity index 100% rename from test/fixtures/pictures.yml rename to test/support/database/fixtures/pictures.yml diff --git a/test/fixtures/planet_types.yml b/test/support/database/fixtures/planet_types.yml similarity index 100% rename from test/fixtures/planet_types.yml rename to test/support/database/fixtures/planet_types.yml diff --git a/test/fixtures/planets.yml b/test/support/database/fixtures/planets.yml similarity index 100% rename from test/fixtures/planets.yml rename to test/support/database/fixtures/planets.yml diff --git a/test/fixtures/posts.yml b/test/support/database/fixtures/posts.yml similarity index 100% rename from test/fixtures/posts.yml rename to test/support/database/fixtures/posts.yml diff --git a/test/fixtures/posts_tags.yml b/test/support/database/fixtures/posts_tags.yml similarity index 100% rename from test/fixtures/posts_tags.yml rename to test/support/database/fixtures/posts_tags.yml diff --git a/test/fixtures/preferences.yml b/test/support/database/fixtures/preferences.yml similarity index 100% rename from test/fixtures/preferences.yml rename to test/support/database/fixtures/preferences.yml diff --git a/test/fixtures/products.yml b/test/support/database/fixtures/products.yml similarity index 100% rename from test/fixtures/products.yml rename to test/support/database/fixtures/products.yml diff --git a/test/fixtures/purchase_orders.yml b/test/support/database/fixtures/purchase_orders.yml similarity index 100% rename from test/fixtures/purchase_orders.yml rename to test/support/database/fixtures/purchase_orders.yml diff --git a/test/fixtures/questions.yml b/test/support/database/fixtures/questions.yml similarity index 100% rename from test/fixtures/questions.yml rename to test/support/database/fixtures/questions.yml diff --git a/test/fixtures/related_things.yml b/test/support/database/fixtures/related_things.yml similarity index 100% rename from test/fixtures/related_things.yml rename to test/support/database/fixtures/related_things.yml diff --git a/test/fixtures/sections.yml b/test/support/database/fixtures/sections.yml similarity index 100% rename from test/fixtures/sections.yml rename to test/support/database/fixtures/sections.yml diff --git a/test/fixtures/tags.yml b/test/support/database/fixtures/tags.yml similarity index 100% rename from test/fixtures/tags.yml rename to test/support/database/fixtures/tags.yml diff --git a/test/fixtures/things.yml b/test/support/database/fixtures/things.yml similarity index 100% rename from test/fixtures/things.yml rename to test/support/database/fixtures/things.yml diff --git a/test/fixtures/users.yml b/test/support/database/fixtures/users.yml similarity index 100% rename from test/fixtures/users.yml rename to test/support/database/fixtures/users.yml diff --git a/test/fixtures/vehicles.yml b/test/support/database/fixtures/vehicles.yml similarity index 100% rename from test/fixtures/vehicles.yml rename to test/support/database/fixtures/vehicles.yml diff --git a/test/fixtures/web_pages.yml b/test/support/database/fixtures/web_pages.yml similarity index 100% rename from test/fixtures/web_pages.yml rename to test/support/database/fixtures/web_pages.yml diff --git a/test/support/database/generator.rb b/test/support/database/generator.rb new file mode 100644 index 000000000..17d22216d --- /dev/null +++ b/test/support/database/generator.rb @@ -0,0 +1,359 @@ +# In order to simplify testing of different ORMs and reduce differences in their schema +# generators, we use ActiveRecord::Schema to define our schema for readability and editing purposes. +# When running tests, all different ORMs (Sequel + ActiveRecord) will use the schema.sql to run +# their specs, ensuring a) a consistent test environment and b) easier adding of orms in the future. + +require 'active_support' +require 'active_record' +require 'yaml' + +ActiveSupport.eager_load! + +connection_spec = YAML.load_file(File.expand_path('../../database/config.yml', __FILE__))["test"] + +begin + ActiveRecord::Base.establish_connection(connection_spec) + + ActiveRecord::Schema.verbose = false + + puts "Loading schema into #{connection_spec["database"]}" + + ActiveRecord::Schema.define do + create_table :people, force: true do |t| + t.string :name + t.string :email + t.datetime :date_joined + t.belongs_to :preferences + t.integer :hair_cut_id, index: true + t.boolean :book_admin, default: false + t.boolean :special, default: false + t.timestamps null: false + end + + create_table :author_details, force: true do |t| + t.integer :person_id + t.string :author_stuff + end + + create_table :posts, force: true do |t| + t.string :title, length: 255 + t.text :body + t.integer :author_id + t.integer :parent_post_id + t.belongs_to :section, index: true + t.timestamps null: false + end + + create_table :comments, force: true do |t| + t.text :body + t.belongs_to :post, index: true + t.integer :author_id + t.timestamps null: false + end + + create_table :companies, force: true do |t| + t.string :type + t.string :name + t.string :address + t.timestamps null: false + end + + create_table :tags, force: true do |t| + t.string :name + t.timestamps null: false + end + + create_table :sections, force: true do |t| + t.string :name + t.timestamps null: false + end + + create_table :posts_tags, force: true do |t| + t.references :post, :tag, index: true + end + add_index :posts_tags, [:post_id, :tag_id], unique: true + + create_table :special_post_tags, force: true do |t| + t.references :post, :tag, index: true + end + add_index :special_post_tags, [:post_id, :tag_id], unique: true + + create_table :comments_tags, force: true do |t| + t.references :comment, :tag, index: true + end + + create_table :iso_currencies, id: false, force: true do |t| + t.string :code, limit: 3, null: false + t.string :name + t.string :country_name + t.string :minor_unit + t.timestamps null: false + end + add_index :iso_currencies, :code, unique: true + + create_table :expense_entries, force: true do |t| + t.string :currency_code, limit: 3, null: false + t.integer :employee_id, null: false + t.decimal :cost, precision: 12, scale: 4, null: false + t.date :transaction_date + t.timestamps null: false + end + + create_table :planets, force: true do |t| + t.string :name + t.string :description + t.integer :planet_type_id + end + + create_table :planets_tags, force: true do |t| + t.references :planet, :tag, index: true + end + add_index :planets_tags, [:planet_id, :tag_id], unique: true + + create_table :planet_types, force: true do |t| + t.string :name + end + + create_table :moons, force: true do |t| + t.string :name + t.string :description + t.integer :planet_id + t.timestamps null: false + end + + create_table :craters, id: false, force: true do |t| + t.string :code + t.string :description + t.integer :moon_id + t.timestamps null: false + end + + create_table :preferences, force: true do |t| + t.integer :person_id + t.boolean :advanced_mode, default: false + t.timestamps null: false + end + + create_table :facts, force: true do |t| + t.integer :person_id + t.string :spouse_name + t.text :bio + t.float :quality_rating + t.decimal :salary, precision: 12, scale: 2 + t.datetime :date_time_joined + t.date :birthday + t.time :bedtime + t.binary :photo, limit: 1.kilobyte + t.boolean :cool + t.timestamps null: false + end + + create_table :books, force: true do |t| + t.string :title + t.string :isbn + t.boolean :banned, default: false + t.timestamps null: false + end + + create_table :book_authors, force: true do |t| + t.integer :book_id + t.integer :person_id + end + + create_table :book_comments, force: true do |t| + t.text :body + t.belongs_to :book, index: true + t.integer :author_id + t.boolean :approved, default: true + t.timestamps null: false + end + + create_table :customers, force: true do |t| + t.string :name + t.timestamps null: false + end + + create_table :purchase_orders, force: true do |t| + t.date :order_date + t.date :requested_delivery_date + t.date :delivery_date + t.integer :customer_id + t.string :delivery_name + t.string :delivery_address_1 + t.string :delivery_address_2 + t.string :delivery_city + t.string :delivery_state + t.string :delivery_postal_code + t.float :delivery_fee + t.float :tax + t.float :total + t.timestamps null: false + end + + create_table :order_flags, force: true do |t| + t.string :name + end + + create_table :purchase_orders_order_flags, force: true do |t| + t.references :purchase_order, :order_flag, index: true + end + add_index :purchase_orders_order_flags, [:purchase_order_id, :order_flag_id], unique: true, name: "po_flags_idx" + + create_table :line_items, force: true do |t| + t.integer :purchase_order_id + t.string :part_number + t.string :quantity + t.float :item_cost + t.timestamps null: false + end + + create_table :hair_cuts, force: true do |t| + t.string :style + end + + create_table :numeros_telefone, force: true do |t| + t.string :numero_telefone + t.timestamps null: false + end + + create_table :categories, force: true do |t| + t.string :name + t.string :status, limit: 10 + t.timestamps null: false + end + + create_table :pictures, force: true do |t| + t.string :name + t.integer :imageable_id + t.string :imageable_type + t.timestamps null: false + end + + create_table :documents, force: true do |t| + t.string :name + t.timestamps null: false + end + + create_table :products, force: true do |t| + t.string :name + t.timestamps null: false + end + + create_table :vehicles, force: true do |t| + t.string :type + t.string :make + t.string :model + t.string :length_at_water_line + t.string :drive_layout + t.string :serial_number + t.integer :person_id + t.timestamps null: false + end + + create_table :makes, force: true do |t| + t.string :model + t.timestamps null: false + end + + # special cases - fields that look like they should be reserved names + create_table :hrefs, force: true do |t| + t.string :name + t.timestamps null: false + end + + create_table :links, force: true do |t| + t.string :name + t.timestamps null: false + end + + create_table :web_pages, force: true do |t| + t.string :href + t.string :link + t.timestamps null: false + end + + create_table :questionables, force: true do |t| + t.timestamps null: false + end + + create_table :boxes, force: true do |t| + t.string :name + t.timestamps null: false + end + + create_table :things, force: true do |t| + t.string :name + t.references :user + t.references :box + + t.timestamps null: false + end + + create_table :users, force: true do |t| + t.string :name + t.timestamps null: false + end + + create_table :related_things, force: true do |t| + t.string :name + t.references :from, references: :thing + t.references :to, references: :thing + + t.timestamps null: false + end + + create_table :questions, force: true do |t| + t.string :text + end + + create_table :answers, force: true do |t| + t.references :question + t.integer :respondent_id + t.string :respondent_type + t.string :text + end + + create_table :patients, force: true do |t| + t.string :name + end + + create_table :doctors, force: true do |t| + t.string :name + end + + # special cases + end + + class FixtureGenerator + include ActiveRecord::TestFixtures + self.fixture_path = File.expand_path('../fixtures', __FILE__) + fixtures :all + + def self.load_fixtures + require_relative '../inflections' + require_relative '../active_record/models' + ActiveRecord::Base.connection.disable_referential_integrity do + FixtureGenerator.new.send(:load_fixtures, ActiveRecord::Base) + end + end + end + + puts "Loading fixture data into #{connection_spec["database"]}" + + FixtureGenerator.load_fixtures + + puts "Dumping data into data.sql" + + File.open(File.expand_path('../dump.sql', __FILE__), "w") do |f| + `sqlite3 test_db .tables`.split(/\s+/).each do |table_name| + f << %{DROP TABLE IF EXISTS "#{table_name}";\n} + puts "Dumping data from #{table_name}..." + f << `sqlite3 #{connection_spec["database"]} ".dump #{table_name}"` + end.join("\n") + f << "PRAGMA foreign_keys=ON;" # reenable foreign_keys + end + + puts "Done!" +ensure + File.delete(connection_spec["database"]) if File.exists?(connection_spec["database"]) +end \ No newline at end of file diff --git a/test/support/inflections.rb b/test/support/inflections.rb new file mode 100644 index 000000000..9e313956b --- /dev/null +++ b/test/support/inflections.rb @@ -0,0 +1,6 @@ +# These come from the model definitions and are required for fixture creation as well +# as test running. +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.uncountable 'preferences' + inflect.irregular 'numero_telefone', 'numeros_telefone' +end \ No newline at end of file diff --git a/test/support/sequel/app_config.rb b/test/support/sequel/app_config.rb new file mode 100644 index 000000000..560c111a6 --- /dev/null +++ b/test/support/sequel/app_config.rb @@ -0,0 +1 @@ +JSONAPI.configuration.default_record_accessor_klass = JSONAPI::SequelRecordAccessor diff --git a/test/support/sequel/import_schema.rb b/test/support/sequel/import_schema.rb new file mode 100644 index 000000000..872e6ab66 --- /dev/null +++ b/test/support/sequel/import_schema.rb @@ -0,0 +1,6 @@ +statements = File.read(File.expand_path('../../database/dump.sql', __FILE__)).split(/;$/) +statements.pop # the last empty statement + +statements.each do |statement| + Sequel::Model.db[statement] +end \ No newline at end of file diff --git a/test/support/sequel/initialize.rb b/test/support/sequel/initialize.rb new file mode 100644 index 000000000..48354f18b --- /dev/null +++ b/test/support/sequel/initialize.rb @@ -0,0 +1,2 @@ +require 'sequel_rails' +require 'sequel_rails/sequel/database/active_support_notification' \ No newline at end of file diff --git a/test/support/sequel/models.rb b/test/support/sequel/models.rb new file mode 100644 index 000000000..c55f8d94c --- /dev/null +++ b/test/support/sequel/models.rb @@ -0,0 +1,382 @@ +Sequel::Model.class_eval do + plugin :validation_class_methods + plugin :hook_class_methods + plugin :timestamps, update_on_create: true + plugin :association_pks + plugin :association_dependencies + plugin :polymorphic + + db.integer_booleans = false # Fixtures use 't' and 'f' for true and false (ActiveRecord convention) +end + +### MODELS +class Person < Sequel::Model + one_to_many :posts, key: :author_id + one_to_many :comments, key: :author_id + one_to_many :expense_entries, key: :employee_id, dependent: :restrict_with_exception + one_to_many :vehicles + many_to_one :preferences + many_to_one :hair_cut + one_to_one :author_detail + + many_to_many :books, join_table: :book_authors + + one_to_many :even_posts, conditions: 'posts.id % 2 = 0', class: 'Post', key: :author_id + one_to_many :odd_posts, conditions: 'posts.id % 2 = 1', class: 'Post', key: :author_id + + ### Validations + validates_presence_of :name, :date_joined, message: "can't be blank" +end + +class AuthorDetail < Sequel::Model + many_to_one :author, class: 'Person', key: :person_id +end + +class Post < Sequel::Model + many_to_one :author, class: 'Person', key: :author_id + many_to_one :writer, class: 'Person', key: :author_id + one_to_many :comments + many_to_many :tags, join_table: :posts_tags + one_to_many :special_post_tags, source: :tag + many_to_many :special_tags, join_table: :special_post_tags, right_key: :tag_id + many_to_one :section + one_to_one :parent_post, class: 'Post', key: :parent_post_id + + validates_presence_of :author, message: "can't be blank" + validates_length_of :title, maximum: 35 + + before_destroy :destroy_callback + + def destroy_callback + if title == "can't destroy me" + errors.add(:title, "can't destroy me") + false + end + end + + # For specs + def tag_ids + tag_pks + end + + # For specs + def comment_ids + comment_pks + end + +end + +class PostWithBadAfterSave < Sequel::Model + set_dataset :posts + after_save :do_some_after_save_stuff + + def do_some_after_save_stuff + errors[:base] << 'Boom! Error added in after_save callback.' + raise Sequel::ValidationFailed.new(self) + end +end + +# Sequel Does Not Support Validation Context +# class PostWithCustomValidationContext < Sequel::Model +# set_dataset :posts +# validate :api_specific_check, on: :json_api_create +# +# def api_specific_check +# errors[:base] << 'Record is invalid' +# end +# end + +module Legacy + class FlatPost < Sequel::Model + set_dataset :posts + end +end + +class SpecialPostTag < Sequel::Model + many_to_one :tag + many_to_one :post +end + +class Comment < Sequel::Model + many_to_one :author, class: 'Person', key: :author_id + many_to_one :post + many_to_many :tags, join_table: :comments_tags +end + +class Company < Sequel::Model + plugin :single_table_inheritance, :type +end + +class Firm < Company +end + +class Tag < Sequel::Model + many_to_many :posts, join_table: :posts_tags + many_to_many :planets, join_table: :planets_tags +end + +class Section < Sequel::Model + one_to_many :posts +end + +class HairCut < Sequel::Model + one_to_many :people +end + +class Property < Sequel::Model +end + +class Customer < Sequel::Model +end + +class BadlyNamedAttributes < Sequel::Model +end + +class Cat < Sequel::Model +end + +class IsoCurrency < Sequel::Model + set_primary_key :code + # one_to_many :expense_entries, key: :currency_code +end + +class ExpenseEntry < Sequel::Model + many_to_one :employee, class: 'Person', key: :employee_id + many_to_one :iso_currency, key: :currency_code +end + +class Planet < Sequel::Model + one_to_many :moons + many_to_one :planet_type + + many_to_many :tags, join_table: :planets_tags + + # Test model callback cancelling save + before_save :check_not_pluto + + # Pluto can't be a planet, so cancel the save by returning false + def check_not_pluto + name.downcase != 'pluto' + end +end + +class PlanetType < Sequel::Model + one_to_many :planets +end + +class Moon < Sequel::Model + many_to_one :planet + + one_to_many :craters +end + +class Crater < Sequel::Model + set_primary_key :code + + many_to_one :moon +end + +class Preferences < Sequel::Model + one_to_one :author, class: 'Person', :reciprocal => :preferences +end + +class Fact < Sequel::Model + validates_presence_of :spouse_name, :bio, message: "can't be blank" +end + +class Like < Sequel::Model +end + +class Breed + + def initialize(id = nil, name = nil) + if id.nil? + @id = $breed_data.new_id + $breed_data.add(self) + else + @id = id + end + @name = name + @errors = Sequel::Model::Errors.new + end + + attr_accessor :id, :name + + def destroy(options={}) + $breed_data.remove(@id) + end + + def valid?(context = nil) + @errors.clear + if name.is_a?(String) && name.length > 0 + return true + else + @errors.add(:name, "can't be blank") + return false + end + end + + def errors + @errors + end +end + +class Book < Sequel::Model + one_to_many :book_comments + one_to_many :approved_book_comments, conditions: {approved: true}, class: "BookComment" + + many_to_many :authors, join_table: :book_authors, right_key: :person_id, class: "Person" +end + +class BookComment < Sequel::Model + many_to_one :author, class: 'Person', key: :author_id + many_to_one :book + + def self.for_user(current_user) + records = self + # Hide the unapproved comments from people who are not book admins + unless current_user && current_user.book_admin + records = records.where(approved: 't') + end + records + end +end + +class BreedData + def initialize + @breeds = {} + end + + def breeds + @breeds + end + + def new_id + @breeds.keys.max + 1 + end + + def add(breed) + @breeds[breed.id] = breed + end + + def remove(id) + @breeds.delete(id) + end +end + +class Customer < Sequel::Model + one_to_many :purchase_orders +end + +class PurchaseOrder < Sequel::Model + many_to_one :customer + one_to_many :line_items + one_to_many :admin_line_items, class: 'LineItem', key: :purchase_order_id + + many_to_many :order_flags, join_table: :purchase_orders_order_flags + + many_to_many :admin_order_flags, join_table: :purchase_orders_order_flags, class: 'OrderFlag' +end + +class OrderFlag < Sequel::Model + many_to_many :purchase_orders, join_table: :purchase_orders_order_flags +end + +class LineItem < Sequel::Model + many_to_one :purchase_order +end + +class NumeroTelefone < Sequel::Model +end + +class Category < Sequel::Model +end + +class Picture < Sequel::Model + many_to_one :imageable, polymorphic: true +end + +class Vehicle < Sequel::Model + many_to_one :person + plugin :single_table_inheritance, :type +end + +class Car < Vehicle +end + +class Boat < Vehicle +end + +class Document < Sequel::Model + one_to_many :pictures, as: :imageable, reciprocal_type: :many_to_one +end + +class Document::Topic < Document +end + +class Product < Sequel::Model + one_to_one :picture, as: :imageable, reciprocal_type: :many_to_one +end + +class Make < Sequel::Model +end + +class WebPage < Sequel::Model +end + +class Box < Sequel::Model + one_to_many :things +end + +class User < Sequel::Model + one_to_many :things +end + +class Thing < Sequel::Model + many_to_one :box + many_to_one :user + + one_to_many :related_things, key: :from_id + many_to_many :things, join_table: :related_things, left_key: :from_id, right_key: :to_id +end + +class RelatedThing < Sequel::Model + many_to_one :from, class: Thing, key: :from_id + many_to_one :to, class: Thing, key: :to_id +end + +class Question < Sequel::Model + one_to_one :answer + + def respondent + answer.try(:respondent) + end +end + +class Answer < Sequel::Model + many_to_one :question + many_to_one :respondent, polymorphic: true +end + +class Patient < Sequel::Model +end + +class Doctor < Sequel::Model +end + +module Api + module V7 + class Client < Customer + end + + class Customer < Customer + end + end +end + +### PORO Data - don't do this in a production app +$breed_data = BreedData.new +$breed_data.add(Breed.new(0, 'persian')) +$breed_data.add(Breed.new(1, 'siamese')) +$breed_data.add(Breed.new(2, 'sphinx')) +$breed_data.add(Breed.new(3, 'to_delete')) diff --git a/test/support/sequel/rollback.rb b/test/support/sequel/rollback.rb new file mode 100644 index 000000000..655af72f8 --- /dev/null +++ b/test/support/sequel/rollback.rb @@ -0,0 +1,22 @@ +module Minitest + module Rollback + + def before_setup + Sequel::Model.db.synchronize do |conn| + Sequel::Model.db.send(:add_transaction, conn, {}) + Sequel::Model.db.send(:begin_transaction, conn) + end + super + end + + def after_teardown + super + Sequel::Model.db.synchronize {|conn| Sequel::Model.db.send(:rollback_transaction, conn) } + end + + end + + class Test + include Rollback + end +end \ No newline at end of file diff --git a/test/test_helper.rb b/test/test_helper.rb index cb1d47991..035868a92 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -18,17 +18,16 @@ end end -require 'active_record/railtie' +ENV["ORM"] ||= "active_record" + +require 'rails' +require_relative "support/#{ENV["ORM"]}/initialize" +require_relative "support/inflections" require 'rails/test_help' require 'minitest/mock' require 'jsonapi-resources' require 'pry' -require File.expand_path('../helpers/value_matchers', __FILE__) -require File.expand_path('../helpers/assertions', __FILE__) -require File.expand_path('../helpers/functional_helpers', __FILE__) -require File.expand_path('../helpers/configuration_helpers', __FILE__) - Rails.env = 'test' I18n.load_path += Dir[File.expand_path("../../locales/*.yml", __FILE__)] @@ -38,7 +37,7 @@ config.json_key_format = :camelized_key end -puts "Testing With RAILS VERSION #{Rails.version}" +puts "Testing With RAILS VERSION #{Rails.version} and #{ENV["ORM"]} ORM" class TestApp < Rails::Application config.eager_load = false @@ -49,14 +48,14 @@ class TestApp < Rails::Application #Raise errors on unsupported parameters config.action_controller.action_on_unpermitted_parameters = :raise - ActiveRecord::Schema.verbose = false - config.active_record.schema_format = :none config.active_support.test_order = :random + config.paths["config/database"] = "support/database/config.yml" + + ActiveSupport::Deprecation.silenced = true + if Rails::VERSION::MAJOR >= 5 config.active_support.halt_callback_chains_on_return_false = false - config.active_record.time_zone_aware_types = [:time, :datetime] - config.active_record.belongs_to_required_by_default = false end end @@ -189,8 +188,12 @@ def assign_parameters(routes, controller_path, action, parameters, generated_pat def assert_query_count(expected, msg = nil, &block) @queries = [] - callback = lambda {|_, _, _, _, payload| @queries.push payload[:sql] } - ActiveSupport::Notifications.subscribed(callback, 'sql.active_record', &block) + callback = lambda do |_, _, _, _, payload| + # Ignore ORM methods to introspect column names and primary keys. + # This happens when the Model first has methods accessed on it. + @queries.push(payload[:sql]) unless payload[:sql]=~/sqlite_temp_master|PRAGMA/ + end + ActiveSupport::Notifications.subscribed(callback, "sql.#{ENV["ORM"]}", &block) show_queries unless expected == @queries.size assert expected == @queries.size, "Expected #{expected} queries, ran #{@queries.size} queries" @@ -203,9 +206,24 @@ def show_queries end end +# For debugging purposes, put in global scope to be used anywhere in the code. +def show_sql(&block) + output_sql = lambda {|_, _, _, _, payload| puts payload[:sql] } + ActiveSupport::Notifications.subscribed(output_sql, "sql.#{ENV["ORM"]}", &block) +end + TestApp.initialize! -require File.expand_path('../fixtures/active_record', __FILE__) +require_relative "support/#{ENV["ORM"]}/app_config" + +# We used to have the schema in the ActiveRecord schema creation format, but then we would need +# to reimplement the schema bulider in other ORMs that we are testing. We could always require ActiveRecord +# for the purposes of schema creation, but then we would have to try to remove ActiveRecord from the global +# namespace to really have no side-effects when running the specs. The goal of running the specs with other +# orms is to not have ActiveRecord required in at all. +require_relative "support/#{ENV["ORM"]}/import_schema" +require_relative "support/#{ENV["ORM"]}/models" +require_relative "support/controllers_resources_processors" module Pets module V1 @@ -407,32 +425,31 @@ class CatResource < JSONAPI::Resource # Ensure backward compatibility with Minitest 4 Minitest::Test = MiniTest::Unit::TestCase unless defined?(Minitest::Test) +require_relative 'helpers/assertions' +require_relative 'helpers/value_matchers' +require_relative 'helpers/functional_helpers' +require_relative 'helpers/configuration_helpers' +require_relative 'helpers/record_accessor_helpers' + class Minitest::Test include Helpers::Assertions include Helpers::ValueMatchers include Helpers::FunctionalHelpers include Helpers::ConfigurationHelpers - include ActiveRecord::TestFixtures + include Helpers::RecordAccessorHelpers def run_in_transaction? true end - - self.fixture_path = "#{Rails.root}/fixtures" - fixtures :all end class ActiveSupport::TestCase - self.fixture_path = "#{Rails.root}/fixtures" - fixtures :all setup do @routes = TestApp.routes end end class ActionDispatch::IntegrationTest - self.fixture_path = "#{Rails.root}/fixtures" - fixtures :all def assert_jsonapi_response(expected_status, msg = nil) assert_equal JSONAPI::MEDIA_TYPE, response.content_type @@ -487,7 +504,7 @@ def assert_cacheable_get(action, *args) normal_queries = [] normal_query_callback = lambda {|_, _, _, _, payload| normal_queries.push payload[:sql] } - ActiveSupport::Notifications.subscribed(normal_query_callback, 'sql.active_record') do + ActiveSupport::Notifications.subscribed(normal_query_callback, "sql.#{ENV["ORM"]}") do get action, *args end non_caching_response = json_response_sans_backtraces @@ -497,15 +514,15 @@ def assert_cacheable_get(action, *args) orig_queries = @queries.try(:dup) orig_request_headers = @request.headers.dup - ar_resource_klass = nil + resource_class = nil modes = {none: [], all: :all} if @controller.class.included_modules.include?(JSONAPI::ActsAsResourceController) - ar_resource_klass = @controller.send(:resource_klass) - if ar_resource_klass._model_class.respond_to?(:arel_table) - modes[:root_only] = [ar_resource_klass] - modes[:all_but_root] = {except: [ar_resource_klass]} + resource_class = @controller.send(:resource_klass) + if resource_class.model_class_compatible_with_record_accessor? + modes[:root_only] = [resource_class] + modes[:all_but_root] = {except: [resource_class]} else - ar_resource_klass = nil + resource_class = nil end end @@ -518,7 +535,7 @@ def assert_cacheable_get(action, *args) cache_queries = [] cache_query_callback = lambda {|_, _, _, _, payload| cache_queries.push payload[:sql] } cache_activity[phase] = with_resource_caching(cache, cached_resources) do - ActiveSupport::Notifications.subscribed(cache_query_callback, 'sql.active_record') do + ActiveSupport::Notifications.subscribed(cache_query_callback, "sql.#{ENV["ORM"]}") do @controller = nil setup_controller_request_and_response @request.headers.merge!(orig_request_headers.dup) @@ -538,6 +555,11 @@ def assert_cacheable_get(action, *args) response.status, "Cache (mode: #{mode}) #{phase} response status must match normal response" ) + # puts "non_caching_response.pretty_inspect" + # puts non_caching_response.pretty_inspect + # puts "json_response_sans_backtraces.pretty_inspect" + # puts json_response_sans_backtraces.pretty_inspect + # debugger if @response.body.include?("error") assert_equal( non_caching_response.pretty_inspect, json_response_sans_backtraces.pretty_inspect, @@ -554,7 +576,7 @@ def assert_cacheable_get(action, *args) if mode == :all # TODO Should also be caching :show_related_resource (non-plural) action if [:index, :show, :show_related_resources].include?(action) - if ar_resource_klass && response.status == 200 && json_response["data"].try(:size) > 0 + if resource_class && response.status == 200 && json_response["data"].try(:size) > 0 assert_operator( cache_activity[:warmup][:total][:misses], :>, @@ -645,3 +667,8 @@ def unformat(value) end end end + +# Implement rollback functionality for each spec. The schema and data is loaded +# initially via the support/database/dump.sql file and each spec simply runs in a transaction +# and returns the database to it's current state. +require_relative "support/#{ENV["ORM"]}/rollback" \ No newline at end of file diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index d1dd28d8a..60a623d1b 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -8,25 +8,6 @@ def self.records(options) end end -class PostWithBadAfterSave < ActiveRecord::Base - self.table_name = 'posts' - after_save :do_some_after_save_stuff - - def do_some_after_save_stuff - errors[:base] << 'Boom! Error added in after_save callback.' - raise ActiveRecord::RecordInvalid.new(self) - end -end - -class PostWithCustomValidationContext < ActiveRecord::Base - self.table_name = 'posts' - validate :api_specific_check, on: :json_api_create - - def api_specific_check - errors[:base] << 'Record is invalid' - end -end - class ArticleWithBadAfterSaveResource < JSONAPI::Resource model_name 'PostWithBadAfterSave' attribute :title @@ -238,15 +219,15 @@ def test_duplicate_attribute_name end def test_find_with_customized_base_records - author = Person.find(1) + author = find_first(Person, 1) posts = ArticleResource.find([], context: author).map(&:_model) - assert(posts.include?(Post.find(1))) - refute(posts.include?(Post.find(3))) + assert(posts.include?(find_first(Post, 1))) + refute(posts.include?(find_first(Post, 3))) end def test_records_for - author = Person.find(1) + author = find_first(Person, 1) preferences = Preferences.first refute(preferences == nil) author.update! preferences: preferences @@ -263,7 +244,7 @@ def test_records_for end def test_records_for_meta_method_for_to_one - author = Person.find(1) + author = find_first(Person, 1) author.update! preferences: Preferences.first author_resource = PersonWithCustomRecordsForRelationshipsResource.new(author, nil) assert_equal(author_resource.class._record_accessor.records_for( @@ -271,7 +252,7 @@ def test_records_for_meta_method_for_to_one end def test_records_for_meta_method_for_to_one_calling_records_for - author = Person.find(1) + author = find_first(Person, 1) author.update! preferences: Preferences.first author_resource = PersonWithCustomRecordsForResource.new(author, nil) assert_equal(author_resource.class._record_accessor.records_for( @@ -279,26 +260,26 @@ def test_records_for_meta_method_for_to_one_calling_records_for end def test_associated_records_meta_method_for_to_many - author = Person.find(1) - author.posts << Post.find(1) + author = find_first(Person, 1) + author.posts << find_first(Post, 1) author_resource = PersonWithCustomRecordsForRelationshipsResource.new(author, nil) assert_equal(author_resource.class._record_accessor.records_for( author_resource, :posts), :records_for_posts) end def test_associated_records_meta_method_for_to_many_calling_records_for - author = Person.find(1) - author.posts << Post.find(1) + author = find_first(Person, 1) + author.posts << find_first(Post, 1) author_resource = PersonWithCustomRecordsForResource.new(author, nil) assert_equal(author_resource.class._record_accessor.records_for( author_resource, :posts), :records_for) end def test_find_by_key_with_customized_base_records - author = Person.find(1) + author = find_first(Person, 1) post = ArticleResource.find_by_key(1, context: author)._model - assert_equal(post, Post.find(1)) + assert_equal(post, find_first(Post, 1)) assert_raises JSONAPI::Exceptions::RecordNotFound do ArticleResource.find_by_key(3, context: author)._model @@ -330,7 +311,7 @@ def test_filter_on_has_one_relationship_id end def test_to_many_relationship_filters - post_resource = PostResource.new(Post.find(1), nil) + post_resource = PostResource.new(find_first(Post, 1), nil) comments = post_resource.comments assert_equal(2, comments.size) @@ -379,7 +360,7 @@ def apply_filters(records, filters, options) end def test_to_many_relationship_sorts - post_resource = PostResource.new(Post.find(1), nil) + post_resource = PostResource.new(find_first(Post, 1), nil) comment_ids = post_resource.comments.map{|c| c._model.id } assert_equal [1,2], comment_ids @@ -450,7 +431,7 @@ def test_build_joins end def test_to_many_relationship_pagination - post_resource = PostResource.new(Post.find(1), nil) + post_resource = PostResource.new(find_first(Post, 1), nil) comments = post_resource.comments assert_equal 2, comments.size @@ -567,6 +548,7 @@ def test_key_type_proc end def test_id_attr_deprecation + tmp, ActiveSupport::Deprecation.silenced = ActiveSupport::Deprecation.silenced, false _out, err = capture_io do eval <<-CODE class ProblemResource < JSONAPI::Resource @@ -575,6 +557,8 @@ class ProblemResource < JSONAPI::Resource CODE 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 = tmp end def test_id_attr_with_format @@ -653,7 +637,7 @@ class NoModelAbstractResource < JSONAPI::Resource end def test_correct_error_surfaced_if_validation_errors_in_after_save_callback - post = PostWithBadAfterSave.find(1) + post = find_first(PostWithBadAfterSave, 1) post_resource = ArticleWithBadAfterSaveResource.new(post, nil) err = assert_raises JSONAPI::Exceptions::ValidationErrors do post_resource.replace_fields({:attributes => {:title => 'Some title'}}) @@ -669,7 +653,7 @@ def test_resource_for_model_use_hint end def test_resource_performs_validations_in_custom_context - post = PostWithCustomValidationContext.find(1) + post = find_first(PostWithCustomValidationContext, 1) post_resource = ArticleWithCustomValidationContextResource.new(post, nil) err = assert_raises JSONAPI::Exceptions::ValidationErrors do post_resource._save diff --git a/test/unit/serializer/polymorphic_serializer_test.rb b/test/unit/serializer/polymorphic_serializer_test.rb index bb905fde8..7d9f51750 100644 --- a/test/unit/serializer/polymorphic_serializer_test.rb +++ b/test/unit/serializer/polymorphic_serializer_test.rb @@ -5,7 +5,7 @@ class PolymorphismTest < ActionDispatch::IntegrationTest def setup @pictures = Picture.all - @person = Person.find(1) + @person = find_first(Person, 1) @questions = Question.all diff --git a/test/unit/serializer/serializer_test.rb b/test/unit/serializer/serializer_test.rb index 163c39cad..adaf79f10 100644 --- a/test/unit/serializer/serializer_test.rb +++ b/test/unit/serializer/serializer_test.rb @@ -4,10 +4,10 @@ class SerializerTest < ActionDispatch::IntegrationTest def setup - @post = Post.find(1) - @fred = Person.find_by(name: 'Fred Reader') + @post = find_first(Post, 1) + @fred = find_first(Person, name: 'Fred Reader') - @expense_entry = ExpenseEntry.find(1) + @expense_entry = find_first(ExpenseEntry, 1) JSONAPI.configuration.json_key_format = :camelized_key JSONAPI.configuration.route_format = :camelized_route @@ -530,7 +530,7 @@ def test_serializer_include_sub_objects end def test_serializer_keeps_sorted_order_of_objects_with_self_referential_relationships - post1, post2, post3 = Post.find(1), Post.find(2), Post.find(3) + post1, post2, post3 = find_first(Post, 1), find_first(Post, 2), find_first(Post, 3) post1.parent_post = post3 ordered_posts = [post1, post2, post3] serialized_data = JSONAPI::ResourceSerializer.new( @@ -672,7 +672,7 @@ def test_serializer_different_foreign_key def test_serializer_array_of_resources_always_include_to_one_linkage_data posts = [] - Post.find(1, 2).each do |post| + find_all(Post, 1, 2).each do |post| posts.push PostResource.new(post, nil) end @@ -988,7 +988,7 @@ def test_serializer_array_of_resources_always_include_to_one_linkage_data def test_serializer_always_include_to_one_linkage_data_does_not_load_association JSONAPI.configuration.always_include_to_one_linkage_data = true - post = Post.find(1) + post = find_first(Post, 1) resource = Api::V1::PostResource.new(post, nil) JSONAPI::ResourceSerializer.new(Api::V1::PostResource).serialize_to_hash(resource) @@ -1000,7 +1000,7 @@ def test_serializer_always_include_to_one_linkage_data_does_not_load_association def test_serializer_array_of_resources posts = [] - Post.find(1, 2).each do |post| + find_all(Post, 1, 2).each do |post| posts.push PostResource.new(post, nil) end @@ -1275,7 +1275,7 @@ def test_serializer_array_of_resources def test_serializer_array_of_resources_limited_fields posts = [] - Post.find(1, 2).each do |post| + find_all(Post, 1, 2).each do |post| posts.push PostResource.new(post, nil) end @@ -1714,7 +1714,7 @@ def test_serializer_to_one serialized = JSONAPI::ResourceSerializer.new( Api::V5::AuthorResource, include: ['author_detail'] - ).serialize_to_hash(Api::V5::AuthorResource.new(Person.find(1), nil)) + ).serialize_to_hash(Api::V5::AuthorResource.new(find_first(Person, 1), nil)) assert_hash_equals( { @@ -1773,7 +1773,7 @@ def meta(options) serialized = JSONAPI::ResourceSerializer.new( Api::V5::AuthorResource, include: ['author_detail'] - ).serialize_to_hash(Api::V5::AuthorResource.new(Person.find(1), nil)) + ).serialize_to_hash(Api::V5::AuthorResource.new(find_first(Person, 1), nil)) assert_hash_equals( { @@ -2098,7 +2098,7 @@ def test_custom_links_with_if_condition_equals_false def test_custom_links_with_if_condition_equals_true serialized_custom_link_resource = JSONAPI::ResourceSerializer .new(CustomLinkWithIfCondition, base_url: 'http://example.com') - .serialize_to_hash(CustomLinkWithIfCondition.new(Post.find_by(title: "JR Solves your serialization woes!"), {})) + .serialize_to_hash(CustomLinkWithIfCondition.new(find_first(Post, title: "JR Solves your serialization woes!"), {})) custom_link_spec = { data: { @@ -2190,7 +2190,7 @@ def test_custom_links_with_lambda def test_includes_two_relationships_with_same_foreign_key serialized_resource = JSONAPI::ResourceSerializer .new(PersonWithEvenAndOddPostsResource, include: ['even_posts','odd_posts']) - .serialize_to_hash(PersonWithEvenAndOddPostsResource.new(Person.find(1), nil)) + .serialize_to_hash(PersonWithEvenAndOddPostsResource.new(find_first(Person, 1), nil)) assert_hash_equals( {