Commit 8d20e067 authored by Toon Claes's avatar Toon Claes

Merge branch '34296-fix_notification_settings_N+1' into 'master'

Remove the N+1 in Profiles::NotificationsController

See merge request gitlab-org/gitlab!40457
parents 1a862ca0 0338f3ac
# frozen_string_literal: true
class Profiles::NotificationsController < Profiles::ApplicationController
NOTIFICATIONS_PER_PAGE = 10
# rubocop: disable CodeReuse/ActiveRecord
def show
@user = current_user
@user_groups = user_groups
@group_notifications = user_groups.map { |group| current_user.notification_settings_for(group, inherit: true) }
@group_notifications = UserGroupNotificationSettingsFinder.new(current_user, user_groups).execute
@project_notifications = current_user.notification_settings.for_projects.order(:id)
.preload_source_route
......@@ -35,6 +33,6 @@ class Profiles::NotificationsController < Profiles::ApplicationController
private
def user_groups
GroupsFinder.new(current_user, all_available: false).execute.order_name_asc.page(params[:page]).per(NOTIFICATIONS_PER_PAGE)
GroupsFinder.new(current_user, all_available: false).execute.order_name_asc.page(params[:page])
end
end
# frozen_string_literal: true
class UserGroupNotificationSettingsFinder
def initialize(user, groups)
@user = user
@groups = groups
end
def execute
groups_with_ancestors = Gitlab::ObjectHierarchy.new(groups).base_and_ancestors
@loaded_groups_with_ancestors = groups_with_ancestors.index_by(&:id)
@loaded_notification_settings = user.notification_settings_for_groups(groups_with_ancestors).preload_source_route.index_by(&:source_id)
groups.map do |group|
find_notification_setting_for(group)
end
end
private
attr_reader :user, :groups, :loaded_groups_with_ancestors, :loaded_notification_settings
def find_notification_setting_for(group)
return loaded_notification_settings[group.id] if loaded_notification_settings[group.id]
return user.notification_settings.build(source: group) if group.parent_id.nil?
parent_setting = loaded_notification_settings[group.parent_id]
if should_copy?(parent_setting)
user.notification_settings.build(source: group) do |ns|
ns.assign_attributes(parent_setting.slice(*NotificationSetting.allowed_fields))
end
else
find_notification_setting_for(loaded_groups_with_ancestors[group.parent_id])
end
end
def should_copy?(parent_setting)
return false unless parent_setting
parent_setting.level != NotificationSetting.levels[:global] || parent_setting.notification_email.present?
end
end
......@@ -1461,6 +1461,11 @@ class User < ApplicationRecord
end
end
def notification_settings_for_groups(groups)
ids = groups.is_a?(ActiveRecord::Relation) ? groups.select(:id) : groups.map(&:id)
notification_settings.for_groups.where(source_id: ids)
end
# Lazy load global notification setting
# Initializes User setting with Participating level if setting not persisted
def global_notification_setting
......
......@@ -37,7 +37,7 @@ RSpec.describe Profiles::NotificationsController do
expect(assigns(:group_notifications).map(&:source_id)).to include(subgroup.id)
end
it 'has an N+1 (but should not)' do
it 'does not have an N+1' do
sign_in(user)
control = ActiveRecord::QueryRecorder.new do
......@@ -46,10 +46,9 @@ RSpec.describe Profiles::NotificationsController do
create_list(:group, 2, parent: group)
# We currently have an N + 1, switch to `not_to` once fixed
expect do
get :show
end.to exceed_query_limit(control)
end.not_to exceed_query_limit(control)
end
end
......@@ -62,7 +61,7 @@ RSpec.describe Profiles::NotificationsController do
before do
group.add_developer(user)
sign_in(user)
stub_const('Profiles::NotificationsController::NOTIFICATIONS_PER_PAGE', notifications_per_page)
allow(Kaminari.config).to receive(:default_per_page).and_return(notifications_per_page)
end
it 'paginates the groups' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe UserGroupNotificationSettingsFinder do
let_it_be(:user) { create(:user) }
subject { described_class.new(user, Group.where(id: groups.map(&:id))).execute }
def attributes(&proc)
subject.map(&proc).uniq
end
context 'when the groups have no existing notification settings' do
context 'when the groups have no ancestors' do
let_it_be(:groups) { create_list(:group, 3) }
it 'will be a default Global notification setting', :aggregate_failures do
expect(subject.count).to eq(3)
expect(attributes(&:notification_email)).to eq([nil])
expect(attributes(&:level)).to eq(['global'])
end
end
context 'when the groups have ancestors' do
context 'when an ancestor has a level other than Global' do
let_it_be(:ancestor_a) { create(:group) }
let_it_be(:group_a) { create(:group, parent: ancestor_a) }
let_it_be(:ancestor_b) { create(:group) }
let_it_be(:group_b) { create(:group, parent: ancestor_b) }
let_it_be(:email) { create(:email, :confirmed, email: 'ancestor@example.com', user: user) }
let_it_be(:groups) { [group_a, group_b] }
before do
create(:notification_setting, user: user, source: ancestor_a, level: 'participating', notification_email: email.email)
create(:notification_setting, user: user, source: ancestor_b, level: 'participating', notification_email: email.email)
end
it 'has the same level set' do
expect(attributes(&:level)).to eq(['participating'])
end
it 'has the same email set' do
expect(attributes(&:notification_email)).to eq(['ancestor@example.com'])
end
it 'only returns the two queried groups' do
expect(subject.count).to eq(2)
end
end
context 'when an ancestor has a Global level but has an email set' do
let_it_be(:grand_ancestor) { create(:group) }
let_it_be(:ancestor) { create(:group, parent: grand_ancestor) }
let_it_be(:group) { create(:group, parent: ancestor) }
let_it_be(:ancestor_email) { create(:email, :confirmed, email: 'ancestor@example.com', user: user) }
let_it_be(:grand_email) { create(:email, :confirmed, email: 'grand@example.com', user: user) }
let_it_be(:groups) { [group] }
before do
create(:notification_setting, user: user, source: grand_ancestor, level: 'participating', notification_email: grand_email.email)
create(:notification_setting, user: user, source: ancestor, level: 'global', notification_email: ancestor_email.email)
end
it 'has the same email and level set', :aggregate_failures do
expect(subject.count).to eq(1)
expect(attributes(&:level)).to eq(['global'])
expect(attributes(&:notification_email)).to eq(['ancestor@example.com'])
end
end
it 'does not cause an N+1', :aggregate_failures do
parent = create(:group)
child = create(:group, parent: parent)
control = ActiveRecord::QueryRecorder.new do
described_class.new(user, Group.where(id: child.id)).execute
end
other_parent = create(:group)
other_children = create_list(:group, 2, parent: other_parent)
result = nil
expect do
result = described_class.new(user, Group.where(id: other_children.append(child).map(&:id))).execute
end.not_to exceed_query_limit(control)
expect(result.count).to eq(3)
end
end
end
end
......@@ -4491,6 +4491,44 @@ RSpec.describe User do
end
end
describe '#notification_settings_for_groups' do
let_it_be(:user) { create(:user) }
let_it_be(:groups) { create_list(:group, 2) }
subject { user.notification_settings_for_groups(arg) }
before do
groups.each do |group|
group.add_maintainer(user)
end
end
shared_examples_for 'notification_settings_for_groups method' do
it 'returns NotificationSetting objects for provided groups', :aggregate_failures do
expect(subject.count).to eq(groups.count)
expect(subject.map(&:source_id)).to match_array(groups.map(&:id))
end
end
context 'when given an ActiveRecord relationship' do
let_it_be(:arg) { Group.where(id: groups.map(&:id)) }
it_behaves_like 'notification_settings_for_groups method'
it 'uses #select to maintain lazy querying behavior' do
expect(arg).to receive(:select).and_call_original
subject
end
end
context 'when given an Array of Groups' do
let_it_be(:arg) { groups }
it_behaves_like 'notification_settings_for_groups method'
end
end
describe '#notification_email_for' do
let(:user) { create(:user) }
let(:group) { create(:group) }
......
......@@ -25,8 +25,7 @@ RSpec.describe 'view user notifications' do
end
describe 'GET /profile/notifications' do
# To be fixed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40457
it 'has an N+1 due to an additional groups (with no parent group) - but should not' do
it 'does not have an N+1 due to an additional groups (with no parent group)' do
get_profile_notifications
control = ActiveRecord::QueryRecorder.new do
......@@ -37,7 +36,7 @@ RSpec.describe 'view user notifications' do
expect do
get_profile_notifications
end.to exceed_query_limit(control)
end.not_to exceed_query_limit(control)
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment