mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
Collapse work-package preload state into one cache value object
Pairs unscoped label resolution and viewer-scoped link gating in a WorkPackagePreloadCache instead of two RequestStore keys with a five-method save/restore protocol. Exposes one `current_cache` reader; consumers ask the cache directly via `fetch` and `visible?`. Extracts a `text_only?` predicate in the WP link handler so the `context[:plain_text]` and invisible-WP guards collapse into a single call site. `SemanticIdentifier.format` renames its parameter to reflect that the input may or may not be semantic.
This commit is contained in:
@@ -137,10 +137,12 @@ module WorkPackage::SemanticIdentifier
|
||||
WorkPackage::SemanticIdentifier.format(display_id)
|
||||
end
|
||||
|
||||
# Module-level variant for callers that already hold a display id and
|
||||
# don't need the WorkPackage record.
|
||||
def self.format(display_id)
|
||||
display_id.is_a?(String) && display_id.match?(/[A-Za-z]/) ? display_id : "##{display_id}"
|
||||
# Module-level variant of `#formatted_id` for callers that already hold
|
||||
# an identifier value (which may or may not be semantic) and don't need
|
||||
# the WorkPackage record. Semantic shapes pass through unchanged;
|
||||
# numeric shapes get the classic `#N` prefix.
|
||||
def self.format(value)
|
||||
value.is_a?(String) && value.match?(/[A-Za-z]/) ? value : "##{value}"
|
||||
end
|
||||
|
||||
# Override ActiveRecord's default `to_param` so Rails URL helpers
|
||||
|
||||
@@ -78,7 +78,7 @@ module OpenProject::TextFormatting::Matchers
|
||||
# Both quickinfo and plain link need the WP record so the rendered
|
||||
# HTML can carry the record id in `data-id`. Unresolved WP →
|
||||
# literal text rather than a broken reference.
|
||||
wp = OpenProject::TextFormatting::Matchers::ResourceLinksMatcher.work_package_for(display_id)
|
||||
wp = preload_cache.fetch(display_id)
|
||||
return nil unless wp
|
||||
|
||||
if quickinfo?
|
||||
@@ -89,7 +89,7 @@ module OpenProject::TextFormatting::Matchers
|
||||
end
|
||||
|
||||
def render_for_numeric(wp_id)
|
||||
wp = OpenProject::TextFormatting::Matchers::ResourceLinksMatcher.work_package_for(wp_id)
|
||||
wp = preload_cache.fetch(wp_id)
|
||||
|
||||
if quickinfo?
|
||||
render_work_package_macro(work_package: wp, fallback_id: wp_id, detailed: detailed?)
|
||||
@@ -103,8 +103,7 @@ module OpenProject::TextFormatting::Matchers
|
||||
display_id = work_package&.display_id || fallback_id
|
||||
label = WorkPackage::SemanticIdentifier.format(display_id)
|
||||
|
||||
return label if context[:plain_text]
|
||||
return label if work_package && !visible_to_current_user?(work_package.id)
|
||||
return label if text_only?(work_package)
|
||||
|
||||
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
|
||||
"",
|
||||
@@ -116,8 +115,7 @@ module OpenProject::TextFormatting::Matchers
|
||||
# render path bypassing `PatternMatcherFilter`) rather than running a
|
||||
# per-link query inside the renderer.
|
||||
label = work_package&.formatted_id || "##{fallback_id}"
|
||||
return label if context[:plain_text]
|
||||
return label if work_package && !visible_to_current_user?(work_package.id)
|
||||
return label if text_only?(work_package)
|
||||
|
||||
href_id = work_package&.display_id || fallback_id
|
||||
|
||||
@@ -130,9 +128,16 @@ module OpenProject::TextFormatting::Matchers
|
||||
})
|
||||
end
|
||||
|
||||
def visible_to_current_user?(work_package_id)
|
||||
OpenProject::TextFormatting::Matchers::ResourceLinksMatcher
|
||||
.visible_to_current_user?(work_package_id)
|
||||
# Plain-text channels and inaccessible WPs both render the label
|
||||
# without an anchor or quickinfo. Visibility is checked only when a
|
||||
# WP was preloaded — a nil work_package means a classic-mode render
|
||||
# or an unresolved reference, neither of which needs gating.
|
||||
def text_only?(work_package)
|
||||
context[:plain_text] || (work_package && !preload_cache.visible?(work_package.id))
|
||||
end
|
||||
|
||||
def preload_cache
|
||||
OpenProject::TextFormatting::Matchers::ResourceLinksMatcher.current_cache
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,9 +69,32 @@ module OpenProject::TextFormatting
|
||||
# identifier:version:1.0.0
|
||||
# identifier:source:some/file
|
||||
class ResourceLinksMatcher < RegexMatcher
|
||||
WORK_PACKAGES_LOOKUP_KEY = :text_formatting_work_packages_lookup
|
||||
VISIBLE_WORK_PACKAGE_IDS_KEY = :text_formatting_visible_work_package_ids
|
||||
private_constant :WORK_PACKAGES_LOOKUP_KEY, :VISIBLE_WORK_PACKAGE_IDS_KEY
|
||||
# Per-render preload state shared between the matcher and the link
|
||||
# handler. Pairs unscoped label resolution (`lookup`) with viewer-scoped
|
||||
# link gating (`visible_ids`) so a single render computes both with a
|
||||
# bounded number of round-trips and renders consistent labels for
|
||||
# invisible WPs.
|
||||
class WorkPackagePreloadCache
|
||||
attr_reader :lookup, :visible_ids
|
||||
|
||||
def initialize(lookup:, visible_ids:)
|
||||
@lookup = lookup
|
||||
@visible_ids = visible_ids
|
||||
end
|
||||
|
||||
def fetch(identifier)
|
||||
lookup[identifier.to_s]
|
||||
end
|
||||
|
||||
def visible?(work_package_id)
|
||||
visible_ids.include?(work_package_id)
|
||||
end
|
||||
|
||||
EMPTY = new(lookup: {}.freeze, visible_ids: Set.new.freeze).freeze
|
||||
end
|
||||
|
||||
CACHE_KEY = :text_formatting_work_package_preload_cache
|
||||
private_constant :CACHE_KEY
|
||||
|
||||
include ::OpenProject::TextFormatting::Truncation
|
||||
# used for the work package quick links
|
||||
@@ -131,57 +154,31 @@ module OpenProject::TextFormatting
|
||||
]
|
||||
end
|
||||
|
||||
# Returns the preloaded WorkPackage for the given identifier (numeric
|
||||
# or semantic), or nil if no preload is active (classic mode, no `#N`
|
||||
# references) or the WP couldn't be resolved. Lookup keys are always
|
||||
# strings — see `index_by_id_and_identifier`.
|
||||
def self.work_package_for(identifier)
|
||||
RequestStore.store.dig(WORK_PACKAGES_LOOKUP_KEY, identifier.to_s)
|
||||
end
|
||||
|
||||
# The main lookup is unscoped so labels resolve regardless of
|
||||
# current-user permissions. This predicate gates whether the
|
||||
# link handler emits a navigable anchor or a plain-text label.
|
||||
def self.visible_to_current_user?(work_package_id)
|
||||
RequestStore.store[VISIBLE_WORK_PACKAGE_IDS_KEY]&.include?(work_package_id) || false
|
||||
# The active preload cache for the current render, or an empty
|
||||
# singleton when no preload is in scope (classic mode, no `#N`
|
||||
# references, or rendering outside `with_preloaded_resources`).
|
||||
def self.current_cache
|
||||
RequestStore.store[CACHE_KEY] || WorkPackagePreloadCache::EMPTY
|
||||
end
|
||||
|
||||
# Doc-level preload called by `PatternMatcherFilter`. Save/restores
|
||||
# the lookup so a nested `format_text` (e.g. custom-field formatter
|
||||
# the cache so a nested `format_text` (e.g. custom-field formatter
|
||||
# re-entering the pipeline) doesn't clobber the outer render. Classic
|
||||
# mode skips the load — `display_id` collapses to numeric, so the
|
||||
# link handler can render from the matched id alone.
|
||||
def self.with_preloaded_resources(doc, _context)
|
||||
previous = stash_preload_state
|
||||
previous = RequestStore.store[CACHE_KEY]
|
||||
return yield unless Setting::WorkPackageIdentifier.semantic?
|
||||
|
||||
identifiers = collect_work_package_identifiers(doc)
|
||||
return yield if identifiers.empty?
|
||||
|
||||
install_preload_state(*build_lookup(identifiers))
|
||||
RequestStore.store[CACHE_KEY] = build_cache(identifiers)
|
||||
yield
|
||||
ensure
|
||||
restore_preload_state(previous)
|
||||
RequestStore.store[CACHE_KEY] = previous
|
||||
end
|
||||
|
||||
def self.stash_preload_state
|
||||
[RequestStore.store[WORK_PACKAGES_LOOKUP_KEY],
|
||||
RequestStore.store[VISIBLE_WORK_PACKAGE_IDS_KEY]]
|
||||
end
|
||||
private_class_method :stash_preload_state
|
||||
|
||||
def self.install_preload_state(lookup, visible_ids)
|
||||
RequestStore.store[WORK_PACKAGES_LOOKUP_KEY] = lookup
|
||||
RequestStore.store[VISIBLE_WORK_PACKAGE_IDS_KEY] = visible_ids
|
||||
end
|
||||
private_class_method :install_preload_state
|
||||
|
||||
def self.restore_preload_state(previous)
|
||||
RequestStore.store[WORK_PACKAGES_LOOKUP_KEY] = previous[0]
|
||||
RequestStore.store[VISIBLE_WORK_PACKAGE_IDS_KEY] = previous[1]
|
||||
end
|
||||
private_class_method :restore_preload_state
|
||||
|
||||
def self.collect_work_package_identifiers(doc)
|
||||
identifiers = Set.new
|
||||
doc.search(".//text()").each do |node|
|
||||
@@ -218,14 +215,15 @@ module OpenProject::TextFormatting
|
||||
# for historical aliases — the loaded row carries only the current
|
||||
# identifier, so unmapped inputs are filled in from
|
||||
# `WorkPackageSemanticAlias`.
|
||||
def self.build_lookup(identifiers)
|
||||
def self.build_cache(identifiers)
|
||||
work_packages = WorkPackage.where_display_id_in(*identifiers).select(:id, :identifier).to_a
|
||||
all_wp_ids = work_packages.map(&:id)
|
||||
visible_ids = WorkPackage.visible.where(id: all_wp_ids).pluck(:id).to_set
|
||||
lookup = index_by_id_and_identifier(work_packages)
|
||||
fold_in_alias_keys(lookup, identifiers, all_wp_ids:)
|
||||
[lookup, visible_ids]
|
||||
WorkPackagePreloadCache.new(lookup:, visible_ids:)
|
||||
end
|
||||
private_class_method :build_cache
|
||||
|
||||
# Keys are stringified at write time so callers can read with a single
|
||||
# `identifier.to_s` regardless of whether the input is a numeric id or
|
||||
|
||||
@@ -94,17 +94,17 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
|
||||
inner_doc = Nokogiri::HTML.fragment("##{inner.id}")
|
||||
|
||||
matcher.with_preloaded_resources(outer_doc, {}) do
|
||||
expect(matcher.work_package_for(outer.id)).to eq(outer)
|
||||
expect(matcher.current_cache.fetch(outer.id)).to eq(outer)
|
||||
|
||||
matcher.with_preloaded_resources(inner_doc, {}) do
|
||||
expect(matcher.work_package_for(inner.id)).to eq(inner)
|
||||
expect(matcher.current_cache.fetch(inner.id)).to eq(inner)
|
||||
end
|
||||
|
||||
expect(matcher.work_package_for(outer.id))
|
||||
expect(matcher.current_cache.fetch(outer.id))
|
||||
.to eq(outer), "outer lookup should be restored after nested call"
|
||||
end
|
||||
|
||||
expect(matcher.work_package_for(outer.id)).to be_nil
|
||||
expect(matcher.current_cache.fetch(outer.id)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user