diff --git a/AGENTS.md b/AGENTS.md index c41477d1..7e077217 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,12 @@ Types derived from `v_object` should follow the project-wide constructor pattern - Use camelCase for local helper variables, lambdas, and method names in C++ code. +### Python binding wrapper access + +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. + +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()`. + ### MorphingBIndex: address and type can change on mutation 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**. diff --git a/dbzero/dbzero/dbzero.py b/dbzero/dbzero/dbzero.py index 6bf38564..c9e4f4dc 100644 --- a/dbzero/dbzero/dbzero.py +++ b/dbzero/dbzero/dbzero.py @@ -10,7 +10,7 @@ def load_dynamic(name, path): def __bootstrap__(): global __bootstrap__, __loader__, __file__ - paths = [os.path.join(os.path.split(__file__)[0]), "/src/dev/build/", "/usr/local/lib/python3/dist-packages/dbzero/"] + paths = [os.path.join(os.path.split(__file__)[0]), "/src/dev/build/release", "/usr/local/lib/python3/dist-packages/dbzero/"] __file__ = None for path in paths: if os.path.isdir(path): diff --git a/dbzero/dbzero/dbzero.pyi b/dbzero/dbzero/dbzero.pyi index 7fc7c83d..da68fd37 100644 --- a/dbzero/dbzero/dbzero.pyi +++ b/dbzero/dbzero/dbzero.pyi @@ -2,7 +2,7 @@ Type stubs for dbzero module. """ -from typing import Any, Optional, Iterable, Dict, List, Tuple, Union, Callable +from typing import Any, Optional, Iterable, Dict, List, Tuple, Union, Callable, Sequence from .interfaces import ( Memo, MemoWeakProxy, QueryObject, Tag, TagSet, EnumValue, ListObject, IndexObject, TupleObject, SetObject, DictObject, ByteArrayObject, @@ -607,6 +607,18 @@ def rename_field(class_obj: type, from_name: str, to_name: str) -> None: """ ... +def set_field_access(class_obj: type, account_id: Union[int, Sequence[int]], mode: Tuple[EnumValue, ...], *fields: str) -> None: + """Set protected-field access flags for one or more fields of a memo class. + + The memo class must be declared with ``protect_fields=True``. Pass an empty + ``mode`` tuple to clear all access flags for the specified account and fields. + """ + ... + +def get_field_access(class_obj: type, account_id: int) -> Iterable[Tuple[str, Tuple[str, ...]]]: + """Return protected-field access flags for a memo class and account.""" + ... + # Cache management def clear_cache() -> None: diff --git a/dbzero/dbzero/memo.py b/dbzero/dbzero/memo.py index b567c969..0f9f36d3 100644 --- a/dbzero/dbzero/memo.py +++ b/dbzero/dbzero/memo.py @@ -168,6 +168,10 @@ def memo(cls: Optional[type] = None, **kwargs) -> type: no_default_tags : bool, default False If True, dbzero will not automatically add default system tags (such as the class name) to new instances of this class. + protect_fields : bool, default False + If True, the persistent class is marked for field protection. Once the class is + materialized, removing this argument from the Python definition does not clear + the persisted flag; use reset_protect_fields on the dbzero Class object instead. Returns ------- diff --git a/python_tests/test_memo_protect_fields.py b/python_tests/test_memo_protect_fields.py new file mode 100644 index 00000000..abdf5f30 --- /dev/null +++ b/python_tests/test_memo_protect_fields.py @@ -0,0 +1,298 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# Copyright (c) 2025 DBZero Software sp. z o.o. + +from dataclasses import dataclass + +import dbzero as db0 + +from .conftest import DB0_DIR + + +@db0.enum(values=["CREATE", "READ", "UPDATE", "DELETE"]) +class FieldAccess: + pass + + +@db0.memo(protect_fields=True) +@dataclass +class MemoProtectedFieldsClass: + name: str + value: int + + +@db0.memo +@dataclass +class MemoUnprotectedFieldsClass: + name: str + value: int + + +@db0.memo(protect_fields=True) +class MemoProtectedDynamicFieldsClass: + def __init__(self, count): + for i in range(count): + setattr(self, f"field_{i}", i) + + +@db0.memo(protect_fields=True) +class MemoProtectedManyInitFieldsClass: + def __init__(self, count): + values = ( + lambda i: i, + lambda i: bool(i % 2), + lambda i: f"value-{i}", + lambda i: i / 10, + lambda i: bytes([i % 256]), + lambda i: (i, f"tuple-{i}"), + ) + for i in range(count): + setattr(self, f"init_field_{i}", values[i % len(values)](i)) + + +def get_memo_class_object(obj): + return db0.get_memo_class(obj).get_class() + + +def test_protect_fields_defaults_to_false(db0_fixture): + obj = MemoUnprotectedFieldsClass("alpha", 1) + assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is False + description = db0.describe(obj) + assert description["protected_fields"] is False + assert description["field_offset_range"] == 0 + + +def test_protect_fields_is_persisted_on_class(db0_fixture): + obj = MemoProtectedFieldsClass("alpha", 1) + flags = get_memo_class_object(obj).get_type_flags() + assert flags["protect_fields"] is True + assert flags["singleton"] is False + assert flags["no_default_tags"] is False + assert flags["immutable"] is False + description = db0.describe(obj) + assert description["protected_fields"] is True + assert description["field_offset_range"] > 0 + + +def test_protect_fields_survives_redecoration_without_parameter(db0_fixture): + @db0.memo(id="dbzero-software/dbzero/tests/protected-redecorated", protect_fields=True) + @dataclass + class ProtectedBefore: + name: str + + obj = ProtectedBefore("alpha") + obj_id = db0.uuid(obj) + assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True + db0.commit() + + db0.close() + db0.init(DB0_DIR) + db0.open("my-test-prefix") + + @db0.memo(id="dbzero-software/dbzero/tests/protected-redecorated") + @dataclass + class ProtectedAfter: + name: str + + obj = db0.fetch(ProtectedAfter, obj_id) + assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True + + +def test_explicit_false_does_not_unprotect_materialized_class(db0_fixture): + @db0.memo(id="dbzero-software/dbzero/tests/protected-explicit-false", protect_fields=True) + @dataclass + class ProtectedBefore: + name: str + + obj = ProtectedBefore("alpha") + obj_id = db0.uuid(obj) + assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True + db0.commit() + + db0.close() + db0.init(DB0_DIR) + db0.open("my-test-prefix") + + @db0.memo(id="dbzero-software/dbzero/tests/protected-explicit-false", protect_fields=False) + @dataclass + class ProtectedAfter: + name: str + + obj = db0.fetch(ProtectedAfter, obj_id) + assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True + + +def test_protect_fields_can_be_enabled_after_class_materialization(db0_fixture): + @db0.memo(id="dbzero-software/dbzero/tests/protected-enabled-later") + @dataclass + class ProtectedBefore: + name: str + + obj = ProtectedBefore("alpha") + obj_id = db0.uuid(obj) + assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is False + db0.commit() + + db0.close() + db0.init(DB0_DIR) + db0.open("my-test-prefix") + + @db0.memo(id="dbzero-software/dbzero/tests/protected-enabled-later", protect_fields=True) + @dataclass + class ProtectedAfter: + name: str + + obj = db0.fetch(ProtectedAfter, obj_id) + assert get_memo_class_object(obj).get_type_flags()["protect_fields"] is True + + +def test_reset_protect_fields_clears_persisted_flag(db0_fixture): + obj = MemoProtectedFieldsClass("alpha", 1) + memo_class = get_memo_class_object(obj) + assert memo_class.get_type_flags()["protect_fields"] is True + + memo_class.reset_protect_fields() + assert memo_class.get_type_flags()["protect_fields"] is False + + +def test_set_field_access_accepts_single_and_multiple_accounts(db0_fixture): + MemoProtectedFieldsClass("alpha", 1) + + db0.set_field_access(MemoProtectedFieldsClass, 123, (FieldAccess.READ,), "name") + db0.set_field_access( + MemoProtectedFieldsClass, + [123, 456], + (FieldAccess.CREATE, FieldAccess.UPDATE), + "name", + "value", + ) + + +def test_set_field_access_accepts_empty_mode_to_clear(db0_fixture): + MemoProtectedFieldsClass("alpha", 1) + + db0.set_field_access(MemoProtectedFieldsClass, 123, (), "name") + + +def test_set_field_access_requires_protected_class(db0_fixture): + MemoUnprotectedFieldsClass("alpha", 1) + + try: + db0.set_field_access(MemoUnprotectedFieldsClass, 123, (FieldAccess.READ,), "name") + except Exception as exc: + assert "protected fields" in str(exc) + else: + assert False, "set_field_access should fail for unprotected classes" + + +def test_set_field_access_accepts_unknown_field_names(db0_fixture): + MemoProtectedFieldsClass("alpha", 1) + + db0.set_field_access(MemoProtectedFieldsClass, 123, (FieldAccess.READ,), "missing") + + +def test_get_field_access_returns_assigned_masks(db0_fixture): + MemoProtectedFieldsClass("alpha", 1) + db0.set_field_access(MemoProtectedFieldsClass, 123, (FieldAccess.READ,), "name") + db0.set_field_access( + MemoProtectedFieldsClass, + 123, + (FieldAccess.CREATE, FieldAccess.UPDATE), + "value", + ) + + result = dict(db0.get_field_access(MemoProtectedFieldsClass, 123)) + + assert result["name"] == ("READ",) + assert result["value"] == ("CREATE", "UPDATE") + + +def test_get_field_access_returns_empty_mask_after_clear(db0_fixture): + MemoProtectedFieldsClass("alpha", 1) + db0.set_field_access(MemoProtectedFieldsClass, 123, (), "name") + + result = dict(db0.get_field_access(MemoProtectedFieldsClass, 123)) + + assert result["name"] == () + + +def test_get_field_access_returns_predeclared_and_later_materialized_field(db0_fixture): + obj = MemoProtectedFieldsClass("alpha", 1) + expected = { + "missing_a": ("READ",), + "missing_b": ("CREATE", "UPDATE"), + "missing_c": ("DELETE",), + } + db0.set_field_access(MemoProtectedFieldsClass, 123, (FieldAccess.READ,), "missing_a") + db0.set_field_access( + MemoProtectedFieldsClass, + 123, + (FieldAccess.CREATE, FieldAccess.UPDATE), + "missing_b", + ) + db0.set_field_access(MemoProtectedFieldsClass, 123, (FieldAccess.DELETE,), "missing_c") + + predeclared = dict(db0.get_field_access(MemoProtectedFieldsClass, 123)) + for field_name, mask in expected.items(): + assert predeclared[field_name] == mask + + for index, field_name in enumerate(expected): + setattr(obj, field_name, index) + materialized = dict(db0.get_field_access(MemoProtectedFieldsClass, 123)) + for expected_field_name, mask in expected.items(): + assert materialized[expected_field_name] == mask + + +def test_get_field_access_returns_empty_for_unknown_account(db0_fixture): + MemoProtectedFieldsClass("alpha", 1) + + assert list(db0.get_field_access(MemoProtectedFieldsClass, 999)) == [] + + +def test_describe_field_offset_range_stays_constrained_for_many_fields(db0_fixture): + field_count = 512 + total_field_count = field_count * 2 + obj = MemoProtectedDynamicFieldsClass(field_count) + initial_range = db0.describe(obj)["field_offset_range"] + + assert db0.describe(obj)["protected_fields"] is True + assert initial_range < field_count * 8 + + for i in range(field_count, field_count * 2): + setattr(obj, f"field_{i}", i) + + dynamic_range = db0.describe(obj)["field_offset_range"] + assert dynamic_range < total_field_count * 2 + 128 + + +def test_describe_field_offset_range_stays_constrained_for_many_init_fields_with_various_types(db0_fixture): + field_count = 768 + obj = MemoProtectedManyInitFieldsClass(field_count) + + description = db0.describe(obj) + + assert description["protected_fields"] is True + assert description["field_offset_range"] < field_count * 2 + 128 + + +def test_describe_field_offset_range_counts_predeclared_access_fields(db0_fixture): + obj = MemoProtectedDynamicFieldsClass(3) + baseline_range = db0.describe(obj)["field_offset_range"] + + for i in range(120): + db0.set_field_access( + MemoProtectedDynamicFieldsClass, + 123, + (FieldAccess.READ,), + f"future_field_{i}", + ) + + predeclared_range = db0.describe(obj)["field_offset_range"] + assert predeclared_range > baseline_range + assert predeclared_range < 256 + + for i in range(120): + setattr(obj, f"future_field_{i}", i) + + materialized_range = db0.describe(obj)["field_offset_range"] + assert materialized_range >= predeclared_range + assert materialized_range < 120 * 2 + 128 diff --git a/src/dbzero/bindings/python/Memo.cpp b/src/dbzero/bindings/python/Memo.cpp index 351c81fa..d334a729 100644 --- a/src/dbzero/bindings/python/Memo.cpp +++ b/src/dbzero/bindings/python/Memo.cpp @@ -831,7 +831,7 @@ namespace db0::python PyObject *wrapPyType(PyTypeObject *base_class, bool is_singleton, bool no_default_tags, const char *prefix_name, const char *type_id, const char *file_name, std::vector &&init_vars, PyObject *py_dyn_prefix_callable, - std::vector &&migrations, bool no_cache, bool immutable) + std::vector &&migrations, bool no_cache, bool immutable, bool protect_fields) { auto py_class = Py_BORROW(base_class); auto py_module = Py_OWN(findModule(*Py_OWN(PyObject_GetAttrString((PyObject*)*py_class, "__module__")))); @@ -872,6 +872,9 @@ namespace db0::python if (immutable) { type_flags.set(MemoOptions::IMMUTABLE); } + if (protect_fields) { + type_flags.set(MemoOptions::PROTECT_FIELDS); + } auto type_info = MemoTypeDecoration( py_module, prefix_name, @@ -911,11 +914,13 @@ namespace db0::python PyObject *py_migrations = nullptr; PyObject *py_no_cache = nullptr; PyObject *py_immutable = nullptr; + PyObject *py_protect_fields = nullptr; static const char *kwlist[] = { "input", "singleton", "no_default_tags", "prefix", "id", "py_file", "py_init_vars", - "py_dyn_prefix", "py_migrations", "no_cache", "immutable", NULL }; - if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O|OOOOOOOOOO", const_cast(kwlist), &class_obj, &py_singleton, - &py_no_default_tags, &py_prefix_name, &py_type_id, &py_file_name, &py_init_vars, &py_dyn_prefix, &py_migrations, &py_no_cache, &py_immutable)) + "py_dyn_prefix", "py_migrations", "no_cache", "immutable", "protect_fields", NULL }; + if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O|OOOOOOOOOOO", const_cast(kwlist), &class_obj, &py_singleton, + &py_no_default_tags, &py_prefix_name, &py_type_id, &py_file_name, &py_init_vars, &py_dyn_prefix, &py_migrations, + &py_no_cache, &py_immutable, &py_protect_fields)) { return NULL; } @@ -924,6 +929,7 @@ namespace db0::python bool no_default_tags = py_no_default_tags && PyObject_IsTrue(py_no_default_tags); bool no_cache = py_no_cache && PyObject_IsTrue(py_no_cache); bool immutable = py_immutable && PyObject_IsTrue(py_immutable); + bool protect_fields = py_protect_fields && PyObject_IsTrue(py_protect_fields); const char *prefix_name = (py_prefix_name && py_prefix_name != Py_None) ? PyUnicode_AsUTF8(py_prefix_name) : nullptr; const char *type_id = py_type_id ? PyUnicode_AsUTF8(py_type_id) : nullptr; const char *file_name = (py_file_name && py_file_name != Py_None) ? PyUnicode_AsUTF8(py_file_name) : nullptr; @@ -958,7 +964,7 @@ namespace db0::python auto migrations = extractMigrations(py_migrations); return wrapPyType(castToType(class_obj), is_singleton, no_default_tags, prefix_name, type_id, file_name, - std::move(init_vars), py_dyn_prefix, std::move(migrations), no_cache, immutable + std::move(init_vars), py_dyn_prefix, std::move(migrations), no_cache, immutable, protect_fields ); } @@ -1040,6 +1046,10 @@ namespace db0::python PySafeDict_SetItemString(*py_result, "uuid", Py_OWN(tryGetUUID(self))); PySafeDict_SetItemString(*py_result, "type", Py_OWN(PyUnicode_FromString(self->ext().getType().getName().c_str()))); PySafeDict_SetItemString(*py_result, "size_of", Py_OWN(PyLong_FromLong(self->ext()->sizeOf()))); + PySafeDict_SetItemString(*py_result, "protected_fields", + Py_OWN(PyBool_fromBool(self->ext().getType().isProtectFields()))); + PySafeDict_SetItemString(*py_result, "field_offset_range", + Py_OWN(PyLong_FromUnsignedLong(self->ext().getType().getFieldOffsetRange()))); return py_result.steal(); } diff --git a/src/dbzero/bindings/python/MemoTypeDecoration.cpp b/src/dbzero/bindings/python/MemoTypeDecoration.cpp index 6e5e9c4e..2a02ad64 100644 --- a/src/dbzero/bindings/python/MemoTypeDecoration.cpp +++ b/src/dbzero/bindings/python/MemoTypeDecoration.cpp @@ -154,4 +154,4 @@ namespace db0::python return m_prefix_name.isValid(); } -} \ No newline at end of file +} diff --git a/src/dbzero/bindings/python/PyAPI.cpp b/src/dbzero/bindings/python/PyAPI.cpp index 10003ac9..9d2f420e 100644 --- a/src/dbzero/bindings/python/PyAPI.cpp +++ b/src/dbzero/bindings/python/PyAPI.cpp @@ -615,6 +615,208 @@ namespace db0::python return runSafe(tryRenameField, args, kwargs); } + namespace + { + std::vector extractAccountIDs(PyObject *py_account_id) + { + std::vector result; + if (PyLong_Check(py_account_id)) { + result.push_back(PyLong_AsUnsignedLongLong(py_account_id)); + if (PyErr_Occurred()) { + THROWF(db0::InputException) << "Invalid account ID"; + } + return result; + } + + auto sequence = Py_OWN(PySequence_Fast(py_account_id, "account_id must be an int or a sequence of ints")); + if (!sequence) { + THROWF(db0::InputException) << "account_id must be an int or a sequence of ints"; + } + auto size = PySequence_Fast_GET_SIZE(*sequence); + result.reserve(size); + for (Py_ssize_t i = 0; i < size; ++i) { + auto item = PySequence_Fast_GET_ITEM(*sequence, i); + if (!PyLong_Check(item)) { + THROWF(db0::InputException) << "account_id sequence must contain only ints"; + } + result.push_back(PyLong_AsUnsignedLongLong(item)); + if (PyErr_Occurred()) { + THROWF(db0::InputException) << "Invalid account ID"; + } + } + return result; + } + + db0::object_model::FieldMaskOptions extractFieldMaskOption(PyObject *py_mode) + { + std::string mode; + if (PyEnumValue_Check(py_mode)) { + mode = reinterpret_cast(py_mode)->ext().m_str_repr; + } else if (PyEnumValueRepr_Check(py_mode)) { + mode = reinterpret_cast(py_mode)->ext().m_str_repr; + } else if (PyUnicode_Check(py_mode)) { + mode = PyUnicode_AsUTF8(py_mode); + } else { + THROWF(db0::InputException) << "Field access mode must contain enum values"; + } + + if (mode == "CREATE") { + return db0::object_model::FieldMaskOptions::CREATE; + } + if (mode == "READ") { + return db0::object_model::FieldMaskOptions::READ; + } + if (mode == "UPDATE") { + return db0::object_model::FieldMaskOptions::UPDATE; + } + if (mode == "DELETE") { + return db0::object_model::FieldMaskOptions::DELETE; + } + THROWF(db0::InputException) << "Invalid field access mode: " << mode; + return db0::object_model::FieldMaskOptions::READ; + } + + db0::object_model::FieldMaskFlags extractFieldMask(PyObject *py_mode) + { + auto sequence = Py_OWN(PySequence_Fast(py_mode, "mode must be a sequence of field access enum values")); + if (!sequence) { + THROWF(db0::InputException) << "mode must be a sequence of field access enum values"; + } + + db0::object_model::FieldMaskFlags result; + auto size = PySequence_Fast_GET_SIZE(*sequence); + for (Py_ssize_t i = 0; i < size; ++i) { + result.set(extractFieldMaskOption(PySequence_Fast_GET_ITEM(*sequence, i)), true); + } + return result; + } + + std::vector extractFieldNames(PyObject *args) + { + auto arg_count = PyTuple_Size(args); + if (arg_count < 4) { + THROWF(db0::InputException) << "At least one field name is required"; + } + + std::vector result; + result.reserve(arg_count - 3); + for (Py_ssize_t i = 3; i < arg_count; ++i) { + auto py_field_name = PyTuple_GetItem(args, i); + if (!PyUnicode_Check(py_field_name)) { + THROWF(db0::InputException) << "Field names must be strings"; + } + result.push_back(PyUnicode_AsUTF8(py_field_name)); + } + return result; + } + + PyObject *fieldMaskToTuple(db0::object_model::FieldMaskFlags mask) + { + std::vector names; + if (mask[db0::object_model::FieldMaskOptions::CREATE]) { + names.push_back("CREATE"); + } + if (mask[db0::object_model::FieldMaskOptions::READ]) { + names.push_back("READ"); + } + if (mask[db0::object_model::FieldMaskOptions::UPDATE]) { + names.push_back("UPDATE"); + } + if (mask[db0::object_model::FieldMaskOptions::DELETE]) { + names.push_back("DELETE"); + } + + auto values = Py_OWN(PyTuple_New(names.size())); + Py_ssize_t index = 0; + for (auto name: names) { + PySafeTuple_SetItem(*values, index++, Py_OWN(PyUnicode_FromString(name))); + } + return values.steal(); + } + } + + PyObject *trySetFieldAccess(PyObject *args) + { + if (PyTuple_Size(args) < 4) { + THROWF(db0::InputException) << "set_field_access requires type, account_id, mode, and at least one field name"; + } + + auto py_type = PyTuple_GetItem(args, 0); + if (!PyType_Check(py_type)) { + THROWF(db0::InputException) << "First argument must be a type"; + } + if (!PyAnyMemoType_Check(reinterpret_cast(py_type))) { + THROWF(db0::InputException) << "First argument must be a dbzero memo type"; + } + + auto account_ids = extractAccountIDs(PyTuple_GetItem(args, 1)); + auto mask = extractFieldMask(PyTuple_GetItem(args, 2)); + auto field_names = extractFieldNames(args); + + using ClassFactory = db0::object_model::ClassFactory; + auto fixture_uuid = MemoTypeDecoration::get(reinterpret_cast(py_type)).getFixtureUUID(); + auto fixture = PyToolkit::getPyWorkspace().getWorkspace().getFixture(fixture_uuid, AccessType::READ_WRITE); + auto &class_factory = fixture->get(); + auto type = class_factory.getExistingType(reinterpret_cast(py_type)); + if (!type->isProtectFields()) { + THROWF(db0::InputException) << "Class " << type->getName() << " does not have protected fields enabled"; + } + + db0::FixtureLock lock(fixture); + type->setFieldAccess(account_ids, mask, field_names); + + Py_RETURN_NONE; + } + + PyObject *setFieldAccess(PyObject *, PyObject *args) + { + PY_API_FUNC + return runSafe(trySetFieldAccess, args); + } + + PyObject *tryGetFieldAccess(PyObject *args) + { + PyObject *py_type = nullptr; + unsigned long long account_id = 0; + if (!PyArg_ParseTuple(args, "OK:get_field_access", &py_type, &account_id)) { + return nullptr; + } + if (!PyType_Check(py_type)) { + THROWF(db0::InputException) << "First argument must be a type"; + } + if (!PyAnyMemoType_Check(reinterpret_cast(py_type))) { + THROWF(db0::InputException) << "First argument must be a dbzero memo type"; + } + + using ClassFactory = db0::object_model::ClassFactory; + auto fixture_uuid = MemoTypeDecoration::get(reinterpret_cast(py_type)).getFixtureUUID(); + auto fixture = PyToolkit::getPyWorkspace().getWorkspace().getFixture(fixture_uuid, AccessType::READ_WRITE); + auto &class_factory = fixture->get(); + auto type = class_factory.getExistingType(reinterpret_cast(py_type)); + if (!type->isProtectFields()) { + THROWF(db0::InputException) << "Class " << type->getName() << " does not have protected fields enabled"; + } + + auto access = type->getFieldAccess(account_id); + auto result = Py_OWN(PyList_New(access.size())); + Py_ssize_t index = 0; + for (const auto &[field_name, mask]: access) { + PySafeList_SetItem( + *result, + index++, + Py_OWN(PySafeTuple_Pack( + Py_OWN(PyUnicode_FromString(field_name.c_str())), + Py_OWN(fieldMaskToTuple(mask))))); + } + return result.steal(); + } + + PyObject *getFieldAccess(PyObject *, PyObject *args) + { + PY_API_FUNC + return runSafe(tryGetFieldAccess, args); + } + PyObject *TryPyAPI_isSingleton(PyObject *py_object) { assert((PyMemo_Check(py_object))); @@ -1496,4 +1698,4 @@ namespace db0::python return reinterpret_cast(runSafe(tryCopyPrefix, args, kwargs)); } -} \ No newline at end of file +} diff --git a/src/dbzero/bindings/python/PyAPI.hpp b/src/dbzero/bindings/python/PyAPI.hpp index bbe1b7a1..78fc50a4 100644 --- a/src/dbzero/bindings/python/PyAPI.hpp +++ b/src/dbzero/bindings/python/PyAPI.hpp @@ -106,6 +106,10 @@ namespace db0::python PyObject *renameField(PyObject *self, PyObject *args, PyObject *kwargs); + PyObject *setFieldAccess(PyObject *self, PyObject *args); + + PyObject *getFieldAccess(PyObject *self, PyObject *args); + PyObject *PyAPI_isSingleton(PyObject *self, PyObject *args); PyObject *PyAPI_getRefCount(PyObject *self, PyObject *args); @@ -197,4 +201,4 @@ namespace db0::python template <> db0::object_model::StorageClass getStorageClass(); template <> db0::object_model::StorageClass getStorageClass(); -} \ No newline at end of file +} diff --git a/src/dbzero/bindings/python/PyToolkit.cpp b/src/dbzero/bindings/python/PyToolkit.cpp index 3d341d24..a6349735 100644 --- a/src/dbzero/bindings/python/PyToolkit.cpp +++ b/src/dbzero/bindings/python/PyToolkit.cpp @@ -751,6 +751,15 @@ namespace db0::python } } + bool PyToolkit::isProtectFields(TypeObjectPtr py_type) + { + if (isAnyMemoType(py_type)) { + return MemoTypeDecoration::get(py_type).getFlags()[MemoOptions::PROTECT_FIELDS]; + } else { + return false; + } + } + FlagSet PyToolkit::getMemoFlags(TypeObjectPtr py_type) { if (isAnyMemoType(py_type)) { @@ -1011,4 +1020,4 @@ namespace db0::python } } -} \ No newline at end of file +} diff --git a/src/dbzero/bindings/python/PyToolkit.hpp b/src/dbzero/bindings/python/PyToolkit.hpp index 6b918e11..fc53424a 100644 --- a/src/dbzero/bindings/python/PyToolkit.hpp +++ b/src/dbzero/bindings/python/PyToolkit.hpp @@ -205,6 +205,7 @@ namespace db0::python static bool isNoCache(TypeObjectPtr); // type marked as immutable static bool isImmutable(TypeObjectPtr); + static bool isProtectFields(TypeObjectPtr); static FlagSet getMemoFlags(TypeObjectPtr); inline static void incRef(ObjectPtr py_object) { @@ -276,4 +277,4 @@ namespace db0::python static SafeRMutex m_api_mutex; }; -} \ No newline at end of file +} diff --git a/src/dbzero/bindings/python/dbzero.cpp b/src/dbzero/bindings/python/dbzero.cpp index 0c0ec1d9..4476ce6b 100644 --- a/src/dbzero/bindings/python/dbzero.cpp +++ b/src/dbzero/bindings/python/dbzero.cpp @@ -66,6 +66,8 @@ static PyMethodDef dbzero_methods[] = {"begin_locked", (PyCFunction)&py::PyAPI_beginLocked, METH_FASTCALL, "Enter a new locked section"}, {"describe", &py::describeObject, METH_VARARGS, "Get dbzero object's description"}, {"rename_field", (PyCFunction)&py::renameField, METH_VARARGS | METH_KEYWORDS, "Get snapshot of dbzero state"}, + {"set_field_access", (PyCFunction)&py::setFieldAccess, METH_VARARGS, "Set protected field access masks for a memo class"}, + {"get_field_access", (PyCFunction)&py::getFieldAccess, METH_VARARGS, "Get protected field access masks for a memo class and account"}, {"is_singleton", &py::PyAPI_isSingleton, METH_VARARGS, "Check if a specific instance is a dbzero singleton"}, {"getrefcount", &py::PyAPI_getRefCount, METH_VARARGS, "Get dbzero ref counts"}, {"no", (PyCFunction)&py::negTagSet, METH_FASTCALL, "Tag negation function"}, diff --git a/src/dbzero/bindings/python/types/PyClass.cpp b/src/dbzero/bindings/python/types/PyClass.cpp index ead0c569..7b2156d1 100644 --- a/src/dbzero/bindings/python/types/PyClass.cpp +++ b/src/dbzero/bindings/python/types/PyClass.cpp @@ -26,6 +26,8 @@ namespace db0::python {"type_exists", (PyCFunction)&PyAPI_PyClass_type_exists, METH_NOARGS, "Check if the associated Python type exists without raising an exception"}, {"get_attributes", (PyCFunction)&PyAPI_PyClass_get_attributes, METH_NOARGS, "Get memo class attributes"}, {"type_info", (PyCFunction)&PyAPI_PyClass_type_info, METH_NOARGS, "Get memo class type information"}, + {"get_type_flags", (PyCFunction)&PyAPI_PyClass_get_type_flags, METH_NOARGS, "Get persisted memo class flags"}, + {"reset_protect_fields", (PyCFunction)&PyAPI_PyClass_reset_protect_fields, METH_NOARGS, "Disable field protection for this memo class"}, {NULL} }; @@ -95,6 +97,35 @@ namespace db0::python PY_API_FUNC return runSafe(tryGetTypeInfo, reinterpret_cast(self)->ext()); } + + PyObject *tryPyClassGetTypeFlags(PyObject *self) + { + auto &type = reinterpret_cast(self)->ext(); + auto py_result = Py_OWN(PyDict_New()); + PySafeDict_SetItemString(*py_result, "singleton", Py_OWN(PyBool_fromBool(type.isSingleton()))); + PySafeDict_SetItemString(*py_result, "no_default_tags", Py_OWN(PyBool_fromBool(type.isNoDefaultTags()))); + PySafeDict_SetItemString(*py_result, "immutable", Py_OWN(PyBool_fromBool(type.isImmutable()))); + PySafeDict_SetItemString(*py_result, "protect_fields", Py_OWN(PyBool_fromBool(type.isProtectFields()))); + return py_result.steal(); + } + + PyObject *PyAPI_PyClass_get_type_flags(PyObject *self, PyObject *) + { + PY_API_FUNC + return runSafe(tryPyClassGetTypeFlags, self); + } + + PyObject *tryPyClassResetProtectFields(PyObject *self) + { + reinterpret_cast(self)->modifyExt().resetProtectFields(); + Py_RETURN_NONE; + } + + PyObject *PyAPI_PyClass_reset_protect_fields(PyObject *self, PyObject *) + { + PY_API_FUNC + return runSafe(tryPyClassResetProtectFields, self); + } PyTypeObject ClassObjectType = { PYVAROBJECT_HEAD_INIT_DESIGNATED, @@ -112,7 +143,7 @@ namespace db0::python ClassObject *makeClass(std::shared_ptr class_ptr) { auto class_obj = ClassDefaultObject_new(); - class_obj.get()->makeNew(std::dynamic_pointer_cast(class_ptr)); + class_obj.get()->makeNew(class_ptr); return class_obj.steal(); } diff --git a/src/dbzero/bindings/python/types/PyClass.hpp b/src/dbzero/bindings/python/types/PyClass.hpp index d67bcfae..bafb3e0e 100644 --- a/src/dbzero/bindings/python/types/PyClass.hpp +++ b/src/dbzero/bindings/python/types/PyClass.hpp @@ -11,8 +11,7 @@ namespace db0::python { - // immutable Class object - using ClassObject = PySharedWrapper; + using ClassObject = PySharedWrapper; ClassObject *ClassObject_new(PyTypeObject *type, PyObject *, PyObject *); shared_py_object ClassDefaultObject_new(); @@ -22,6 +21,8 @@ namespace db0::python PyObject *PyAPI_PyClass_type_exists(PyObject *, PyObject *); PyObject *PyAPI_PyClass_get_attributes(PyObject *, PyObject *); PyObject *PyAPI_PyClass_type_info(PyObject *, PyObject *); + PyObject *PyAPI_PyClass_get_type_flags(PyObject *, PyObject *); + PyObject *PyAPI_PyClass_reset_protect_fields(PyObject *, PyObject *); extern PyTypeObject ClassObjectType; @@ -32,4 +33,4 @@ namespace db0::python PyObject *tryGetClassAttributes(const db0::object_model::Class &); PyObject *tryGetTypeInfo(const db0::object_model::Class &); -} \ No newline at end of file +} diff --git a/src/dbzero/object_model/class.hpp b/src/dbzero/object_model/class.hpp index 575c1c31..c9a9ef61 100644 --- a/src/dbzero/object_model/class.hpp +++ b/src/dbzero/object_model/class.hpp @@ -8,4 +8,5 @@ #include "class/Field.hpp" #include "class/FieldIDMapper.hpp" #include "class/FieldMask.hpp" +#include "class/FieldSafe.hpp" #include "class/Schema.hpp" diff --git a/src/dbzero/object_model/class/Class.cpp b/src/dbzero/object_model/class/Class.cpp index b8bad111..12ddc9ec 100644 --- a/src/dbzero/object_model/class/Class.cpp +++ b/src/dbzero/object_model/class/Class.cpp @@ -11,7 +11,7 @@ #include #include "Schema.hpp" -DEFINE_ENUM_VALUES(db0::ClassOptions, "SINGLETON", "NO_DEFAULT_TAGS", "IMMUTABLE") +DEFINE_ENUM_VALUES(db0::ClassOptions, "SINGLETON", "NO_DEFAULT_TAGS", "IMMUTABLE", "PROTECT_FIELDS") namespace db0::object_model @@ -112,6 +112,9 @@ namespace db0::object_model , m_uid(this->fetchUID()) , m_member_cache(m_members, *this, this->getRefreshCallback()) { + if (isProtectFields()) { + ensureFieldSafe(); + } m_schema.postInit(getTotalFunc()); } @@ -124,6 +127,7 @@ namespace db0::object_model , m_uid(this->fetchUID()) , m_member_cache(m_members, *this, this->getRefreshCallback()) { + openFieldSafe(); m_schema.postInit(getTotalFunc()); // initialize base class if such exists if ((*this)->m_base_class_ref) { @@ -163,19 +167,29 @@ namespace db0::object_model } MemberID Class::addField(const char *name, unsigned int fidelity) + { + return addFieldInternal(name, fidelity, true); + } + + MemberID Class::addFieldInternal(const char *name, unsigned int fidelity, bool registerFieldAccess) { assert(fidelity < std::numeric_limits::max()); + // NOTE: before creating with fidelity = 0 we'll always pre-register // a slot for fidelity = 2 which will be used as the PRIMARY identifier if (fidelity == 0 && !hasSlot(name, PRIMARY_FIDELITY)) { - addField(name, PRIMARY_FIDELITY); + addFieldInternal(name, PRIMARY_FIDELITY, false); } auto pos = assignSlot(fidelity); // reserve the slot m_members.set(pos, o_field { getFixture()->getLimitedStringPool(), name }); m_member_cache.reload(pos); - return m_index[name].first; + auto member_id = m_index[name].first; + if (registerFieldAccess && m_field_safe) { + m_field_safe->getFieldIDMapper().onFieldIDAssigned(name, member_id.primary().first); + } + return member_id; } bool Class::hasSlot(const char *name, unsigned int fidelity) const @@ -284,6 +298,155 @@ namespace db0::object_model bool Class::isSingleton() const { return (*this)->m_flags[ClassOptions::SINGLETON]; } + + bool Class::isNoDefaultTags() const { + return (*this)->m_flags[ClassOptions::NO_DEFAULT_TAGS]; + } + + bool Class::isImmutable() const { + return (*this)->m_flags[ClassOptions::IMMUTABLE]; + } + + bool Class::isProtectFields() const { + return (*this)->m_flags[ClassOptions::PROTECT_FIELDS]; + } + + void Class::assertFieldSafeSupported() const + { + if ((*this)->getObjVer() < FIELD_SAFE_MIN_VERSION) { + THROWF(db0::InputException) << "Class version too low to support protected fields. Current is: " + << (*this)->getObjVer() << ", for minimum support you need " << FIELD_SAFE_MIN_VERSION; + } + } + + void Class::openFieldSafe() const + { + if ((*this)->getObjVer() >= FIELD_SAFE_MIN_VERSION && (*this)->m_field_safe_ptr && !m_field_safe) { + m_field_safe.emplace(getFixture()->myPtr((*this)->m_field_safe_ptr.getAddress()), getFixture()->getVObjectCache()); + } + } + + FieldSafe &Class::ensureFieldSafe() + { + if (m_field_safe) { + return *m_field_safe; + } + assertFieldSafeSupported(); + openFieldSafe(); + if (!m_field_safe) { + m_field_safe.emplace(*getFixture(), getFixture()->getVObjectCache()); + modify().m_field_safe_ptr = *m_field_safe; + } + return *m_field_safe; + } + + void Class::setProtectFields() { + ensureFieldSafe(); + modify().m_flags.set(ClassOptions::PROTECT_FIELDS, true); + } + + void Class::resetProtectFields() { + modify().m_flags.set(ClassOptions::PROTECT_FIELDS, false); + } + + bool Class::hasFieldSafe() const + { + return (*this)->getObjVer() >= FIELD_SAFE_MIN_VERSION && (*this)->m_field_safe_ptr; + } + + FieldSafe &Class::getFieldSafe() + { + if (!m_field_safe) { + THROWF(db0::InputException) << "FieldSafe is not initialized for class " << getName(); + } + return *m_field_safe; + } + + const FieldSafe &Class::getFieldSafe() const + { + if (!m_field_safe) { + THROWF(db0::InputException) << "FieldSafe is not initialized for class " << getName(); + } + return *m_field_safe; + } + + void Class::setFieldAccess(const std::vector &account_ids, FieldMaskFlags mask, + const std::vector &field_names) + { + if (!isProtectFields()) { + THROWF(db0::InputException) << "Class " << getName() << " does not have protected fields enabled"; + } + if (account_ids.empty()) { + THROWF(db0::InputException) << "At least one account ID is required"; + } + if (field_names.empty()) { + THROWF(db0::InputException) << "At least one field name is required"; + } + + auto &field_safe = getFieldSafe(); + auto &field_id_mapper = field_safe.getFieldIDMapper(); + auto &field_mask_manager = field_safe.getFieldMaskManager(); + + std::vector field_offsets; + field_offsets.reserve(field_names.size()); + for (const auto &field_name: field_names) { + auto member = tryGetMember(field_name.c_str()); + if (member) { + field_offsets.push_back(field_id_mapper.assignFieldOffset(member->m_field_id)); + } else { + field_offsets.push_back(field_id_mapper.assignFieldOffset(field_name.c_str())); + } + } + + for (auto account_id: account_ids) { + auto field_mask = field_mask_manager.createFieldMask(account_id); + for (auto field_offset: field_offsets) { + field_mask->setMask(field_offset, mask); + } + } + } + + std::vector > Class::getFieldAccess(std::uint64_t account_id) const + { + if (!isProtectFields()) { + THROWF(db0::InputException) << "Class " << getName() << " does not have protected fields enabled"; + } + + auto &field_safe = getFieldSafe(); + auto field_mask = field_safe.getFieldMaskManager().tryGetFieldMask(account_id); + if (!field_mask) { + return {}; + } + + auto &field_id_mapper = field_safe.getFieldIDMapper(); + auto field_offsets = field_id_mapper.getAssignedNameOffsets(); + for (const auto &[field_name, member_id]: getMembers()) { + auto maybe_offset = field_id_mapper.tryGetAssignedFieldOffset(member_id.primary().first); + if (maybe_offset) { + field_offsets[field_name] = *maybe_offset; + } + } + + std::vector > result; + result.reserve(field_offsets.size()); + for (const auto &[field_name, field_offset]: field_offsets) { + auto mask = field_mask->getAssignedMask(field_offset); + if (mask) { + result.emplace_back(field_name, *mask); + } + } + return result; + } + + std::uint32_t Class::getFieldOffsetRange() const + { + if (!m_field_safe) { + return 0; + } + + m_member_cache.refresh(); + return m_field_safe->getFieldIDMapper().getFieldOffsetRange(); + } bool Class::isExistingSingleton() const { return isSingleton() && (*this)->m_singleton_address.isValid(); @@ -436,6 +599,9 @@ namespace db0::object_model m_member_cache.detach(); m_fidelities.detach(); m_schema.detach(); + if (m_field_safe) { + m_field_safe->detach(); + } super_t::detach(); } @@ -456,6 +622,9 @@ namespace db0::object_model m_members.commit(); m_fidelities.commit(); m_schema.commit(); + if (m_field_safe) { + m_field_safe->commit(); + } super_t::commit(); } diff --git a/src/dbzero/object_model/class/Class.hpp b/src/dbzero/object_model/class/Class.hpp index 1c4ba1b8..4f52f581 100644 --- a/src/dbzero/object_model/class/Class.hpp +++ b/src/dbzero/object_model/class/Class.hpp @@ -4,10 +4,12 @@ #pragma once #include "Field.hpp" +#include "FieldSafe.hpp" #include "MemberID.hpp" #include #include +#include #include #include #include @@ -34,14 +36,15 @@ namespace db0 SINGLETON = 0x0001, // instances of this type opted out of auto-assigned type tags NO_DEFAULT_TAGS = 0x0002, - IMMUTABLE = 0x0004 + IMMUTABLE = 0x0004, + PROTECT_FIELDS = 0x0008 }; using ClassFlags = db0::FlagSet; } -DECLARE_ENUM_VALUES(db0::ClassOptions, 3) +DECLARE_ENUM_VALUES(db0::ClassOptions, 4) namespace db0::object_model @@ -61,7 +64,7 @@ namespace db0::object_model using VFidelityVector = db0::v_bvector >; DB0_PACKED_BEGIN - struct DB0_PACKED_ATTR o_class: public db0::o_fixed_versioned + struct DB0_PACKED_ATTR o_class: public db0::o_fixed_versioned { // common object header db0::o_object_header m_header; @@ -80,6 +83,9 @@ DB0_PACKED_BEGIN UniqueAddress m_singleton_address = {}; const std::uint32_t m_base_class_ref; const std::uint32_t m_num_bases; + + // Version 1 fields. + db0_ptr m_field_safe_ptr; o_class(RC_LimitedStringPool &, const std::string &name, std::optional module_name, const VFieldMatrix &, const VFidelityVector &, const Schema &, const char *type_id, const char *prefix_name, ClassFlags, @@ -107,6 +113,7 @@ DB0_PACKED_END public: static constexpr std::uint32_t SLOT_NUM = Fixture::TYPE_SLOT_NUM; static constexpr unsigned int PRIMARY_FIDELITY = 2; + static constexpr std::uint16_t FIELD_SAFE_MIN_VERSION = 1; struct Member { @@ -165,7 +172,19 @@ DB0_PACKED_END * Check if this is a singleton class */ bool isSingleton() const; + bool isNoDefaultTags() const; + bool isImmutable() const; bool assignDefaultTags() const; + bool isProtectFields() const; + void setProtectFields(); + void resetProtectFields(); + bool hasFieldSafe() const; + FieldSafe &getFieldSafe(); + const FieldSafe &getFieldSafe() const; + void setFieldAccess(const std::vector &account_ids, FieldMaskFlags mask, + const std::vector &field_names); + std::vector > getFieldAccess(std::uint64_t account_id) const; + std::uint32_t getFieldOffsetRange() const; /** * Check if this class has an associated singleton instance @@ -309,6 +328,7 @@ DB0_PACKED_END // only holds non-default fidelities (i.e. > 0) VFidelityVector m_fidelities; Schema m_schema; + mutable std::optional m_field_safe; std::shared_ptr m_base_class_ptr; // Field by-name index (cache) @@ -326,8 +346,12 @@ DB0_PACKED_END // A function to retrieve the total number of instances of the schema std::function getTotalFunc() const; std::function getRefreshCallback() const; + MemberID addFieldInternal(const char *name, unsigned int fidelity, bool registerFieldAccess); // callback for MemberID updates void onMemberIDUpdated(const MemberID &) const; + void assertFieldSafeSupported() const; + FieldSafe &ensureFieldSafe(); + void openFieldSafe() const; // translate member's field ID into a unique key FieldID getPrimaryKey(unsigned int index) const; diff --git a/src/dbzero/object_model/class/ClassFactory.cpp b/src/dbzero/object_model/class/ClassFactory.cpp index 01d97a7f..e5c27c57 100644 --- a/src/dbzero/object_model/class/ClassFactory.cpp +++ b/src/dbzero/object_model/class/ClassFactory.cpp @@ -155,6 +155,9 @@ namespace db0::object_model if (class_ptr) { // pull existing dbzero class instance by pointer type = getTypeByPtr(class_ptr, lang_type).m_class; + if (LangToolkit::isProtectFields(lang_type) && !type->isProtectFields()) { + type->setProtectFields(); + } } else { auto fixture = getFixture(); if (!checkAccessType(*fixture, AccessType::READ_WRITE)) { @@ -166,9 +169,10 @@ namespace db0::object_model ClassFlags flags; if (is_singleton) { flags += ClassOptions::SINGLETON; - } + } flags.set(ClassOptions::NO_DEFAULT_TAGS, LangToolkit::isNoDefaultTags(lang_type)); flags.set(ClassOptions::IMMUTABLE, LangToolkit::isImmutable(lang_type)); + flags.set(ClassOptions::PROTECT_FIELDS, LangToolkit::isProtectFields(lang_type)); auto memo_base = LangToolkit::getBaseMemoType(lang_type); std::shared_ptr base_class; if (memo_base) { @@ -198,6 +202,8 @@ namespace db0::object_model it_cached = m_type_cache.insert({lang_type, type}).first; m_pending_types.push_back(lang_type); + } else if (LangToolkit::isProtectFields(lang_type) && !it_cached->second->isProtectFields()) { + it_cached->second->setProtectFields(); } return it_cached->second; } @@ -234,6 +240,9 @@ namespace db0::object_model it_cached->second.m_class->setInitVars(LangToolkit::getInitVars(lang_type)); it_cached->second.m_class->setRuntimeFlags(LangToolkit::getMemoFlags(lang_type)); } + if (lang_type && LangToolkit::isProtectFields(lang_type) && !it_cached->second.m_class->isProtectFields()) { + it_cached->second.m_class->setProtectFields(); + } return it_cached->second.m_class; } @@ -298,6 +307,9 @@ namespace db0::object_model if (lang_type) { type->setInitVars(LangToolkit::getInitVars(lang_type)); type->setRuntimeFlags(LangToolkit::getMemoFlags(lang_type)); + if (LangToolkit::isProtectFields(lang_type) && !type->isProtectFields()) { + type->setProtectFields(); + } } // register the mapping to language specific type object it_cached = m_ptr_cache.insert({ptr, ClassItem { type, lang_type }}).first; @@ -309,6 +321,9 @@ namespace db0::object_model it_cached->second.m_class->setInitVars(LangToolkit::getInitVars(lang_type)); it_cached->second.m_class->setRuntimeFlags(LangToolkit::getMemoFlags(lang_type)); } + if (lang_type && LangToolkit::isProtectFields(lang_type) && !it_cached->second.m_class->isProtectFields()) { + it_cached->second.m_class->setProtectFields(); + } return it_cached->second; } @@ -433,4 +448,4 @@ namespace db0::object_model return classRef(class_addr, m_type_slot_addr_range); } -} \ No newline at end of file +} diff --git a/src/dbzero/object_model/class/FieldIDMapper.cpp b/src/dbzero/object_model/class/FieldIDMapper.cpp index 41dd10e8..3e15489b 100644 --- a/src/dbzero/object_model/class/FieldIDMapper.cpp +++ b/src/dbzero/object_model/class/FieldIDMapper.cpp @@ -208,6 +208,7 @@ namespace db0::object_model assert(field_name); auto maybe_offset = tryGetNameOffset(field_name); if (maybe_offset) { + recordFieldOffsetRange(*maybe_offset); return *maybe_offset; } @@ -219,6 +220,7 @@ namespace db0::object_model } m_name_offset_cache[field_name] = offset; + recordFieldOffsetRange(offset); return offset; } @@ -282,6 +284,7 @@ namespace db0::object_model { auto maybe_offset = tryGetAssignedFieldOffset(field_id); if (maybe_offset) { + recordFieldOffsetRange(*maybe_offset); return *maybe_offset; } @@ -295,7 +298,9 @@ namespace db0::object_model } m_field_cluster_offset_cache[field_id.getIndex()] = cluster_offset; - return cluster_offset + field_id.getOffset(); + auto offset = cluster_offset + field_id.getOffset(); + recordFieldOffsetRange(offset); + return offset; } std::uint32_t FieldIDMapper::assignFieldExceptionOffset(FieldID field_id, std::uint32_t offset) @@ -309,6 +314,7 @@ namespace db0::object_model } m_field_exception_offset_cache[field_id.getLongIndex()] = offset; + recordFieldOffsetRange(offset); return offset; } @@ -317,7 +323,12 @@ namespace db0::object_model assert(field_name); auto maybe_offset = tryGetNameOffset(field_name); if (!maybe_offset) { - return assignFieldOffset(field_id); + auto maybe_field_offset = tryGetAssignedFieldOffset(field_id); + if (maybe_field_offset) { + recordFieldOffsetRange(*maybe_field_offset); + return *maybe_field_offset; + } + return assignFieldExceptionOffset(field_id, assignNextNameOffset()); } auto offset = *maybe_offset; @@ -333,12 +344,42 @@ namespace db0::object_model auto maybe_exception_offset = tryGetFieldExceptionOffset(field_id); if (maybe_exception_offset) { + recordFieldOffsetRange(*maybe_exception_offset); return *maybe_exception_offset; } return assignFieldExceptionOffset(field_id, offset); } + std::unordered_map FieldIDMapper::getAssignedNameOffsets() const + { + std::unordered_map result; + auto name_offsets = getNameOffsets(); + if (!name_offsets) { + return result; + } + + for (auto it = name_offsets->begin(); it != name_offsets->end(); ++it) { + auto field_name = it->first().toString(); + auto offset = static_cast(it->second()); + result[field_name] = offset; + m_name_offset_cache[field_name] = offset; + } + return result; + } + + std::uint32_t FieldIDMapper::getFieldOffsetRange() const + { + return (*this)->m_field_offset_range; + } + + void FieldIDMapper::recordFieldOffsetRange(std::uint32_t offset) + { + if (offset > (*this)->m_field_offset_range) { + this->modify().m_field_offset_range = offset; + } + } + std::uint32_t FieldIDMapper::getFieldIDMappingCount() const { return getFieldIDClusterMappingCount() + getFieldIDExceptionMappingCount(); diff --git a/src/dbzero/object_model/class/FieldIDMapper.hpp b/src/dbzero/object_model/class/FieldIDMapper.hpp index 06739ae3..bb1a07ed 100644 --- a/src/dbzero/object_model/class/FieldIDMapper.hpp +++ b/src/dbzero/object_model/class/FieldIDMapper.hpp @@ -33,6 +33,7 @@ DB0_PACKED_BEGIN db0_ptr m_name_offsets_ptr; std::uint32_t m_next_name_offset = FieldID::getClusterSize(); std::uint32_t m_next_cluster_offset = FieldID::getClusterSize(); + std::uint32_t m_field_offset_range = 0; o_field_id_mapper() = default; }; @@ -57,6 +58,8 @@ DB0_PACKED_END bool renameField(const char *old_field_name, const char *new_field_name); std::optional tryGetAssignedFieldOffset(FieldID) const; + std::unordered_map getAssignedNameOffsets() const; + std::uint32_t getFieldOffsetRange() const; void detach() const; void commit() const; @@ -91,6 +94,7 @@ DB0_PACKED_END std::uint32_t assignNextNameOffset(); std::uint32_t assignNextClusterOffset(); std::uint32_t assignFieldExceptionOffset(FieldID, std::uint32_t offset); + void recordFieldOffsetRange(std::uint32_t offset); }; } diff --git a/src/dbzero/object_model/class/FieldMask.cpp b/src/dbzero/object_model/class/FieldMask.cpp index 90fe9eb9..f97dab7e 100644 --- a/src/dbzero/object_model/class/FieldMask.cpp +++ b/src/dbzero/object_model/class/FieldMask.cpp @@ -22,10 +22,16 @@ namespace db0::object_model } std::optional FieldMask::getMask(std::uint32_t field_offset) const + { + auto result = getAssignedMask(field_offset); + return result ? result : FieldMaskFlags {}; + } + + std::optional FieldMask::getAssignedMask(std::uint32_t field_offset) const { auto slot = getSlot(field_offset); if (slot >= this->size()) { - return FieldMaskFlags {}; + return std::nullopt; } return decode(this->getItem(slot), getShift(field_offset)); diff --git a/src/dbzero/object_model/class/FieldMask.hpp b/src/dbzero/object_model/class/FieldMask.hpp index b4e71faa..0b138457 100644 --- a/src/dbzero/object_model/class/FieldMask.hpp +++ b/src/dbzero/object_model/class/FieldMask.hpp @@ -52,6 +52,7 @@ namespace db0::object_model void setMask(std::uint32_t field_offset, FieldMaskFlags); std::optional getMask(std::uint32_t field_offset) const; + std::optional getAssignedMask(std::uint32_t field_offset) const; private: static constexpr std::uint8_t VALUE_MASK = 0x0f; diff --git a/src/dbzero/object_model/class/FieldSafe.cpp b/src/dbzero/object_model/class/FieldSafe.cpp new file mode 100644 index 00000000..e8f2ae7b --- /dev/null +++ b/src/dbzero/object_model/class/FieldSafe.cpp @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (c) 2025 DBZero Software sp. z o.o. + +#include "FieldSafe.hpp" + +namespace db0::object_model + +{ + + FieldSafe::FieldSafe(db0::Memspace &memspace, db0::VObjectCache &cache) + : super_t(memspace) + , m_field_id_mapper(memspace) + , m_field_mask_manager(memspace, cache) + { + auto &self = this->modify(); + self.m_field_id_mapper_ptr = m_field_id_mapper; + self.m_field_mask_manager_ptr = m_field_mask_manager; + } + + FieldSafe::FieldSafe(db0::mptr ptr, db0::VObjectCache &cache) + : super_t(ptr) + , m_field_id_mapper((*this)->m_field_id_mapper_ptr(this->getMemspace())) + , m_field_mask_manager(this->getMemspace().myPtr((*this)->m_field_mask_manager_ptr.getAddress()), cache) + { + } + + FieldIDMapper &FieldSafe::getFieldIDMapper() + { + return m_field_id_mapper; + } + + const FieldIDMapper &FieldSafe::getFieldIDMapper() const + { + return m_field_id_mapper; + } + + FieldMaskManager &FieldSafe::getFieldMaskManager() + { + return m_field_mask_manager; + } + + const FieldMaskManager &FieldSafe::getFieldMaskManager() const + { + return m_field_mask_manager; + } + + void FieldSafe::detach() const + { + m_field_id_mapper.detach(); + m_field_mask_manager.detach(); + super_t::detach(); + } + + void FieldSafe::commit() const + { + m_field_id_mapper.commit(); + m_field_mask_manager.commit(); + super_t::commit(); + } + +} diff --git a/src/dbzero/object_model/class/FieldSafe.hpp b/src/dbzero/object_model/class/FieldSafe.hpp new file mode 100644 index 00000000..7f8e2c28 --- /dev/null +++ b/src/dbzero/object_model/class/FieldSafe.hpp @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (c) 2025 DBZero Software sp. z o.o. + +#pragma once + +#include "FieldIDMapper.hpp" +#include "FieldMask.hpp" +#include +#include +#include + +namespace db0::object_model + +{ + +DB0_PACKED_BEGIN + struct DB0_PACKED_ATTR o_field_safe: public db0::o_fixed_versioned + { + db0_ptr m_field_id_mapper_ptr; + db0_ptr m_field_mask_manager_ptr; + + o_field_safe() = default; + }; +DB0_PACKED_END + + class FieldSafe: public db0::v_object + { + public: + using super_t = db0::v_object; + + FieldSafe(db0::Memspace &, db0::VObjectCache &); + FieldSafe(db0::mptr, db0::VObjectCache &); + + FieldIDMapper &getFieldIDMapper(); + const FieldIDMapper &getFieldIDMapper() const; + FieldMaskManager &getFieldMaskManager(); + const FieldMaskManager &getFieldMaskManager() const; + + void detach() const; + void commit() const; + + private: + mutable FieldIDMapper m_field_id_mapper; + mutable FieldMaskManager m_field_mask_manager; + }; + +} diff --git a/src/dbzero/object_model/object/Options.cpp b/src/dbzero/object_model/object/Options.cpp index 9519df72..4a14bce0 100644 --- a/src/dbzero/object_model/object/Options.cpp +++ b/src/dbzero/object_model/object/Options.cpp @@ -3,4 +3,4 @@ #include "Options.hpp" -DEFINE_ENUM_VALUES(db0::object_model::MemoOptions, "NO_DEFAULT_TAGS", "NO_CACHE", "IMMUTABLE") +DEFINE_ENUM_VALUES(db0::object_model::MemoOptions, "NO_DEFAULT_TAGS", "NO_CACHE", "IMMUTABLE", "PROTECT_FIELDS") diff --git a/src/dbzero/object_model/object/Options.hpp b/src/dbzero/object_model/object/Options.hpp index f7b7e5a6..b3f964c0 100644 --- a/src/dbzero/object_model/object/Options.hpp +++ b/src/dbzero/object_model/object/Options.hpp @@ -16,11 +16,12 @@ namespace db0::object_model NO_DEFAULT_TAGS = 0x0001, // instances of this type opted out of caching NO_CACHE = 0x0002, - IMMUTABLE = 0x0004 + IMMUTABLE = 0x0004, + PROTECT_FIELDS = 0x0008 }; using MemoFlags = db0::FlagSet; } -DECLARE_ENUM_VALUES(db0::object_model::MemoOptions, 3) \ No newline at end of file +DECLARE_ENUM_VALUES(db0::object_model::MemoOptions, 4) diff --git a/tests/unit_tests/ClassFactoryTest.cpp b/tests/unit_tests/ClassFactoryTest.cpp index 4e740ce4..d6e1e231 100644 --- a/tests/unit_tests/ClassFactoryTest.cpp +++ b/tests/unit_tests/ClassFactoryTest.cpp @@ -6,7 +6,9 @@ #include #include #include +#include #include +#include using namespace std; using namespace db0; @@ -44,4 +46,37 @@ namespace tests m_workspace.close(); } -} \ No newline at end of file + TEST_F( ClassFactoryTest , testProtectFieldsCreatesFieldSafe ) + { + Workspace workspace("", {}, {}, {}, {}, db0::object_model::initializer()); + auto fixture = workspace.getFixture("my-test-prefix_1"); + auto type = std::shared_ptr(new SubClass( + fixture, "ProtectedObject", std::nullopt, "protected.object", "test_prefix", {}, ClassFlags(), nullptr) + ); + + ASSERT_FALSE(type->hasFieldSafe()); + type->setProtectFields(); + + ASSERT_TRUE(type->isProtectFields()); + ASSERT_TRUE(type->hasFieldSafe()); + auto offset = type->getFieldSafe().getFieldIDMapper().assignFieldOffset("status"); + ASSERT_EQ(type->getFieldSafe().getFieldIDMapper().assignFieldOffset("status"), offset); + workspace.close(); + } + + TEST_F( ClassFactoryTest , testProtectFieldsOnUnsupportedClassVersionRaises ) + { + Workspace workspace("", {}, {}, {}, {}, db0::object_model::initializer()); + auto fixture = workspace.getFixture("my-test-prefix_1"); + auto type = std::shared_ptr(new SubClass( + fixture, "LegacyObject", std::nullopt, "legacy.object", "test_prefix", {}, ClassFlags(), nullptr) + ); + type->forceObjVersionForTest(0); + + ASSERT_THROW(type->setProtectFields(), db0::InputException); + ASSERT_FALSE(type->isProtectFields()); + ASSERT_FALSE(type->hasFieldSafe()); + workspace.close(); + } + +} diff --git a/tests/unit_tests/FieldIDMapperTest.cpp b/tests/unit_tests/FieldIDMapperTest.cpp index 9971b500..e064904b 100644 --- a/tests/unit_tests/FieldIDMapperTest.cpp +++ b/tests/unit_tests/FieldIDMapperTest.cpp @@ -97,6 +97,25 @@ namespace tests ASSERT_EQ(mapper.getNameMappingCount(), 1u); } + TEST_F( FieldIDMapperTest , testFieldOffsetRangeTracksHighestAssignedOffset ) + { + auto memspace = getMemspace(); + TestFieldIDMapper mapper(memspace); + + ASSERT_EQ(mapper.getFieldOffsetRange(), 0u); + ASSERT_EQ(mapper.assignFieldOffset(FieldID::fromIndex(0, 3)), 3u); + ASSERT_EQ(mapper.getFieldOffsetRange(), 3u); + ASSERT_EQ(mapper.assignFieldOffset("status"), FieldIDMapper::CLUSTER_SIZE); + ASSERT_EQ(mapper.getFieldOffsetRange(), FieldIDMapper::CLUSTER_SIZE); + + auto field_id = FieldID::fromIndex(100, 7); + auto offset = mapper.assignFieldOffset(field_id); + + ASSERT_EQ(mapper.getFieldOffsetRange(), offset); + ASSERT_EQ(mapper.assignFieldOffset(FieldID::fromIndex(0, 1)), 1u); + ASSERT_EQ(mapper.getFieldOffsetRange(), offset); + } + TEST_F( FieldIDMapperTest , testRenameFieldMovesExistingNameMapping ) { auto memspace = getMemspace(); @@ -239,6 +258,42 @@ namespace tests ASSERT_EQ(mapper.getFieldIDExceptionMappingCount(), 1u); } + TEST_F( FieldIDMapperTest , testNamedFieldIDAssignmentWithoutPredeclaredNameUsesCompactExceptionMapping ) + { + auto memspace = getMemspace(); + TestFieldIDMapper mapper(memspace); + auto field_id = FieldID::fromIndex(100, 0); + auto next_field_id = FieldID::fromIndex(101, 0); + + auto offset = mapper.onFieldIDAssigned("status", field_id); + auto next_offset = mapper.onFieldIDAssigned("type", next_field_id); + + ASSERT_EQ(offset, FieldIDMapper::CLUSTER_SIZE); + ASSERT_EQ(next_offset, offset + 1); + ASSERT_EQ(mapper.tryGetAssignedFieldOffset(field_id), offset); + ASSERT_EQ(mapper.tryGetAssignedFieldOffset(next_field_id), next_offset); + ASSERT_EQ(mapper.getFieldIDClusterMappingCount(), 0u); + ASSERT_EQ(mapper.getFieldIDExceptionMappingCount(), 2u); + ASSERT_EQ(mapper.getFieldOffsetRange(), next_offset); + } + + TEST_F( FieldIDMapperTest , testRepeatedNamedFieldIDAssignmentReusesExistingFieldMapping ) + { + auto memspace = getMemspace(); + TestFieldIDMapper mapper(memspace); + auto field_id = FieldID::fromIndex(100, 0); + + auto offset = mapper.onFieldIDAssigned("status", field_id); + ASSERT_EQ(mapper.onFieldIDAssigned("status", field_id), offset); + auto next_offset = mapper.onFieldIDAssigned("type", FieldID::fromIndex(101, 0)); + + ASSERT_EQ(next_offset, offset + 1); + ASSERT_EQ(mapper.tryGetAssignedFieldOffset(field_id), offset); + ASSERT_EQ(mapper.getFieldIDExceptionMappingCount(), 2u); + ASSERT_EQ(mapper.getNameMappingCount(), 0u); + ASSERT_EQ(mapper.getFieldOffsetRange(), next_offset); + } + TEST_F( FieldIDMapperTest , testClusterAndNameAllocationsDoNotOverlap ) { auto memspace = getMemspace(); diff --git a/tests/unit_tests/FieldSafeTest.cpp b/tests/unit_tests/FieldSafeTest.cpp new file mode 100644 index 00000000..1f1bdfee --- /dev/null +++ b/tests/unit_tests/FieldSafeTest.cpp @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (c) 2025 DBZero Software sp. z o.o. + +#include +#include +#include +#include +#include + +namespace tests + +{ + + using namespace db0::object_model; + + class FieldSafeTest: public testing::Test + { + public: + static constexpr const char *prefix_name = "field-safe-test"; + static constexpr const char *file_name = "field-safe-test.db0"; + + void SetUp() override { + db0::tests::drop(file_name); + } + + void TearDown() override { + db0::tests::drop(file_name); + } + }; + + TEST_F( FieldSafeTest , testFieldOffsetsAndMasksShareDurableRoot ) + { + db0::Workspace workspace("", {}, {}, {}, {}, db0::object_model::initializer()); + auto fixture = workspace.getFixture(prefix_name); + FieldSafe field_safe(*fixture, fixture->getVObjectCache()); + auto field_safe_address = field_safe.getAddress(); + + auto offset = field_safe.getFieldIDMapper().assignFieldOffset("status"); + auto mask = field_safe.getFieldMaskManager().createFieldMask(123); + mask->setMask(offset, { FieldMaskOptions::READ }); + + FieldSafe reopened(fixture->myPtr(field_safe_address), fixture->getVObjectCache()); + auto reopened_offset = reopened.getFieldIDMapper().assignFieldOffset("status"); + auto reopened_mask = reopened.getFieldMaskManager().tryGetFieldMask(123); + + ASSERT_EQ(reopened_offset, offset); + ASSERT_TRUE(reopened_mask != nullptr); + ASSERT_TRUE((*reopened_mask->getMask(reopened_offset))[FieldMaskOptions::READ]); + workspace.close(); + } + + TEST_F( FieldSafeTest , testMaskAssignedByNameFollowsFieldIDAssignment ) + { + db0::Workspace workspace("", {}, {}, {}, {}, db0::object_model::initializer()); + auto fixture = workspace.getFixture(prefix_name); + FieldSafe field_safe(*fixture, fixture->getVObjectCache()); + auto field_id = FieldID::fromIndex(100, 3); + + auto name_offset = field_safe.getFieldIDMapper().assignFieldOffset("status"); + field_safe.getFieldMaskManager() + .createFieldMask(123) + ->setMask(name_offset, { FieldMaskOptions::CREATE, FieldMaskOptions::UPDATE }); + + auto offset = field_safe.getFieldIDMapper().onFieldIDAssigned("status", field_id); + auto field_offset = field_safe.getFieldIDMapper().assignFieldOffset(field_id); + auto result = field_safe.getFieldMaskManager().tryGetFieldMask(123)->getMask(field_offset); + + ASSERT_EQ(field_offset, offset); + ASSERT_TRUE((*result)[FieldMaskOptions::CREATE]); + ASSERT_TRUE((*result)[FieldMaskOptions::UPDATE]); + workspace.close(); + } + +} diff --git a/tests/utils/SubClass.cpp b/tests/utils/SubClass.cpp index de6cad47..f17dc43f 100644 --- a/tests/utils/SubClass.cpp +++ b/tests/utils/SubClass.cpp @@ -16,6 +16,11 @@ namespace tests this->incRef(false); this->incRef(false); } + + void SubClass::forceObjVersionForTest(std::uint16_t version) + { + *reinterpret_cast(&this->modify()) = version; + } std::shared_ptr getTestClass(db0::swine_ptr &fixture) { @@ -24,4 +29,4 @@ namespace tests ); } -} \ No newline at end of file +} diff --git a/tests/utils/SubClass.hpp b/tests/utils/SubClass.hpp index fd71aa11..aba97f43 100644 --- a/tests/utils/SubClass.hpp +++ b/tests/utils/SubClass.hpp @@ -19,6 +19,8 @@ namespace tests SubClass(db0::swine_ptr &fixture, const std::string &name, std::optional module_name, const char *type_id, const char *prefix_name, const std::vector &init_vars, ClassFlags flags, std::shared_ptr base_class); + + void forceObjVersionForTest(std::uint16_t version); }; // constructs a mocked type @@ -26,4 +28,3 @@ namespace tests } -