Skip to content

Commit 51654a5

Browse files
authored
1036: Fix importer n+1 (#625)
## Status - Closes RaspberryPiFoundation/digital-editor-issues#1036 ## Points for consideration: - Security - Performance ## What's changed? - Refactor sso batch endpoint to only create new roles where necessary, avoiding an n+1 - Refactors to smaller methods to avoid a cyclomatic depdency issue ## Steps to perform after deploying to production N/A
1 parent 003712f commit 51654a5

File tree

2 files changed

+94
-7
lines changed

2 files changed

+94
-7
lines changed

lib/concepts/school_student/create_batch_sso.rb

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,45 @@ def call(school:, school_students_params:, current_user:)
3232
private
3333

3434
def create_batch_sso(school, students, token)
35+
students = normalize_student_params(students)
36+
responses = ProfileApiClient.create_school_students_sso(token:, students:, school_id: school.id)
37+
38+
create_student_roles(school, responses)
39+
format_student_responses(responses)
40+
rescue ProfileApiClient::Student422Error => e
41+
handle_validations(e.errors)
42+
end
43+
44+
def normalize_student_params(students)
3545
# Ensure that nil values are empty strings, else Profile will swallow validations
36-
students = students.map do |student|
46+
students.map do |student|
3747
student.transform_values { |value| value.nil? ? '' : value }
3848
end
49+
end
3950

40-
responses = ProfileApiClient.create_school_students_sso(token:, students:, school_id: school.id)
51+
def create_student_roles(school, responses)
52+
user_ids = responses.pluck(:id)
53+
existing_user_ids = Role.student.where(school_id: school.id, user_id: user_ids).pluck(:user_id)
54+
new_user_ids = user_ids - existing_user_ids
4155

42-
responses.each do |student|
43-
Role.student.find_or_create_by(school_id: school.id, user_id: student[:id])
56+
return if new_user_ids.empty?
57+
58+
# Use insert_all to avoid N+1 INSERT queries
59+
new_roles = new_user_ids.map do |user_id|
60+
{
61+
role: Role.roles[:student],
62+
school_id: school.id,
63+
user_id: user_id
64+
}
4465
end
4566

67+
# We know the school and uniqueness is ok at this stage, so we can skip validations
68+
# rubocop:disable Rails/SkipsModelValidations
69+
Role.insert_all(new_roles, unique_by: %i[user_id school_id role])
70+
# rubocop:enable Rails/SkipsModelValidations
71+
end
72+
73+
def format_student_responses(responses)
4674
# Convert hash responses to User objects with separate metadata
4775
# This separates student data from metadata (success, error, created flags)
4876
responses.map do |student_data|
@@ -53,8 +81,6 @@ def create_batch_sso(school, students, token)
5381
created: student_data[:created]
5482
}
5583
end
56-
rescue ProfileApiClient::Student422Error => e
57-
handle_validations(e.errors)
5884
end
5985

6086
def handle_validations(errors)

spec/concepts/school_student/create_batch_sso_spec.rb

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
let(:user_ids) { [SecureRandom.uuid, SecureRandom.uuid] }
2424

2525
before do
26+
user_ids
2627
stub_profile_api_create_school_students_sso(user_ids:)
2728
end
2829

@@ -34,7 +35,6 @@
3435
it 'makes a profile API call with correct parameters' do
3536
described_class.call(school:, school_students_params:, current_user:)
3637

37-
# TODO: Replace with WebMock assertion once the profile API has been built.
3838
expect(ProfileApiClient).to have_received(:create_school_students_sso)
3939
.with(token: current_user.token, students: school_students_params, school_id: school.id)
4040
end
@@ -63,6 +63,35 @@
6363
end
6464
end
6565

66+
# This test fails if Role validations change. Check create_batch_sso insert_all usage is still safe, otherwise
67+
# add the validation to the expected list
68+
it 'fails if Role validations change to ensure CreateBatchSSO is updated' do
69+
actual_custom = Role._validate_callbacks
70+
.select { |cb| cb.filter.is_a?(Symbol) }
71+
.map(&:filter)
72+
.reject { |v| v.to_s.start_with?('cant_modify_encrypted_attributes', 'validate_associated_records') }
73+
.sort
74+
75+
actual_builtin = Role.validators
76+
.map { |v| { attributes: v.attributes.sort, kind: v.class.name.demodulize } }
77+
.sort_by { |v| [v[:kind], v[:attributes]] }
78+
79+
expected_custom = %i[students_cannot_have_additional_roles users_can_only_have_roles_in_one_school]
80+
expected_builtin = [
81+
{ attributes: [:role], kind: 'PresenceValidator' },
82+
{ attributes: [:school], kind: 'PresenceValidator' },
83+
{ attributes: [:user_id], kind: 'PresenceValidator' },
84+
{ attributes: [:role], kind: 'UniquenessValidator' }
85+
]
86+
87+
expect(actual_custom).to eq(expected_custom),
88+
"Custom Role validations changed! Got: #{actual_custom.inspect}
89+
Consider updating CreateBatchSSO to ensure insert_all usage is still safe."
90+
expect(actual_builtin).to eq(expected_builtin),
91+
"Built-in Role validations changed! Got: #{actual_builtin.inspect}
92+
Consider updating CreateBatchSSO to ensure insert_all usage is still safe."
93+
end
94+
6695
it 'returns the student data from Profile API' do
6796
response = described_class.call(school:, school_students_params:, current_user:)
6897
students = response[:school_students]
@@ -83,6 +112,38 @@
83112
expect(second_student_item[:student].name).to eq('SSO Test Student 2')
84113
expect(second_student_item[:success]).to be(true)
85114
end
115+
116+
context 'when roles already exist for some students' do
117+
let(:user_ids) { [SecureRandom.uuid, SecureRandom.uuid] }
118+
119+
before do
120+
Role.create!(role: :student, school_id: school.id, user_id: user_ids[0])
121+
end
122+
123+
it 'does not duplicate existing roles' do
124+
roles_before_call = Role.student.where(school_id: school.id).to_a
125+
126+
described_class.call(school:, school_students_params:, current_user:)
127+
128+
roles_after_call = Role.student.where(school_id: school.id).to_a
129+
new_student_roles = roles_after_call - roles_before_call
130+
131+
# Should only create 1 new student role (for second student) since first already exists
132+
expect(new_student_roles.length).to eq(1)
133+
expect(new_student_roles.first.user_id).to eq(user_ids[1])
134+
end
135+
136+
it 'does not raise an error' do
137+
expect do
138+
described_class.call(school:, school_students_params:, current_user:)
139+
end.not_to raise_error
140+
end
141+
142+
it 'still returns all students in the response' do
143+
response = described_class.call(school:, school_students_params:, current_user:)
144+
expect(response[:school_students].length).to eq(2)
145+
end
146+
end
86147
end
87148

88149
context 'when validation errors occur' do

0 commit comments

Comments
 (0)