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..2d1c40e0 --- /dev/null +++ b/db/migrate/20260827134208_expand_coplan_reference_urls.co_plan.rb @@ -0,0 +1,51 @@ +# This migration comes from co_plan (originally 20260827000000) +class ExpandCoplanReferenceUrls < ActiveRecord::Migration[8.1] + 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 + end + + def down + 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 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 ] + 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 + 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 6f8cba98..457ddcec 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, 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"], 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/db/migrate/20260827000000_expand_coplan_reference_urls.rb b/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb new file mode 100644 index 00000000..5a756f9f --- /dev/null +++ b/engine/db/migrate/20260827000000_expand_coplan_reference_urls.rb @@ -0,0 +1,50 @@ +class ExpandCoplanReferenceUrls < ActiveRecord::Migration[8.1] + 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 + end + + def down + 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 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 ] + 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 + 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 84744083..0775a888 100644 --- a/spec/models/coplan/reference_spec.rb +++ b/spec/models/coplan/reference_spec.rb @@ -36,12 +36,51 @@ 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) + + expect(ref.reload.url).to eq(url) + expect(ref.url_digest).to eq(Digest::SHA256.hexdigest(url)) + end + + 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.reload.url_digest).to eq(Digest::SHA256.hexdigest(ref.url)) + end + it "allows same url on different plans" do other_plan = create(:plan) create(:reference, plan: plan, url: "https://example.com") 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 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