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