From ce624e10becbc94b0d2b55be1c97611f5c2268e7 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Thu, 27 Aug 2026 09:56:47 -0500 Subject: [PATCH 1/4] Support long reference URLs Reference extraction runs after an edit commits, so a URL longer than the MySQL varchar limit could turn a successful edit into a 500 response. Store full URLs as text and retain database uniqueness through a SHA-256 digest. Amp-Thread-ID: https://ampcode.com/threads/T-01a0436f-c1c9-7775-a5ed-72abb9b7b20a Co-authored-by: Amp --- ...08_expand_coplan_reference_urls.co_plan.rb | 32 +++++++++++++++++++ db/schema.rb | 7 ++-- engine/app/models/coplan/reference.rb | 11 +++++++ ...0827000000_expand_coplan_reference_urls.rb | 31 ++++++++++++++++++ spec/models/coplan/reference_spec.rb | 15 +++++++++ spec/requests/api/v1/operations_spec.rb | 18 +++++++++++ .../references/extract_from_content_spec.rb | 8 +++++ 7 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb create mode 100644 engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb diff --git a/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb b/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb new file mode 100644 index 00000000..99dca275 --- /dev/null +++ b/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb @@ -0,0 +1,32 @@ +# This migration comes from co_plan (originally 20260827000000) +require "digest" + +class ExpandCoplanReferenceUrls < ActiveRecord::Migration[8.1] + class MigrationReference < ActiveRecord::Base + self.table_name = "coplan_references" + end + + def up + add_column :coplan_references, :url_digest, :string, limit: 64 + + MigrationReference.reset_column_information + MigrationReference.find_each do |reference| + reference.update_column(:url_digest, Digest::SHA256.hexdigest(reference.url)) + end + + # Keep this nullable for rolling-deploy compatibility: old application + # processes do not populate the digest. The new model fills any missing + # digest before validation, including rows written during this migration. + remove_index :coplan_references, column: [ :plan_id, :url ] + change_column :coplan_references, :url, :text, null: false + add_index :coplan_references, [ :plan_id, :url_digest ], unique: true, + name: "index_coplan_references_on_plan_id_and_url_digest" + end + + def down + remove_index :coplan_references, name: "index_coplan_references_on_plan_id_and_url_digest" + change_column :coplan_references, :url, :string, null: false + add_index :coplan_references, [ :plan_id, :url ], unique: true + remove_column :coplan_references, :url_digest + end +end diff --git a/db/schema.rb b/db/schema.rb index 6f8cba98..f0d63983 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_24_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_08_27_134208) do create_table "active_admin_comments", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "author_id" t.string "author_type" @@ -385,9 +385,10 @@ t.string "target_plan_id", limit: 36 t.string "title" t.datetime "updated_at", null: false - t.string "url", null: false + t.text "url", null: false + t.string "url_digest", limit: 64 t.index ["plan_id", "key"], name: "index_coplan_references_on_plan_id_and_key", unique: true - t.index ["plan_id", "url"], name: "index_coplan_references_on_plan_id_and_url", unique: true + t.index ["plan_id", "url_digest"], name: "index_coplan_references_on_plan_id_and_url_digest", unique: true t.index ["source"], name: "index_coplan_references_on_source" t.index ["target_plan_id"], name: "index_coplan_references_on_target_plan_id" end diff --git a/engine/app/models/coplan/reference.rb b/engine/app/models/coplan/reference.rb index e2507428..d329fab6 100644 --- a/engine/app/models/coplan/reference.rb +++ b/engine/app/models/coplan/reference.rb @@ -1,3 +1,5 @@ +require "digest" + module CoPlan class Reference < ApplicationRecord SOURCES = %w[extracted explicit].freeze @@ -7,11 +9,14 @@ class Reference < ApplicationRecord belongs_to :target_plan, class_name: "CoPlan::Plan", optional: true validates :url, presence: true, uniqueness: { scope: :plan_id }, format: { with: /\Ahttps?:\/\//i, message: "must start with http:// or https://" } + validates :url_digest, presence: true, uniqueness: { scope: :plan_id } validates :key, uniqueness: { scope: :plan_id }, allow_nil: true, format: { with: /\A[a-z0-9][a-z0-9_-]*\z/, message: "must be lowercase alphanumeric with hyphens/underscores" }, length: { maximum: 64 } validates :reference_type, presence: true, inclusion: { in: REFERENCE_TYPES } validates :source, presence: true, inclusion: { in: SOURCES } + before_validation :compute_url_digest, if: -> { url_digest.blank? || url_changed? } + scope :extracted, -> { where(source: "extracted") } scope :explicit, -> { where(source: "explicit") } @@ -139,5 +144,11 @@ def self.ransackable_attributes(auth_object = nil) def self.ransackable_associations(auth_object = nil) %w[plan target_plan] end + + private + + def compute_url_digest + self.url_digest = Digest::SHA256.hexdigest(url) if url.present? + end end end diff --git a/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb b/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb new file mode 100644 index 00000000..4f999652 --- /dev/null +++ b/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb @@ -0,0 +1,31 @@ +require "digest" + +class ExpandCoplanReferenceUrls < ActiveRecord::Migration[8.1] + class MigrationReference < ActiveRecord::Base + self.table_name = "coplan_references" + end + + def up + add_column :coplan_references, :url_digest, :string, limit: 64 + + MigrationReference.reset_column_information + MigrationReference.find_each do |reference| + reference.update_column(:url_digest, Digest::SHA256.hexdigest(reference.url)) + end + + # Keep this nullable for rolling-deploy compatibility: old application + # processes do not populate the digest. The new model fills any missing + # digest before validation, including rows written during this migration. + remove_index :coplan_references, column: [ :plan_id, :url ] + change_column :coplan_references, :url, :text, null: false + add_index :coplan_references, [ :plan_id, :url_digest ], unique: true, + name: "index_coplan_references_on_plan_id_and_url_digest" + end + + def down + remove_index :coplan_references, name: "index_coplan_references_on_plan_id_and_url_digest" + change_column :coplan_references, :url, :string, null: false + add_index :coplan_references, [ :plan_id, :url ], unique: true + remove_column :coplan_references, :url_digest + end +end diff --git a/spec/models/coplan/reference_spec.rb b/spec/models/coplan/reference_spec.rb index 84744083..077f5e38 100644 --- a/spec/models/coplan/reference_spec.rb +++ b/spec/models/coplan/reference_spec.rb @@ -36,6 +36,21 @@ expect(ref).not_to be_valid end + it "stores URLs longer than a database string" do + url = "https://example.com/?query=#{"x" * 500}" + ref = create(:reference, plan: plan, url: url) + + expect(ref.reload.url).to eq(url) + expect(ref.url_digest).to eq(Digest::SHA256.hexdigest(url)) + end + + it "fills a digest omitted by an older application process" do + ref = create(:reference, plan: plan) + ref.update_column(:url_digest, nil) + + expect { ref.update!(title: "Updated") }.to change(ref, :url_digest).from(nil) + end + it "allows same url on different plans" do other_plan = create(:plan) create(:reference, plan: plan, url: "https://example.com") diff --git a/spec/requests/api/v1/operations_spec.rb b/spec/requests/api/v1/operations_spec.rb index 7e2e61e1..4cf66280 100644 --- a/spec/requests/api/v1/operations_spec.rb +++ b/spec/requests/api/v1/operations_spec.rb @@ -38,6 +38,24 @@ expect(body["revision"]).to eq(plan.current_revision + 1) end + it "applies content containing a URL longer than a database string" do + url = "https://example.com/?query=#{"x" * 500}" + + post api_v1_plan_operations_path(plan), + params: { + lease_token: lease_token, + base_revision: plan.current_revision, + operations: [ + { op: "replace_exact", old_text: "Some content here.", new_text: "Read [the report](#{url}).", count: 1 } + ] + }, + headers: headers, + as: :json + + expect(response).to have_http_status(:created) + expect(plan.references.find_by!(url: url).source).to eq("extracted") + end + it "apply operations fails without lease" do CoPlan::EditLease.find_by(plan_id: plan.id)&.destroy diff --git a/spec/services/coplan/references/extract_from_content_spec.rb b/spec/services/coplan/references/extract_from_content_spec.rb index d06c99b4..290ba107 100644 --- a/spec/services/coplan/references/extract_from_content_spec.rb +++ b/spec/services/coplan/references/extract_from_content_spec.rb @@ -116,5 +116,13 @@ def update_content(plan, content) expect(plan.references.count).to eq(1) expect(plan.references.first.url).to eq("https://example.com") end + + it "extracts URLs longer than a database string" do + url = "https://example.com/?query=#{"x" * 500}" + update_content(plan, "Visit [the report](#{url}) for more info.") + + expect { described_class.call(plan: plan) }.to change(plan.references, :count).by(1) + expect(plan.references.first.url).to eq(url) + end end end From 7d375fc071e9ae9e3100f210b3c40b6765ee2789 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Thu, 27 Aug 2026 10:13:54 -0500 Subject: [PATCH 2/4] Make URL digests database-generated Generated SHA-256 digests preserve the uniqueness constraint for old and new application processes throughout a rolling deploy. Treat rollback as irreversible rather than risking truncation of stored long URLs. Amp-Thread-ID: https://ampcode.com/threads/T-01a0436f-c1c9-7775-a5ed-72abb9b7b20a Co-authored-by: Amp --- ...08_expand_coplan_reference_urls.co_plan.rb | 51 +++++++++++-------- db/schema.rb | 2 +- engine/app/models/coplan/reference.rb | 11 ---- ...0827000000_expand_coplan_reference_urls.rb | 51 +++++++++++-------- spec/models/coplan/reference_spec.rb | 24 +++++++-- 5 files changed, 83 insertions(+), 56 deletions(-) diff --git a/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb b/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb index 99dca275..2d2f47a7 100644 --- a/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb +++ b/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb @@ -1,32 +1,43 @@ # This migration comes from co_plan (originally 20260827000000) -require "digest" - class ExpandCoplanReferenceUrls < ActiveRecord::Migration[8.1] - class MigrationReference < ActiveRecord::Base - self.table_name = "coplan_references" - end - def up - add_column :coplan_references, :url_digest, :string, limit: 64 - - MigrationReference.reset_column_information - MigrationReference.find_each do |reference| - reference.update_column(:url_digest, Digest::SHA256.hexdigest(reference.url)) + if connection.adapter_name == "PostgreSQL" + remove_url_index_and_expand_column + add_digest_column + else + add_digest_column + remove_url_index_and_expand_column end - # Keep this nullable for rolling-deploy compatibility: old application - # processes do not populate the digest. The new model fills any missing - # digest before validation, including rows written during this migration. - remove_index :coplan_references, column: [ :plan_id, :url ] - change_column :coplan_references, :url, :text, null: false add_index :coplan_references, [ :plan_id, :url_digest ], unique: true, name: "index_coplan_references_on_plan_id_and_url_digest" end def down - remove_index :coplan_references, name: "index_coplan_references_on_plan_id_and_url_digest" - change_column :coplan_references, :url, :string, null: false - add_index :coplan_references, [ :plan_id, :url ], unique: true - remove_column :coplan_references, :url_digest + raise ActiveRecord::IrreversibleMigration, + "reference URLs may exceed the former 255-character limit" + end + + private + + def add_digest_column + add_column :coplan_references, :url_digest, :virtual, type: :string, limit: 64, + as: digest_expression, stored: true + end + + def remove_url_index_and_expand_column + remove_index :coplan_references, column: [ :plan_id, :url ] + change_column :coplan_references, :url, :text, null: false + end + + def digest_expression + case connection.adapter_name + when "Mysql2" + "SHA2(url, 256)" + when "PostgreSQL" + "encode(sha256(url::bytea), 'hex')" + else + raise "Unsupported database adapter: #{connection.adapter_name}" + end end end diff --git a/db/schema.rb b/db/schema.rb index f0d63983..ad765bbe 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -386,7 +386,7 @@ t.string "title" t.datetime "updated_at", null: false t.text "url", null: false - t.string "url_digest", limit: 64 + t.virtual "url_digest", type: :string, limit: 64, as: "sha2(`url`,256)", stored: true t.index ["plan_id", "key"], name: "index_coplan_references_on_plan_id_and_key", unique: true t.index ["plan_id", "url_digest"], name: "index_coplan_references_on_plan_id_and_url_digest", unique: true t.index ["source"], name: "index_coplan_references_on_source" diff --git a/engine/app/models/coplan/reference.rb b/engine/app/models/coplan/reference.rb index d329fab6..e2507428 100644 --- a/engine/app/models/coplan/reference.rb +++ b/engine/app/models/coplan/reference.rb @@ -1,5 +1,3 @@ -require "digest" - module CoPlan class Reference < ApplicationRecord SOURCES = %w[extracted explicit].freeze @@ -9,14 +7,11 @@ class Reference < ApplicationRecord belongs_to :target_plan, class_name: "CoPlan::Plan", optional: true validates :url, presence: true, uniqueness: { scope: :plan_id }, format: { with: /\Ahttps?:\/\//i, message: "must start with http:// or https://" } - validates :url_digest, presence: true, uniqueness: { scope: :plan_id } validates :key, uniqueness: { scope: :plan_id }, allow_nil: true, format: { with: /\A[a-z0-9][a-z0-9_-]*\z/, message: "must be lowercase alphanumeric with hyphens/underscores" }, length: { maximum: 64 } validates :reference_type, presence: true, inclusion: { in: REFERENCE_TYPES } validates :source, presence: true, inclusion: { in: SOURCES } - before_validation :compute_url_digest, if: -> { url_digest.blank? || url_changed? } - scope :extracted, -> { where(source: "extracted") } scope :explicit, -> { where(source: "explicit") } @@ -144,11 +139,5 @@ def self.ransackable_attributes(auth_object = nil) def self.ransackable_associations(auth_object = nil) %w[plan target_plan] end - - private - - def compute_url_digest - self.url_digest = Digest::SHA256.hexdigest(url) if url.present? - end end end diff --git a/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb b/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb index 4f999652..c16f75d2 100644 --- a/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb +++ b/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb @@ -1,31 +1,42 @@ -require "digest" - class ExpandCoplanReferenceUrls < ActiveRecord::Migration[8.1] - class MigrationReference < ActiveRecord::Base - self.table_name = "coplan_references" - end - def up - add_column :coplan_references, :url_digest, :string, limit: 64 - - MigrationReference.reset_column_information - MigrationReference.find_each do |reference| - reference.update_column(:url_digest, Digest::SHA256.hexdigest(reference.url)) + if connection.adapter_name == "PostgreSQL" + remove_url_index_and_expand_column + add_digest_column + else + add_digest_column + remove_url_index_and_expand_column end - # Keep this nullable for rolling-deploy compatibility: old application - # processes do not populate the digest. The new model fills any missing - # digest before validation, including rows written during this migration. - remove_index :coplan_references, column: [ :plan_id, :url ] - change_column :coplan_references, :url, :text, null: false add_index :coplan_references, [ :plan_id, :url_digest ], unique: true, name: "index_coplan_references_on_plan_id_and_url_digest" end def down - remove_index :coplan_references, name: "index_coplan_references_on_plan_id_and_url_digest" - change_column :coplan_references, :url, :string, null: false - add_index :coplan_references, [ :plan_id, :url ], unique: true - remove_column :coplan_references, :url_digest + raise ActiveRecord::IrreversibleMigration, + "reference URLs may exceed the former 255-character limit" + end + + private + + def add_digest_column + add_column :coplan_references, :url_digest, :virtual, type: :string, limit: 64, + as: digest_expression, stored: true + end + + def remove_url_index_and_expand_column + remove_index :coplan_references, column: [ :plan_id, :url ] + change_column :coplan_references, :url, :text, null: false + end + + def digest_expression + case connection.adapter_name + when "Mysql2" + "SHA2(url, 256)" + when "PostgreSQL" + "encode(sha256(url::bytea), 'hex')" + else + raise "Unsupported database adapter: #{connection.adapter_name}" + end end end diff --git a/spec/models/coplan/reference_spec.rb b/spec/models/coplan/reference_spec.rb index 077f5e38..d241a172 100644 --- a/spec/models/coplan/reference_spec.rb +++ b/spec/models/coplan/reference_spec.rb @@ -36,6 +36,21 @@ expect(ref).not_to be_valid end + it "enforces database uniqueness when a writer omits the generated digest" do + create(:reference, plan: plan, url: "https://example.com") + attributes = { + id: SecureRandom.uuid, + plan_id: plan.id, + url: "https://example.com", + reference_type: "link", + source: "extracted", + created_at: Time.current, + updated_at: Time.current + } + + expect { described_class.insert_all!([ attributes ]) }.to raise_error(ActiveRecord::RecordNotUnique) + end + it "stores URLs longer than a database string" do url = "https://example.com/?query=#{"x" * 500}" ref = create(:reference, plan: plan, url: url) @@ -44,11 +59,12 @@ expect(ref.url_digest).to eq(Digest::SHA256.hexdigest(url)) end - it "fills a digest omitted by an older application process" do - ref = create(:reference, plan: plan) - ref.update_column(:url_digest, nil) + it "updates the database-generated digest when the URL changes" do + ref = create(:reference, plan: plan, url: "https://example.com/old") + + ref.update!(url: "https://example.com/new") - expect { ref.update!(title: "Updated") }.to change(ref, :url_digest).from(nil) + expect(ref.reload.url_digest).to eq(Digest::SHA256.hexdigest(ref.url)) end it "allows same url on different plans" do From 117e854f9367d1e9cd826d85a3cb49cf7baf7136 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Thu, 27 Aug 2026 11:27:26 -0500 Subject: [PATCH 3/4] Keep URL uniqueness during migration Build the generated-digest unique index before removing the old URL index on MySQL so concurrent writes remain protected throughout its nontransactional DDL sequence. Amp-Thread-ID: https://ampcode.com/threads/T-01a0436f-c1c9-7775-a5ed-72abb9b7b20a Co-authored-by: Amp --- ...60827134208_expand_coplan_reference_urls.co_plan.rb | 10 +++++++--- .../20260827000000_expand_coplan_reference_urls.rb | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb b/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb index 2d2f47a7..3b8acd21 100644 --- a/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb +++ b/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb @@ -4,13 +4,12 @@ def up if connection.adapter_name == "PostgreSQL" remove_url_index_and_expand_column add_digest_column + add_digest_index else add_digest_column + add_digest_index remove_url_index_and_expand_column end - - add_index :coplan_references, [ :plan_id, :url_digest ], unique: true, - name: "index_coplan_references_on_plan_id_and_url_digest" end def down @@ -25,6 +24,11 @@ def add_digest_column as: digest_expression, stored: true end + def add_digest_index + add_index :coplan_references, [ :plan_id, :url_digest ], unique: true, + name: "index_coplan_references_on_plan_id_and_url_digest" + end + def remove_url_index_and_expand_column remove_index :coplan_references, column: [ :plan_id, :url ] change_column :coplan_references, :url, :text, null: false diff --git a/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb b/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb index c16f75d2..9fefe145 100644 --- a/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb +++ b/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb @@ -3,13 +3,12 @@ def up if connection.adapter_name == "PostgreSQL" remove_url_index_and_expand_column add_digest_column + add_digest_index else add_digest_column + add_digest_index remove_url_index_and_expand_column end - - add_index :coplan_references, [ :plan_id, :url_digest ], unique: true, - name: "index_coplan_references_on_plan_id_and_url_digest" end def down @@ -24,6 +23,11 @@ def add_digest_column as: digest_expression, stored: true end + def add_digest_index + add_index :coplan_references, [ :plan_id, :url_digest ], unique: true, + name: "index_coplan_references_on_plan_id_and_url_digest" + end + def remove_url_index_and_expand_column remove_index :coplan_references, column: [ :plan_id, :url ] change_column :coplan_references, :url, :text, null: false From ccad241dca679cc6187bbb6b59416f414ff0d359 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Thu, 27 Aug 2026 11:36:25 -0500 Subject: [PATCH 4/4] Align URL digest collation semantics Use a binary collation for MySQL reference URLs so application equality, database uniqueness, and byte-based SHA-256 digests agree while retaining case-sensitive URL paths. Amp-Thread-ID: https://ampcode.com/threads/T-01a0436f-c1c9-7775-a5ed-72abb9b7b20a Co-authored-by: Amp --- ...20260827134208_expand_coplan_reference_urls.co_plan.rb | 6 +++++- db/schema.rb | 2 +- .../20260827000000_expand_coplan_reference_urls.rb | 6 +++++- spec/models/coplan/reference_spec.rb | 8 ++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb b/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb index 3b8acd21..2d1c40e0 100644 --- a/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb +++ b/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb @@ -31,7 +31,11 @@ def add_digest_index def remove_url_index_and_expand_column remove_index :coplan_references, column: [ :plan_id, :url ] - change_column :coplan_references, :url, :text, null: false + if connection.adapter_name == "Mysql2" + execute "ALTER TABLE coplan_references MODIFY url TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL" + else + change_column :coplan_references, :url, :text, null: false + end end def digest_expression diff --git a/db/schema.rb b/db/schema.rb index ad765bbe..457ddcec 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -385,7 +385,7 @@ t.string "target_plan_id", limit: 36 t.string "title" t.datetime "updated_at", null: false - t.text "url", null: false + t.text "url", null: false, collation: "utf8mb4_bin" t.virtual "url_digest", type: :string, limit: 64, as: "sha2(`url`,256)", stored: true t.index ["plan_id", "key"], name: "index_coplan_references_on_plan_id_and_key", unique: true t.index ["plan_id", "url_digest"], name: "index_coplan_references_on_plan_id_and_url_digest", unique: true diff --git a/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb b/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb index 9fefe145..5a756f9f 100644 --- a/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb +++ b/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb @@ -30,7 +30,11 @@ def add_digest_index def remove_url_index_and_expand_column remove_index :coplan_references, column: [ :plan_id, :url ] - change_column :coplan_references, :url, :text, null: false + if connection.adapter_name == "Mysql2" + execute "ALTER TABLE coplan_references MODIFY url TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL" + else + change_column :coplan_references, :url, :text, null: false + end end def digest_expression diff --git a/spec/models/coplan/reference_spec.rb b/spec/models/coplan/reference_spec.rb index d241a172..0775a888 100644 --- a/spec/models/coplan/reference_spec.rb +++ b/spec/models/coplan/reference_spec.rb @@ -73,6 +73,14 @@ ref = build(:reference, plan: other_plan, url: "https://example.com") expect(ref).to be_valid end + + it "treats case-sensitive URL paths as distinct" do + create(:reference, plan: plan, url: "https://example.com/Report") + + expect { + create(:reference, plan: plan, url: "https://example.com/report") + }.to change(described_class, :count).by(1) + end end describe ".classify_url" do