ExportXMLWordPrintableJSON

    • Type: Bug
    • Resolution: Unresolved
    • Priority: Major - P3
    • None
    • Affects Version/s: 9.1.1
    • Component/s: Associations
    • None
    • None
    • Ruby Drivers
    • Not Needed
    • None
    • None
    • None
    • None
    • None
    • None

      Problem

      When I use includes with nested associations, Mongoid ignores the nested part if the parent association's class_name is not exactly the class name. This happens in two common cases:

      • class_name: "::Photo" (the name starts with "::")
      • class_name: "Item" inside the module Shop. Mongoid finds the class Shop::Item.

      Mongoid still eager loads the parent association, but it does not eager load the nested associations. It raises no error. Each nested association is then loaded with one query per document (N+1).

      Versions: Mongoid 9.1.1, Ruby driver 2.26.0, Ruby 3.3.11. The same code is on master.

      How to reproduce

      Run this script. Set MONGO_HOST if MongoDB is not on 127.0.0.1:27017.
      Each case has 3 parents. Each parent has 1 child, and each child has 1 author. When nested includes work, Mongoid loads all authors with 1 query.

      # frozen_string_literal: true
      
      require "mongoid"
      
      Mongoid.configure do |config|
        config.clients.default = { hosts: [ENV.fetch("MONGO_HOST", "127.0.0.1:27017")], database: "mongoid_nested_includes_repro" }
      end
      Mongo::Logger.logger.level = Logger::WARN
      
      class FindCounter
        attr_reader :finds
      
        def initialize = @finds= Hash.new(0)
        def started(event) = (finds[event.command["find"]] += 1 if event.command_name == "find")
        def succeeded(_) = nil
        def failed(_) = nil
      end
      
      class Author
        include Mongoid::Document
      end
      
      class Gallery
        include Mongoid::Document
        has_many :plain_photos, class_name: "Photo", inverse_of: :gallery
        has_many :rooted_photos, class_name: "::Photo", inverse_of: :gallery
      end
      
      class Photo
        include Mongoid::Document
        belongs_to :gallery
        belongs_to :author
      end
      
      module Shop
        class Order
          include Mongoid::Document
          has_many :items, class_name: "Item", inverse_of: :order
        end
      
        class Item
          include Mongoid::Document
          belongs_to :order, class_name: "Order", inverse_of: :items
          belongs_to :author, class_name: "::Author"
        end
      end
      
      Mongoid.purge!
      3.times do
        gallery = Gallery.create!
        order = Shop::Order.create!
        Photo.create!(gallery: gallery, author: Author.create!)
        Shop::Item.create!(order: order, author: Author.create!)
      end
      
      def author_finds(label)
        counter = FindCounter.new
        subscriber = Mongoid.default_client.subscribe(Mongo::Monitoring::COMMAND, counter)
        yield
        Mongoid.default_client.unsubscribe(Mongo::Monitoring::COMMAND, subscriber)
        puts format("%-62s authors finds: %d", label, counter.finds["authors"])
      end
      
      puts "mongoid #{Mongoid::VERSION}, driver #{Mongo::VERSION}, ruby #{RUBY_VERSION}"
      puts "rooted_photos class_name=#{Gallery.relations['rooted_photos'].class_name.inspect} klass=#{Gallery.relations['rooted_photos'].klass}"
      puts "items class_name=#{Shop::Order.relations['items'].class_name.inspect} klass=#{Shop::Order.relations['items'].klass}"
      author_finds('Gallery.includes(plain_photos: :author)  [class_name "Photo"]') { Gallery.includes(plain_photos: :author).each { |g| g.plain_photos.each(&:author) } }
      author_finds('Gallery.includes(rooted_photos: :author) [class_name "::Photo"]') { Gallery.includes(rooted_photos: :author).each { |g| g.rooted_photos.each(&:author) } }
      author_finds('Shop::Order.includes(items: :author)     [class_name "Item"]') { Shop::Order.includes(items: :author).each { |o| o.items.each(&:author) } }
      Mongoid.default_client.database.drop
      

      Actual result

      Gallery.includes(plain_photos: :author)  [class_name "Photo"]  authors finds: 1
      Gallery.includes(rooted_photos: :author) [class_name "::Photo"] authors finds: 3
      Shop::Order.includes(items: :author)     [class_name "Item"]   authors finds: 3
      

      Expected result

      authors finds: 1 in all three cases.

      Cause

      In Mongoid::Association::EagerLoadable#preload:

      assoc_map = associations.group_by(&:inverse_class_name)
      # ...
      queue << assoc.class_name
      

      The keys of assoc_map are real class names, like "Photo" or "Shop::Item". They come from Criteria::Includable#extract_includes_list, which calls extract_includes_list(association.klass, ...) for nested includes.
      But the queue gets the raw :class_name option, like "::Photo" or "Item". These values never match a key, so Mongoid skips the nested associations.

      Possible fix

      Put the real class name in the queue:

      queue << (assoc.polymorphic? ? assoc.class_name : assoc.klass.name)
      

      With this change, the script prints authors finds: 1 in all three cases.
      This change adds no new class lookups. extract_includes_list already calls association.klass for every association with nested includes, and the eager loader already calls @association.klass for every association it loads.

      Related tickets

      • MONGOID-4751: the same kind of problem (relation_class_name is compared with class names). The preload queue is not in its list.
      • MONGOID-4882: the same "::" problem, fixed for embedded associations.
      • MONGOID-4716: added the nested preload queue.

            Assignee:
            Jamis Buck
            Reporter:
            Saygun İçyüz (EXT)
            Votes:
            0 Vote for this issue
            Watchers:
            3 Start watching this issue

              Created:
              Updated: