Commit 3416ee42 authored by Alex Kalderimis's avatar Alex Kalderimis

Add wiki page metadata models

Adds wiki page slug model:

A slug represents a historical set of slugs that identify wiki pages. At
most one can be canonical at any one time, per page.

Adds wiki page meta model:

The wiki page metadata model contains the current title, and has access
to a collection of slugs, at most one of which may be canonical at any
one point (all others are historic).

Rather than polluting the model namespace, and to clarify the meaning of
the class names, these classes are placed under the `WikiPage`
namespace.

Adds adds wiki page meta/spec factories with sequences for use in the
specs, guaranteeing unique titles and slugs
parent 2af8ad0f
......@@ -29,6 +29,12 @@ class WikiPage
alias_method :==, :eql?
def meta
raise 'Metadata only available for valid pages' unless valid?
@meta ||= WikiPage::Meta.find_or_create(slug, self)
end
# Sorts and groups pages by directory.
#
# pages - an array of WikiPage objects.
......@@ -236,6 +242,7 @@ class WikiPage
end
save do
@meta = nil if title_changed?
wiki.update_page(
@page,
content: content,
......
# frozen_string_literal: true
class WikiPage
class Meta < ApplicationRecord
include Gitlab::Utils::StrongMemoize
self.table_name = 'wiki_page_meta'
belongs_to :project
has_many :slugs, class_name: 'WikiPage::Slug', foreign_key: 'wiki_page_meta_id', inverse_of: :wiki_page_meta
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
validates :title, presence: true
validates :project_id, presence: true
alias_method :resource_parent, :project
# Return the (updated) WikiPage::Meta record for a given wiki page
#
# If none is found, then a new record is created, and its fields are set
# to reflect the wiki_page passed.
#
# @param [String] last_known_slug
# @param [WikiPage] wiki_page
#
# As with all `find_or_create` methods, this one raises errors on
# validation issues.
def self.find_or_create(last_known_slug, wiki_page)
project = wiki_page.wiki.project
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
def update_wiki_page_attributes(page)
update_column(:title, page.title) unless page.title == title
end
def insert_slugs(strings, canonical)
slug_attrs = strings.uniq.map do |slug|
{ wiki_page_meta_id: id, slug: slug }
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 })
# Prevent queries for canonical_slug
meta.instance_variable_set(:@canonical_slug, canonical_slug) if meta
meta
end
def canonical_slug
strong_memoize(:canonical_slug) { slugs.canonical.first&.slug }
end
def canonical_slug=(slug)
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?
else
slugs.new(slug: slug, canonical: true)
end
@canonical_slug = slug
end
end
end
# frozen_string_literal: true
class WikiPage
class Slug < ApplicationRecord
self.table_name = 'wiki_page_slugs'
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
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
# frozen_string_literal: true
require 'spec_helper'
describe WikiPage::Meta do
let_it_be(:project) { create(:project) }
describe 'Associations' do
it { is_expected.to belong_to(:project) }
it { is_expected.to have_many(:slugs) }
it { is_expected.to have_many(:events) }
it 'can find slugs' do
meta = create(:wiki_page_meta)
slugs = create_list(:wiki_page_slug, 3, wiki_page_meta: meta)
expect(meta.slugs).to match_array(slugs)
end
end
describe 'Validations' do
subject do
described_class.new(title: 'some title', project: project)
end
it { is_expected.to validate_presence_of(:project_id) }
it { is_expected.to validate_presence_of(:title) }
end
describe '#canonical_slug' do
subject { described_class.find(meta.id) }
let_it_be(:meta) do
described_class.create(title: generate(:wiki_page_title), project: project)
end
context 'there are no slugs' do
it { is_expected.to have_attributes(canonical_slug: be_nil) }
end
it 'can be set on initialization' do
meta = create(:wiki_page_meta, canonical_slug: 'foo')
expect(meta.canonical_slug).to eq('foo')
end
context 'we have some non-canonical slugs' do
before do
create_list(:wiki_page_slug, 2, wiki_page_meta: subject)
end
it { is_expected.to have_attributes(canonical_slug: be_nil) }
it 'issues at most one query' do
expect { subject.canonical_slug }.not_to exceed_query_limit(1)
end
it 'issues no queries if we already know the slug' do
subject.canonical_slug
expect { subject.canonical_slug }.not_to exceed_query_limit(0)
end
end
context 'we have a canonical slug' do
before do
create_list(:wiki_page_slug, 2, wiki_page_meta: subject)
end
it 'has the correct value' do
slug = create(:wiki_page_slug, :canonical, wiki_page_meta: subject)
is_expected.to have_attributes(canonical_slug: slug.slug)
end
end
describe '= slug' do
shared_examples 'canonical_slug setting examples' do
let(:lower_query_limit) { [query_limit - 1, 0].max }
let(:other_slug) { generate(:sluggified_title) }
it 'changes it to the correct value' do
subject.canonical_slug = slug
expect(subject).to have_attributes(canonical_slug: slug)
end
it 'ensures the slug is in the db' do
subject.canonical_slug = slug
expect(subject.slugs.canonical.where(slug: slug)).to exist
end
it 'issues at most N queries' do
expect { subject.canonical_slug = slug }.not_to exceed_query_limit(query_limit)
end
it 'issues fewer queries if we already know the current slug' do
subject.canonical_slug = other_slug
expect { subject.canonical_slug = slug }.not_to exceed_query_limit(lower_query_limit)
end
end
context 'the slug is not known to us' do
let(:slug) { generate(:sluggified_title) }
let(:query_limit) { 8 }
include_examples 'canonical_slug setting examples'
end
context 'the slug is already in the DB (but not canonical)' do
let_it_be(:slug_record) { create(:wiki_page_slug, wiki_page_meta: meta) }
let(:slug) { slug_record.slug }
let(:query_limit) { 4 }
include_examples 'canonical_slug setting examples'
end
context 'the slug is already in the DB (and canonical)' do
let_it_be(:slug_record) { create(:wiki_page_slug, :canonical, wiki_page_meta: meta) }
let(:slug) { slug_record.slug }
let(:query_limit) { 4 }
include_examples 'canonical_slug setting examples'
end
context 'the slug is up to date and in the DB' do
let(:slug) { generate(:sluggified_title) }
let(:query_limit) { 0 }
before do
subject.canonical_slug = slug
end
include_examples 'canonical_slug setting examples' do
let(:other_slug) { slug }
end
end
end
end
describe '.find_or_create' do
let(:old_title) { FactoryBot.generate(:wiki_page_title) }
let(:last_known_slug) { FactoryBot.generate(:sluggified_title) }
let(:current_slug) { wiki_page.slug }
let(:title) { wiki_page.title }
let(:wiki_page) { create(:wiki_page, project: project) }
def find_record
described_class.find_or_create(last_known_slug, wiki_page)
end
def create_previous_version(title = old_title, slug = last_known_slug)
described_class.create!(title: title, project: project, canonical_slug: slug)
end
shared_examples 'metadata examples' do
it 'establishes the correct state', :aggregate_failures do
meta = find_record
expect(meta).to have_attributes(
canonical_slug: wiki_page.slug,
title: wiki_page.title,
project: wiki_page.wiki.project
)
expect(meta.slugs.where(slug: last_known_slug)).to exist
expect(meta.slugs.canonical.where(slug: wiki_page.slug)).to exist
end
it 'makes a reasonable number of DB queries' do
expect(project).to eq(wiki_page.wiki.project)
expect { find_record }.not_to exceed_query_limit(query_limit)
end
end
context 'the slug is too long' do
let(:last_known_slug) { FFaker::Lorem.characters(2050) }
it 'raises an error' do
expect { find_record }.to raise_error ActiveRecord::ValueTooLong
end
end
context 'no existing record exists' do
include_examples 'metadata examples' do
# The base case is 7 queries:
# - 1 to find the metadata object if it exists
# - 1 to create it if it does not
# - 2 for 1 savepoint
# - 1 to insert last_known_slug and current_slug
# - 1 to find the current slug
# - 2 to set canonical status correctly
#
# (Log has been edited for clarity)
# SELECT * FROM wiki_page_meta
# INNER JOIN wiki_page_slugs
# ON wiki_page_slugs.wiki_page_meta_id = wiki_page_meta.id
# WHERE wiki_page_meta.project_id = ?
# AND wiki_page_slugs.canonical = TRUE
# AND wiki_page_slugs.slug = ?
# LIMIT 1
# SAVEPOINT active_record_2
# INSERT INTO wiki_page_meta (project_id, title) VALUES (?, ?) RETURNING id
# RELEASE SAVEPOINT active_record_2
# INSERT INTO wiki_page_slugs (wiki_page_meta_id,slug)
# VALUES (?, ?) (?, ?)
# ON CONFLICT DO NOTHING RETURNING id
# UPDATE wiki_page_slugs SET canonical = FALSE WHERE wiki_page_meta_id = ?
# SELECT * FROM wiki_page_slugs WHERE wiki_page_meta_id = ? AND slug = ? LIMIT 1
# UPDATE wiki_page_slugs SET canonical = TRUE WHERE id = ?
let(:query_limit) { 8 }
end
end
context 'the last_known_slug is the same as the current slug, as on creation' do
let(:last_known_slug) { current_slug }
include_examples 'metadata examples' do
# Identical to the base case.
let(:query_limit) { 8 }
end
end
context 'a record exists in the DB in the correct state' do
let(:last_known_slug) { current_slug }
let(:old_title) { title }
before do
create_previous_version
end
include_examples 'metadata examples' do
# SELECT * FROM wiki_page_meta
# INNER JOIN wiki_page_slugs
# ON wiki_page_slugs.wiki_page_meta_id = wiki_page_meta.id
# WHERE wiki_page_meta.project_id = ?
# AND wiki_page_slugs.canonical = TRUE
# AND wiki_page_slugs.slug = ?
# LIMIT 1
#
# INSERT INTO wiki_page_slugs (wiki_page_meta_id,slug,canonical)
# VALUES (?, ?, ?) ON CONFLICT DO NOTHING RETURNING id
let(:query_limit) { 2 }
end
end
context 'we need to update the slug, but not the title' do
let(:old_title) { title }
before do
create_previous_version
end
include_examples 'metadata examples' do
# Same as minimal case, plus the additional queries needed to update the
# slug.
#
# SELECT * FROM wiki_page_meta
# INNER JOIN wiki_page_slugs
# ON wiki_page_slugs.wiki_page_meta_id = wiki_page_meta.id
# WHERE wiki_page_meta.project_id = ?
# AND wiki_page_slugs.canonical = TRUE
# AND wiki_page_slugs.slug = ?
# LIMIT 1
#
# INSERT INTO wiki_page_slugs (wiki_page_meta_id,slug,canonical)
# VALUES (?, ?, ?) ON CONFLICT DO NOTHING RETURNING id
#
# SELECT * FROM wiki_page_slugs
# WHERE wiki_page_slugs.wiki_page_meta_id = ?
# AND wiki_page_slugs.slug = ?
# LIMIT 1
#
# UPDATE wiki_page_slugs SET canonical = FALSE WHERE wiki_page_meta_id = ?
# UPDATE wiki_page_slugs SET canonical = TRUE WHERE id = ?
let(:query_limit) { 5 }
end
end
context 'we need to update the title, but not the slug' do
let(:last_known_slug) { wiki_page.slug }
before do
create_previous_version
end
include_examples 'metadata examples' do
# Same as minimal case, plus one query to update the title.
#
# SELECT * FROM wiki_page_meta
# INNER JOIN wiki_page_slugs
# ON wiki_page_slugs.wiki_page_meta_id = wiki_page_meta.id
# WHERE wiki_page_meta.project_id = ?
# AND wiki_page_slugs.canonical = TRUE
# AND wiki_page_slugs.slug = ?
# LIMIT 1
#
# UPDATE wiki_page_meta SET title = ? WHERE id = ?
#
# INSERT INTO wiki_page_slugs (wiki_page_meta_id,slug,canonical)
# VALUES (?, ?, ?) ON CONFLICT DO NOTHING RETURNING id
let(:query_limit) { 3 }
end
end
context 'we want to change the slug back to a previous version' do
let(:slug_1) { 'foo' }
let(:slug_2) { 'bar' }
let(:wiki_page) { create(:wiki_page, title: slug_1, project: project) }
let(:last_known_slug) { slug_2 }
before do
meta = create_previous_version(title, slug_1)
meta.canonical_slug = slug_2
end
include_examples 'metadata examples' do
let(:query_limit) { 5 }
end
end
context 'we want to change the slug a bunch of times' do
let(:slugs) { FactoryBot.generate_list(:sluggified_title, 3) }
before do
meta = create_previous_version
slugs.each { |slug| meta.canonical_slug = slug }
end
include_examples 'metadata examples' do
let(:query_limit) { 8 }
end
end
context 'we need to update the title and the slug' do
before do
create_previous_version
end
include_examples 'metadata examples' do
# Same as minimal case, plus one for the title, and two for the slug
#
# SELECT * FROM wiki_page_meta
# INNER JOIN wiki_page_slugs
# ON wiki_page_slugs.wiki_page_meta_id = wiki_page_meta.id
# WHERE wiki_page_meta.project_id = ?
# AND wiki_page_slugs.canonical = TRUE
# AND wiki_page_slugs.slug = ?
# LIMIT 1
#
# UPDATE wiki_page_meta SET title = ? WHERE id = ?
#
# INSERT INTO wiki_page_slugs (wiki_page_meta_id,slug,canonical)
# VALUES (?, ?, ?) ON CONFLICT DO NOTHING RETURNING id
#
# UPDATE wiki_page_slugs SET canonical = FALSE WHERE wiki_page_meta_id = ?
#
# SELECT * FROM wiki_page_slugs
# WHERE wiki_page_slugs.wiki_page_meta_id = ?
# AND wiki_page_slugs.slug = ?
# LIMIT 1
#
# UPDATE wiki_page_slugs SET canonical = TRUE WHERE id = ?
let(:query_limit) { 6 }
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe WikiPage::Slug do
let_it_be(:meta) { create(:wiki_page_meta) }
describe 'Associations' do
it { is_expected.to belong_to(:wiki_page_meta) }
it 'refers correctly to the wiki_page_meta' do
created = create(:wiki_page_slug, wiki_page_meta: meta)
expect(created.reload.wiki_page_meta).to eq(meta)
end
end
describe 'scopes' do
describe 'canonical' do
subject { described_class.canonical }
context 'there are no slugs' do
it { is_expected.to be_empty }
end
context 'there are some non-canonical slugs' do
before do
create_list(:wiki_page_slug, 3)
end
it { is_expected.to be_empty }
end
context 'there is at least one canonical slugs' do
before do
create(:wiki_page_slug, canonical: true)
end
it { is_expected.not_to be_empty }
end
end
end
describe 'Validations' do
let(:canonical) { false }
subject do
build(:wiki_page_slug, canonical: canonical, wiki_page_meta: meta)
end
it { is_expected.to validate_presence_of(:slug) }
it { is_expected.to validate_uniqueness_of(:slug).scoped_to(:wiki_page_meta_id) }
describe 'only_one_slug_can_be_canonical_per_meta_record' do
context 'there are no other slugs' do
it { is_expected.to be_valid }
context 'the current slug is canonical' do
let(:canonical) { true }
it { is_expected.to be_valid }
end
end
context 'there are other slugs, but they are not canonical' do
before do
create_list(:wiki_page_slug, 3, wiki_page_meta: meta)
end
it { is_expected.to be_valid }
context 'the current slug is canonical' do
let(:canonical) { true }
it { is_expected.to be_valid }
end
end
context 'there is already a canonical slug' do
before do
create(:wiki_page_slug, canonical: true, wiki_page_meta: meta)
end
it { is_expected.to be_valid }
context 'the current slug is canonical' do
let(:canonical) { true }
it { is_expected.not_to be_valid }
end
end
end
end
end
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