Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions dashboard/app/models/lti_course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@
#
# Indexes
#
# fk_rails_19886eb632 (lti_deployment_id)
# index_lti_courses_on_deleted_at (deleted_at)
# index_lti_courses_on_lti_integration_id (lti_integration_id)
# index_on_context_id_and_lti_integration_id (context_id,lti_integration_id)
# index_on_course_id_and_lti_integration_id (course_id,lti_integration_id)
# fk_rails_19886eb632 (lti_deployment_id)
# index_lti_courses_on_context_integration_active (`context_id`, `lti_integration_id`, (if((`deleted_at` is null),true,NULL))) UNIQUE
# index_lti_courses_on_deleted_at (deleted_at)
# index_lti_courses_on_lti_integration_id (lti_integration_id)
# index_on_course_id_and_lti_integration_id (course_id,lti_integration_id)
#
class LtiCourse < ApplicationRecord
acts_as_paranoid
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class AddUniqueIndexToLtiCoursesOnLtiIntegrationIdAndContextIdAndActive < ActiveRecord::Migration[6.1]
def up
execute <<~SQL.squish
CREATE UNIQUE INDEX index_lti_courses_on_context_integration_active ON lti_courses (

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deduplicate active rows before creating unique index

CREATE UNIQUE INDEX will abort this migration as soon as lti_courses contains any existing duplicate active pair (context_id, lti_integration_id with deleted_at IS NULL), and there is no cleanup step in this change to make those rows unique first. In environments that already accumulated duplicates, deploy will fail at migration time; add a pre-index data migration (or equivalent dedupe step) before creating the constraint.

Useful? React with 👍 / 👎.

context_id,
lti_integration_id,
(IF(deleted_at IS NULL, TRUE, NULL)) /* MySQL allows multiple NULL values in a unique index */
);
SQL
end

def down
remove_index :lti_courses, name: :index_lti_courses_on_context_integration_active, if_exists: true
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class RemoveIndexFromLtiCoursesOnContextIdAndLtiIntegrationId < ActiveRecord::Migration[6.1]
def change
remove_index :lti_courses, %i[context_id lti_integration_id], name: :index_on_context_id_and_lti_integration_id
end
end
4 changes: 2 additions & 2 deletions dashboard/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 2026_01_28_232147) do
ActiveRecord::Schema.define(version: 2026_02_11_145245) do

create_table "activities", id: :integer, charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t|
t.integer "user_id"
Expand Down Expand Up @@ -1066,7 +1066,7 @@
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.datetime "deleted_at"
t.index ["context_id", "lti_integration_id"], name: "index_on_context_id_and_lti_integration_id"
t.index "`context_id`, `lti_integration_id`, (if((`deleted_at` is null),true,NULL))", name: "index_lti_courses_on_context_integration_active", unique: true
t.index ["course_id", "lti_integration_id"], name: "index_on_course_id_and_lti_integration_id"
t.index ["deleted_at"], name: "index_lti_courses_on_deleted_at"
t.index ["lti_deployment_id"], name: "fk_rails_19886eb632"
Expand Down
4 changes: 2 additions & 2 deletions dashboard/test/factories/factories.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2125,8 +2125,8 @@
end

factory :lti_course do
lti_integration {create(:lti_integration)}
lti_deployment {create(:lti_deployment, lti_integration: lti_integration)}
association :lti_integration
lti_deployment {build(:lti_deployment, lti_integration:)}
context_id {SecureRandom.uuid}
course_id {SecureRandom.uuid}
nrps_url {"http://test.org/api/names_and_roles"}
Expand Down
34 changes: 34 additions & 0 deletions dashboard/test/models/lti_course_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,38 @@ class LtiCourseTest < ActiveSupport::TestCase
assert lti_section.reload.deleted_at.present?, "lti_section should be deleted"
end
end

describe 'unique constraint on (context_id, lti_integration_id, active)' do
subject(:lti_course_duplicate) do
build(:lti_course).tap do |duplicate|
duplicate.lti_integration = lti_course_original.lti_integration
duplicate.context_id = lti_course_original.context_id
duplicate.deleted_at = nil
duplicate.save!(validate: false)
end
end

let!(:lti_course_original) {create(:lti_course)}

it 'raises ActiveRecord::RecordNotUnique' do
exception = _ {lti_course_duplicate}.must_raise ActiveRecord::RecordNotUnique
_(exception.message).must_match /Duplicate entry .* for key 'lti_courses.index_lti_courses_on_context_integration_active'/
end

context 'when original record is soft deleted' do
before do
lti_course_original.destroy!
end

it 'allows to create duplicate' do
_ {lti_course_duplicate}.must_differ 'LtiCourse.count'
_(lti_course_duplicate.persisted?).must_equal true
end

it 'allows to delete duplicate' do
_ {lti_course_duplicate.destroy!}.must_differ 'LtiCourse.only_deleted.count'
_(lti_course_duplicate.deleted?).must_equal true
end
end
end
end