Commit 932b1603 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch 'save-mentions-on-markdown-columns-change' into 'master'

Save mentions when markdown columns are directly saved to DB

See merge request gitlab-org/gitlab!38034
parents 795b4598 cd8f67c7
...@@ -70,8 +70,12 @@ module CacheMarkdownField ...@@ -70,8 +70,12 @@ module CacheMarkdownField
def refresh_markdown_cache! def refresh_markdown_cache!
updates = refresh_markdown_cache updates = refresh_markdown_cache
if updates.present? && save_markdown(updates)
save_markdown(updates) # save_markdown updates DB columns directly, so compute and save mentions
# by calling store_mentions! or we end-up with missing mentions although those
# would appear in the notes, descriptions, etc in the UI
store_mentions! if mentionable_attributes_changed?(updates)
end
end end
def cached_html_up_to_date?(markdown_field) def cached_html_up_to_date?(markdown_field)
...@@ -106,7 +110,19 @@ module CacheMarkdownField ...@@ -106,7 +110,19 @@ module CacheMarkdownField
def updated_cached_html_for(markdown_field) def updated_cached_html_for(markdown_field)
return unless cached_markdown_fields.markdown_fields.include?(markdown_field) return unless cached_markdown_fields.markdown_fields.include?(markdown_field)
refresh_markdown_cache! if attribute_invalidated?(cached_markdown_fields.html_field(markdown_field)) if attribute_invalidated?(cached_markdown_fields.html_field(markdown_field))
# Invalidated due to Markdown content change
# We should not persist the updated HTML here since this will depend on whether the
# Markdown content change will be persisted. Both will be persisted together when the model is saved.
if changed_attributes.key?(markdown_field)
refresh_markdown_cache
else
# Invalidated due to stale HTML cache
# This could happen when the Markdown cache version is bumped or when a model is imported and the HTML is empty.
# We persist the updated HTML here so that subsequent calls to this method do not have to regenerate the HTML again.
refresh_markdown_cache!
end
end
cached_html_for(markdown_field) cached_html_for(markdown_field)
end end
...@@ -140,6 +156,46 @@ module CacheMarkdownField ...@@ -140,6 +156,46 @@ module CacheMarkdownField
nil nil
end end
def store_mentions!
refs = all_references(self.author)
references = {}
references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence
references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence
references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence
# One retry is enough as next time `model_user_mention` should return the existing mention record,
# that threw the `ActiveRecord::RecordNotUnique` exception in first place.
self.class.safe_ensure_unique(retries: 1) do
user_mention = model_user_mention
# this may happen due to notes polymorphism, so noteable_id may point to a record
# that no longer exists as we cannot have FK on noteable_id
break if user_mention.blank?
user_mention.mentioned_users_ids = references[:mentioned_users_ids]
user_mention.mentioned_groups_ids = references[:mentioned_groups_ids]
user_mention.mentioned_projects_ids = references[:mentioned_projects_ids]
if user_mention.has_mentions?
user_mention.save!
else
user_mention.destroy!
end
end
true
end
def mentionable_attributes_changed?(changes = saved_changes)
return false unless is_a?(Mentionable)
self.class.mentionable_attrs.any? do |attr|
changes.key?(cached_markdown_fields.html_field(attr.first)) &&
changes.fetch(cached_markdown_fields.html_field(attr.first)).last.present?
end
end
included do included do
cattr_reader :cached_markdown_fields do cattr_reader :cached_markdown_fields do
Gitlab::MarkdownCache::FieldData.new Gitlab::MarkdownCache::FieldData.new
......
...@@ -84,7 +84,6 @@ module Issuable ...@@ -84,7 +84,6 @@ module Issuable
validate :description_max_length_for_new_records_is_valid, on: :update validate :description_max_length_for_new_records_is_valid, on: :update
before_validation :truncate_description_on_import! before_validation :truncate_description_on_import!
after_save :store_mentions!, if: :any_mentionable_attributes_changed?
scope :authored, ->(user) { where(author_id: user) } scope :authored, ->(user) { where(author_id: user) }
scope :recent, -> { reorder(id: :desc) } scope :recent, -> { reorder(id: :desc) }
......
...@@ -80,37 +80,6 @@ module Mentionable ...@@ -80,37 +80,6 @@ module Mentionable
all_references(current_user).users all_references(current_user).users
end end
def store_mentions!
refs = all_references(self.author)
references = {}
references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence
references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence
references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence
# One retry should be enough as next time `model_user_mention` should return the existing mention record, that
# threw the `ActiveRecord::RecordNotUnique` exception in first place.
self.class.safe_ensure_unique(retries: 1) do
user_mention = model_user_mention
# this may happen due to notes polymorphism, so noteable_id may point to a record that no longer exists
# as we cannot have FK on noteable_id
break if user_mention.blank?
user_mention.mentioned_users_ids = references[:mentioned_users_ids]
user_mention.mentioned_groups_ids = references[:mentioned_groups_ids]
user_mention.mentioned_projects_ids = references[:mentioned_projects_ids]
if user_mention.has_mentions?
user_mention.save!
else
user_mention.destroy!
end
end
true
end
def referenced_users def referenced_users
User.where(id: user_mentions.select("unnest(mentioned_users_ids)")) User.where(id: user_mentions.select("unnest(mentioned_users_ids)"))
end end
...@@ -216,12 +185,6 @@ module Mentionable ...@@ -216,12 +185,6 @@ module Mentionable
source.select { |key, val| mentionable.include?(key) } source.select { |key, val| mentionable.include?(key) }
end end
def any_mentionable_attributes_changed?
self.class.mentionable_attrs.any? do |attr|
saved_changes.key?(attr.first)
end
end
# Determine whether or not a cross-reference Note has already been created between this Mentionable and # Determine whether or not a cross-reference Note has already been created between this Mentionable and
# the specified target. # the specified target.
def cross_reference_exists?(target) def cross_reference_exists?(target)
...@@ -237,12 +200,12 @@ module Mentionable ...@@ -237,12 +200,12 @@ module Mentionable
end end
# User mention that is parsed from model description rather then its related notes. # User mention that is parsed from model description rather then its related notes.
# Models that have a descriprion attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention. # Models that have a description attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention.
# Other mentionable models like Commit, DesignManagement::Design, will never have such record as those do not have # Other mentionable models like Commit, DesignManagement::Design, will never have such record as those do not have
# a description attribute. # a description attribute.
# #
# Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception # Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception
# in a multithreaded environment. Make sure to use it within a *safe_ensure_unique* block. # in a multi-threaded environment. Make sure to use it within a *safe_ensure_unique* block.
def model_user_mention def model_user_mention
user_mentions.where(note_id: nil).first_or_initialize user_mentions.where(note_id: nil).first_or_initialize
end end
......
...@@ -145,7 +145,6 @@ class Note < ApplicationRecord ...@@ -145,7 +145,6 @@ class Note < ApplicationRecord
after_save :expire_etag_cache, unless: :importing? after_save :expire_etag_cache, unless: :importing?
after_save :touch_noteable, unless: :importing? after_save :touch_noteable, unless: :importing?
after_destroy :expire_etag_cache after_destroy :expire_etag_cache
after_save :store_mentions!, if: :any_mentionable_attributes_changed?
after_commit :notify_after_create, on: :create after_commit :notify_after_create, on: :create
after_commit :notify_after_destroy, on: :destroy after_commit :notify_after_destroy, on: :destroy
...@@ -548,8 +547,8 @@ class Note < ApplicationRecord ...@@ -548,8 +547,8 @@ class Note < ApplicationRecord
private private
# Using this method followed by a call to `save` may result in ActiveRecord::RecordNotUnique exception # Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception
# in a multithreaded environment. Make sure to use it within a `safe_ensure_unique` block. # in a multi-threaded environment. Make sure to use it within a *safe_ensure_unique* block.
def model_user_mention def model_user_mention
return if user_mentions.is_a?(ActiveRecord::NullRelation) return if user_mentions.is_a?(ActiveRecord::NullRelation)
......
...@@ -69,7 +69,6 @@ class Snippet < ApplicationRecord ...@@ -69,7 +69,6 @@ class Snippet < ApplicationRecord
validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values } validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values }
after_save :store_mentions!, if: :any_mentionable_attributes_changed?
after_create :create_statistics after_create :create_statistics
# Scopes # Scopes
......
---
title: Update user mentions when markdown columns are directly saved to DB
merge_request: 38034
author:
type: fixed
...@@ -10,6 +10,7 @@ module Gitlab ...@@ -10,6 +10,7 @@ module Gitlab
# Using before_update here conflicts with elasticsearch-model somehow # Using before_update here conflicts with elasticsearch-model somehow
before_create :refresh_markdown_cache, if: :invalidated_markdown_cache? before_create :refresh_markdown_cache, if: :invalidated_markdown_cache?
before_update :refresh_markdown_cache, if: :invalidated_markdown_cache? before_update :refresh_markdown_cache, if: :invalidated_markdown_cache?
after_save :store_mentions!, if: :mentionable_attributes_changed?
end end
# Always exclude _html fields from attributes (including serialization). # Always exclude _html fields from attributes (including serialization).
......
...@@ -233,7 +233,7 @@ RSpec.describe CacheMarkdownField, :clean_gitlab_redis_cache do ...@@ -233,7 +233,7 @@ RSpec.describe CacheMarkdownField, :clean_gitlab_redis_cache do
end end
it 'calls #refresh_markdown_cache!' do it 'calls #refresh_markdown_cache!' do
expect(thing).to receive(:refresh_markdown_cache!) expect(thing).to receive(:refresh_markdown_cache)
expect(thing.updated_cached_html_for(:description)).to eq(html) expect(thing.updated_cached_html_for(:description)).to eq(html)
end end
...@@ -279,10 +279,101 @@ RSpec.describe CacheMarkdownField, :clean_gitlab_redis_cache do ...@@ -279,10 +279,101 @@ RSpec.describe CacheMarkdownField, :clean_gitlab_redis_cache do
end end
end end
shared_examples 'a class with mentionable markdown fields' do
let(:mentionable) { klass.new(description: markdown, description_html: html, title: markdown, title_html: html, cached_markdown_version: cache_version) }
context 'when klass is a Mentionable', :aggregate_failures do
before do
klass.send(:include, Mentionable)
klass.send(:attr_mentionable, :description)
end
describe '#mentionable_attributes_changed?' do
message = Struct.new(:text)
let(:changes) do
msg = message.new('test')
changes = {}
changes[msg] = ['', 'some message']
changes[:random_sym_key] = ['', 'some message']
changes["description"] = ['', 'some message']
changes
end
it 'returns true with key string' do
changes["description_html"] = ['', 'some message']
allow(mentionable).to receive(:saved_changes).and_return(changes)
expect(mentionable.send(:mentionable_attributes_changed?)).to be true
end
it 'returns false with key symbol' do
changes[:description_html] = ['', 'some message']
allow(mentionable).to receive(:saved_changes).and_return(changes)
expect(mentionable.send(:mentionable_attributes_changed?)).to be false
end
it 'returns false when no attr_mentionable keys' do
allow(mentionable).to receive(:saved_changes).and_return(changes)
expect(mentionable.send(:mentionable_attributes_changed?)).to be false
end
end
describe '#save' do
context 'when cache is outdated' do
before do
thing.cached_markdown_version += 1
end
context 'when the markdown field also a mentionable attribute' do
let(:thing) { klass.new(description: markdown, description_html: html, cached_markdown_version: cache_version) }
it 'calls #store_mentions!' do
expect(thing).to receive(:mentionable_attributes_changed?).and_return(true)
expect(thing).to receive(:store_mentions!)
thing.try(:save)
expect(thing.description_html).to eq(html)
end
end
context 'when the markdown field is not mentionable attribute' do
let(:thing) { klass.new(title: markdown, title_html: html, cached_markdown_version: cache_version) }
it 'does not call #store_mentions!' do
expect(thing).not_to receive(:store_mentions!)
expect(thing).to receive(:refresh_markdown_cache)
thing.try(:save)
expect(thing.title_html).to eq(html)
end
end
end
context 'when the markdown field does not exist' do
let(:thing) { klass.new(cached_markdown_version: cache_version) }
it 'does not call #store_mentions!' do
expect(thing).not_to receive(:store_mentions!)
thing.try(:save)
end
end
end
end
end
context 'for Active record classes' do context 'for Active record classes' do
let(:klass) { ar_class } let(:klass) { ar_class }
it_behaves_like 'a class with cached markdown fields' it_behaves_like 'a class with cached markdown fields'
it_behaves_like 'a class with mentionable markdown fields'
describe '#attribute_invalidated?' do describe '#attribute_invalidated?' do
let(:thing) { klass.create!(description: markdown, description_html: html, cached_markdown_version: cache_version) } let(:thing) { klass.create!(description: markdown, description_html: html, cached_markdown_version: cache_version) }
......
...@@ -29,42 +29,6 @@ RSpec.describe Mentionable do ...@@ -29,42 +29,6 @@ RSpec.describe Mentionable do
expect(mentionable.referenced_mentionables).to be_empty expect(mentionable.referenced_mentionables).to be_empty
end end
end end
describe '#any_mentionable_attributes_changed?' do
message = Struct.new(:text)
let(:mentionable) { Example.new }
let(:changes) do
msg = message.new('test')
changes = {}
changes[msg] = ['', 'some message']
changes[:random_sym_key] = ['', 'some message']
changes["random_string_key"] = ['', 'some message']
changes
end
it 'returns true with key string' do
changes["message"] = ['', 'some message']
allow(mentionable).to receive(:saved_changes).and_return(changes)
expect(mentionable.send(:any_mentionable_attributes_changed?)).to be true
end
it 'returns false with key symbol' do
changes[:message] = ['', 'some message']
allow(mentionable).to receive(:saved_changes).and_return(changes)
expect(mentionable.send(:any_mentionable_attributes_changed?)).to be false
end
it 'returns false when no attr_mentionable keys' do
allow(mentionable).to receive(:saved_changes).and_return(changes)
expect(mentionable.send(:any_mentionable_attributes_changed?)).to be false
end
end
end end
RSpec.describe Issue, "Mentionable" do RSpec.describe Issue, "Mentionable" do
......
...@@ -92,7 +92,7 @@ RSpec.shared_examples 'a mentionable' do ...@@ -92,7 +92,7 @@ RSpec.shared_examples 'a mentionable' do
end end
end end
expect(subject).to receive(:cached_markdown_fields).at_least(:once).and_call_original expect(subject).to receive(:cached_markdown_fields).at_least(1).and_call_original
subject.all_references(author) subject.all_references(author)
end end
...@@ -151,7 +151,7 @@ RSpec.shared_examples 'an editable mentionable' do ...@@ -151,7 +151,7 @@ RSpec.shared_examples 'an editable mentionable' do
end end
it 'persists the refreshed cache so that it does not have to be refreshed every time' do it 'persists the refreshed cache so that it does not have to be refreshed every time' do
expect(subject).to receive(:refresh_markdown_cache).once.and_call_original expect(subject).to receive(:refresh_markdown_cache).at_least(1).and_call_original
subject.all_references(author) subject.all_references(author)
......
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