From 3abad74d7528e2d020289fa482f21c821ad85dbb Mon Sep 17 00:00:00 2001 From: akrivi <20168326+akrivi@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:26:27 -0600 Subject: [PATCH 01/15] fix attributes copy object --- src/plexosdb/db.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/plexosdb/db.py b/src/plexosdb/db.py index fc2e6f8..92fc27b 100644 --- a/src/plexosdb/db.py +++ b/src/plexosdb/db.py @@ -1365,14 +1365,25 @@ def copy_object( original_object_name: str, new_object_name: str, copy_properties: bool = True, + copy_attributes: bool = True, ) -> int: - """Copy an object and its properties, tags, and texts.""" + """Copy an object and its memberships, attributes, properties, tags and texts.""" object_id = self.get_object_id(object_class, name=original_object_name) category_id = self.query("SELECT category_id from t_object WHERE object_id = ?", (object_id,)) category = self.query("SELECT name from t_category WHERE category_id = ?", (category_id[0][0],)) + new_object_id = self.add_object(object_class, new_object_name, category=category[0][0]) + + if copy_attributes: + self._copy_object_attributes( + old_object_id=object_id, + new_object_id=new_object_id, + ) + membership_mapping = self.copy_object_memberships( - object_class=object_class, original_name=original_object_name, new_name=new_object_name + object_class=object_class, + original_name=original_object_name, + new_name=new_object_name, ) system_collection = get_default_collection(object_class) @@ -1549,6 +1560,16 @@ def _copy_object_properties(self, membership_mapping: dict[int, int]) -> bool: self._db.execute("DROP TABLE IF EXISTS temp_mapping") self._db.execute("DROP TABLE IF EXISTS temp_data_mapping") return True + + def _copy_object_attributes(self, old_object_id: int, new_object_id: int) -> bool: + """Copy attribute values from one object to another.""" + query = """ + INSERT INTO t_attribute_data (object_id, attribute_id, value, state) + SELECT ?, attribute_id, value, state + FROM t_attribute_data + WHERE object_id = ? + """ + return self._db.execute(query, (new_object_id, old_object_id)) def create_object_scenario( self, From bdf513e00e4a6ab13e6815813c2d32a42c57ed66 Mon Sep 17 00:00:00 2001 From: akrivi <20168326+akrivi@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:11:12 -0600 Subject: [PATCH 02/15] fix: checks should pass --- CHANGELOG.md | 55 ++++++++++++++++++++++++++++++++-------------- src/plexosdb/db.py | 2 +- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92124cb..b4155e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,38 +36,61 @@ All notable changes to this project will be documented in this file. ## [1.3.4](https://github.com/NatLabRockies/plexosdb/compare/v1.3.3...v1.3.4) (2026-03-27) - ### 🧩 CI -* use release/v1 tag for pypa/gh-action-pypi-publish ([#107](https://github.com/NatLabRockies/plexosdb/issues/107)) ([c4e58b8](https://github.com/NatLabRockies/plexosdb/commit/c4e58b8dc062f3302216b3caa7c9c6c1cc423c86)) - +- use release/v1 tag for pypa/gh-action-pypi-publish + ([#107](https://github.com/NatLabRockies/plexosdb/issues/107)) + ([c4e58b8](https://github.com/NatLabRockies/plexosdb/commit/c4e58b8dc062f3302216b3caa7c9c6c1cc423c86)) ### 📦 Build -* **deps:** bump actions/cache from 5.0.3 to 5.0.4 ([#115](https://github.com/NatLabRockies/plexosdb/issues/115)) ([1ff162a](https://github.com/NatLabRockies/plexosdb/commit/1ff162afe66dfe3bcad7d0dbb0a534b4a9d3374a)) -* **deps:** bump astral-sh/setup-uv from 7.5.0 to 7.6.0 ([#117](https://github.com/NatLabRockies/plexosdb/issues/117)) ([13fb3cf](https://github.com/NatLabRockies/plexosdb/commit/13fb3cfb4c7a2affd7df504a2e152c9a2b0c1295)) -* **deps:** bump astral-sh/setup-uv from b75dde52aef63a238519e7aecbbe79a4a52e4315 to e06108dd0aef18192324c70427afc47652e63a82 ([#114](https://github.com/NatLabRockies/plexosdb/issues/114)) ([96f3975](https://github.com/NatLabRockies/plexosdb/commit/96f397540b06e68e45075a5d700d2b0a91ebe112)) -* **deps:** bump codecov/codecov-action from 5.5.2 to 5.5.3 ([#116](https://github.com/NatLabRockies/plexosdb/issues/116)) ([c86c8e2](https://github.com/NatLabRockies/plexosdb/commit/c86c8e254a85909043c8a7b25a10ff7d169d1e02)) -* **deps:** bump googleapis/release-please-action from c3fc4de07084f75a2b61a5b933069bda6edf3d5c to 16a9c90856f42705d54a6fda1823352bdc62cf38 ([#112](https://github.com/NatLabRockies/plexosdb/issues/112)) ([4150d56](https://github.com/NatLabRockies/plexosdb/commit/4150d56065f41cf916912daf9cda39281cf4e3df)) -* **deps:** bump peaceiris/actions-gh-pages from e9c66a37f080288a11235e32cbe2dc5fb3a679cc to 4f9cc6602d3f66b9c108549d475ec49e8ef4d45e ([#113](https://github.com/NatLabRockies/plexosdb/issues/113)) ([c697f1d](https://github.com/NatLabRockies/plexosdb/commit/c697f1d7642dac4c16cd5ea9e11e327d684ff548)) +- **deps:** bump actions/cache from 5.0.3 to 5.0.4 + ([#115](https://github.com/NatLabRockies/plexosdb/issues/115)) + ([1ff162a](https://github.com/NatLabRockies/plexosdb/commit/1ff162afe66dfe3bcad7d0dbb0a534b4a9d3374a)) +- **deps:** bump astral-sh/setup-uv from 7.5.0 to 7.6.0 + ([#117](https://github.com/NatLabRockies/plexosdb/issues/117)) + ([13fb3cf](https://github.com/NatLabRockies/plexosdb/commit/13fb3cfb4c7a2affd7df504a2e152c9a2b0c1295)) +- **deps:** bump astral-sh/setup-uv from + b75dde52aef63a238519e7aecbbe79a4a52e4315 to + e06108dd0aef18192324c70427afc47652e63a82 + ([#114](https://github.com/NatLabRockies/plexosdb/issues/114)) + ([96f3975](https://github.com/NatLabRockies/plexosdb/commit/96f397540b06e68e45075a5d700d2b0a91ebe112)) +- **deps:** bump codecov/codecov-action from 5.5.2 to 5.5.3 + ([#116](https://github.com/NatLabRockies/plexosdb/issues/116)) + ([c86c8e2](https://github.com/NatLabRockies/plexosdb/commit/c86c8e254a85909043c8a7b25a10ff7d169d1e02)) +- **deps:** bump googleapis/release-please-action from + c3fc4de07084f75a2b61a5b933069bda6edf3d5c to + 16a9c90856f42705d54a6fda1823352bdc62cf38 + ([#112](https://github.com/NatLabRockies/plexosdb/issues/112)) + ([4150d56](https://github.com/NatLabRockies/plexosdb/commit/4150d56065f41cf916912daf9cda39281cf4e3df)) +- **deps:** bump peaceiris/actions-gh-pages from + e9c66a37f080288a11235e32cbe2dc5fb3a679cc to + 4f9cc6602d3f66b9c108549d475ec49e8ef4d45e + ([#113](https://github.com/NatLabRockies/plexosdb/issues/113)) + ([c697f1d](https://github.com/NatLabRockies/plexosdb/commit/c697f1d7642dac4c16cd5ea9e11e327d684ff548)) ## [1.3.3](https://github.com/NatLabRockies/plexosdb/compare/v1.3.2...v1.3.3) (2026-03-16) - ### 🐛 Bug Fixes -* **ci:** harden all workflows per zizmor audit ([#105](https://github.com/NatLabRockies/plexosdb/issues/105)) ([67ca845](https://github.com/NatLabRockies/plexosdb/commit/67ca84584d1e66410dc66b014a9b710a24b00b95)) - +- **ci:** harden all workflows per zizmor audit + ([#105](https://github.com/NatLabRockies/plexosdb/issues/105)) + ([67ca845](https://github.com/NatLabRockies/plexosdb/commit/67ca84584d1e66410dc66b014a9b710a24b00b95)) ### ⚡ Performance -* Improving performance of adding memberships from records ([#104](https://github.com/NatLabRockies/plexosdb/issues/104)) ([1ea4a39](https://github.com/NatLabRockies/plexosdb/commit/1ea4a39612a1bef1a0f290eaeb40441874a2b8f0)) - +- Improving performance of adding memberships from records + ([#104](https://github.com/NatLabRockies/plexosdb/issues/104)) + ([1ea4a39](https://github.com/NatLabRockies/plexosdb/commit/1ea4a39612a1bef1a0f290eaeb40441874a2b8f0)) ### 📦 Build -* **deps:** bump actions/download-artifact from 7 to 8 ([#101](https://github.com/NatLabRockies/plexosdb/issues/101)) ([0e572a0](https://github.com/NatLabRockies/plexosdb/commit/0e572a07a930f6e25f196e98fc879f65f7dd9daa)) -* **deps:** bump actions/upload-artifact from 6 to 7 ([#102](https://github.com/NatLabRockies/plexosdb/issues/102)) ([22b8374](https://github.com/NatLabRockies/plexosdb/commit/22b8374aa7ed9d29eb36258a6a5ad16feb2e21c5)) +- **deps:** bump actions/download-artifact from 7 to 8 + ([#101](https://github.com/NatLabRockies/plexosdb/issues/101)) + ([0e572a0](https://github.com/NatLabRockies/plexosdb/commit/0e572a07a930f6e25f196e98fc879f65f7dd9daa)) +- **deps:** bump actions/upload-artifact from 6 to 7 + ([#102](https://github.com/NatLabRockies/plexosdb/issues/102)) + ([22b8374](https://github.com/NatLabRockies/plexosdb/commit/22b8374aa7ed9d29eb36258a6a5ad16feb2e21c5)) ## [1.3.2](https://github.com/NatLabRockies/plexosdb/compare/v1.3.1...v1.3.2) (2026-02-12) diff --git a/src/plexosdb/db.py b/src/plexosdb/db.py index 92fc27b..aaa3810 100644 --- a/src/plexosdb/db.py +++ b/src/plexosdb/db.py @@ -1560,7 +1560,7 @@ def _copy_object_properties(self, membership_mapping: dict[int, int]) -> bool: self._db.execute("DROP TABLE IF EXISTS temp_mapping") self._db.execute("DROP TABLE IF EXISTS temp_data_mapping") return True - + def _copy_object_attributes(self, old_object_id: int, new_object_id: int) -> bool: """Copy attribute values from one object to another.""" query = """ From 028446d7e877d112c884ef90d8648fb325cb61fd Mon Sep 17 00:00:00 2001 From: akrivi <20168326+akrivi@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:11:18 -0600 Subject: [PATCH 03/15] feat: bulk add attributes --- src/plexosdb/db.py | 130 +++++++++++++++++++++++++++++++++++++++++- src/plexosdb/utils.py | 31 ++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/src/plexosdb/db.py b/src/plexosdb/db.py index aaa3810..a87e5c0 100644 --- a/src/plexosdb/db.py +++ b/src/plexosdb/db.py @@ -40,6 +40,7 @@ normalize_names, plan_property_inserts, resolve_membership_id, + _normalize_attribute_records, ) from .xml_handler import XMLHandler @@ -961,6 +962,133 @@ def add_properties_from_records( logger.debug(f"Successfully processed {len(records)} property and text records in batches") return + def add_attributes_from_records( + self, + records: list[dict[str, Any]], + /, + *, + object_class: ClassEnum, + chunksize: int = 10_000, + ) -> None: + """Bulk insert attribute values for objects. + + Efficiently adds multiple object-level attribute values in batches. + This method is much more efficient than calling `add_attribute` repeatedly. + + Each record should contain: + - 'name': object name + - 'attribute': attribute name + - 'value': attribute value + + Alternatively, records may be provided in the following format: + - {'name': ..., 'Attr1': val1, 'Attr2': val2} + + Optional: + - 'state' + + Parameters + ---------- + records : list[dict[str, Any]] + List of attribute records in explicit or wide format. + object_class : ClassEnum + Class of the objects. + chunksize : int, optional + Batch size for inserts, by default 10_000. + + Returns + ------- + None + + See Also + -------- + add_attribute + add_properties_from_records + add_memberships_from_records + + Examples + -------- + >>> records = [ + ... {"name": "2020", "Step Type": 4.0, "Chrono Step Count": 366.0}, + ... ] + + >>> db.add_attributes_from_records(records, object_class=ClassEnum.Horizon) + """ + if not records: + logger.warning("No records provided for bulk attribute insertion") + return + + records = _normalize_attribute_records(records) + + if chunksize < 1: + msg = f"chunksize must be >= 1, received {chunksize}" + raise ValueError(msg) + + class_id = self.get_class_id(object_class) + + object_names = tuple({record["name"] for record in records}) + object_placeholders = ", ".join("?" for _ in object_names) + + object_rows = self._db.query( + f""" + SELECT object_id, name + FROM t_object + WHERE class_id = ? + AND name IN ({object_placeholders}) + """, + (class_id, *object_names), + ) + name_to_object_id = {name: object_id for object_id, name in object_rows} + + attribute_rows = self._db.query( + """ + SELECT attribute_id, name + FROM t_attribute + WHERE class_id = ? + """, + (class_id,), + ) + name_to_attribute_id = {name: attribute_id for attribute_id, name in attribute_rows} + + params: list[tuple[int, int, Any, Any]] = [] + seen: set[tuple[int, int]] = set() + + for record in records: + try: + object_id = name_to_object_id[record["name"]] + attribute_id = name_to_attribute_id[record["attribute"]] + except KeyError as exc: + raise KeyError(f"Invalid attribute record: {record}") from exc + + key = (object_id, attribute_id) + if key in seen: + raise ValueError( + f"Duplicate attribute record for object={record['name']!r}, " + f"attribute={record['attribute']!r}" + ) + seen.add(key) + + params.append( + ( + object_id, + attribute_id, + record["value"], + record.get("state"), + ) + ) + + query = f""" + INSERT INTO {Schema.AttributeData.name} + (object_id, attribute_id, value, state) + VALUES (?, ?, ?, ?) + """ + + with self._db.transaction(): + for batch in batched(params, chunksize): + result = self._db.executemany(query, list(batch)) + assert result + + logger.debug("Added {} attribute values.", len(params)) + def _handle_dates( self, data_id: int, @@ -1562,7 +1690,7 @@ def _copy_object_properties(self, membership_mapping: dict[int, int]) -> bool: return True def _copy_object_attributes(self, old_object_id: int, new_object_id: int) -> bool: - """Copy attribute values from one object to another.""" + """Copy attribute values from original object to new object.""" query = """ INSERT INTO t_attribute_data (object_id, attribute_id, value, state) SELECT ?, attribute_id, value, state diff --git a/src/plexosdb/utils.py b/src/plexosdb/utils.py index 445ff87..b4037b0 100644 --- a/src/plexosdb/utils.py +++ b/src/plexosdb/utils.py @@ -273,6 +273,37 @@ def _flatten_property_records(records: list[dict[str, Any]]) -> tuple[list[dict[ return normalized_records, deprecated_format_used +def _normalize_attribute_records( + records: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Normalize wide or explicit attribute records into explicit records.""" + reserved_fields = {"name", "attribute", "value", "state"} + normalized: list[dict[str, Any]] = [] + + for record in records: + if "attribute" in record and "value" in record: + normalized.append(record) + continue + + if "name" not in record: + raise KeyError(f"Attribute record is missing required 'name': {record}") + + for key, value in record.items(): + if key in reserved_fields: + continue + + normalized.append( + { + "name": record["name"], + "attribute": key, + "value": value, + "state": record.get("state"), + } + ) + + return normalized + + def plan_property_inserts( db: PlexosDB, records: list[dict[str, Any]], From 610a087bb2fa2818e170fe81465cee75d122210d Mon Sep 17 00:00:00 2001 From: akrivi <20168326+akrivi@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:48:45 -0600 Subject: [PATCH 04/15] test: tests --- tests/test_plexosdb_from_records.py | 156 ++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/tests/test_plexosdb_from_records.py b/tests/test_plexosdb_from_records.py index 51291e3..c5147d3 100644 --- a/tests/test_plexosdb_from_records.py +++ b/tests/test_plexosdb_from_records.py @@ -375,3 +375,159 @@ def test_get_memberships_system_chunks_over_900_names(db_base: PlexosDB): result = db.get_memberships_system(names, object_class=ClassEnum.Generator) assert len(result) == 950 assert {r["name"] for r in result} == set(names) + + +def _seed_model_attributes(db: PlexosDB) -> None: + from plexosdb import ClassEnum + + class_id = db.get_class_id(ClassEnum.Model) + + db._db.executemany( + """ + INSERT INTO t_attribute (attribute_id, class_id, name) + VALUES (?, ?, ?) + """, + [ + (1001, class_id, "Enabled"), + (1002, class_id, "Random Number Seed"), + ], + ) + + +def test_add_attributes_from_records_explicit_format(db_instance_with_schema: PlexosDB): + from plexosdb import ClassEnum + + db = db_instance_with_schema + _seed_model_attributes(db) + db.add_object(ClassEnum.Model, "AttrModel") + + records = [ + {"name": "AttrModel", "attribute": "Enabled", "value": -1}, + {"name": "AttrModel", "attribute": "Random Number Seed", "value": 1000}, + ] + + db.add_attributes_from_records(records, object_class=ClassEnum.Model) + + rows = db._db.fetchall( + """ + SELECT attr.name, data.value, data.state + FROM t_attribute_data AS data + JOIN t_attribute AS attr ON attr.attribute_id = data.attribute_id + JOIN t_object AS obj ON obj.object_id = data.object_id + WHERE obj.name = ? + ORDER BY attr.name + """, + ("AttrModel",), + ) + + assert rows == [ + ("Enabled", -1.0, None), + ("Random Number Seed", 1000.0, None), + ] + + +def test_add_attributes_from_records_wide_format(db_instance_with_schema: PlexosDB): + from plexosdb import ClassEnum + + db = db_instance_with_schema + _seed_model_attributes(db) + db.add_object(ClassEnum.Model, "WideAttrModel") + + db.add_attributes_from_records( + [{"name": "WideAttrModel", "Enabled": -1, "Random Number Seed": 1000}], + object_class=ClassEnum.Model, + ) + + rows = db._db.fetchall( + """ + SELECT attr.name, data.value + FROM t_attribute_data AS data + JOIN t_attribute AS attr ON attr.attribute_id = data.attribute_id + JOIN t_object AS obj ON obj.object_id = data.object_id + WHERE obj.name = ? + ORDER BY attr.name + """, + ("WideAttrModel",), + ) + + assert rows == [ + ("Enabled", -1.0), + ("Random Number Seed", 1000.0), + ] + + +def test_add_attributes_from_records_rejects_duplicates(db_instance_with_schema: PlexosDB): + from plexosdb import ClassEnum + + db = db_instance_with_schema + _seed_model_attributes(db) + db.add_object(ClassEnum.Model, "DuplicateAttrModel") + + records = [ + {"name": "DuplicateAttrModel", "Enabled": -1}, + {"name": "DuplicateAttrModel", "Enabled": 0}, + ] + + with pytest.raises(ValueError, match="Duplicate attribute record"): + db.add_attributes_from_records(records, object_class=ClassEnum.Model) + + assert db._db.fetchone("SELECT COUNT(*) FROM t_attribute_data")[0] == 0 + + +def test_add_attributes_from_records_unknown_attribute(db_instance_with_schema: PlexosDB): + from plexosdb import ClassEnum + + db = db_instance_with_schema + _seed_model_attributes(db) + db.add_object(ClassEnum.Model, "BadAttrModel") + + with pytest.raises(KeyError, match="Invalid attribute record"): + db.add_attributes_from_records( + [{"name": "BadAttrModel", "Fake Attribute": 123}], + object_class=ClassEnum.Model, + ) + + assert db._db.fetchone("SELECT COUNT(*) FROM t_attribute_data")[0] == 0 + + +def test_add_attributes_from_records_respects_chunksize( + db_instance_with_schema: PlexosDB, + monkeypatch: pytest.MonkeyPatch, +): + from plexosdb import ClassEnum + + db = db_instance_with_schema + _seed_model_attributes(db) + names = [f"ChunkAttrModel_{idx}" for idx in range(5)] + db.add_objects(ClassEnum.Model, *names) + + records = [{"name": name, "Enabled": -1} for name in names] + + observed_batch_sizes: list[int] = [] + original_executemany = db._db.executemany + + def spy_executemany(query, params_seq): + observed_batch_sizes.append(len(params_seq)) + return original_executemany(query, params_seq) + + monkeypatch.setattr(db._db, "executemany", spy_executemany) + db.add_attributes_from_records(records, object_class=ClassEnum.Model, chunksize=2) + + assert observed_batch_sizes == [2, 2, 1] + + +def test_add_attributes_from_records_rejects_non_positive_chunksize( + db_instance_with_schema: PlexosDB, +): + from plexosdb import ClassEnum + + db = db_instance_with_schema + _seed_model_attributes(db) + db.add_object(ClassEnum.Model, "BadChunkAttrModel") + + with pytest.raises(ValueError, match="chunksize must be >= 1"): + db.add_attributes_from_records( + [{"name": "BadChunkAttrModel", "Enabled": -1}], + object_class=ClassEnum.Model, + chunksize=0, + ) From e6d332a41c34fe7cce75f92108a5e4c946654974 Mon Sep 17 00:00:00 2001 From: akrivi <20168326+akrivi@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:22:32 -0600 Subject: [PATCH 05/15] test: more tests --- tests/test_plexosdb_copy_object.py | 47 +++++++++++++++++++++++++++++ tests/test_plexosdb_from_records.py | 11 +++++++ 2 files changed, 58 insertions(+) diff --git a/tests/test_plexosdb_copy_object.py b/tests/test_plexosdb_copy_object.py index c2263bf..3d65376 100644 --- a/tests/test_plexosdb_copy_object.py +++ b/tests/test_plexosdb_copy_object.py @@ -107,3 +107,50 @@ def test_copy_object_with_memberships(db_base: PlexosDB): new_child_membership = db.get_membership_id(new_object_name, child_object_name, collection) assert membership_id_child in membership_mapping assert membership_mapping[membership_id_child] == new_child_membership + + +def test_copy_object_copies_attributes(db_base: PlexosDB): + from plexosdb import ClassEnum + + db = db_base + object_class = ClassEnum.Generator + + original_object_name = "TestGenWithAttribute" + new_object_name = "TestGenWithAttributeCopy" + + original_object_id = db.add_object(object_class, original_object_name) + + db.add_attributes_from_records( + [ + {"name": original_object_name, "attribute": "Latitude", "value": 42.0}, + {"name": original_object_name, "attribute": "Longitude", "value": -105.0}, + ], + object_class=object_class, + ) + + new_object_id = db.copy_object(object_class, original_object_name, new_object_name) + + old_rows = db._db.fetchall( + """ + SELECT attribute_id, value, state + FROM t_attribute_data + WHERE object_id = ? + ORDER BY attribute_id + """, + (original_object_id,), + ) + new_rows = db._db.fetchall( + """ + SELECT attribute_id, value, state + FROM t_attribute_data + WHERE object_id = ? + ORDER BY attribute_id + """, + (new_object_id,), + ) + + assert old_rows == [ + (1, 42.0, None), + (2, -105.0, None), + ] + assert new_rows == old_rows diff --git a/tests/test_plexosdb_from_records.py b/tests/test_plexosdb_from_records.py index c5147d3..5fc47d9 100644 --- a/tests/test_plexosdb_from_records.py +++ b/tests/test_plexosdb_from_records.py @@ -531,3 +531,14 @@ def test_add_attributes_from_records_rejects_non_positive_chunksize( object_class=ClassEnum.Model, chunksize=0, ) + + +def test_add_attributes_from_records_no_records(db_instance_with_schema: PlexosDB, caplog): + from plexosdb import ClassEnum + + db = db_instance_with_schema + + db.add_attributes_from_records([], object_class=ClassEnum.Model) + + assert "No records provided" in caplog.text + assert db._db.fetchone("SELECT COUNT(*) FROM t_attribute_data")[0] == 0 From d742122f56ed682651a3dad229c50c331611f742 Mon Sep 17 00:00:00 2001 From: akrivi <20168326+akrivi@users.noreply.github.com> Date: Fri, 1 May 2026 07:27:09 -0600 Subject: [PATCH 06/15] test: one more attributes test --- tests/test_plexosdb_copy_object.py | 51 ++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/tests/test_plexosdb_copy_object.py b/tests/test_plexosdb_copy_object.py index 3d65376..677915a 100644 --- a/tests/test_plexosdb_copy_object.py +++ b/tests/test_plexosdb_copy_object.py @@ -132,25 +132,56 @@ def test_copy_object_copies_attributes(db_base: PlexosDB): old_rows = db._db.fetchall( """ - SELECT attribute_id, value, state - FROM t_attribute_data - WHERE object_id = ? - ORDER BY attribute_id + SELECT attr.name, data.value, data.state + FROM t_attribute_data AS data + JOIN t_attribute AS attr ON attr.attribute_id = data.attribute_id + WHERE data.object_id = ? + ORDER BY attr.name """, (original_object_id,), ) + new_rows = db._db.fetchall( """ - SELECT attribute_id, value, state - FROM t_attribute_data - WHERE object_id = ? - ORDER BY attribute_id + SELECT attr.name, data.value, data.state + FROM t_attribute_data AS data + JOIN t_attribute AS attr ON attr.attribute_id = data.attribute_id + WHERE data.object_id = ? + ORDER BY attr.name """, (new_object_id,), ) assert old_rows == [ - (1, 42.0, None), - (2, -105.0, None), + ("Latitude", 42.0, None), + ("Longitude", -105.0, None), ] assert new_rows == old_rows + + +def test_copy_object_without_attributes(db_base: PlexosDB): + from plexosdb import ClassEnum + + db = db_base + object_class = ClassEnum.Generator + + original_object_name = "TestGenNoAttr" + new_object_name = "TestGenNoAttrCopy" + + original_object_id = db.add_object(object_class, original_object_name) + new_object_id = db.copy_object(object_class, original_object_name, new_object_name) + + assert ( + db._db.fetchall( + "SELECT * FROM t_attribute_data WHERE object_id = ?", + (original_object_id,), + ) + == [] + ) + assert ( + db._db.fetchall( + "SELECT * FROM t_attribute_data WHERE object_id = ?", + (new_object_id,), + ) + == [] + ) From 6c6ae163b13da3ec23f3b8b1154529b14e6e39f9 Mon Sep 17 00:00:00 2001 From: akrivi <20168326+akrivi@users.noreply.github.com> Date: Fri, 1 May 2026 08:11:18 -0600 Subject: [PATCH 07/15] docs: attributes docs --- docs/source/howtos/add_attributes.md | 2 +- docs/source/howtos/bulk_operations.md | 21 +++++++++++++++++++++ docs/source/howtos/copy_objects.md | 22 +++++++++++++++------- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/docs/source/howtos/add_attributes.md b/docs/source/howtos/add_attributes.md index d1481c7..d21dced 100644 --- a/docs/source/howtos/add_attributes.md +++ b/docs/source/howtos/add_attributes.md @@ -1,7 +1,7 @@ # Adding Attributes to the objects Objects in PlexosDB can have attributes that are saved on the `t_attribute_data` -table. +table and represent static metadata (e.g. "Step Type", "Chrono Step Count"). ## Listing available attributes per `ClassEnum` diff --git a/docs/source/howtos/bulk_operations.md b/docs/source/howtos/bulk_operations.md index 3ddb074..5cad05f 100644 --- a/docs/source/howtos/bulk_operations.md +++ b/docs/source/howtos/bulk_operations.md @@ -227,3 +227,24 @@ your model: This approach can dramatically improve performance when creating large, complex models. + +## Bulk Inserting Attributes + +For efficiently adding multiple attribute values, use +`add_attributes_from_records`. + +### Basic Usage (Wide Format) + +```python +from plexosdb.enums import ClassEnum + +records = [ + {"name": "Horizon1", "Step Type": 4.0, "Chrono Step Count": 366.0}, + {"name": "Horizon2", "Step Type": 4.0, "Chrono Step Count": 365.0}, +] + +db.add_attributes_from_records( + records, + object_class=ClassEnum.Horizon, +) +``` diff --git a/docs/source/howtos/copy_objects.md b/docs/source/howtos/copy_objects.md index 9a227bf..5f59ccb 100644 --- a/docs/source/howtos/copy_objects.md +++ b/docs/source/howtos/copy_objects.md @@ -1,11 +1,11 @@ # Copying Objects PlexosDB allows you to create copies of existing objects along with their -properties, memberships, and related property records. +properties, memberships, attributes and related property records. ## Basic Object Copying -Copy an object and all its properties: +Copy an object and all its memberships, properties and attributes: ```python from plexosdb import PlexosDB @@ -32,20 +32,26 @@ db.add_property( scenario="Base Case" ) -# Copy a generator with all its properties +# Copy a generator with its memberships, properties and attributes new_object_id = db.copy_object( object_class=ClassEnum.Generator, original_object_name="Generator1", new_object_name="Generator1_Copy", - copy_properties=True # Default is True + copy_properties=True, # Default is True + copy_attributes=True # Default is True ) print(f"Created new object with ID: {new_object_id}") ``` +```{note} +Object-level attributes (stored in `t_attribute_data`) are also copied when using `copy_object`. +``` + ## Copying Objects Without Properties -You can also copy the object and its memberships without copying properties: +You can also copy only the object and its memberships by disabling property and +attribute copying: ```python # Add Generator object @@ -65,7 +71,8 @@ new_object_id = db.copy_object( object_class=ClassEnum.Generator, original_object_name="Generator1", new_object_name="Generator1_Skeleton", - copy_properties=False + copy_properties=False, + copy_attributes=False ) ``` @@ -93,7 +100,8 @@ new_object_id = db.copy_object( object_class=ClassEnum.Node, original_object_name="Node1", new_object_name="Node1_Copy", - copy_properties=False + copy_properties=False, + copy_attributes=False ) # Check the memberships of the new object From b9c6227423bd8ee02c1ac88b427dc9c42fdc271b Mon Sep 17 00:00:00 2001 From: akrivi <20168326+akrivi@users.noreply.github.com> Date: Fri, 1 May 2026 13:00:23 -0600 Subject: [PATCH 08/15] fix: addressing comments --- src/plexosdb/utils.py | 2 ++ tests/test_plexosdb_from_records.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/plexosdb/utils.py b/src/plexosdb/utils.py index b4037b0..ce09f16 100644 --- a/src/plexosdb/utils.py +++ b/src/plexosdb/utils.py @@ -282,6 +282,8 @@ def _normalize_attribute_records( for record in records: if "attribute" in record and "value" in record: + if "name" not in record: + raise KeyError(f"Attribute record is missing required 'name': {record}") normalized.append(record) continue diff --git a/tests/test_plexosdb_from_records.py b/tests/test_plexosdb_from_records.py index 5fc47d9..b8d9e26 100644 --- a/tests/test_plexosdb_from_records.py +++ b/tests/test_plexosdb_from_records.py @@ -542,3 +542,17 @@ def test_add_attributes_from_records_no_records(db_instance_with_schema: PlexosD assert "No records provided" in caplog.text assert db._db.fetchone("SELECT COUNT(*) FROM t_attribute_data")[0] == 0 + + +def test_add_attributes_from_records_missing_name_explicit_format( + db_instance_with_schema: PlexosDB, +): + from plexosdb import ClassEnum + + db = db_instance_with_schema + + with pytest.raises(KeyError, match="missing required 'name'"): + db.add_attributes_from_records( + [{"attribute": "Enabled", "value": -1}], + object_class=ClassEnum.Model, + ) From 0746df11eebd83744a3ad00a0640eadba4a16aee Mon Sep 17 00:00:00 2001 From: akrivi <20168326+akrivi@users.noreply.github.com> Date: Thu, 7 May 2026 16:55:39 -0600 Subject: [PATCH 09/15] fix: updated attribute error handling --- src/plexosdb/db.py | 4 +++- tests/test_plexosdb_from_records.py | 34 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/plexosdb/db.py b/src/plexosdb/db.py index a87e5c0..d1e4bd0 100644 --- a/src/plexosdb/db.py +++ b/src/plexosdb/db.py @@ -1085,7 +1085,9 @@ def add_attributes_from_records( with self._db.transaction(): for batch in batched(params, chunksize): result = self._db.executemany(query, list(batch)) - assert result + if not result: + msg = f"Failed to add attribute values for {object_class}." + raise RuntimeError(msg) logger.debug("Added {} attribute values.", len(params)) diff --git a/tests/test_plexosdb_from_records.py b/tests/test_plexosdb_from_records.py index b8d9e26..b8e5052 100644 --- a/tests/test_plexosdb_from_records.py +++ b/tests/test_plexosdb_from_records.py @@ -378,6 +378,7 @@ def test_get_memberships_system_chunks_over_900_names(db_base: PlexosDB): def _seed_model_attributes(db: PlexosDB) -> None: + """Insert test attribute definitions for the Model class.""" from plexosdb import ClassEnum class_id = db.get_class_id(ClassEnum.Model) @@ -395,6 +396,7 @@ def _seed_model_attributes(db: PlexosDB) -> None: def test_add_attributes_from_records_explicit_format(db_instance_with_schema: PlexosDB): + """Insert attribute records using explicit attribute/value format.""" from plexosdb import ClassEnum db = db_instance_with_schema @@ -427,6 +429,7 @@ def test_add_attributes_from_records_explicit_format(db_instance_with_schema: Pl def test_add_attributes_from_records_wide_format(db_instance_with_schema: PlexosDB): + """Insert attribute records using wide-column attribute format.""" from plexosdb import ClassEnum db = db_instance_with_schema @@ -457,6 +460,7 @@ def test_add_attributes_from_records_wide_format(db_instance_with_schema: Plexos def test_add_attributes_from_records_rejects_duplicates(db_instance_with_schema: PlexosDB): + """Reject duplicate object/attribute pairs in the same batch.""" from plexosdb import ClassEnum db = db_instance_with_schema @@ -475,6 +479,7 @@ def test_add_attributes_from_records_rejects_duplicates(db_instance_with_schema: def test_add_attributes_from_records_unknown_attribute(db_instance_with_schema: PlexosDB): + """Raise an error when inserting attributes not defined for the class.""" from plexosdb import ClassEnum db = db_instance_with_schema @@ -494,6 +499,7 @@ def test_add_attributes_from_records_respects_chunksize( db_instance_with_schema: PlexosDB, monkeypatch: pytest.MonkeyPatch, ): + """Split attribute inserts into batches according to chunksize.""" from plexosdb import ClassEnum db = db_instance_with_schema @@ -519,6 +525,7 @@ def spy_executemany(query, params_seq): def test_add_attributes_from_records_rejects_non_positive_chunksize( db_instance_with_schema: PlexosDB, ): + """Reject non-positive chunksize values.""" from plexosdb import ClassEnum db = db_instance_with_schema @@ -534,6 +541,7 @@ def test_add_attributes_from_records_rejects_non_positive_chunksize( def test_add_attributes_from_records_no_records(db_instance_with_schema: PlexosDB, caplog): + """Gracefully handle empty attribute payloads.""" from plexosdb import ClassEnum db = db_instance_with_schema @@ -547,6 +555,7 @@ def test_add_attributes_from_records_no_records(db_instance_with_schema: PlexosD def test_add_attributes_from_records_missing_name_explicit_format( db_instance_with_schema: PlexosDB, ): + """Require explicit-format attribute records to include object names.""" from plexosdb import ClassEnum db = db_instance_with_schema @@ -556,3 +565,28 @@ def test_add_attributes_from_records_missing_name_explicit_format( [{"attribute": "Enabled", "value": -1}], object_class=ClassEnum.Model, ) + + +def test_add_attributes_from_records_raises_runtime_error_on_insert_failure( + db_instance_with_schema: PlexosDB, + monkeypatch: pytest.MonkeyPatch, +): + """Raise RuntimeError when bulk attribute insertion fails.""" + from plexosdb import ClassEnum + + db = db_instance_with_schema + _seed_model_attributes(db) + db.add_object(ClassEnum.Model, "InsertFailAttrModel") + + def fail_executemany(query, params_seq): + return False + + monkeypatch.setattr(db._db, "executemany", fail_executemany) + + with pytest.raises(RuntimeError, match="Failed to add attribute values"): + db.add_attributes_from_records( + [{"name": "InsertFailAttrModel", "Enabled": -1}], + object_class=ClassEnum.Model, + ) + + assert db._db.fetchone("SELECT COUNT(*) FROM t_attribute_data")[0] == 0 From 49e50bcda52d2749429d5ab09ff1f9eeb35afbfa Mon Sep 17 00:00:00 2001 From: akrivi <20168326+akrivi@users.noreply.github.com> Date: Thu, 7 May 2026 17:21:32 -0600 Subject: [PATCH 10/15] fix: add chunking for add_attributes_from_records lookups --- src/plexosdb/db.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/plexosdb/db.py b/src/plexosdb/db.py index d1e4bd0..500294d 100644 --- a/src/plexosdb/db.py +++ b/src/plexosdb/db.py @@ -1026,18 +1026,23 @@ def add_attributes_from_records( class_id = self.get_class_id(object_class) object_names = tuple({record["name"] for record in records}) - object_placeholders = ", ".join("?" for _ in object_names) + name_to_object_id: dict[str, int] = {} - object_rows = self._db.query( - f""" - SELECT object_id, name - FROM t_object - WHERE class_id = ? - AND name IN ({object_placeholders}) - """, - (class_id, *object_names), - ) - name_to_object_id = {name: object_id for object_id, name in object_rows} + CHUNK = 900 # noqa: N806 + for i in range(0, len(object_names), CHUNK): + chunk = object_names[i : i + CHUNK] + object_placeholders = ", ".join("?" for _ in chunk) + + object_rows = self._db.query( + f""" + SELECT object_id, name + FROM t_object + WHERE class_id = ? + AND name IN ({object_placeholders}) + """, + (class_id, *chunk), + ) + name_to_object_id.update({name: object_id for object_id, name in object_rows}) attribute_rows = self._db.query( """ From 8dcc90829cb370cb76c345e7250c273843c0d63b Mon Sep 17 00:00:00 2001 From: akrivi <20168326+akrivi@users.noreply.github.com> Date: Thu, 7 May 2026 19:42:52 -0600 Subject: [PATCH 11/15] feat: implement delete_attribute --- src/plexosdb/db.py | 26 ++++++++- tests/conftest.py | 17 ++++++ tests/test_plexosdb_attributes.py | 87 +++++++++++++++++++++++++++++ tests/test_plexosdb_from_records.py | 53 +++++------------- 4 files changed, 143 insertions(+), 40 deletions(-) diff --git a/src/plexosdb/db.py b/src/plexosdb/db.py index 500294d..f318382 100644 --- a/src/plexosdb/db.py +++ b/src/plexosdb/db.py @@ -1874,7 +1874,31 @@ def delete_attribute( object_class: ClassEnum, ) -> None: """Delete an attribute from an object.""" - raise NotImplementedError # pragma: no cover + if not self.check_object_exists(object_class, object_name): + msg = f"Object = `{object_name}` does not exist for class `{object_class}`." + raise NotFoundError(msg) + + object_id = self.get_object_id(object_class, name=object_name) + attribute_id = self.get_attribute_id(object_class, name=attribute_name) + + find_query = """ + SELECT 1 + FROM t_attribute_data + WHERE object_id = ? AND attribute_id = ? + """ + result = self._db.fetchone(find_query, (object_id, attribute_id)) + + if not result: + msg = f"Attribute '{attribute_name}' not found for object '{object_name}'." + raise NotFoundError(msg) + + delete_query = """ + DELETE FROM t_attribute_data + WHERE object_id = ? AND attribute_id = ? + """ + + with self._db.transaction(): + self._db.execute(delete_query, (object_id, attribute_id)) def delete_category(self, category: str, /, *, class_name: ClassEnum) -> None: """Delete a category from the database.""" diff --git a/tests/conftest.py b/tests/conftest.py index e1f4ef9..24af0ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -156,6 +156,23 @@ def db_instance_with_schema() -> PlexosDB: # type: ignore db._db.close() +@pytest.fixture(scope="function") +def db_with_model_attributes(db_instance_with_schema) -> PlexosDB: # type: ignore + """Create a schema-backed DB with Model attribute definitions.""" + db_instance_with_schema._db.executemany( + """ + INSERT INTO t_attribute (attribute_id, class_id, name) + VALUES (?, ?, ?) + """, + [ + (1001, 8, "Enabled"), + (1002, 8, "Random Number Seed"), + ], + ) + + yield db_instance_with_schema + + @pytest.fixture(scope="function") def db_manager_instance_empty_with_schema() -> Generator[SQLiteManager, None, None]: db: PlexosDB = PlexosDB() diff --git a/tests/test_plexosdb_attributes.py b/tests/test_plexosdb_attributes.py index bfa1e0f..2d3befb 100644 --- a/tests/test_plexosdb_attributes.py +++ b/tests/test_plexosdb_attributes.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING +import pytest + if TYPE_CHECKING: from plexosdb.db import PlexosDB @@ -34,3 +36,88 @@ def test_list_attributes(db_base: PlexosDB): result = db.list_attributes(ClassEnum.Generator) assert result assert len(result) == 2 + + +def test_delete_attribute_removes_single_attribute(db_with_model_attributes: PlexosDB): + """Delete one attribute value without removing other attributes.""" + from plexosdb import ClassEnum + + db = db_with_model_attributes + db.add_object(ClassEnum.Model, "Model1") + + db.add_attributes_from_records( + [{"name": "Model1", "Enabled": -1, "Random Number Seed": 1000}], + object_class=ClassEnum.Model, + ) + + db.delete_attribute("Enabled", object_name="Model1", object_class=ClassEnum.Model) + + rows = db._db.fetchall( + """ + SELECT attr.name, data.value + FROM t_attribute_data AS data + JOIN t_attribute AS attr ON attr.attribute_id = data.attribute_id + JOIN t_object AS obj ON obj.object_id = data.object_id + WHERE obj.name = ? + ORDER BY attr.name + """, + ("Model1",), + ) + + assert rows == [("Random Number Seed", 1000.0)] + + +def test_delete_attribute_does_not_affect_other_objects(db_with_model_attributes: PlexosDB): + """Delete an attribute from one object without changing another object.""" + from plexosdb import ClassEnum + + db = db_with_model_attributes + db.add_object(ClassEnum.Model, "Model1") + db.add_object(ClassEnum.Model, "Model2") + + db.add_attributes_from_records( + [ + {"name": "Model1", "Enabled": -1}, + {"name": "Model2", "Enabled": -1}, + ], + object_class=ClassEnum.Model, + ) + + db.delete_attribute("Enabled", object_name="Model1", object_class=ClassEnum.Model) + + rows = db._db.fetchall( + """ + SELECT obj.name, attr.name, data.value + FROM t_attribute_data AS data + JOIN t_attribute AS attr ON attr.attribute_id = data.attribute_id + JOIN t_object AS obj ON obj.object_id = data.object_id + ORDER BY obj.name, attr.name + """ + ) + + assert rows == [("Model2", "Enabled", -1.0)] + + +def test_delete_attribute_fails_with_nonexistent_object(db_with_model_attributes: PlexosDB): + """Raise NotFoundError when deleting an attribute from a missing object.""" + from plexosdb import ClassEnum + from plexosdb.exceptions import NotFoundError + + db = db_with_model_attributes + + with pytest.raises(NotFoundError, match="Object = `MissingModel` does not exist"): + db.delete_attribute("Enabled", object_name="MissingModel", object_class=ClassEnum.Model) + + +def test_delete_attribute_fails_with_nonexistent_attribute_value( + db_with_model_attributes: PlexosDB, +): + """Raise NotFoundError when the object has no value for the attribute.""" + from plexosdb import ClassEnum + from plexosdb.exceptions import NotFoundError + + db = db_with_model_attributes + db.add_object(ClassEnum.Model, "Model1") + + with pytest.raises(NotFoundError, match="Attribute 'Enabled' not found for object 'Model1'"): + db.delete_attribute("Enabled", object_name="Model1", object_class=ClassEnum.Model) diff --git a/tests/test_plexosdb_from_records.py b/tests/test_plexosdb_from_records.py index b8e5052..9f56398 100644 --- a/tests/test_plexosdb_from_records.py +++ b/tests/test_plexosdb_from_records.py @@ -377,30 +377,11 @@ def test_get_memberships_system_chunks_over_900_names(db_base: PlexosDB): assert {r["name"] for r in result} == set(names) -def _seed_model_attributes(db: PlexosDB) -> None: - """Insert test attribute definitions for the Model class.""" - from plexosdb import ClassEnum - - class_id = db.get_class_id(ClassEnum.Model) - - db._db.executemany( - """ - INSERT INTO t_attribute (attribute_id, class_id, name) - VALUES (?, ?, ?) - """, - [ - (1001, class_id, "Enabled"), - (1002, class_id, "Random Number Seed"), - ], - ) - - -def test_add_attributes_from_records_explicit_format(db_instance_with_schema: PlexosDB): +def test_add_attributes_from_records_explicit_format(db_with_model_attributes: PlexosDB): """Insert attribute records using explicit attribute/value format.""" from plexosdb import ClassEnum - db = db_instance_with_schema - _seed_model_attributes(db) + db = db_with_model_attributes db.add_object(ClassEnum.Model, "AttrModel") records = [ @@ -428,12 +409,11 @@ def test_add_attributes_from_records_explicit_format(db_instance_with_schema: Pl ] -def test_add_attributes_from_records_wide_format(db_instance_with_schema: PlexosDB): +def test_add_attributes_from_records_wide_format(db_with_model_attributes: PlexosDB): """Insert attribute records using wide-column attribute format.""" from plexosdb import ClassEnum - db = db_instance_with_schema - _seed_model_attributes(db) + db = db_with_model_attributes db.add_object(ClassEnum.Model, "WideAttrModel") db.add_attributes_from_records( @@ -459,12 +439,11 @@ def test_add_attributes_from_records_wide_format(db_instance_with_schema: Plexos ] -def test_add_attributes_from_records_rejects_duplicates(db_instance_with_schema: PlexosDB): +def test_add_attributes_from_records_rejects_duplicates(db_with_model_attributes: PlexosDB): """Reject duplicate object/attribute pairs in the same batch.""" from plexosdb import ClassEnum - db = db_instance_with_schema - _seed_model_attributes(db) + db = db_with_model_attributes db.add_object(ClassEnum.Model, "DuplicateAttrModel") records = [ @@ -478,12 +457,11 @@ def test_add_attributes_from_records_rejects_duplicates(db_instance_with_schema: assert db._db.fetchone("SELECT COUNT(*) FROM t_attribute_data")[0] == 0 -def test_add_attributes_from_records_unknown_attribute(db_instance_with_schema: PlexosDB): +def test_add_attributes_from_records_unknown_attribute(db_with_model_attributes: PlexosDB): """Raise an error when inserting attributes not defined for the class.""" from plexosdb import ClassEnum - db = db_instance_with_schema - _seed_model_attributes(db) + db = db_with_model_attributes db.add_object(ClassEnum.Model, "BadAttrModel") with pytest.raises(KeyError, match="Invalid attribute record"): @@ -496,14 +474,13 @@ def test_add_attributes_from_records_unknown_attribute(db_instance_with_schema: def test_add_attributes_from_records_respects_chunksize( - db_instance_with_schema: PlexosDB, + db_with_model_attributes: PlexosDB, monkeypatch: pytest.MonkeyPatch, ): """Split attribute inserts into batches according to chunksize.""" from plexosdb import ClassEnum - db = db_instance_with_schema - _seed_model_attributes(db) + db = db_with_model_attributes names = [f"ChunkAttrModel_{idx}" for idx in range(5)] db.add_objects(ClassEnum.Model, *names) @@ -523,13 +500,12 @@ def spy_executemany(query, params_seq): def test_add_attributes_from_records_rejects_non_positive_chunksize( - db_instance_with_schema: PlexosDB, + db_with_model_attributes: PlexosDB, ): """Reject non-positive chunksize values.""" from plexosdb import ClassEnum - db = db_instance_with_schema - _seed_model_attributes(db) + db = db_with_model_attributes db.add_object(ClassEnum.Model, "BadChunkAttrModel") with pytest.raises(ValueError, match="chunksize must be >= 1"): @@ -568,14 +544,13 @@ def test_add_attributes_from_records_missing_name_explicit_format( def test_add_attributes_from_records_raises_runtime_error_on_insert_failure( - db_instance_with_schema: PlexosDB, + db_with_model_attributes: PlexosDB, monkeypatch: pytest.MonkeyPatch, ): """Raise RuntimeError when bulk attribute insertion fails.""" from plexosdb import ClassEnum - db = db_instance_with_schema - _seed_model_attributes(db) + db = db_with_model_attributes db.add_object(ClassEnum.Model, "InsertFailAttrModel") def fail_executemany(query, params_seq): From aae2d8887387a00b2a2af743261b0c227b0a6ef6 Mon Sep 17 00:00:00 2001 From: akrivi <20168326+akrivi@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:02:34 -0600 Subject: [PATCH 12/15] fix: add_attributes returns attribute_id instead of True --- AGENTS.md | 28 ++++++++++++----- CHANGELOG.md | 52 +++++++++++++++++++++---------- src/plexosdb/db.py | 46 ++++++++++++++++++++++++--- tests/test_plexosdb_attributes.py | 40 ++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 11f0848..819d340 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,22 @@ # AGENTS.md -- Assume concurrent human/agent work; never revert or overwrite changes you did not author. -- This repo processes model data into SQLite; watch query/transform cost, memory churn, and fixture representativeness. -- Prefer minimal, behavior-proven changes; add regression coverage for bug fixes and use existing fixtures under `tests/fixtures` or `tests/data` when practical. -- No breadcrumbs after deleting or moving code; remove the old code/comment instead of leaving relocation notes. -- Use `uv sync --all-groups` for setup. For changed areas, run the narrow relevant check first; before broad handoff use `uv run prek run --show-diff-on-failure --color=always --all-files --hook-stage pre-push` when feasible. -- Type-check package changes with `uv run ty check --output-format github ./src/plexosdb`. -- Test package changes with `uv run pytest --cov --cov-report=xml`; benchmark-sensitive changes may need `uv run pytest benchmarks/ -k "not xlarge_300k" --benchmark-only --benchmark-json=benchmark-results.json --no-cov`. -- Build docs for user/operator-facing documentation changes with `uv run sphinx-build docs/source/ docs/_build/`. +- Assume concurrent human/agent work; never revert or overwrite changes you did + not author. +- This repo processes model data into SQLite; watch query/transform cost, memory + churn, and fixture representativeness. +- Prefer minimal, behavior-proven changes; add regression coverage for bug fixes + and use existing fixtures under `tests/fixtures` or `tests/data` when + practical. +- No breadcrumbs after deleting or moving code; remove the old code/comment + instead of leaving relocation notes. +- Use `uv sync --all-groups` for setup. For changed areas, run the narrow + relevant check first; before broad handoff use + `uv run prek run --show-diff-on-failure --color=always --all-files --hook-stage pre-push` + when feasible. +- Type-check package changes with + `uv run ty check --output-format github ./src/plexosdb`. +- Test package changes with `uv run pytest --cov --cov-report=xml`; + benchmark-sensitive changes may need + `uv run pytest benchmarks/ -k "not xlarge_300k" --benchmark-only --benchmark-json=benchmark-results.json --no-cov`. +- Build docs for user/operator-facing documentation changes with + `uv run sphinx-build docs/source/ docs/_build/`. diff --git a/CHANGELOG.md b/CHANGELOG.md index b4155e1..b64ac67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,35 +4,55 @@ All notable changes to this project will be documented in this file. ## [1.4.1](https://github.com/NatLabRockies/plexosdb/compare/v1.4.0...v1.4.1) (2026-05-22) - ### 🐛 Bug Fixes -* copy_object date range metadata copying ([#143](https://github.com/NatLabRockies/plexosdb/issues/143)) ([b60e5ad](https://github.com/NatLabRockies/plexosdb/commit/b60e5ad21032f05702ed74bb345f3f4579e2379d)) +- copy_object date range metadata copying + ([#143](https://github.com/NatLabRockies/plexosdb/issues/143)) + ([b60e5ad](https://github.com/NatLabRockies/plexosdb/commit/b60e5ad21032f05702ed74bb345f3f4579e2379d)) ## [1.4.0](https://github.com/NatLabRockies/plexosdb/compare/v1.3.4...v1.4.0) (2026-05-21) - ### 🚀 Features -* add new Purchaser enum for handling new type of loads ([#127](https://github.com/NatLabRockies/plexosdb/issues/127)) ([c5d9a38](https://github.com/NatLabRockies/plexosdb/commit/c5d9a38e29ba929869c0194da4fddca3860bcc3c)) -* address mixed issues ([#128](https://github.com/NatLabRockies/plexosdb/issues/128)) ([eabaa5b](https://github.com/NatLabRockies/plexosdb/commit/eabaa5bdd80e3898a5ddb37aa082b4736f4579e9)) - +- add new Purchaser enum for handling new type of loads + ([#127](https://github.com/NatLabRockies/plexosdb/issues/127)) + ([c5d9a38](https://github.com/NatLabRockies/plexosdb/commit/c5d9a38e29ba929869c0194da4fddca3860bcc3c)) +- address mixed issues + ([#128](https://github.com/NatLabRockies/plexosdb/issues/128)) + ([eabaa5b](https://github.com/NatLabRockies/plexosdb/commit/eabaa5bdd80e3898a5ddb37aa082b4736f4579e9)) ### 🐛 Bug Fixes -* add safe parsing for long int values ([#140](https://github.com/NatLabRockies/plexosdb/issues/140)) ([c565313](https://github.com/NatLabRockies/plexosdb/commit/c565313d5b37839def642261ab05dd493ab1e879)) -* resolve bug on add properties from records related methods ([#129](https://github.com/NatLabRockies/plexosdb/issues/129)) ([b3f4dbb](https://github.com/NatLabRockies/plexosdb/commit/b3f4dbb5d85cb2077e0402b499fa929a67221df7)) - +- add safe parsing for long int values + ([#140](https://github.com/NatLabRockies/plexosdb/issues/140)) + ([c565313](https://github.com/NatLabRockies/plexosdb/commit/c565313d5b37839def642261ab05dd493ab1e879)) +- resolve bug on add properties from records related methods + ([#129](https://github.com/NatLabRockies/plexosdb/issues/129)) + ([b3f4dbb](https://github.com/NatLabRockies/plexosdb/commit/b3f4dbb5d85cb2077e0402b499fa929a67221df7)) ### 📦 Build -* **deps-dev:** bump ipython from 9.8.0 to 9.13.0 ([#137](https://github.com/NatLabRockies/plexosdb/issues/137)) ([98c66d4](https://github.com/NatLabRockies/plexosdb/commit/98c66d4046183ae059e6bc4354913809f711e74a)) -* **deps-dev:** bump sphinxcontrib-mermaid from 1.0.0 to 2.0.2 ([#136](https://github.com/NatLabRockies/plexosdb/issues/136)) ([8c26ac3](https://github.com/NatLabRockies/plexosdb/commit/8c26ac3ee0ffe32060d90b74d68745c3434117dd)) -* **deps:** bump actions/upload-artifact from 7.0.0 to 7.0.1 ([#121](https://github.com/NatLabRockies/plexosdb/issues/121)) ([85194fe](https://github.com/NatLabRockies/plexosdb/commit/85194fec3d3c14f924ec0f33126271ceb0ec8524)) -* **deps:** bump astral-sh/setup-uv from 7.6.0 to 8.1.0 ([#122](https://github.com/NatLabRockies/plexosdb/issues/122)) ([e189e14](https://github.com/NatLabRockies/plexosdb/commit/e189e146477ee75f38e70c473d0b612979ed787f)) -* **deps:** bump benchmark-action/github-action-benchmark from 1.21.0 to 1.22.0 ([#120](https://github.com/NatLabRockies/plexosdb/issues/120)) ([4ac3c99](https://github.com/NatLabRockies/plexosdb/commit/4ac3c991fd18d703caceb68c38dc7556477d2385)) -* **deps:** bump codecov/codecov-action from 5.5.3 to 6.0.0 ([#118](https://github.com/NatLabRockies/plexosdb/issues/118)) ([fc5c2e8](https://github.com/NatLabRockies/plexosdb/commit/fc5c2e8834f1932f960db3003a70d0a0dee9df87)) -* **deps:** bump googleapis/release-please-action from 4.4.0 to 4.4.1 ([#123](https://github.com/NatLabRockies/plexosdb/issues/123)) ([8959d18](https://github.com/NatLabRockies/plexosdb/commit/8959d183c9872c450906af288148ba033d45b77f)) +- **deps-dev:** bump ipython from 9.8.0 to 9.13.0 + ([#137](https://github.com/NatLabRockies/plexosdb/issues/137)) + ([98c66d4](https://github.com/NatLabRockies/plexosdb/commit/98c66d4046183ae059e6bc4354913809f711e74a)) +- **deps-dev:** bump sphinxcontrib-mermaid from 1.0.0 to 2.0.2 + ([#136](https://github.com/NatLabRockies/plexosdb/issues/136)) + ([8c26ac3](https://github.com/NatLabRockies/plexosdb/commit/8c26ac3ee0ffe32060d90b74d68745c3434117dd)) +- **deps:** bump actions/upload-artifact from 7.0.0 to 7.0.1 + ([#121](https://github.com/NatLabRockies/plexosdb/issues/121)) + ([85194fe](https://github.com/NatLabRockies/plexosdb/commit/85194fec3d3c14f924ec0f33126271ceb0ec8524)) +- **deps:** bump astral-sh/setup-uv from 7.6.0 to 8.1.0 + ([#122](https://github.com/NatLabRockies/plexosdb/issues/122)) + ([e189e14](https://github.com/NatLabRockies/plexosdb/commit/e189e146477ee75f38e70c473d0b612979ed787f)) +- **deps:** bump benchmark-action/github-action-benchmark from 1.21.0 to 1.22.0 + ([#120](https://github.com/NatLabRockies/plexosdb/issues/120)) + ([4ac3c99](https://github.com/NatLabRockies/plexosdb/commit/4ac3c991fd18d703caceb68c38dc7556477d2385)) +- **deps:** bump codecov/codecov-action from 5.5.3 to 6.0.0 + ([#118](https://github.com/NatLabRockies/plexosdb/issues/118)) + ([fc5c2e8](https://github.com/NatLabRockies/plexosdb/commit/fc5c2e8834f1932f960db3003a70d0a0dee9df87)) +- **deps:** bump googleapis/release-please-action from 4.4.0 to 4.4.1 + ([#123](https://github.com/NatLabRockies/plexosdb/issues/123)) + ([8959d18](https://github.com/NatLabRockies/plexosdb/commit/8959d183c9872c450906af288148ba033d45b77f)) ## [1.3.4](https://github.com/NatLabRockies/plexosdb/compare/v1.3.3...v1.3.4) (2026-03-27) diff --git a/src/plexosdb/db.py b/src/plexosdb/db.py index f318382..3288268 100644 --- a/src/plexosdb/db.py +++ b/src/plexosdb/db.py @@ -283,6 +283,11 @@ def add_attribute( int attribute_id + Raises + ------ + RuntimeError + If the attribute value could not be inserted. + Notes ----- By default, we add all objects to the system membership. @@ -294,7 +299,8 @@ def add_attribute( query = ( f"INSERT INTO {Schema.AttributeData.name}(object_id, attribute_id, value) VALUES({placeholders})" ) - attribute_id = self._db.execute(query, params) + result = self._db.execute(query, params) + assert result, f"Failed to add attribute '{attribute_name}' to object '{object_name}'." return attribute_id def add_band( @@ -1873,8 +1879,38 @@ def delete_attribute( object_name: str, object_class: ClassEnum, ) -> None: - """Delete an attribute from an object.""" - if not self.check_object_exists(object_class, object_name): + """Delete an attribute value from an object. + + Removes an object-level attribute value from the database. + + Parameters + ---------- + attribute_name : str + Name of the attribute to delete. + object_name : str + Name of the object the attribute belongs to. + object_class : ClassEnum + Class enumeration of the object. + + Returns + ------- + None + + Raises + ------ + NotFoundError + If the object does not exist or the attribute value is not assigned + to the object. + + Examples + -------- + >>> db.delete_attribute( + ... "Enabled", + ... object_name="Base_Model", + ... object_class=ClassEnum.Model, + ... ) + """ + if not checks_module.check_object_exists(self, object_class, object_name): msg = f"Object = `{object_name}` does not exist for class `{object_class}`." raise NotFoundError(msg) @@ -2155,7 +2191,9 @@ def get_attribute_id(self, class_enum: ClassEnum, /, name: str) -> int: AND t_class.name = ? """ result = self._db.fetchone(query, (name, class_enum)) - assert result + if not result: + msg = f"Attribute '{name}' not found for class '{class_enum}'." + raise NotFoundError(msg) return cast(int, result[0]) def get_attributes( diff --git a/tests/test_plexosdb_attributes.py b/tests/test_plexosdb_attributes.py index 2d3befb..d8550d7 100644 --- a/tests/test_plexosdb_attributes.py +++ b/tests/test_plexosdb_attributes.py @@ -28,6 +28,25 @@ def test_add_attribute(db_base: PlexosDB): assert result == 10.1 +def test_add_attribute_returns_attribute_id(db_base: PlexosDB): + """Return the attribute definition ID rather than an insert-success flag.""" + from plexosdb.enums import ClassEnum + + db: PlexosDB = db_base + db.add_object(ClassEnum.Generator, "TestGen") + + attribute_id = db.get_attribute_id(ClassEnum.Generator, "Longitude") + result = db.add_attribute( + ClassEnum.Generator, + "TestGen", + attribute_name="Longitude", + attribute_value=10.1, + ) + + assert type(result) is int + assert result == attribute_id + + def test_list_attributes(db_base: PlexosDB): from plexosdb.enums import ClassEnum @@ -121,3 +140,24 @@ def test_delete_attribute_fails_with_nonexistent_attribute_value( with pytest.raises(NotFoundError, match="Attribute 'Enabled' not found for object 'Model1'"): db.delete_attribute("Enabled", object_name="Model1", object_class=ClassEnum.Model) + + +def test_delete_attribute_fails_with_undefined_attribute( + db_with_model_attributes: PlexosDB, +): + """Raise NotFoundError when the attribute is undefined for the object's class.""" + from plexosdb import ClassEnum + from plexosdb.exceptions import NotFoundError + + db = db_with_model_attributes + db.add_object(ClassEnum.Model, "Model1") + + with pytest.raises( + NotFoundError, + match="Attribute 'Non_Valid_Attribute' not found for class 'Model'", + ): + db.delete_attribute( + "Non_Valid_Attribute", + object_name="Model1", + object_class=ClassEnum.Model, + ) From 20471af3c1827c2dded0491e753091310f081511 Mon Sep 17 00:00:00 2001 From: akrivi <20168326+akrivi@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:53:21 -0600 Subject: [PATCH 13/15] feat: get_attributes --- src/plexosdb/db.py | 63 ++++++++++++++++++++-- tests/test_plexosdb_attributes.py | 86 +++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/src/plexosdb/db.py b/src/plexosdb/db.py index 3288268..5cd0c79 100644 --- a/src/plexosdb/db.py +++ b/src/plexosdb/db.py @@ -2202,10 +2202,67 @@ def get_attributes( /, *, object_class: ClassEnum, - attribute_names: list[str] | None = None, + attribute_names: str | Iterable[str] | None = None, ) -> list[dict[str, Any]]: - """Get all attributes for a specific object.""" - raise NotImplementedError # pragma: no cover + """Retrieve assigned attribute values for a specific object. + + Parameters + ---------- + object_name : str + Name of the object. + object_class : ClassEnum + Class of the object. + attribute_names : str | Iterable[str] | None, optional + Attribute name or names to retrieve. If omitted, retrieves all + assigned attribute values. + + Returns + ------- + list[dict[str, Any]] + Attribute-value records with ``name``, ``attribute``, ``value``, + and ``state`` keys. Returns an empty list when the object has no + assigned attribute values. + + Raises + ------ + NotFoundError + If the object does not exist for the requested class. + """ + if not checks_module.check_object_exists(self, object_class, object_name): + msg = f"Object = `{object_name}` does not exist in class = {object_class}. " + msg += "See available objects with list_objects_by_class" + raise NotFoundError(msg) + + object_id = self.get_object_id(object_class, object_name) + class_id = self.get_class_id(object_class) + + conditions = [ + "data.object_id = ?", + "attr.class_id = ?", + ] + params: list[Any] = [object_id, class_id] + + if attribute_names is not None: + names = normalize_names(attribute_names) + if names: + placeholders = ", ".join("?" for _ in names) + conditions.append(f"attr.name IN ({placeholders})") + params.extend(names) + + where_clause = " AND ".join(conditions) + query = f""" + SELECT + obj.name AS name, + attr.name AS attribute, + data.value, + data.state + FROM t_attribute_data AS data + JOIN t_object AS obj ON obj.object_id = data.object_id + JOIN t_attribute AS attr ON attr.attribute_id = data.attribute_id + WHERE {where_clause} + ORDER BY attr.name + """ + return self._db.fetchall_dict(query, tuple(params)) def get_category_id(self, class_enum: ClassEnum, /, name: str) -> int: """Return the ID for a given category. diff --git a/tests/test_plexosdb_attributes.py b/tests/test_plexosdb_attributes.py index d8550d7..8765953 100644 --- a/tests/test_plexosdb_attributes.py +++ b/tests/test_plexosdb_attributes.py @@ -57,6 +57,92 @@ def test_list_attributes(db_base: PlexosDB): assert len(result) == 2 +def test_get_attributes_returns_assigned_values(db_with_model_attributes: PlexosDB): + """Return assigned attributes for one object only.""" + from plexosdb import ClassEnum + + db = db_with_model_attributes + db.add_object(ClassEnum.Model, "Model1") + db.add_object(ClassEnum.Model, "Model2") + db.add_attributes_from_records( + [ + {"name": "Model1", "attribute": "Enabled", "value": -1, "state": 1}, + {"name": "Model1", "attribute": "Random Number Seed", "value": 1000}, + {"name": "Model2", "attribute": "Enabled", "value": 0}, + ], + object_class=ClassEnum.Model, + ) + + result = db.get_attributes("Model1", object_class=ClassEnum.Model) + + assert result == [ + {"name": "Model1", "attribute": "Enabled", "value": -1.0, "state": 1}, + { + "name": "Model1", + "attribute": "Random Number Seed", + "value": 1000.0, + "state": None, + }, + ] + + +def test_get_attributes_filters_by_attribute_names( + db_with_model_attributes: PlexosDB, +): + """Filter assigned attributes by name.""" + from plexosdb import ClassEnum + + db = db_with_model_attributes + db.add_object(ClassEnum.Model, "Model1") + db.add_attributes_from_records( + [{"name": "Model1", "Enabled": -1, "Random Number Seed": 1000}], + object_class=ClassEnum.Model, + ) + + single_result = db.get_attributes( + "Model1", + object_class=ClassEnum.Model, + attribute_names="Enabled", + ) + assert [record["attribute"] for record in single_result] == ["Enabled"] + + multiple_result = db.get_attributes( + "Model1", + object_class=ClassEnum.Model, + attribute_names=["Random Number Seed", "Enabled"], + ) + assert [record["attribute"] for record in multiple_result] == [ + "Enabled", + "Random Number Seed", + ] + + +def test_get_attributes_returns_empty_list_without_assignments( + db_with_model_attributes: PlexosDB, +): + """Return an empty list when an object has no assigned attributes.""" + from plexosdb import ClassEnum + + db = db_with_model_attributes + db.add_object(ClassEnum.Model, "Model1") + + assert db.get_attributes("Model1", object_class=ClassEnum.Model) == [] + + +def test_get_attributes_fails_with_nonexistent_object( + db_with_model_attributes: PlexosDB, +): + """Raise NotFoundError when the object does not exist.""" + from plexosdb import ClassEnum + from plexosdb.exceptions import NotFoundError + + with pytest.raises(NotFoundError, match="Object = `MissingModel` does not exist"): + db_with_model_attributes.get_attributes( + "MissingModel", + object_class=ClassEnum.Model, + ) + + def test_delete_attribute_removes_single_attribute(db_with_model_attributes: PlexosDB): """Delete one attribute value without removing other attributes.""" from plexosdb import ClassEnum From 67e3f0a3e335b3a2ff406fa979e9782867e5a977 Mon Sep 17 00:00:00 2001 From: akrivi <20168326+akrivi@users.noreply.github.com> Date: Mon, 22 Jun 2026 08:06:44 -0600 Subject: [PATCH 14/15] feat: check_attribute_exists --- src/plexosdb/checks.py | 14 +++++++++++- tests/test_plexosdb_attributes.py | 36 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/plexosdb/checks.py b/src/plexosdb/checks.py index fd69d1d..043ac0f 100644 --- a/src/plexosdb/checks.py +++ b/src/plexosdb/checks.py @@ -66,7 +66,19 @@ def check_attribute_exists( ----- This check is not yet implemented. """ - raise NotImplementedError + query = """ + SELECT 1 + FROM t_attribute_data AS data + JOIN t_object AS obj ON obj.object_id = data.object_id + JOIN t_attribute AS attr ON attr.attribute_id = data.attribute_id + JOIN t_class AS class ON class.class_id = obj.class_id + WHERE obj.name = ? + AND class.name = ? + AND attr.name = ? + AND attr.class_id = obj.class_id + LIMIT 1 + """ + return bool(db._db.query(query, (object_name, object_class, attribute_name))) def check_class_exists(db: PlexosDB, class_enum: ClassEnum) -> bool: diff --git a/tests/test_plexosdb_attributes.py b/tests/test_plexosdb_attributes.py index 8765953..fa73f1e 100644 --- a/tests/test_plexosdb_attributes.py +++ b/tests/test_plexosdb_attributes.py @@ -247,3 +247,39 @@ def test_delete_attribute_fails_with_undefined_attribute( object_name="Model1", object_class=ClassEnum.Model, ) + + +def test_check_attribute_exists_for_assigned_attribute( + db_with_model_attributes: PlexosDB, +): + """Return True for an assigned attribute.""" + from plexosdb import ClassEnum + + db = db_with_model_attributes + db.add_object(ClassEnum.Model, "Model1") + db.add_attributes_from_records( + [{"name": "Model1", "Enabled": -1}], + object_class=ClassEnum.Model, + ) + + assert db.check_attribute_exists( + "Enabled", + object_name="Model1", + object_class=ClassEnum.Model, + ) + + +def test_check_attribute_exists_for_unassigned_attribute( + db_with_model_attributes: PlexosDB, +): + """Return False for an unassigned attribute.""" + from plexosdb import ClassEnum + + db = db_with_model_attributes + db.add_object(ClassEnum.Model, "Model1") + + assert not db.check_attribute_exists( + "Enabled", + object_name="Model1", + object_class=ClassEnum.Model, + ) From 98fb45ff8969300cead2094fe6d4ae5ae152188c Mon Sep 17 00:00:00 2001 From: akrivi <20168326+akrivi@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:27:34 -0600 Subject: [PATCH 15/15] docs: attributes documentation --- docs/source/howtos/add_attributes.md | 160 ++++++++++++++++++++++----- docs/source/howtos/index.md | 1 + src/plexosdb/checks.py | 6 +- src/plexosdb/db.py | 23 ++-- 4 files changed, 142 insertions(+), 48 deletions(-) diff --git a/docs/source/howtos/add_attributes.md b/docs/source/howtos/add_attributes.md index d21dced..b3507d9 100644 --- a/docs/source/howtos/add_attributes.md +++ b/docs/source/howtos/add_attributes.md @@ -1,15 +1,27 @@ -# Adding Attributes to the objects +# Working with Attributes -Objects in PlexosDB can have attributes that are saved on the `t_attribute_data` -table and represent static metadata (e.g. "Step Type", "Chrono Step Count"). +Objects in PlexosDB can have attributes stored in the `t_attribute_data` table. + +Attributes are different from properties in the following ways: + +- Attribute definitions are valid for a class and are stored in `t_attribute`. +- Attribute values are assigned directly to individual objects in + `t_attribute_data`. + +```{note} +See the [API Reference](api/index.md) for class-specific attribute +tables that list available attributes, default values, validation rules and +descriptions. +``` ## Listing available attributes per `ClassEnum` -To see the list of available attributes per `ClassEnum` use: +We can use `list_attributes()` to see which attributes are valid for a specific +class. This returns definitions, not values assigned to a particular object. ```python -from plexosdb import PlexosDB, ClassEnum -db = PlexosDB.from_xml("/path/to/your/xml") +from plexosdb import ClassEnum, PlexosDB +db = PlexosDB.from_xml("/path/to/your/model.xml") attributes = db.list_attributes(ClassEnum.Generator) print(attributes) @@ -17,6 +29,9 @@ print(attributes) ## Adding an attribute to an existing object +We can use `add_attribute()` to assign one attribute value. The object must +already exist and the attribute name must be valid for its class. + ```python from plexosdb import PlexosDB from plexosdb.enums import ClassEnum, CollectionEnum @@ -30,38 +45,129 @@ if not db.check_object_exists(ClassEnum.Generator, "Generator1"): db.add_object(ClassEnum.Generator, "Generator1") # Add a property to the generator -attribute_name = "Latitude" db.add_attribute( + ClassEnum.Generator, + "Generator1", + attribute_name="Latitude", + attribute_value=100.0, +) +``` + +## Extracting a single attribute from an object + +We can use `get_attribute()` when we know the attribute name and need its +assigned value. + +```python +step_count = db.get_attribute( ClassEnum.Generator, object_name="Generator1", - attribute_name=attribute_name, - attribute_value=100.0 + attribute_name="Latitude", ) +print(step_count) ``` -## Extracting an attribute from an object +## Extracting all assigned attributes from an object + +We can use `get_attributes()` to retrieve all values assigned to an object. ```python -from plexosdb import PlexosDB -from plexosdb.enums import ClassEnum, CollectionEnum +attributes = db.get_attributes( + "2020", + object_class=ClassEnum.Horizon, +) -# Initialize database -db = PlexosDB() -db.create_schema() +for attribute in attributes: + print(attribute["attribute"], attribute["value"]) +``` -# Create a generator object if it doesn't exist -if not db.check_object_exists(ClassEnum.Generator, "Generator1"): - db.add_object(ClassEnum.Generator, "Generator1") +The result contains dictionaries with the following keys: -# Add a property to the generator -attribute_name = "Latitude" -db.add_attribute( - ClassEnum.Generator, - object_name="Generator1", - attribute_name=attribute_name, - attribute_value=19.8 +```text +name +attribute +value +state +``` + +We can also filter to one attribute or a group of attributes. + +```python +step_type = db.get_attributes( + "2020", + object_class=ClassEnum.Horizon, + attribute_names="Step Type", +) + +step_attributes = db.get_attributes( + "2020", + object_class=ClassEnum.Horizon, + attribute_names=["Chrono Step Count", "Step Type"], +) +``` + +If the object exists but has no assigned values matching the request, +`get_attributes()` returns an empty list. + +## Checking and deleting assigned attributes + +We can use `check_attribute_exists()` to determine whether an object has an +assigned value for a particular attribute. + +```python +has_step_count = db.check_attribute_exists( + "Chrono Step Count", + object_name="2020", + object_class=ClassEnum.Horizon, +) + +print(has_step_count) +``` + +We can use `delete_attribute()` to remove an assigned value. + +```python +db.delete_attribute( + "Chrono Step Count", + object_name="2020", + object_class=ClassEnum.Horizon, +) +``` + +## Adding attributes in bulk + +We can use `add_attributes_from_records()` when assigning values to many +objects. The method accepts either explicit records or a wide format. + +For explicit records we can use one row per object and attribute: + +```python +db.add_attributes_from_records( + [ + {"name": "2020", "attribute": "Chrono Step Count", "value": 366}, + {"name": "2020", "attribute": "Step Type", "value": 4}, + {"name": "2021", "attribute": "Chrono Step Count", "value": 365}, + ], + object_class=ClassEnum.Horizon, ) +``` -db.get_attribute(ClassEnum.Generator, object_name="Generator1", - attribute_name=attribute_name) +For wide records we can use one column per attribute: + +```python +db.add_attributes_from_records( + [ + { + "name": "2020", + "Chrono Step Count": 366, + "Step Type": 4, + }, + { + "name": "2021", + "Chrono Step Count": 365, + "Step Type": 4, + }, + ], + object_class=ClassEnum.Horizon, +) ``` diff --git a/docs/source/howtos/index.md b/docs/source/howtos/index.md index d05b2f5..5e171ee 100644 --- a/docs/source/howtos/index.md +++ b/docs/source/howtos/index.md @@ -13,6 +13,7 @@ This section contains practical guides for common tasks with PlexosDB. create_db add_objects add_properties +add_attributes delete_objects query_database work_with_scenarios diff --git a/src/plexosdb/checks.py b/src/plexosdb/checks.py index 043ac0f..e530450 100644 --- a/src/plexosdb/checks.py +++ b/src/plexosdb/checks.py @@ -60,11 +60,7 @@ def check_attribute_exists( Returns ------- bool - True if the attribute exists for the object. - - Notes - ----- - This check is not yet implemented. + True if the object has an assigned value for the attribute. """ query = """ SELECT 1 diff --git a/src/plexosdb/db.py b/src/plexosdb/db.py index 5cd0c79..bb290a2 100644 --- a/src/plexosdb/db.py +++ b/src/plexosdb/db.py @@ -282,15 +282,6 @@ def add_attribute( ------- int attribute_id - - Raises - ------ - RuntimeError - If the attribute value could not be inserted. - - Notes - ----- - By default, we add all objects to the system membership. """ object_id = self.get_object_id(object_class, name=object_name) attribute_id = self.get_attribute_id(object_class, name=attribute_name) @@ -1899,8 +1890,8 @@ def delete_attribute( Raises ------ NotFoundError - If the object does not exist or the attribute value is not assigned - to the object. + If the object does not exist, the attribute is not defined for the + object's class or the attribute value is not assigned to the object. Examples -------- @@ -2140,7 +2131,7 @@ def get_attribute( object_name: str, attribute_name: str, ) -> Any: - """Get attribute details for a specific object.""" + """Get an assigned attribute value for a specific object.""" query = """ SELECT t_attribute_data.value @@ -2172,12 +2163,12 @@ def get_attribute_id(self, class_enum: ClassEnum, /, name: str) -> int: Returns ------- int - ID of the category + ID of the attribute Raises ------ - AssertionError - If the category does not exist + NotFoundError + If the attribute does not exist for the specified class. """ query = """ SELECT @@ -3319,7 +3310,7 @@ def list_attributes(self, class_enum: ClassEnum) -> list[str]: Parameters ---------- class_enum : ClassEnum - Class enumeration to list categories for + Class enumeration to list attributes Returns -------