Module: CacheMarkdownField

Overview

This module takes care of updating cache columns for Markdown-containing fields. Use like this in the body of your class:

include CacheMarkdownField
cache_markdown_field :foo
cache_markdown_field :bar
cache_markdown_field :baz, pipeline: :single_line
cache_markdown_field :baz, whitelisted: true

Corresponding foo_html, bar_html and baz_html fields should exist.

Constant Summary collapse

INVALIDATED_BY =

changes to these attributes cause the cache to be invalidates

%w[author project].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#skip_markdown_cache_validationObject Also known as: skip_markdown_cache_validation?

Returns the value of attribute skip_markdown_cache_validation.



27
28
29
# File 'app/models/concerns/cache_markdown_field.rb', line 27

def skip_markdown_cache_validation
  @skip_markdown_cache_validation
end

Instance Method Details

#attribute_invalidated?(attr) ⇒ Boolean

Returns:

  • (Boolean)


100
101
102
# File 'app/models/concerns/cache_markdown_field.rb', line 100

def attribute_invalidated?(attr)
  __send__("#{attr}_invalidated?") # rubocop:disable GitlabSecurity/PublicSend
end

#banzai_render_context(field) ⇒ Object

Returns the default Banzai render context for the cached markdown field.

Raises:

  • (ArgumentError)


31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'app/models/concerns/cache_markdown_field.rb', line 31

def banzai_render_context(field)
  raise ArgumentError, "Unknown field: #{field.inspect}" unless
    cached_markdown_fields.key?(field)

  # Always include a project key, or Banzai complains
  project = self.project if self.respond_to?(:project)
  group   = self.group if self.respond_to?(:group)
  context = cached_markdown_fields[field].merge(project: project, group: group)

  # Banzai is less strict about authors, so don't always have an author key
  context[:author] = self.author if self.respond_to?(:author)

  context[:markdown_engine] = Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE

  if Feature.enabled?(:personal_snippet_reference_filters, context[:author])
    context[:user] = self.parent_user
  end

  context
end

#cached_html_for(markdown_field) ⇒ Object

Raises:

  • (ArgumentError)


104
105
106
107
108
109
# File 'app/models/concerns/cache_markdown_field.rb', line 104

def cached_html_for(markdown_field)
  raise ArgumentError, "Unknown field: #{markdown_field}" unless
    cached_markdown_fields.key?(markdown_field)

  __send__(cached_markdown_fields.html_field(markdown_field)) # rubocop:disable GitlabSecurity/PublicSend
end

#cached_html_up_to_date?(markdown_field) ⇒ Boolean

Returns:

  • (Boolean)


84
85
86
87
88
89
90
91
92
93
94
# File 'app/models/concerns/cache_markdown_field.rb', line 84

def cached_html_up_to_date?(markdown_field)
  return false if cached_html_for(markdown_field).nil? && __send__(markdown_field).present? # rubocop:disable GitlabSecurity/PublicSend

  html_field = cached_markdown_fields.html_field(markdown_field)

  markdown_changed = markdown_field_changed?(markdown_field)
  html_changed = markdown_field_changed?(html_field)

  latest_cached_markdown_version == cached_markdown_version &&
    (html_changed || markdown_changed == html_changed)
end

#can_cache_field?(field) ⇒ Boolean

Returns:

  • (Boolean)


23
24
25
# File 'app/models/concerns/cache_markdown_field.rb', line 23

def can_cache_field?(field)
  true
end

#invalidated_markdown_cache?Boolean

Returns:

  • (Boolean)


96
97
98
# File 'app/models/concerns/cache_markdown_field.rb', line 96

def invalidated_markdown_cache?
  cached_markdown_fields.html_fields.any? { |html_field| attribute_invalidated?(html_field) }
end

#latest_cached_markdown_versionObject



133
134
135
# File 'app/models/concerns/cache_markdown_field.rb', line 133

def latest_cached_markdown_version
  @latest_cached_markdown_version ||= (Gitlab::MarkdownCache::CACHE_COMMONMARK_VERSION << 16) | local_version
end

#local_versionObject



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'app/models/concerns/cache_markdown_field.rb', line 137

def local_version
  # because local_markdown_version is stored in application_settings which
  # uses cached_markdown_version too, we check explicitly to avoid
  # endless loop
  return local_markdown_version if respond_to?(:has_attribute?) && has_attribute?(:local_markdown_version)

  settings = Gitlab::CurrentSettings.current_application_settings

  # Following migrations are not properly isolated and
  # use real models (by calling .ghost method), in these migrations
  # local_markdown_version attribute doesn't exist yet, so we
  # use a default value:
  # db/migrate/20170825104051_migrate_issues_to_ghost_user.rb
  # db/migrate/20171114150259_merge_requests_author_id_foreign_key.rb
  if settings.respond_to?(:local_markdown_version)
    settings.local_markdown_version
  else
    0
  end
end

#mentionable_attributes_changed?(changes = saved_changes) ⇒ Boolean

Returns:

  • (Boolean)


195
196
197
198
199
200
201
202
# File 'app/models/concerns/cache_markdown_field.rb', line 195

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

#mentioned_filtered_user_ids_for(refs) ⇒ Object

Overriden on objects that needs to filter mentioned users that cannot read them, for example, guest users that are referenced on a confidential note.



191
192
193
# File 'app/models/concerns/cache_markdown_field.rb', line 191

def mentioned_filtered_user_ids_for(refs)
  refs.mentioned_user_ids.presence
end

#parent_userObject



158
159
160
# File 'app/models/concerns/cache_markdown_field.rb', line 158

def parent_user
  nil
end

#refresh_markdown_cacheObject

Update every applicable column in a row if any one is invalidated, as we only store one version per row



61
62
63
64
65
66
67
68
69
70
71
72
# File 'app/models/concerns/cache_markdown_field.rb', line 61

def refresh_markdown_cache
  updates = cached_markdown_fields.markdown_fields.to_h do |markdown_field|
    [
      cached_markdown_fields.html_field(markdown_field),
      rendered_field_content(markdown_field)
    ]
  end

  updates['cached_markdown_version'] = latest_cached_markdown_version

  updates.each { |field, data| write_markdown_field(field, data) }
end

#refresh_markdown_cache!Object



74
75
76
77
78
79
80
81
82
# File 'app/models/concerns/cache_markdown_field.rb', line 74

def refresh_markdown_cache!
  updates = refresh_markdown_cache
  if updates.present? && 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

#rendered_field_content(markdown_field) ⇒ Object



52
53
54
55
56
57
# File 'app/models/concerns/cache_markdown_field.rb', line 52

def rendered_field_content(markdown_field)
  return unless can_cache_field?(markdown_field)

  options = { skip_project_check: skip_project_check? }
  Banzai::Renderer.cacheless_render_field(self, markdown_field, options)
end

#skip_project_check?Boolean

Returns:

  • (Boolean)


19
20
21
# File 'app/models/concerns/cache_markdown_field.rb', line 19

def skip_project_check?
  false
end

#store_mentions!Object



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'app/models/concerns/cache_markdown_field.rb', line 162

def store_mentions!
  # We can only store mentions if the mentionable is a database object
  return unless self.is_a?(ApplicationRecord)

  identifier = user_mention_identifier

  # 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
  return if identifier.blank?

  refs = all_references(self.author)

  references = {}
  references[:mentioned_users_ids] = mentioned_filtered_user_ids_for(refs)
  references[:mentioned_groups_ids] = refs.mentioned_group_ids.presence
  references[:mentioned_projects_ids] = refs.mentioned_project_ids.presence

  if references.compact.any?
    user_mention_class.upsert(references.merge(identifier), unique_by: identifier.compact.keys)
  else
    user_mention_class.delete_by(identifier)
  end

  true
end

#updated_cached_html_for(markdown_field) ⇒ Object

Updates the markdown cache if necessary, then returns the field Unlike ‘cached_html_for` it returns `nil` if the field does not exist



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'app/models/concerns/cache_markdown_field.rb', line 113

def updated_cached_html_for(markdown_field)
  return unless cached_markdown_fields.key?(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)
end