Skip to content

Commit 36312c0

Browse files
wskozlowskiWojtek
andauthored
Feature/1217 (#749)
* protect fields * FieldSafe integration * get_field_access + more tests * post-review fixes --------- Co-authored-by: Wojtek <wskozlowski@dbzero.io>
1 parent b6e9469 commit 36312c0

31 files changed

Lines changed: 1153 additions & 33 deletions

‎AGENTS.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ Types derived from `v_object` should follow the project-wide constructor pattern
4141

4242
- Use camelCase for local helper variables, lambdas, and method names in C++ code.
4343

44+
### Python binding wrapper access
45+
46+
When accessing a C++ object stored inside a Python wrapper, use `ext()` for read-only operations and for operations that are explicitly documented as non-mutating wrapper/runtime attachment updates.
47+
48+
Use `modifyExt()` for real object mutations, especially durable state changes. Do not use `const_cast` on `ext()` to call a mutating method. If a wrapper currently exposes only a const object but needs a mutating API, change the wrapper type or access path so the mutation can go through `modifyExt()`.
49+
4450
### MorphingBIndex: address and type can change on mutation
4551

4652
A `MorphingBIndex` does not behave like a typical container. On mutation (`insert`, `erase`) it may morph into a different internal storage variant (itty / array_2..4 / vector / bindex), and the morph can change both its **address** and its **type**.

‎dbzero/dbzero/dbzero.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def load_dynamic(name, path):
1010

1111
def __bootstrap__():
1212
global __bootstrap__, __loader__, __file__
13-
paths = [os.path.join(os.path.split(__file__)[0]), "/src/dev/build/", "/usr/local/lib/python3/dist-packages/dbzero/"]
13+
paths = [os.path.join(os.path.split(__file__)[0]), "/src/dev/build/release", "/usr/local/lib/python3/dist-packages/dbzero/"]
1414
__file__ = None
1515
for path in paths:
1616
if os.path.isdir(path):

‎dbzero/dbzero/dbzero.pyi‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Type stubs for dbzero module.
33
"""
44

5-
from typing import Any, Optional, Iterable, Dict, List, Tuple, Union, Callable
5+
from typing import Any, Optional, Iterable, Dict, List, Tuple, Union, Callable, Sequence
66
from .interfaces import (
77
Memo, MemoWeakProxy, QueryObject, Tag, TagSet, EnumValue,
88
ListObject, IndexObject, TupleObject, SetObject, DictObject, ByteArrayObject,
@@ -607,6 +607,18 @@ def rename_field(class_obj: type, from_name: str, to_name: str) -> None:
607607
"""
608608
...
609609

610+
def set_field_access(class_obj: type, account_id: Union[int, Sequence[int]], mode: Tuple[EnumValue, ...], *fields: str) -> None:
611+
"""Set protected-field access flags for one or more fields of a memo class.
612+
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.
615+
"""
616+
...
617+
618+
def get_field_access(class_obj: type, account_id: int) -> Iterable[Tuple[str, Tuple[str, ...]]]:
619+
"""Return protected-field access flags for a memo class and account."""
620+
...
621+
610622
# Cache management
611623

612624
def clear_cache() -> None:

‎dbzero/dbzero/memo.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ def memo(cls: Optional[type] = None, **kwargs) -> type:
168168
no_default_tags : bool, default False
169169
If True, dbzero will not automatically add default system tags (such as the class
170170
name) to new instances of this class.
171+
protect_fields : bool, default False
172+
If True, the persistent class is marked for field protection. Once the class is
173+
materialized, removing this argument from the Python definition does not clear
174+
the persisted flag; use reset_protect_fields on the dbzero Class object instead.
171175
172176
Returns
173177
-------
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
# SPDX-License-Identifier: LGPL-2.1-or-later
2+
# Copyright (c) 2025 DBZero Software sp. z o.o.
3+
4+
from dataclasses import dataclass
5+
6+
import dbzero as db0
7+
8+
from .conftest import DB0_DIR
9+
10+
11+
@db0.enum(values=["CREATE", "READ", "UPDATE", "DELETE"])
12+
class FieldAccess:
13+
pass
14+
15+
16+
@db0.memo(protect_fields=True)
17+
@dataclass
18+
class MemoProtectedFieldsClass:
19+
name: str
20+
value: int
21+
22+
23+
@db0.memo
24+
@dataclass
25+
class MemoUnprotectedFieldsClass:
26+
name: str
27+
value: int
28+
29+
30+
@db0.memo(protect_fields=True)
31+
class MemoProtectedDynamicFieldsClass:
32+
def __init__(self, count):
33+
for i in range(count):
34+
setattr(self, f"field_{i}", i)
35+
36+
37+
@db0.memo(protect_fields=True)
38+
class MemoProtectedManyInitFieldsClass:
39+
def __init__(self, count):
40+
values = (
41+
lambda i: i,
42+
lambda i: bool(i % 2),
43+
lambda i: f"value-{i}",
44+
lambda i: i / 10,
45+
lambda i: bytes([i % 256]),
46+
lambda i: (i, f"tuple-{i}"),
47+
)
48+
for i in range(count):
49+
setattr(self, f"init_field_{i}", values[i % len(values)](i))
50+
51+
52+
def get_memo_class_object(obj):
53+
return db0.get_memo_class(obj).get_class()
54+
55+
56+
def test_protect_fields_defaults_to_false(db0_fixture):
57+
obj = MemoUnprotectedFieldsClass("alpha", 1)
58+
assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is False
59+
description = db0.describe(obj)
60+
assert description["protected_fields"] is False
61+
assert description["field_offset_range"] == 0
62+
63+
64+
def test_protect_fields_is_persisted_on_class(db0_fixture):
65+
obj = MemoProtectedFieldsClass("alpha", 1)
66+
flags = get_memo_class_object(obj).get_type_flags()
67+
assert flags["protect_fields"] is True
68+
assert flags["singleton"] is False
69+
assert flags["no_default_tags"] is False
70+
assert flags["immutable"] is False
71+
description = db0.describe(obj)
72+
assert description["protected_fields"] is True
73+
assert description["field_offset_range"] > 0
74+
75+
76+
def test_protect_fields_survives_redecoration_without_parameter(db0_fixture):
77+
@db0.memo(id="dbzero-software/dbzero/tests/protected-redecorated", protect_fields=True)
78+
@dataclass
79+
class ProtectedBefore:
80+
name: str
81+
82+
obj = ProtectedBefore("alpha")
83+
obj_id = db0.uuid(obj)
84+
assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True
85+
db0.commit()
86+
87+
db0.close()
88+
db0.init(DB0_DIR)
89+
db0.open("my-test-prefix")
90+
91+
@db0.memo(id="dbzero-software/dbzero/tests/protected-redecorated")
92+
@dataclass
93+
class ProtectedAfter:
94+
name: str
95+
96+
obj = db0.fetch(ProtectedAfter, obj_id)
97+
assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True
98+
99+
100+
def test_explicit_false_does_not_unprotect_materialized_class(db0_fixture):
101+
@db0.memo(id="dbzero-software/dbzero/tests/protected-explicit-false", protect_fields=True)
102+
@dataclass
103+
class ProtectedBefore:
104+
name: str
105+
106+
obj = ProtectedBefore("alpha")
107+
obj_id = db0.uuid(obj)
108+
assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True
109+
db0.commit()
110+
111+
db0.close()
112+
db0.init(DB0_DIR)
113+
db0.open("my-test-prefix")
114+
115+
@db0.memo(id="dbzero-software/dbzero/tests/protected-explicit-false", protect_fields=False)
116+
@dataclass
117+
class ProtectedAfter:
118+
name: str
119+
120+
obj = db0.fetch(ProtectedAfter, obj_id)
121+
assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True
122+
123+
124+
def test_protect_fields_can_be_enabled_after_class_materialization(db0_fixture):
125+
@db0.memo(id="dbzero-software/dbzero/tests/protected-enabled-later")
126+
@dataclass
127+
class ProtectedBefore:
128+
name: str
129+
130+
obj = ProtectedBefore("alpha")
131+
obj_id = db0.uuid(obj)
132+
assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is False
133+
db0.commit()
134+
135+
db0.close()
136+
db0.init(DB0_DIR)
137+
db0.open("my-test-prefix")
138+
139+
@db0.memo(id="dbzero-software/dbzero/tests/protected-enabled-later", protect_fields=True)
140+
@dataclass
141+
class ProtectedAfter:
142+
name: str
143+
144+
obj = db0.fetch(ProtectedAfter, obj_id)
145+
assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True
146+
147+
148+
def test_reset_protect_fields_clears_persisted_flag(db0_fixture):
149+
obj = MemoProtectedFieldsClass("alpha", 1)
150+
memo_class = get_memo_class_object(obj)
151+
assert memo_class.get_type_flags()["protect_fields"] is True
152+
153+
memo_class.reset_protect_fields()
154+
assert memo_class.get_type_flags()["protect_fields"] is False
155+
156+
157+
def test_set_field_access_accepts_single_and_multiple_accounts(db0_fixture):
158+
MemoProtectedFieldsClass("alpha", 1)
159+
160+
db0.set_field_access(MemoProtectedFieldsClass, 123, (FieldAccess.READ,), "name")
161+
db0.set_field_access(
162+
MemoProtectedFieldsClass,
163+
[123, 456],
164+
(FieldAccess.CREATE, FieldAccess.UPDATE),
165+
"name",
166+
"value",
167+
)
168+
169+
170+
def test_set_field_access_accepts_empty_mode_to_clear(db0_fixture):
171+
MemoProtectedFieldsClass("alpha", 1)
172+
173+
db0.set_field_access(MemoProtectedFieldsClass, 123, (), "name")
174+
175+
176+
def test_set_field_access_requires_protected_class(db0_fixture):
177+
MemoUnprotectedFieldsClass("alpha", 1)
178+
179+
try:
180+
db0.set_field_access(MemoUnprotectedFieldsClass, 123, (FieldAccess.READ,), "name")
181+
except Exception as exc:
182+
assert "protected fields" in str(exc)
183+
else:
184+
assert False, "set_field_access should fail for unprotected classes"
185+
186+
187+
def test_set_field_access_accepts_unknown_field_names(db0_fixture):
188+
MemoProtectedFieldsClass("alpha", 1)
189+
190+
db0.set_field_access(MemoProtectedFieldsClass, 123, (FieldAccess.READ,), "missing")
191+
192+
193+
def test_get_field_access_returns_assigned_masks(db0_fixture):
194+
MemoProtectedFieldsClass("alpha", 1)
195+
db0.set_field_access(MemoProtectedFieldsClass, 123, (FieldAccess.READ,), "name")
196+
db0.set_field_access(
197+
MemoProtectedFieldsClass,
198+
123,
199+
(FieldAccess.CREATE, FieldAccess.UPDATE),
200+
"value",
201+
)
202+
203+
result = dict(db0.get_field_access(MemoProtectedFieldsClass, 123))
204+
205+
assert result["name"] == ("READ",)
206+
assert result["value"] == ("CREATE", "UPDATE")
207+
208+
209+
def test_get_field_access_returns_empty_mask_after_clear(db0_fixture):
210+
MemoProtectedFieldsClass("alpha", 1)
211+
db0.set_field_access(MemoProtectedFieldsClass, 123, (), "name")
212+
213+
result = dict(db0.get_field_access(MemoProtectedFieldsClass, 123))
214+
215+
assert result["name"] == ()
216+
217+
218+
def test_get_field_access_returns_predeclared_and_later_materialized_field(db0_fixture):
219+
obj = MemoProtectedFieldsClass("alpha", 1)
220+
expected = {
221+
"missing_a": ("READ",),
222+
"missing_b": ("CREATE", "UPDATE"),
223+
"missing_c": ("DELETE",),
224+
}
225+
db0.set_field_access(MemoProtectedFieldsClass, 123, (FieldAccess.READ,), "missing_a")
226+
db0.set_field_access(
227+
MemoProtectedFieldsClass,
228+
123,
229+
(FieldAccess.CREATE, FieldAccess.UPDATE),
230+
"missing_b",
231+
)
232+
db0.set_field_access(MemoProtectedFieldsClass, 123, (FieldAccess.DELETE,), "missing_c")
233+
234+
predeclared = dict(db0.get_field_access(MemoProtectedFieldsClass, 123))
235+
for field_name, mask in expected.items():
236+
assert predeclared[field_name] == mask
237+
238+
for index, field_name in enumerate(expected):
239+
setattr(obj, field_name, index)
240+
materialized = dict(db0.get_field_access(MemoProtectedFieldsClass, 123))
241+
for expected_field_name, mask in expected.items():
242+
assert materialized[expected_field_name] == mask
243+
244+
245+
def test_get_field_access_returns_empty_for_unknown_account(db0_fixture):
246+
MemoProtectedFieldsClass("alpha", 1)
247+
248+
assert list(db0.get_field_access(MemoProtectedFieldsClass, 999)) == []
249+
250+
251+
def test_describe_field_offset_range_stays_constrained_for_many_fields(db0_fixture):
252+
field_count = 512
253+
total_field_count = field_count * 2
254+
obj = MemoProtectedDynamicFieldsClass(field_count)
255+
initial_range = db0.describe(obj)["field_offset_range"]
256+
257+
assert db0.describe(obj)["protected_fields"] is True
258+
assert initial_range < field_count * 8
259+
260+
for i in range(field_count, field_count * 2):
261+
setattr(obj, f"field_{i}", i)
262+
263+
dynamic_range = db0.describe(obj)["field_offset_range"]
264+
assert dynamic_range < total_field_count * 2 + 128
265+
266+
267+
def test_describe_field_offset_range_stays_constrained_for_many_init_fields_with_various_types(db0_fixture):
268+
field_count = 768
269+
obj = MemoProtectedManyInitFieldsClass(field_count)
270+
271+
description = db0.describe(obj)
272+
273+
assert description["protected_fields"] is True
274+
assert description["field_offset_range"] < field_count * 2 + 128
275+
276+
277+
def test_describe_field_offset_range_counts_predeclared_access_fields(db0_fixture):
278+
obj = MemoProtectedDynamicFieldsClass(3)
279+
baseline_range = db0.describe(obj)["field_offset_range"]
280+
281+
for i in range(120):
282+
db0.set_field_access(
283+
MemoProtectedDynamicFieldsClass,
284+
123,
285+
(FieldAccess.READ,),
286+
f"future_field_{i}",
287+
)
288+
289+
predeclared_range = db0.describe(obj)["field_offset_range"]
290+
assert predeclared_range > baseline_range
291+
assert predeclared_range < 256
292+
293+
for i in range(120):
294+
setattr(obj, f"future_field_{i}", i)
295+
296+
materialized_range = db0.describe(obj)["field_offset_range"]
297+
assert materialized_range >= predeclared_range
298+
assert materialized_range < 120 * 2 + 128

0 commit comments

Comments
 (0)