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)


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

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
# 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[:user] = self.parent_user if Feature.enabled?(:personal_snippet_reference_filters, context[:author])

  context
end

#cached_html_for(markdown_field) ⇒ Object

Raises:

  • (ArgumentError)


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

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)


80
81
82
83
84
85
86
87
88
89
90
# File 'app/models/concerns/cache_markdown_field.rb', line 80

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)


92
93
94
# File 'app/models/concerns/cache_markdown_field.rb', line 92

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

#latest_cached_markdown_versionObject



131
132
133
134
135
136
137
138
139
140
141
142
# File 'app/models/concerns/cache_markdown_field.rb', line 131

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

  # rubocop:disable Gitlab/ModuleWithInstanceVariables -- acceptable use case
  # See https://docs.gitlab.com/ee/development/module_with_instance_variables.html#acceptable-use
  @latest_cached_markdown_version ||= Gitlab::MarkdownCache.latest_cached_markdown_version(
    local_version: local_version
  )
  # rubocop:enable Gitlab/ModuleWithInstanceVariables
end

#mentionable_attributes_changed?(changes = saved_changes) ⇒ Boolean

Returns:

  • (Boolean)


181
182
183
184
185
186
187
188
# File 'app/models/concerns/cache_markdown_field.rb', line 181

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.



177
178
179
# File 'app/models/concerns/cache_markdown_field.rb', line 177

def mentioned_filtered_user_ids_for(refs)
  refs.mentioned_user_ids.presence
end

#parent_userObject



144
145
146
# File 'app/models/concerns/cache_markdown_field.rb', line 144

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



57
58
59
60
61
62
63
64
65
66
67
68
# File 'app/models/concerns/cache_markdown_field.rb', line 57

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



70
71
72
73
74
75
76
77
78
# File 'app/models/concerns/cache_markdown_field.rb', line 70

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 store_mentions? && mentionable_attributes_changed?(updates)
  end
end

#rendered_field_content(markdown_field) ⇒ Object



48
49
50
51
52
53
# File 'app/models/concerns/cache_markdown_field.rb', line 48

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



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'app/models/concerns/cache_markdown_field.rb', line 148

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

#store_mentions?Boolean

Returns:

  • (Boolean)


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

def store_mentions?
  true
end

#store_mentions_after_commit?Boolean

Returns:

  • (Boolean)


194
195
196
# File 'app/models/concerns/cache_markdown_field.rb', line 194

def store_mentions_after_commit?
  false
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



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

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