Commit 56b298f5 authored by Alex Kalderimis's avatar Alex Kalderimis

Avoid canonical slug conflict in the same project

This adds validations that ensure that wiki pages cannot have the same
canonical slug in the same project. We cannot easily move this check to
the DB, so this is handled in logic - we check in `.find_or_create` if
either of the known slugs matches a record, and we raise errors on
conflict.

This also introduces some optimisations that eliminate unnecessary
queries.

Minor updates from review comments

Includes:

- Use Class#name instead of constant
- Wrap mutable state in an outer transaction

  The one subtle thing here is that the first query (to find the record)
  must be within the transaction, even though it does not mutate the state
  of the DB. This is because we learn important facts from that query
  (namely whether there are any conflicting canonical slugs) that could be
  invalidated outside a transaction.

- Use standard uniqueness validator

  This is more declarative than our hand-rolled implementation

- Move page construction into factory

  This requires a new trait for the wiki page factory to create pages that
  are in the wiki repo.

- Changes to some test descriptions

- Reduce DB queries in specs

we don't strictly need multiple slugs here - one will do.

- Avoid running validation if there is no project_id

- Raise error if there are no slugs at all
parent 7d278de4
...@@ -81,7 +81,7 @@ class Event < ApplicationRecord ...@@ -81,7 +81,7 @@ class Event < ApplicationRecord
scope :recent, -> { reorder(id: :desc) } scope :recent, -> { reorder(id: :desc) }
scope :code_push, -> { where(action: PUSHED) } scope :code_push, -> { where(action: PUSHED) }
scope :merged, -> { where(action: MERGED) } scope :merged, -> { where(action: MERGED) }
scope :for_wiki_page, -> { where(target_type: 'WikiPage::Meta') } scope :for_wiki_page, -> { where(target_type: WikiPage::Meta.name) }
scope :with_associations, -> do scope :with_associations, -> do
# We're using preload for "push_event_payload" as otherwise the association # We're using preload for "push_event_payload" as otherwise the association
...@@ -229,7 +229,7 @@ class Event < ApplicationRecord ...@@ -229,7 +229,7 @@ class Event < ApplicationRecord
end end
def wiki_page? def wiki_page?
target_type == "WikiPage::Meta" target_type == WikiPage::Meta.name
end end
def milestone def milestone
......
...@@ -4,6 +4,8 @@ class WikiPage ...@@ -4,6 +4,8 @@ class WikiPage
class Meta < ApplicationRecord class Meta < ApplicationRecord
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
CanonicalSlugConflictError = Class.new(ActiveRecord::RecordInvalid)
self.table_name = 'wiki_page_meta' self.table_name = 'wiki_page_meta'
belongs_to :project belongs_to :project
...@@ -13,6 +15,11 @@ class WikiPage ...@@ -13,6 +15,11 @@ class WikiPage
validates :title, presence: true validates :title, presence: true
validates :project_id, presence: true validates :project_id, presence: true
validate :no_two_metarecords_in_same_project_can_have_same_canonical_slug
scope :with_canonical_slug, ->(slug) do
joins(:slugs).where(wiki_page_slugs: { canonical: true, slug: slug })
end
alias_method :resource_parent, :project alias_method :resource_parent, :project
...@@ -28,33 +35,32 @@ class WikiPage ...@@ -28,33 +35,32 @@ class WikiPage
# validation issues. # validation issues.
def self.find_or_create(last_known_slug, wiki_page) def self.find_or_create(last_known_slug, wiki_page)
project = wiki_page.wiki.project project = wiki_page.wiki.project
known_slugs = [last_known_slug, wiki_page.slug].compact.uniq
raise 'no slugs!' if known_slugs.empty?
meta = find_by_canonical_slug(last_known_slug, project) || create(title: wiki_page.title, project_id: project.id) transaction do
found = find_by_canonical_slug(known_slugs, project)
meta.update_wiki_page_attributes(wiki_page) meta = found || create(title: wiki_page.title, project_id: project.id)
meta.insert_slugs([last_known_slug, wiki_page.slug], wiki_page.slug)
meta.canonical_slug = wiki_page.slug
meta
end
def update_wiki_page_attributes(page) meta.update_state(found.nil?, known_slugs, wiki_page)
update_column(:title, page.title) unless page.title == title
end
def insert_slugs(strings, canonical) # We don't need to run validations here, since find_by_canonical_slug
slug_attrs = strings.uniq.map do |slug| # guarantees that there is no conflict in canonical_slug, and DB
{ wiki_page_meta_id: id, slug: slug } # constraints on title and project_id enforce our other invariants
# This saves us a query.
meta
end end
slugs.insert_all(slug_attrs)
end end
def self.find_by_canonical_slug(canonical_slug, project) def self.find_by_canonical_slug(canonical_slug, project)
meta = joins(:slugs).find_by(project_id: project.id, meta, conflict = with_canonical_slug(canonical_slug)
wiki_page_slugs: { canonical: true, slug: canonical_slug }) .where(project_id: project.id)
.limit(2)
# Prevent queries for canonical_slug if conflict.present?
meta.instance_variable_set(:@canonical_slug, canonical_slug) if meta meta.errors.add(:canonical_slug, 'Duplicate value found')
raise CanonicalSlugConflictError.new(meta)
end
meta meta
end end
...@@ -67,14 +73,48 @@ class WikiPage ...@@ -67,14 +73,48 @@ class WikiPage
return if @canonical_slug == slug return if @canonical_slug == slug
if persisted? if persisted?
slugs.update_all(canonical: false) transaction do
page_slug = slugs.create_with(canonical: true).find_or_create_by(slug: slug) slugs.update_all(canonical: false)
page_slug.update_column(:canonical, true) unless page_slug.canonical? page_slug = slugs.create_with(canonical: true).find_or_create_by(slug: slug)
page_slug.update_column(:canonical, true) unless page_slug.canonical?
end
else else
slugs.new(slug: slug, canonical: true) slugs.new(slug: slug, canonical: true)
end end
@canonical_slug = slug @canonical_slug = slug
end end
def update_state(created, known_slugs, wiki_page)
update_wiki_page_attributes(wiki_page)
insert_slugs(known_slugs, created, wiki_page.slug)
self.canonical_slug = wiki_page.slug
end
private
def update_wiki_page_attributes(page)
update_column(:title, page.title) unless page.title == title
end
def insert_slugs(strings, is_new, canonical_slug)
slug_attrs = strings.map do |slug|
{ wiki_page_meta_id: id, slug: slug, canonical: (is_new && slug == canonical_slug) }
end
slugs.insert_all(slug_attrs) unless !is_new && slug_attrs.size == 1
@canonical_slug = canonical_slug if is_new || strings.size == 1
end
def no_two_metarecords_in_same_project_can_have_same_canonical_slug
return unless project_id.present? && canonical_slug.present?
offending = self.class.with_canonical_slug(canonical_slug).where(project_id: project_id)
offending = offending.where.not(id: id) if persisted?
if offending.exists?
errors.add(:canonical_slug, 'each page in a wiki must have a distinct canonical slug')
end
end
end end
end end
...@@ -7,22 +7,12 @@ class WikiPage ...@@ -7,22 +7,12 @@ class WikiPage
belongs_to :wiki_page_meta, class_name: 'WikiPage::Meta', inverse_of: :slugs belongs_to :wiki_page_meta, class_name: 'WikiPage::Meta', inverse_of: :slugs
validates :slug, presence: true, uniqueness: { scope: :wiki_page_meta_id } validates :slug, presence: true, uniqueness: { scope: :wiki_page_meta_id }
validate :only_one_slug_can_be_canonical_per_meta_record validates :canonical, uniqueness: {
scope: :wiki_page_meta_id,
if: :canonical?,
message: 'Only one slug can be canonical per wiki metadata record'
}
scope :canonical, -> { where(canonical: true) } scope :canonical, -> { where(canonical: true) }
private
def only_one_slug_can_be_canonical_per_meta_record
return unless canonical?
if other_slugs.canonical.exists?
errors.add(:canonical, 'Only one slug can be canonical per wiki metadata record')
end
end
def other_slugs
self.class.unscoped.where(wiki_page_meta_id: wiki_page_meta_id)
end
end end
end end
...@@ -30,6 +30,16 @@ FactoryBot.define do ...@@ -30,6 +30,16 @@ FactoryBot.define do
to_create do |page| to_create do |page|
page.create page.create
end end
trait :with_real_page do
project { create(:project, :repository) }
page do
wiki.create_page(title, content)
page_title, page_dir = wiki.page_title_and_dir(title)
wiki.wiki.page(title: page_title, dir: page_dir, version: nil)
end
end
end end
factory :wiki_page_meta, class: 'WikiPage::Meta' do factory :wiki_page_meta, class: 'WikiPage::Meta' do
......
...@@ -112,17 +112,18 @@ describe Event do ...@@ -112,17 +112,18 @@ describe Event do
end end
context 'for an issue' do context 'for an issue' do
let(:issue) { create(:issue, project: project) } let(:title) { generate(:title) }
let(:issue) { create(:issue, title: title, project: project) }
let(:target) { issue } let(:target) { issue }
it 'delegates to issue title' do it 'delegates to issue title' do
expect(event.target_title).to eq(issue.title) expect(event.target_title).to eq(title)
end end
end end
context 'for a wiki page' do context 'for a wiki page' do
let(:wiki) { create(:project_wiki, project: project) } let(:title) { generate(:wiki_page_title) }
let(:wiki_page) { create(:wiki_page, wiki: wiki) } let(:wiki_page) { create(:wiki_page, title: title, project: project) }
let(:event) { create(:wiki_page_event, project: project, wiki_page: wiki_page) } let(:event) { create(:wiki_page_event, project: project, wiki_page: wiki_page) }
it 'delegates to issue title' do it 'delegates to issue title' do
...@@ -484,16 +485,10 @@ describe Event do ...@@ -484,16 +485,10 @@ describe Event do
describe '#wiki_page and #wiki_page?' do describe '#wiki_page and #wiki_page?' do
let_it_be(:project) { create(:project, :repository) } let_it_be(:project) { create(:project, :repository) }
let_it_be(:title) { FFaker::Lorem.sentence }
context 'for a wiki page event' do context 'for a wiki page event' do
let(:wiki) { create(:project_wiki, project: project) }
let(:wiki_page) do let(:wiki_page) do
wiki.create_page(title, FFaker::Lorem.sentence) create(:wiki_page, :with_real_page, project: project)
page_title, page_dir = wiki.page_title_and_dir(title)
page = wiki.wiki.page(title: page_title, dir: page_dir, version: nil)
WikiPage.new(wiki, page)
end end
subject(:event) { create(:wiki_page_event, project: project, wiki_page: wiki_page) } subject(:event) { create(:wiki_page_event, project: project, wiki_page: wiki_page) }
......
This diff is collapsed.
...@@ -25,7 +25,7 @@ describe WikiPage::Slug do ...@@ -25,7 +25,7 @@ describe WikiPage::Slug do
context 'there are some non-canonical slugs' do context 'there are some non-canonical slugs' do
before do before do
create_list(:wiki_page_slug, 3) create(:wiki_page_slug)
end end
it { is_expected.to be_empty } it { is_expected.to be_empty }
...@@ -33,7 +33,7 @@ describe WikiPage::Slug do ...@@ -33,7 +33,7 @@ describe WikiPage::Slug do
context 'there is at least one canonical slugs' do context 'there is at least one canonical slugs' do
before do before do
create(:wiki_page_slug, canonical: true) create(:wiki_page_slug, :canonical)
end end
it { is_expected.not_to be_empty } it { is_expected.not_to be_empty }
...@@ -64,7 +64,7 @@ describe WikiPage::Slug do ...@@ -64,7 +64,7 @@ describe WikiPage::Slug do
context 'there are other slugs, but they are not canonical' do context 'there are other slugs, but they are not canonical' do
before do before do
create_list(:wiki_page_slug, 3, wiki_page_meta: meta) create(:wiki_page_slug, wiki_page_meta: meta)
end end
it { is_expected.to be_valid } it { is_expected.to be_valid }
......
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