1212from plone .pgcatalog .extraction import decode_meta
1313from Products .ZCatalog .interfaces import ICatalogBrain
1414from ZODB .utils import p64
15+ from zope .component .hooks import getSite
16+ from zope .globalrequest import getRequest
1517from zope .interface import implementer
1618from zope .interface .common .sequence import IFiniteSequence
1719from ZTUtils .Lazy import Lazy
2527_PREFETCH_BATCH = int (os .environ .get ("PGCATALOG_PREFETCH_BATCH" , "100" ))
2628
2729
30+ def _traversal_root ():
31+ """Return a traversable Zope root (Application), or None.
32+
33+ Brains avoid holding a reference to the catalog tool so they can
34+ be cached / pickled / re-queued without dragging the Acquisition
35+ chain along. The root is resolved at call time via
36+ ``zope.component.hooks.getSite`` first (works in request and
37+ thread contexts that have a local-site hook), then falls back to
38+ the Zope traversal root from ``zope.globalrequest.getRequest``.
39+
40+ Returns ``None`` when neither is available — the caller (``getObject``
41+ / ``_unrestrictedGetObject``) then reports "object not found"
42+ rather than raising, matching the long-standing ZCatalog contract.
43+ """
44+ site = getSite ()
45+ if site is not None :
46+ return site .getPhysicalRoot ()
47+ request = getRequest ()
48+ if request is not None :
49+ parents = getattr (request , "PARENTS" , None )
50+ if parents :
51+ return parents [- 1 ]
52+ return None
53+
54+
2855@implementer (ICatalogBrain )
2956class PGCatalogBrain :
3057 """Lightweight catalog brain backed by a PostgreSQL row.
3158
3259 Implements the essential ICatalogBrain interface without requiring
33- Zope Acquisition or Record infrastructure.
60+ Zope Acquisition or Record infrastructure. Deliberately does NOT
61+ store a reference to the catalog tool — traversal resolves the
62+ portal root lazily via ``_traversal_root()``, so a brain remains
63+ safe to cache / pickle across request boundaries.
3464
3565 Supports two modes:
3666 - **Eager** (default): row contains ``idx`` dict, metadata access is direct.
@@ -40,14 +70,18 @@ class PGCatalogBrain:
4070
4171 Args:
4272 row: dict with keys zoid, path (and optionally idx)
43- catalog: reference to the catalog tool (for getObject traversal)
73+ catalog: accepted for backward compatibility with callers still
74+ passing the catalog tool — ignored. Kept until the next
75+ major version for signature compatibility.
4476 """
4577
46- __slots__ = ("_catalog" , " _result_set" , "_row" )
78+ __slots__ = ("_result_set" , "_row" )
4779
4880 def __init__ (self , row , catalog = None ):
81+ # ``catalog`` is accepted for backward compat with callers
82+ # still passing the tool — intentionally unused.
83+ del catalog
4984 self ._row = row
50- self ._catalog = catalog
5185 self ._result_set = None
5286
5387 # -- ICatalogBrain methods -----------------------------------------------
@@ -59,13 +93,15 @@ def getPath(self):
5993 def getURL (self , relative = 0 ):
6094 """Generate a URL for this record.
6195
62- In standalone mode (no request), returns the path.
63- When integrated with Zope (Phase 6), uses request.physicalPathToURL.
96+ Uses ``zope.globalrequest.getRequest()`` — no reference to the
97+ catalog tool. Keeping brains catalog-independent lets callers
98+ cache / pickle / re-queue them without dragging the acquisition
99+ chain along. Returns the plain path in standalone / script
100+ mode when no request is active.
64101 """
65- if self ._catalog is not None :
66- request = getattr (self ._catalog , "REQUEST" , None )
67- if request is not None :
68- return request .physicalPathToURL (self .getPath (), relative )
102+ request = getRequest ()
103+ if request is not None :
104+ return request .physicalPathToURL (self .getPath (), relative )
69105 return self .getPath ()
70106
71107 def _maybe_prefetch (self ):
@@ -83,10 +119,7 @@ def _maybe_prefetch(self):
83119 result_set ._maybe_prefetch_objects (self )
84120
85121 def getObject (self ):
86- """Return the object for this record.
87-
88- Requires a catalog with traversal support (Phase 6 integration).
89- Returns None if the object cannot be found.
122+ """Return the object for this record, or None if not found.
90123
91124 Mirrors upstream ``Products.ZCatalog.CatalogBrains.AbstractCatalogBrain``
92125 semantics: the catalog filter already authorized access to the
@@ -96,30 +129,31 @@ def getObject(self):
96129 e.g. an internal calendar container publishing public events)
97130 raise ``Unauthorized`` on the parent even though the user is
98131 allowed to see the target.
132+
133+ Resolves the traversal root lazily via ``_traversal_root()`` so
134+ brains stay catalog-independent (cache-friendly).
99135 """
100- if self ._catalog is None :
136+ root = _traversal_root ()
137+ if root is None :
101138 return None
102139 self ._maybe_prefetch ()
103140 path = self .getPath ().split ("/" )
104141 if not path :
105142 return None
106143 try :
107- parent = (
108- self ._catalog .unrestrictedTraverse (path [:- 1 ])
109- if len (path ) > 1
110- else self ._catalog
111- )
144+ parent = root .unrestrictedTraverse (path [:- 1 ]) if len (path ) > 1 else root
112145 return parent .restrictedTraverse (path [- 1 ])
113146 except (KeyError , AttributeError ):
114147 return None
115148
116149 def _unrestrictedGetObject (self ):
117- """Return the object without security checks."""
118- if self ._catalog is None :
150+ """Return the object without security checks, or None if not found."""
151+ root = _traversal_root ()
152+ if root is None :
119153 return None
120154 self ._maybe_prefetch ()
121155 try :
122- return self . _catalog .unrestrictedTraverse (self .getPath ())
156+ return root .unrestrictedTraverse (self .getPath ())
123157 except (KeyError , AttributeError ):
124158 return None
125159
@@ -274,14 +308,15 @@ class CatalogSearchResults(Lazy):
274308 using the same connection (and thus the same REPEATABLE READ snapshot).
275309 """
276310
277- def __init__ (self , brains , actual_result_count = None , conn = None ):
311+ def __init__ (self , brains , actual_result_count = None , conn = None , catalog = None ):
278312 self ._brains = list (brains )
279313 self .actual_result_count = (
280314 actual_result_count
281315 if actual_result_count is not None
282316 else len (self ._brains )
283317 )
284318 self ._conn = conn
319+ self ._catalog = catalog # used only for ZODB prefetch (needs _p_jar)
285320 self ._idx_loaded = conn is None # eager if no conn
286321 self ._prefetched_ranges = set () # set of (start, end) tuples
287322 # Build index for O(1) brain → position lookup (used by prefetch)
@@ -360,11 +395,12 @@ def _maybe_prefetch_objects(self, brain):
360395 if not batch :
361396 return
362397
363- # Get storage from the catalog reference on the first brain.
364- catalog = batch [0 ]._catalog
365- if catalog is None :
398+ # Get storage from the result-set's catalog reference (brains
399+ # are catalog-independent — the tool lives only on the
400+ # transient result set).
401+ if self ._catalog is None :
366402 return
367- jar = getattr (catalog , "_p_jar" , None )
403+ jar = getattr (self . _catalog , "_p_jar" , None )
368404 if jar is None :
369405 return
370406 storage = getattr (jar , "_storage" , None )
@@ -390,6 +426,7 @@ def __getitem__(self, index):
390426 result ,
391427 self .actual_result_count ,
392428 conn = self ._conn ,
429+ catalog = self ._catalog ,
393430 )
394431 # Re-wire brains to new result set if idx not yet loaded
395432 if not self ._idx_loaded :
0 commit comments