Skip to content

Commit 13abc55

Browse files
feature(protect_fields): Added inheritance handling (#752)
* feature(protect_fields): Added inheritance handling * fix(mr): merge request fixes * fix(protected_fields): added cache for isProtectFields * fix(mr): Merge request fixes * fix(test): fixed unit test
1 parent f84722d commit 13abc55

12 files changed

Lines changed: 332 additions & 36 deletions

File tree

‎dbzero/dbzero/dbzero.pyi‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -610,8 +610,9 @@ def rename_field(class_obj: type, from_name: str, to_name: str) -> None:
610610
def set_field_access(class_obj: type, account_id: Union[int, Sequence[int]], mode: Tuple[EnumValue, ...], *fields: str) -> None:
611611
"""Set protected-field access flags for one or more fields of a memo class.
612612
613-
The memo class must be declared with ``protect_fields=True``. Pass an empty
614-
``mode`` tuple to clear all access flags for the specified account and fields.
613+
The memo class must be declared with ``protect_fields=True`` or inherit it
614+
from a protected memo base. Pass an empty ``mode`` tuple to clear all access
615+
flags for the specified account and fields.
615616
"""
616617
...
617618

@@ -622,8 +623,9 @@ def get_field_access(class_obj: type, account_id: int) -> Iterable[Tuple[str, Tu
622623
def reset_protect_fields(class_obj: type) -> None:
623624
"""Clear the persisted protect_fields flag for a memo class.
624625
625-
The memo type must no longer be decorated with ``protect_fields=True``.
626-
Remove the argument or set it to ``False`` before calling this function.
626+
The memo type must no longer be decorated with ``protect_fields=True`` and
627+
must not inherit protected fields from a protected memo base. Remove the
628+
argument or set it to ``False`` before calling this function.
627629
"""
628630
...
629631

‎dbzero/dbzero/memo.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ def memo(cls: Optional[type] = None, **kwargs) -> type:
172172
If True, the persistent class is marked for field protection. Once the class is
173173
materialized, removing this argument from the Python definition does not clear
174174
the persisted flag; use reset_protect_fields on the dbzero Class object instead.
175+
Derived memo classes inherit field protection and cannot disable it.
175176
176177
Returns
177178
-------

‎python_tests/test_memo_protect_fields.py‎

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,25 @@ def __init__(self, count):
8181
setattr(self, f"init_field_{i}", values[i % len(values)](i))
8282

8383

84+
@db0.memo
85+
@dataclass
86+
class MemoUnprotectedBaseFieldsClass:
87+
base_value: str
88+
89+
90+
@db0.memo(protect_fields=True)
91+
@dataclass
92+
class MemoProtectedDerivedFieldsClass(MemoUnprotectedBaseFieldsClass):
93+
name: str
94+
value: int
95+
96+
97+
@db0.memo
98+
@dataclass
99+
class MemoImplicitlyProtectedDerivedFieldsClass(MemoProtectedDerivedFieldsClass):
100+
derived_value: float
101+
102+
84103
def get_memo_class_object(obj):
85104
return db0.get_memo_class(obj).get_class()
86105

@@ -242,6 +261,60 @@ def test_set_field_access_requires_protected_class(db0_fixture):
242261
assert False, "set_field_access should fail for unprotected classes"
243262

244263

264+
def test_protect_fields_is_inherited_by_derived_memo_classes(db0_fixture):
265+
obj = MemoImplicitlyProtectedDerivedFieldsClass("base", "alpha", 1, 1.5)
266+
267+
assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True
268+
db0.set_field_access(MemoImplicitlyProtectedDerivedFieldsClass, 123, (FieldAccess.READ,), "derived_value")
269+
270+
271+
def test_protect_fields_cannot_be_disabled_in_derived_memo_class(db0_fixture):
272+
with pytest.raises(RuntimeError, match="protect_fields.*base"):
273+
@db0.memo(protect_fields=False)
274+
@dataclass
275+
class ExplicitlyUnprotectedDerived(MemoProtectedDerivedFieldsClass):
276+
extra_value: str
277+
278+
279+
def test_set_field_access_still_rejects_unprotected_base_of_protected_class(db0_fixture):
280+
MemoImplicitlyProtectedDerivedFieldsClass("base", "alpha", 1, 1.5)
281+
282+
with pytest.raises(RuntimeError, match="protected fields"):
283+
db0.set_field_access(MemoUnprotectedBaseFieldsClass, 123, (FieldAccess.READ,), "base_value")
284+
285+
286+
def test_reset_protect_fields_rejects_inherited_protection(db0_fixture):
287+
obj = MemoImplicitlyProtectedDerivedFieldsClass("base", "alpha", 1, 1.5)
288+
memo_class = get_memo_class_object(obj)
289+
290+
with pytest.raises(RuntimeError, match="inherits.*protect_fields"):
291+
db0.reset_protect_fields(MemoImplicitlyProtectedDerivedFieldsClass)
292+
293+
assert memo_class.get_type_flags()["protect_fields"] is True
294+
295+
296+
def test_class_reset_protect_fields_rejects_inherited_protection(db0_fixture):
297+
obj = MemoImplicitlyProtectedDerivedFieldsClass("base", "alpha", 1, 1.5)
298+
memo_class = get_memo_class_object(obj)
299+
300+
with pytest.raises(RuntimeError, match="inherits.*protect_fields"):
301+
memo_class.reset_protect_fields()
302+
303+
assert memo_class.get_type_flags()["protect_fields"] is True
304+
305+
306+
def test_explicit_true_is_allowed_on_class_derived_from_protected_base(db0_fixture):
307+
@db0.memo(protect_fields=True)
308+
@dataclass
309+
class ExplicitlyProtectedDerived(MemoProtectedDerivedFieldsClass):
310+
extra_value: str
311+
312+
obj = ExplicitlyProtectedDerived("base", "alpha", 1, "extra")
313+
314+
assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True
315+
db0.set_field_access(ExplicitlyProtectedDerived, 123, (FieldAccess.READ,), "extra_value")
316+
317+
245318
def test_set_field_access_accepts_unknown_field_names(db0_fixture):
246319
MemoProtectedFieldsClass("alpha", 1)
247320

@@ -376,6 +449,156 @@ def test_protected_field_getter_requires_read_access(db0_fixture):
376449
_ = obj.value
377450

378451

452+
def test_protected_field_getter_does_not_inherit_base_class_masks(db0_fixture):
453+
account_id = ContextVar("protected_inheritance_account_id")
454+
obj = MemoImplicitlyProtectedDerivedFieldsClass("base", "alpha", 1, 1.5)
455+
db0.set_field_access(MemoProtectedDerivedFieldsClass, 123, (FieldAccess.READ,), "base_value", "name")
456+
db0.set_field_access(MemoImplicitlyProtectedDerivedFieldsClass, 123, (FieldAccess.READ,), "derived_value")
457+
db0._init_data_masking(account_id)
458+
account_id.set(123)
459+
460+
assert obj.derived_value == 1.5
461+
with pytest.raises(PermissionError, match="read"):
462+
_ = obj.base_value
463+
with pytest.raises(PermissionError, match="read"):
464+
_ = obj.name
465+
with pytest.raises(PermissionError, match="read"):
466+
_ = obj.value
467+
468+
469+
def test_protected_field_getter_uses_derived_class_own_masks(db0_fixture):
470+
account_id = ContextVar("protected_inheritance_allow_override_account_id")
471+
obj = MemoImplicitlyProtectedDerivedFieldsClass("base", "alpha", 1, 1.5)
472+
db0.set_field_access(MemoProtectedDerivedFieldsClass, 123, (), "name")
473+
db0.set_field_access(MemoImplicitlyProtectedDerivedFieldsClass, 123, (FieldAccess.READ,), "name")
474+
db0._init_data_masking(account_id)
475+
account_id.set(123)
476+
477+
assert obj.name == "alpha"
478+
with pytest.raises(PermissionError, match="read"):
479+
_ = obj.value
480+
481+
482+
def test_inherited_protect_fields_survives_reopen_without_parameter(db0_fixture):
483+
@db0.memo(id="dbzero-software/dbzero/tests/protected-inherited-reopen-base")
484+
@dataclass
485+
class ReopenBase:
486+
base_value: str
487+
488+
@db0.memo(id="dbzero-software/dbzero/tests/protected-inherited-reopen-mid", protect_fields=True)
489+
@dataclass
490+
class ReopenMid(ReopenBase):
491+
name: str
492+
493+
@db0.memo(id="dbzero-software/dbzero/tests/protected-inherited-reopen-derived")
494+
@dataclass
495+
class ReopenDerived(ReopenMid):
496+
value: int
497+
498+
obj = ReopenDerived("base", "alpha", 1)
499+
obj_id = db0.uuid(obj)
500+
assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True
501+
db0.commit()
502+
503+
db0.close()
504+
db0.init(DB0_DIR)
505+
db0.open("my-test-prefix")
506+
507+
@db0.memo(id="dbzero-software/dbzero/tests/protected-inherited-reopen-base")
508+
@dataclass
509+
class ReopenBaseAfter:
510+
base_value: str
511+
512+
@db0.memo(id="dbzero-software/dbzero/tests/protected-inherited-reopen-mid")
513+
@dataclass
514+
class ReopenMidAfter(ReopenBaseAfter):
515+
name: str
516+
517+
@db0.memo(id="dbzero-software/dbzero/tests/protected-inherited-reopen-derived")
518+
@dataclass
519+
class ReopenDerivedAfter(ReopenMidAfter):
520+
value: int
521+
522+
obj = db0.fetch(ReopenDerivedAfter, obj_id)
523+
assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True
524+
db0.set_field_access(ReopenDerivedAfter, 123, (FieldAccess.READ,), "value")
525+
526+
527+
def test_derived_class_inherits_protect_fields_enabled_on_base_after_materialization(db0_fixture):
528+
@db0.memo(id="dbzero-software/dbzero/tests/protected-base-enabled-later-base")
529+
@dataclass
530+
class BaseBefore:
531+
base_value: str
532+
533+
@db0.memo(id="dbzero-software/dbzero/tests/protected-base-enabled-later-derived")
534+
@dataclass
535+
class DerivedBefore(BaseBefore):
536+
value: int
537+
538+
obj = DerivedBefore("base", 1)
539+
obj_id = db0.uuid(obj)
540+
assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is False
541+
with pytest.raises(RuntimeError, match="protected fields"):
542+
db0.set_field_access(DerivedBefore, 123, (FieldAccess.READ,), "value")
543+
db0.commit()
544+
545+
db0.close()
546+
db0.init(DB0_DIR)
547+
db0.open("my-test-prefix")
548+
549+
@db0.memo(id="dbzero-software/dbzero/tests/protected-base-enabled-later-base", protect_fields=True)
550+
@dataclass
551+
class BaseAfter:
552+
base_value: str
553+
554+
@db0.memo(id="dbzero-software/dbzero/tests/protected-base-enabled-later-derived")
555+
@dataclass
556+
class DerivedAfter(BaseAfter):
557+
value: int
558+
559+
obj = db0.fetch(DerivedAfter, obj_id)
560+
assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True
561+
db0.set_field_access(DerivedAfter, 123, (FieldAccess.READ,), "value")
562+
563+
564+
def test_derived_class_stops_inheriting_protect_fields_after_base_reset(db0_fixture):
565+
@db0.memo(id="dbzero-software/dbzero/tests/protected-base-reset-base", protect_fields=True)
566+
@dataclass
567+
class BaseBefore:
568+
base_value: str
569+
570+
@db0.memo(id="dbzero-software/dbzero/tests/protected-base-reset-derived")
571+
@dataclass
572+
class DerivedBefore(BaseBefore):
573+
value: int
574+
575+
obj = DerivedBefore("base", 1)
576+
obj_id = db0.uuid(obj)
577+
assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True
578+
db0.commit()
579+
580+
db0.close()
581+
db0.init(DB0_DIR)
582+
db0.open("my-test-prefix")
583+
584+
@db0.memo(id="dbzero-software/dbzero/tests/protected-base-reset-base", protect_fields=False)
585+
@dataclass
586+
class BaseAfter:
587+
base_value: str
588+
589+
@db0.memo(id="dbzero-software/dbzero/tests/protected-base-reset-derived")
590+
@dataclass
591+
class DerivedAfter(BaseAfter):
592+
value: int
593+
594+
obj = db0.fetch(DerivedAfter, obj_id)
595+
db0.reset_protect_fields(BaseAfter)
596+
597+
assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is False
598+
with pytest.raises(RuntimeError, match="protected fields"):
599+
db0.set_field_access(DerivedAfter, 123, (FieldAccess.READ,), "value")
600+
601+
379602
def test_protected_field_getter_returns_missing_value_placeholder(db0_fixture):
380603
account_id = ContextVar("protected_placeholder_account_id")
381604
missing_value = object()

‎src/dbzero/bindings/python/Memo.cpp‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include "Migration.hpp"
1313
#include "PyHash.hpp"
1414
#include "DataMasking.hpp"
15+
#include <optional>
1516
#include <dbzero/object_model/object.hpp>
1617
#include <dbzero/object_model/class.hpp>
1718
#include <dbzero/object_model/class/FieldMask.hpp>
@@ -902,7 +903,7 @@ namespace db0::python
902903

903904
PyObject *wrapPyType(PyTypeObject *base_class, bool is_singleton, bool no_default_tags, const char *prefix_name,
904905
const char *type_id, const char *file_name, std::vector<std::string> &&init_vars, PyObject *py_dyn_prefix_callable,
905-
std::vector<Migration> &&migrations, bool no_cache, bool immutable, bool protect_fields)
906+
std::vector<Migration> &&migrations, bool no_cache, bool immutable, std::optional<bool> protect_fields_option)
906907
{
907908
auto py_class = Py_BORROW(base_class);
908909
auto py_module = Py_OWN(findModule(*Py_OWN(PyObject_GetAttrString((PyObject*)*py_class, "__module__"))));
@@ -936,6 +937,15 @@ namespace db0::python
936937
return nullptr;
937938
}
938939

940+
auto base_memo_type = PyToolkit::getBaseMemoType(*new_type);
941+
bool inherited_protect_fields = base_memo_type
942+
&& MemoTypeDecoration::get(base_memo_type).getFlags()[MemoOptions::PROTECT_FIELDS];
943+
if (inherited_protect_fields && protect_fields_option == false) {
944+
THROWF(db0::InputException)
945+
<< "Cannot set protect_fields=False on a class derived from a protect_fields base class";
946+
}
947+
bool protect_fields = protect_fields_option.value_or(false);
948+
939949
MemoFlags type_flags = no_default_tags ? MemoFlags { MemoOptions::NO_DEFAULT_TAGS } : MemoFlags();
940950
if (no_cache) {
941951
type_flags.set(MemoOptions::NO_CACHE);
@@ -1000,7 +1010,10 @@ namespace db0::python
10001010
bool no_default_tags = py_no_default_tags && PyObject_IsTrue(py_no_default_tags);
10011011
bool no_cache = py_no_cache && PyObject_IsTrue(py_no_cache);
10021012
bool immutable = py_immutable && PyObject_IsTrue(py_immutable);
1003-
bool protect_fields = py_protect_fields && PyObject_IsTrue(py_protect_fields);
1013+
std::optional<bool> protect_fields_option;
1014+
if (py_protect_fields) {
1015+
protect_fields_option = PyObject_IsTrue(py_protect_fields);
1016+
}
10041017
const char *prefix_name = (py_prefix_name && py_prefix_name != Py_None) ? PyUnicode_AsUTF8(py_prefix_name) : nullptr;
10051018
const char *type_id = py_type_id ? PyUnicode_AsUTF8(py_type_id) : nullptr;
10061019
const char *file_name = (py_file_name && py_file_name != Py_None) ? PyUnicode_AsUTF8(py_file_name) : nullptr;
@@ -1035,7 +1048,7 @@ namespace db0::python
10351048

10361049
auto migrations = extractMigrations(py_migrations);
10371050
return wrapPyType(castToType(class_obj), is_singleton, no_default_tags, prefix_name, type_id, file_name,
1038-
std::move(init_vars), py_dyn_prefix, std::move(migrations), no_cache, immutable, protect_fields
1051+
std::move(init_vars), py_dyn_prefix, std::move(migrations), no_cache, immutable, protect_fields_option
10391052
);
10401053
}
10411054

‎src/dbzero/bindings/python/PyAPI.cpp‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -944,7 +944,7 @@ namespace db0::python
944944
auto fixture_uuid = MemoTypeDecoration::get(reinterpret_cast<PyTypeObject*>(py_type)).getFixtureUUID();
945945
auto fixture = PyToolkit::getPyWorkspace().getWorkspace().getFixture(fixture_uuid, AccessType::READ_WRITE);
946946
auto &class_factory = fixture->get<ClassFactory>();
947-
auto type = class_factory.getExistingType(reinterpret_cast<PyTypeObject*>(py_type));
947+
auto type = class_factory.getOrCreateType(reinterpret_cast<PyTypeObject*>(py_type));
948948
if (!type->isProtectFields()) {
949949
THROWF(db0::InputException) << "Class " << type->getName() << " does not have protected fields enabled";
950950
}
@@ -979,7 +979,7 @@ namespace db0::python
979979
auto fixture_uuid = MemoTypeDecoration::get(reinterpret_cast<PyTypeObject*>(py_type)).getFixtureUUID();
980980
auto fixture = PyToolkit::getPyWorkspace().getWorkspace().getFixture(fixture_uuid, AccessType::READ_WRITE);
981981
auto &class_factory = fixture->get<ClassFactory>();
982-
auto type = class_factory.getExistingType(reinterpret_cast<PyTypeObject*>(py_type));
982+
auto type = class_factory.getOrCreateType(reinterpret_cast<PyTypeObject*>(py_type));
983983
if (!type->isProtectFields()) {
984984
THROWF(db0::InputException) << "Class " << type->getName() << " does not have protected fields enabled";
985985
}
@@ -1019,16 +1019,22 @@ namespace db0::python
10191019

10201020
auto memo_type = reinterpret_cast<PyTypeObject*>(py_type);
10211021
auto &decor = MemoTypeDecoration::get(memo_type);
1022-
if (decor.getFlags()[MemoOptions::PROTECT_FIELDS]) {
1023-
THROWF(db0::InputException)
1024-
<< "Type is still decorated with protect_fields=True; remove it or set protect_fields=False first";
1025-
}
1026-
10271022
using ClassFactory = db0::object_model::ClassFactory;
10281023
auto fixture_uuid = decor.getFixtureUUID(AccessType::READ_WRITE);
10291024
auto fixture = PyToolkit::getPyWorkspace().getWorkspace().getFixture(fixture_uuid, AccessType::READ_WRITE);
10301025
auto &class_factory = fixture->get<ClassFactory>();
1031-
auto type = class_factory.getExistingType(memo_type);
1026+
auto type = class_factory.getOrCreateType(memo_type);
1027+
1028+
if (type->getBaseClassPtr() && type->getBaseClassPtr()->isProtectFields()) {
1029+
THROWF(db0::InputException)
1030+
<< "Cannot disable protected fields on class " << type->getName()
1031+
<< " because it inherits from a protect_fields base class";
1032+
}
1033+
1034+
if (decor.getFlags()[MemoOptions::PROTECT_FIELDS]) {
1035+
THROWF(db0::InputException)
1036+
<< "Type is still decorated with protect_fields=True; remove it or set protect_fields=False first";
1037+
}
10321038

10331039
db0::FixtureLock lock(fixture);
10341040
type->resetProtectFields();

‎src/dbzero/bindings/python/PyInternalAPI.cpp‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ namespace db0::python
186186
// validate type if requested (no validation for MemoBase)
187187
if (py_expected_type && !PyToolkit::getTypeManager().isMemoBase(py_expected_type)) {
188188
// in other cases the type must match the actual object type
189-
auto expected_class = class_factory.getExistingType(py_expected_type);
189+
auto expected_class = class_factory.getOrCreateType(py_expected_type);
190190
// honor class-specific access flags (e.g. type-level no_cache)
191191
auto result = PyToolkit::unloadObject(fixture, addr, class_factory, nullptr, addr.getInstanceId(),
192192
expected_class->getInstanceFlags()
@@ -1100,4 +1100,4 @@ namespace db0::python
11001100
template PyObject *getMaterializedMemoObject(MemoObject *);
11011101
template PyObject *getMaterializedMemoObject(MemoImmutableObject *);
11021102

1103-
}
1103+
}

0 commit comments

Comments
 (0)