Skip to content

Commit 2e5c09d

Browse files
committed
Add class_factory parameter to DB and ZConfig class-factory option
DB.__init__() opens a connection to verify the root object exists, and that connection is returned to the pool for future reuse. Connections capture the classFactory reference at creation time (in Connection.__init__ via ObjectReader), so setting db.classFactory after DB.__init__() does not apply to connections already in the pool. This is a problem for Zope, which overrides DB.classFactory after construction to provide graceful handling of broken/uninstalled objects. The first connection handed out after startup may silently use the wrong class factory. Add a class_factory parameter to DB.__init__() that is set before any connection is opened, and a corresponding class-factory option to the ZConfig database configuration schema. This allows frameworks like Zope to pass the class factory at construction time.
1 parent 21ae6a7 commit 2e5c09d

6 files changed

Lines changed: 133 additions & 1 deletion

File tree

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
6.3 (unreleased)
66
----------------
77

8+
- Add ``class_factory`` parameter to ``DB.__init__()`` and
9+
``class-factory`` option to ZConfig database configuration.
10+
See `issue #420 <https://github.com/zopefoundation/ZODB/issues/420>`_.
11+
812

913
6.2 (2026-01-23)
1014
================

src/ZODB/DB.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ def __init__(self,
360360
databases=None,
361361
xrefs=True,
362362
large_record_size=1 << 24,
363+
class_factory=None,
363364
**storage_args):
364365
"""Create an object database.
365366
@@ -407,6 +408,13 @@ def __init__(self,
407408
:param int large_record_size: When object records are saved
408409
that are larger than this, a warning is issued,
409410
suggesting that blobs should be used instead.
411+
:param callable class_factory: A callable
412+
``class_factory(connection, module_name, global_name)``
413+
used to resolve persistent object classes during
414+
deserialization. If not provided, the default
415+
``DB.classFactory`` method is used; it wraps
416+
:func:`ZODB.broken.find_global` to provide this
417+
three-argument interface.
410418
:param storage_args: Extra keywork arguments passed to a
411419
storage constructor if a path name or None is passed as
412420
the storage argument.
@@ -465,6 +473,9 @@ def __init__(self,
465473

466474
self.large_record_size = large_record_size
467475

476+
if class_factory is not None:
477+
self.classFactory = class_factory
478+
468479
# Make sure we have a root:
469480
with self.transaction('initial database creation') as conn:
470481
try:
@@ -847,7 +858,6 @@ def setActivityMonitor(self, am):
847858
self._activity_monitor = am
848859

849860
def classFactory(self, connection, modulename, globalname):
850-
# Zope will rebind this method to arbitrary user code at runtime.
851861
return find_global(modulename, globalname)
852862

853863
def setCacheSize(self, size):

src/ZODB/component.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,16 @@
324324
currently possible) are disallowed.
325325
</description>
326326
</key>
327+
<key name="class-factory" datatype=".importable_name">
328+
<description>
329+
A callable used to resolve persistent object classes during
330+
deserialization. The database-level class factory is called as
331+
``class_factory(connection, module_name, global_name)``.
332+
Specify a Python dotted-path name.
333+
If not provided, the database uses its default class factory,
334+
which delegates to ``ZODB.broken.find_global``.
335+
</description>
336+
</key>
327337

328338
</sectiontype>
329339

src/ZODB/config.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,38 @@
1313
##############################################################################
1414
"""Open database and storage from a configuration."""
1515
import os
16+
import traceback
1617
from io import StringIO
1718

1819
import ZConfig
1920

2021
import ZODB
2122

2223

24+
def importable_name(name):
25+
# A datatype that converts a Python dotted-path-name to an object
26+
try:
27+
components = name.split('.')
28+
start = components[0]
29+
g = globals()
30+
package = __import__(start, g, g)
31+
modulenames = [start]
32+
for component in components[1:]:
33+
modulenames.append(component)
34+
try:
35+
package = getattr(package, component)
36+
except AttributeError:
37+
n = '.'.join(modulenames)
38+
package = __import__(n, g, g, component)
39+
return package
40+
except ImportError:
41+
IO = StringIO()
42+
traceback.print_exc(file=IO)
43+
raise ValueError(
44+
f'The object named by {name!r} could not be imported\n'
45+
f'{IO.getvalue()}')
46+
47+
2348
db_schema_path = os.path.join(ZODB.__path__[0], "config.xml")
2449
_db_schema = None
2550

@@ -150,6 +175,7 @@ def _option(name, oname=None):
150175
_option('pool_timeout')
151176
_option('allow_implicit_cross_references', 'xrefs')
152177
_option('large_record_size')
178+
_option('class_factory')
153179

154180
try:
155181
return ZODB.DB(

src/ZODB/tests/testConfig.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,56 @@ def database_xrefs_config():
128128
"""
129129

130130

131+
def dummy_class_factory(connection, module_name, global_name):
132+
"""Helper function for database_class_factory_config
133+
"""
134+
135+
136+
def database_class_factory_config():
137+
r"""The class-factory option sets the class factory used for
138+
deserializing persistent objects.
139+
140+
Without it, the default DB.classFactory method is used:
141+
142+
>>> db = ZODB.config.databaseFromString(
143+
... "<zodb>\n<mappingstorage>\n</mappingstorage>\n</zodb>\n")
144+
>>> import types
145+
>>> isinstance(db.classFactory, types.MethodType)
146+
True
147+
>>> db.close()
148+
149+
With a dotted name, the specified callable is used:
150+
151+
>>> db = ZODB.config.databaseFromString(
152+
... "<zodb>\nclass-factory ZODB.tests.testConfig.dummy_class_factory\n"
153+
... "<mappingstorage>\n</mappingstorage>\n</zodb>\n")
154+
>>> db.classFactory is dummy_class_factory
155+
True
156+
157+
The factory is available to connections, including the one
158+
pooled during __init__:
159+
160+
>>> conn = db.open()
161+
>>> conn._reader._factory is dummy_class_factory
162+
True
163+
>>> conn.close()
164+
>>> db.close()
165+
166+
When the class factory is set to a non-existent callable, a detailed
167+
error is raised:
168+
>>> db = ZODB.config.databaseFromString(
169+
... "<zodb>\n"
170+
... "class-factory ZODB.tests.testConfig.non_existent_class_factory\n"
171+
... "<mappingstorage>\n</mappingstorage>\n</zodb>\n"
172+
... ) # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE
173+
Traceback (most recent call last):
174+
...
175+
ZConfig.DataConversionError: The object named by 'ZODB.tests.testConfig.non_existent_class_factory' could not be imported
176+
Traceback (most recent call last):
177+
...
178+
""" # noqa: E501
179+
180+
131181
def multi_atabases():
132182
r"""If there are multiple codb sections -> multidatabase
133183

src/ZODB/tests/testDB.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,38 @@ def passing_None_to_DB():
324324
"""
325325

326326

327+
def class_factory_parameter():
328+
"""The class_factory parameter lets you set a custom class resolver
329+
at construction time, before any connection is created.
330+
331+
>>> from ZODB.broken import find_global
332+
>>> calls = []
333+
>>> def my_factory(conn, module, name):
334+
... calls.append((module, name))
335+
... return find_global(module, name)
336+
337+
>>> db = ZODB.DB(None, class_factory=my_factory)
338+
>>> db.classFactory is my_factory
339+
True
340+
341+
The connection pooled during __init__ has the custom factory:
342+
343+
>>> conn = db.open()
344+
>>> conn._reader._factory is my_factory
345+
True
346+
>>> conn.close()
347+
348+
Reused connections also have the custom factory:
349+
350+
>>> conn2 = db.open()
351+
>>> conn2._reader._factory is my_factory
352+
True
353+
>>> conn2.close()
354+
355+
>>> db.close()
356+
"""
357+
358+
327359
def open_convenience():
328360
"""Often, we just want to open a single connection.
329361

0 commit comments

Comments
 (0)