sapiopycommons 2024.3.19a157__py3-none-any.whl → 2025.1.17a402__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sapiopycommons might be problematic. Click here for more details.

Files changed (52) hide show
  1. sapiopycommons/callbacks/__init__.py +0 -0
  2. sapiopycommons/callbacks/callback_util.py +2041 -0
  3. sapiopycommons/callbacks/field_builder.py +545 -0
  4. sapiopycommons/chem/IndigoMolecules.py +46 -1
  5. sapiopycommons/chem/Molecules.py +100 -21
  6. sapiopycommons/customreport/__init__.py +0 -0
  7. sapiopycommons/customreport/column_builder.py +60 -0
  8. sapiopycommons/customreport/custom_report_builder.py +137 -0
  9. sapiopycommons/customreport/term_builder.py +315 -0
  10. sapiopycommons/datatype/attachment_util.py +14 -15
  11. sapiopycommons/datatype/data_fields.py +61 -0
  12. sapiopycommons/datatype/pseudo_data_types.py +440 -0
  13. sapiopycommons/eln/experiment_handler.py +355 -91
  14. sapiopycommons/eln/experiment_report_util.py +649 -0
  15. sapiopycommons/eln/plate_designer.py +152 -0
  16. sapiopycommons/files/complex_data_loader.py +31 -0
  17. sapiopycommons/files/file_bridge.py +149 -25
  18. sapiopycommons/files/file_bridge_handler.py +555 -0
  19. sapiopycommons/files/file_data_handler.py +633 -0
  20. sapiopycommons/files/file_util.py +263 -163
  21. sapiopycommons/files/file_validator.py +569 -0
  22. sapiopycommons/files/file_writer.py +377 -0
  23. sapiopycommons/flowcyto/flow_cyto.py +77 -0
  24. sapiopycommons/flowcyto/flowcyto_data.py +75 -0
  25. sapiopycommons/general/accession_service.py +375 -0
  26. sapiopycommons/general/aliases.py +250 -15
  27. sapiopycommons/general/audit_log.py +185 -0
  28. sapiopycommons/general/custom_report_util.py +251 -31
  29. sapiopycommons/general/directive_util.py +86 -0
  30. sapiopycommons/general/exceptions.py +69 -7
  31. sapiopycommons/general/popup_util.py +59 -7
  32. sapiopycommons/general/sapio_links.py +50 -0
  33. sapiopycommons/general/storage_util.py +148 -0
  34. sapiopycommons/general/time_util.py +91 -7
  35. sapiopycommons/multimodal/multimodal.py +146 -0
  36. sapiopycommons/multimodal/multimodal_data.py +490 -0
  37. sapiopycommons/processtracking/__init__.py +0 -0
  38. sapiopycommons/processtracking/custom_workflow_handler.py +406 -0
  39. sapiopycommons/processtracking/endpoints.py +192 -0
  40. sapiopycommons/recordmodel/record_handler.py +621 -148
  41. sapiopycommons/rules/eln_rule_handler.py +87 -8
  42. sapiopycommons/rules/on_save_rule_handler.py +87 -12
  43. sapiopycommons/sftpconnect/__init__.py +0 -0
  44. sapiopycommons/sftpconnect/sftp_builder.py +70 -0
  45. sapiopycommons/webhook/webhook_context.py +39 -0
  46. sapiopycommons/webhook/webhook_handlers.py +614 -71
  47. sapiopycommons/webhook/webservice_handlers.py +317 -0
  48. {sapiopycommons-2024.3.19a157.dist-info → sapiopycommons-2025.1.17a402.dist-info}/METADATA +5 -4
  49. sapiopycommons-2025.1.17a402.dist-info/RECORD +60 -0
  50. {sapiopycommons-2024.3.19a157.dist-info → sapiopycommons-2025.1.17a402.dist-info}/WHEEL +1 -1
  51. sapiopycommons-2024.3.19a157.dist-info/RECORD +0 -28
  52. {sapiopycommons-2024.3.19a157.dist-info → sapiopycommons-2025.1.17a402.dist-info}/licenses/LICENSE +0 -0
@@ -1,17 +1,27 @@
1
+ from __future__ import annotations
2
+
1
3
  from collections.abc import Iterable
2
- from typing import Any
4
+ from weakref import WeakValueDictionary
3
5
 
4
6
  from sapiopylib.rest.DataRecordManagerService import DataRecordManager
7
+ from sapiopylib.rest.User import SapioUser
8
+ from sapiopylib.rest.pojo.CustomReport import CustomReportCriteria, RawReportTerm, ReportColumn
5
9
  from sapiopylib.rest.pojo.DataRecord import DataRecord
6
10
  from sapiopylib.rest.pojo.DataRecordPaging import DataRecordPojoPageCriteria
7
- from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
11
+ from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
12
+ from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnBaseDataType
13
+ from sapiopylib.rest.utils.autopaging import QueryDataRecordsAutoPager, QueryDataRecordByIdListAutoPager, \
14
+ QueryAllRecordsOfTypeAutoPager
8
15
  from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
9
16
  from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelManager, RecordModelInstanceManager, \
10
17
  RecordModelRelationshipManager
11
- from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
12
- from sapiopylib.rest.utils.recordmodel.RelationshipPath import RelationshipPath, RelationshipPathDir
18
+ from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType, WrappedRecordModel
19
+ from sapiopylib.rest.utils.recordmodel.RelationshipPath import RelationshipPath, RelationshipNode, \
20
+ RelationshipNodeType
21
+ from sapiopylib.rest.utils.recordmodel.ancestry import RecordModelAncestorManager
13
22
 
14
- from sapiopycommons.general.aliases import RecordModel, SapioRecord
23
+ from sapiopycommons.general.aliases import RecordModel, SapioRecord, FieldMap, FieldIdentifier, AliasUtil, \
24
+ FieldIdentifierMap, FieldValue, UserIdentifier, FieldIdentifierKey
15
25
  from sapiopycommons.general.custom_report_util import CustomReportUtil
16
26
  from sapiopycommons.general.exceptions import SapioException
17
27
 
@@ -21,21 +31,43 @@ class RecordHandler:
21
31
  """
22
32
  A collection of shorthand methods for dealing with the various record managers.
23
33
  """
24
- __context: SapioWebhookContext
34
+ user: SapioUser
25
35
  dr_man: DataRecordManager
26
36
  rec_man: RecordModelManager
27
37
  inst_man: RecordModelInstanceManager
28
38
  rel_man: RecordModelRelationshipManager
39
+ an_man: RecordModelAncestorManager
40
+
41
+ __instances: WeakValueDictionary[SapioUser, RecordHandler] = WeakValueDictionary()
42
+ __initialized: bool
43
+
44
+ def __new__(cls, context: UserIdentifier):
45
+ """
46
+ :param context: The current webhook context or a user object to send requests from.
47
+ """
48
+ user = AliasUtil.to_sapio_user(context)
49
+ obj = cls.__instances.get(user)
50
+ if not obj:
51
+ obj = object.__new__(cls)
52
+ obj.__initialized = False
53
+ cls.__instances[user] = obj
54
+ return obj
29
55
 
30
- def __init__(self, context: SapioWebhookContext):
56
+ def __init__(self, context: UserIdentifier):
31
57
  """
32
- :param context: The current webhook context.
58
+ :param context: The current webhook context or a user object to send requests from.
33
59
  """
34
- self.__context = context
35
- self.dr_man = context.data_record_manager
36
- self.rec_man = RecordModelManager(context.user)
60
+ self.user = AliasUtil.to_sapio_user(context)
61
+ if self.__initialized:
62
+ return
63
+ self.__initialized = True
64
+
65
+ self.user = context if isinstance(context, SapioUser) else context.user
66
+ self.dr_man = DataRecordManager(self.user)
67
+ self.rec_man = RecordModelManager(self.user)
37
68
  self.inst_man = self.rec_man.instance_manager
38
69
  self.rel_man = self.rec_man.relationship_manager
70
+ self.an_man = RecordModelAncestorManager(self.rec_man)
39
71
 
40
72
  def wrap_model(self, record: DataRecord, wrapper_type: type[WrappedType]) -> WrappedType:
41
73
  """
@@ -45,6 +77,7 @@ class RecordHandler:
45
77
  :param wrapper_type: The record model wrapper to use.
46
78
  :return: The record model for the input.
47
79
  """
80
+ self.__verify_data_type([record], wrapper_type)
48
81
  return self.inst_man.add_existing_record_of_type(record, wrapper_type)
49
82
 
50
83
  def wrap_models(self, records: Iterable[DataRecord], wrapper_type: type[WrappedType]) -> list[WrappedType]:
@@ -55,10 +88,11 @@ class RecordHandler:
55
88
  :param wrapper_type: The record model wrapper to use.
56
89
  :return: The record models for the input.
57
90
  """
91
+ self.__verify_data_type(records, wrapper_type)
58
92
  return self.inst_man.add_existing_records_of_type(list(records), wrapper_type)
59
93
 
60
- def query_models(self, wrapper_type: type[WrappedType], field: str, value_list: Iterable[Any],
61
- page_limit: int | None = None) -> list[WrappedType]:
94
+ def query_models(self, wrapper_type: type[WrappedType], field: FieldIdentifier, value_list: Iterable[FieldValue],
95
+ page_limit: int | None = None, page_size: int | None = None) -> list[WrappedType]:
62
96
  """
63
97
  Shorthand for using the data record manager to query for a list of data records by field value
64
98
  and then converting the results into a list of record models.
@@ -66,12 +100,63 @@ class RecordHandler:
66
100
  :param wrapper_type: The record model wrapper to use.
67
101
  :param field: The field to query on.
68
102
  :param value_list: The values of the field to query on.
69
- :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
103
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
104
+ only functions if you set a page size or the platform enforces a page size.
105
+ :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
70
106
  :return: The record models for the queried records.
71
107
  """
72
- return self.query_models_with_criteria(wrapper_type, field, value_list, None, page_limit)[0]
108
+ criteria: DataRecordPojoPageCriteria | None = None
109
+ if page_size is not None:
110
+ criteria = DataRecordPojoPageCriteria(page_size=page_size)
111
+ return self.query_models_with_criteria(wrapper_type, field, value_list, criteria, page_limit)[0]
112
+
113
+ def query_and_map_models(self, wrapper_type: type[WrappedType], field: FieldIdentifier,
114
+ value_list: Iterable[FieldValue], page_limit: int | None = None,
115
+ page_size: int | None = None, *, mapping_field: FieldIdentifier | None = None) \
116
+ -> dict[FieldValue, list[WrappedType]]:
117
+ """
118
+ Shorthand for using query_models to search for records given values on a specific field and then using
119
+ map_by_field to turn the returned list into a dictionary mapping field values to records.
73
120
 
74
- def query_models_with_criteria(self, wrapper_type: type[WrappedType], field: str, value_list: Iterable[Any],
121
+ :param wrapper_type: The record model wrapper to use.
122
+ :param field: The field to query and map on.
123
+ :param value_list: The values of the field to query on.
124
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
125
+ only functions if you set a page size or the platform enforces a page size.
126
+ :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
127
+ :param mapping_field: If provided, use this field to map against instead of the field that was queried on.
128
+ :return: The record models for the queried records mapped by field values to the records with that value.
129
+ """
130
+ if mapping_field is None:
131
+ mapping_field = field
132
+ return self.map_by_field(self.query_models(wrapper_type, field, value_list, page_limit, page_size),
133
+ mapping_field)
134
+
135
+ def query_and_unique_map_models(self, wrapper_type: type[WrappedType], field: FieldIdentifier,
136
+ value_list: Iterable[FieldValue], page_limit: int | None = None,
137
+ page_size: int | None = None, *, mapping_field: FieldIdentifier | None = None) \
138
+ -> dict[FieldValue, WrappedType]:
139
+ """
140
+ Shorthand for using query_models to search for records given values on a specific field and then using
141
+ map_by_unique_field to turn the returned list into a dictionary mapping field values to records.
142
+ If any two records share the same field value, throws an exception.
143
+
144
+ :param wrapper_type: The record model wrapper to use.
145
+ :param field: The field to query and map on.
146
+ :param value_list: The values of the field to query on.
147
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
148
+ only functions if you set a page size or the platform enforces a page size.
149
+ :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
150
+ :param mapping_field: If provided, use this field to map against instead of the field that was queried on.
151
+ :return: The record models for the queried records mapped by field values to the record with that value.
152
+ """
153
+ if mapping_field is None:
154
+ mapping_field = field
155
+ return self.map_by_unique_field(self.query_models(wrapper_type, field, value_list, page_limit, page_size),
156
+ mapping_field)
157
+
158
+ def query_models_with_criteria(self, wrapper_type: type[WrappedType], field: FieldIdentifier,
159
+ value_list: Iterable[FieldValue],
75
160
  paging_criteria: DataRecordPojoPageCriteria | None = None,
76
161
  page_limit: int | None = None) \
77
162
  -> tuple[list[WrappedType], DataRecordPojoPageCriteria]:
@@ -84,25 +169,33 @@ class RecordHandler:
84
169
  :param value_list: The values of the field to query on.
85
170
  :param paging_criteria: The paging criteria to start the query with.
86
171
  :param page_limit: The maximum number of pages to query from the starting criteria. If None, exhausts all
87
- possible pages.
172
+ possible pages. This parameter only functions if you set a page size in the paging criteria or the platform
173
+ enforces a page size.
88
174
  :return: The record models for the queried records and the final paging criteria.
89
175
  """
90
176
  dt: str = wrapper_type.get_wrapper_data_type_name()
91
- records, paging_criteria = self.__exhaust_query_pages(dt, field, list(value_list), paging_criteria, page_limit)
92
- return self.wrap_models(records, wrapper_type), paging_criteria
177
+ field: str = AliasUtil.to_data_field_name(field)
178
+ pager = QueryDataRecordsAutoPager(dt, field, list(value_list), self.user, paging_criteria)
179
+ pager.max_page = page_limit
180
+ return self.wrap_models(pager.get_all_at_once(), wrapper_type), pager.next_page_criteria
93
181
 
94
182
  def query_models_by_id(self, wrapper_type: type[WrappedType], ids: Iterable[int],
95
- page_limit: int | None = None) -> list[WrappedType]:
183
+ page_limit: int | None = None, page_size: int | None = None) -> list[WrappedType]:
96
184
  """
97
185
  Shorthand for using the data record manager to query for a list of data records by record ID
98
186
  and then converting the results into a list of record models.
99
187
 
100
188
  :param wrapper_type: The record model wrapper to use.
101
189
  :param ids: The list of record IDs to query.
102
- :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
190
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
191
+ only functions if you set a page size or the platform enforces a page size.
192
+ :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
103
193
  :return: The record models for the queried records.
104
194
  """
105
- return self.query_models_by_id_with_criteria(wrapper_type, ids, None, page_limit)[0]
195
+ criteria: DataRecordPojoPageCriteria | None = None
196
+ if page_size is not None:
197
+ criteria = DataRecordPojoPageCriteria(page_size=page_size)
198
+ return self.query_models_by_id_with_criteria(wrapper_type, ids, criteria, page_limit)[0]
106
199
 
107
200
  def query_models_by_id_with_criteria(self, wrapper_type: type[WrappedType], ids: Iterable[int],
108
201
  paging_criteria: DataRecordPojoPageCriteria | None = None,
@@ -116,23 +209,47 @@ class RecordHandler:
116
209
  :param ids: The list of record IDs to query.
117
210
  :param paging_criteria: The paging criteria to start the query with.
118
211
  :param page_limit: The maximum number of pages to query from the starting criteria. If None, exhausts all
119
- possible pages.
212
+ possible pages. This parameter only functions if you set a page size in the paging criteria or the platform
213
+ enforces a page size.
120
214
  :return: The record models for the queried records and the final paging criteria.
121
215
  """
122
216
  dt: str = wrapper_type.get_wrapper_data_type_name()
123
- records, paging_criteria = self.__exhaust_query_id_pages(dt, list(ids), paging_criteria, page_limit)
124
- return self.wrap_models(records, wrapper_type), paging_criteria
217
+ pager = QueryDataRecordByIdListAutoPager(dt, list(ids), self.user, paging_criteria)
218
+ pager.max_page = page_limit
219
+ return self.wrap_models(pager.get_all_at_once(), wrapper_type), pager.next_page_criteria
220
+
221
+ def query_models_by_id_and_map(self, wrapper_type: type[WrappedType], ids: Iterable[int],
222
+ page_limit: int | None = None, page_size: int | None = None) \
223
+ -> dict[int, WrappedType]:
224
+ """
225
+ Shorthand for using the data record manager to query for a list of data records by record ID
226
+ and then converting the results into a dictionary of record ID to the record model for that ID.
125
227
 
126
- def query_all_models(self, wrapper_type: type[WrappedType], page_limit: int | None = None) -> list[WrappedType]:
228
+ :param wrapper_type: The record model wrapper to use.
229
+ :param ids: The list of record IDs to query.
230
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
231
+ only functions if you set a page size or the platform enforces a page size.
232
+ :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
233
+ :return: The record models for the queried records mapped in a dictionary by their record ID.
234
+ """
235
+ return {x.record_id: x for x in self.query_models_by_id(wrapper_type, ids, page_limit, page_size)}
236
+
237
+ def query_all_models(self, wrapper_type: type[WrappedType], page_limit: int | None = None,
238
+ page_size: int | None = None) -> list[WrappedType]:
127
239
  """
128
240
  Shorthand for using the data record manager to query for all data records of a given type
129
241
  and then converting the results into a list of record models.
130
242
 
131
243
  :param wrapper_type: The record model wrapper to use.
132
- :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
244
+ :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
245
+ only functions if you set a page size or the platform enforces a page size.
246
+ :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
133
247
  :return: The record models for the queried records.
134
248
  """
135
- return self.query_all_models_with_criteria(wrapper_type, None, page_limit)[0]
249
+ criteria: DataRecordPojoPageCriteria | None = None
250
+ if page_size is not None:
251
+ criteria = DataRecordPojoPageCriteria(page_size=page_size)
252
+ return self.query_all_models_with_criteria(wrapper_type, criteria, page_limit)[0]
136
253
 
137
254
  def query_all_models_with_criteria(self, wrapper_type: type[WrappedType],
138
255
  paging_criteria: DataRecordPojoPageCriteria | None = None,
@@ -145,32 +262,68 @@ class RecordHandler:
145
262
  :param wrapper_type: The record model wrapper to use.
146
263
  :param paging_criteria: The paging criteria to start the query with.
147
264
  :param page_limit: The maximum number of pages to query from the starting criteria. If None, exhausts all
148
- possible pages.
265
+ possible pages. This parameter only functions if you set a page size in the paging criteria or the platform
266
+ enforces a page size.
149
267
  :return: The record models for the queried records and the final paging criteria.
150
268
  """
151
269
  dt: str = wrapper_type.get_wrapper_data_type_name()
152
- records, paging_criteria = self.__exhaust_query_all_pages(dt, paging_criteria, page_limit)
153
- return self.wrap_models(records, wrapper_type), paging_criteria
270
+ pager = QueryAllRecordsOfTypeAutoPager(dt, self.user, paging_criteria)
271
+ pager.max_page = page_limit
272
+ return self.wrap_models(pager.get_all_at_once(), wrapper_type), pager.next_page_criteria
154
273
 
155
274
  def query_models_by_report(self, wrapper_type: type[WrappedType],
156
- report_name: str,
157
- filters: dict[str, Iterable[Any]] | None = None,
158
- page_limit: int | None = None) -> list[WrappedType]:
275
+ report_name: str | RawReportTerm | CustomReportCriteria,
276
+ filters: dict[FieldIdentifierKey, Iterable[FieldValue]] | None = None,
277
+ page_limit: int | None = None,
278
+ page_size: int | None = None,
279
+ page_number: int | None = None) -> list[WrappedType]:
159
280
  """
160
- Run a system report that contains a RecordId column and query for the records with those IDs.
161
- First runs the custom report, then runs a data record manager query on the results of the custom report.
281
+ Run a report and use the results of that report to query for and return the records in the report results.
282
+ First runs the report, then runs a data record manager query on the results of the custom report.
283
+
284
+ Will throw an exception if given the name of a system report that does not have a RecordId column.
285
+ Quick and custom reports are guaranteed to have a record ID column.
162
286
 
163
- Will throw an exception if the given system report does not have a RecordId column.
287
+ Any given custom report criteria should only have columns from a single data type.
164
288
 
165
289
  :param wrapper_type: The record model wrapper to use.
166
- :param report_name: The name of the system report to run.
290
+ :param report_name: The name of a system report, or a raw report term for a quick report, or custom report
291
+ criteria for a custom report.
167
292
  :param filters: If provided, filter the results of the report using the given mapping of headers to values to
168
293
  filter on. This filtering is done before the records are queried.
169
294
  :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages.
170
- :return: The record models for the queried records.
171
- """
172
- results: list[dict[str, Any]] = CustomReportUtil.run_system_report(self.__context, report_name, filters, page_limit)
173
- # Using the bracket operators because we want to throw an exception if RecordId doesn't exist in the report.
295
+ :param page_size: The size of each page of results in the search. If None, the page size is set by the server.
296
+ If the input report is a custom report criteria, uses the value from the criteria, unless this value is
297
+ not None, in which case it overwrites the given report's value.
298
+ :param page_number: The page number to start the search from, If None, starts on the first page.
299
+ If the input report is a custom report criteria, uses the value from the criteria, unless this value is
300
+ not None, in which case it overwrites the given report's value. Note that the number of the first page is 0.
301
+ :return: The record models for the queried records that matched the given report.
302
+ """
303
+ if isinstance(report_name, str):
304
+ results: list[dict[str, FieldValue]] = CustomReportUtil.run_system_report(self.user, report_name, filters,
305
+ page_limit, page_size, page_number)
306
+ elif isinstance(report_name, RawReportTerm):
307
+ results: list[dict[str, FieldValue]] = CustomReportUtil.run_quick_report(self.user, report_name, filters,
308
+ page_limit, page_size, page_number)
309
+ elif isinstance(report_name, CustomReportCriteria):
310
+ dt: str = wrapper_type.get_wrapper_data_type_name()
311
+ # Ensure that the root data type is the one we're looking for.
312
+ report_name.root_data_type = dt
313
+ # Raise an exception if any column in the report doesn't match the given data type.
314
+ if any([x.data_type_name != dt for x in report_name.column_list]):
315
+ raise SapioException("You may only query records from a report containing columns from that data type.")
316
+ # Enforce that the given custom report has a record ID column.
317
+ if not any([x.data_type_name == dt and x.data_field_name == "RecordId" for x in report_name.column_list]):
318
+ report_name.column_list.append(ReportColumn(dt, "RecordId", FieldType.LONG))
319
+ results: list[dict[str, FieldValue]] = CustomReportUtil.run_custom_report(self.user, report_name, filters,
320
+ page_limit, page_size, page_number)
321
+ else:
322
+ raise SapioException("Unrecognized report object.")
323
+
324
+ # Using the bracket accessor because we want to throw an exception if RecordId doesn't exist in the report.
325
+ # This should only possibly be the case with system reports, as quick reports will include the record ID and
326
+ # we forced any given custom report to have a record ID column.
174
327
  ids: list[int] = [row["RecordId"] for row in results]
175
328
  return self.query_models_by_id(wrapper_type, ids)
176
329
 
@@ -193,7 +346,8 @@ class RecordHandler:
193
346
  """
194
347
  return self.inst_man.add_new_records_of_type(num, wrapper_type)
195
348
 
196
- def add_models_with_data(self, wrapper_type: type[WrappedType], fields: list[dict[str, Any]]) -> list[WrappedType]:
349
+ def add_models_with_data(self, wrapper_type: type[WrappedType], fields: list[FieldIdentifierMap]) \
350
+ -> list[WrappedType]:
197
351
  """
198
352
  Shorthand for using the instance manager to add new models of the given type, and then initializing all those
199
353
  models with the given fields.
@@ -203,11 +357,50 @@ class RecordHandler:
203
357
  :return: The newly added record models with the provided fields set. The records will be in the same order as
204
358
  the fields in the fields list.
205
359
  """
360
+ fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(fields)
206
361
  models: list[WrappedType] = self.add_models(wrapper_type, len(fields))
207
- for model, field in zip(models, fields):
208
- model.set_field_values(field)
362
+ for model, field_list in zip(models, fields):
363
+ model.set_field_values(field_list)
209
364
  return models
210
365
 
366
+ def find_or_add_model(self, wrapper_type: type[WrappedType], primary_identifier: FieldIdentifier,
367
+ id_value: FieldValue, secondary_identifiers: FieldIdentifierMap | None = None) -> WrappedType:
368
+ """
369
+ Find a unique record that matches the given field values. If no such records exist, add a record model to the
370
+ cache with the identifying fields set to the desired values. This record will be created in the system when
371
+ you store and commit changes. If more than one record with the identifying values exists, throws an exception.
372
+
373
+ The record is searched for using the primary identifier field name and value. If multiple records are returned
374
+ by the query on this primary identifier, then the secondary identifiers are used to filter the results.
375
+
376
+ Makes a webservice call to query for the existing record.
377
+
378
+ :param wrapper_type: The record model wrapper to use.
379
+ :param primary_identifier: The data field name of the field to search on.
380
+ :param id_value: The value of the identifying field to search for.
381
+ :param secondary_identifiers: Optional fields used to filter the records that are returned after searching on
382
+ the primary identifier.
383
+ :return: The record model with the identifying field value, either pulled from the system or newly created.
384
+ """
385
+ # PR-46335: Initialize the secondary identifiers parameter if None is provided to avoid an exception.
386
+ # If no secondary identifiers were provided, use an empty dictionary.
387
+ if secondary_identifiers is None:
388
+ secondary_identifiers = {}
389
+
390
+ primary_identifier: str = AliasUtil.to_data_field_name(primary_identifier)
391
+ secondary_identifiers: FieldMap = AliasUtil.to_data_field_names_dict(secondary_identifiers)
392
+ unique_record: WrappedType | None = self.__find_model(wrapper_type, primary_identifier, id_value,
393
+ secondary_identifiers)
394
+ # If a unique record matched the identifiers, return it.
395
+ if unique_record is not None:
396
+ return unique_record
397
+
398
+ # If none of the results matched the identifiers, create a new record with all identifiers set.
399
+ # Put the primary identifier and value into the secondary identifiers list and use that as the fields map
400
+ # for this new record.
401
+ secondary_identifiers.update({primary_identifier: id_value})
402
+ return self.add_models_with_data(wrapper_type, [secondary_identifiers])[0]
403
+
211
404
  def create_models(self, wrapper_type: type[WrappedType], num: int) -> list[WrappedType]:
212
405
  """
213
406
  Shorthand for creating new records via the data record manager and then returning them as wrapped
@@ -222,7 +415,7 @@ class RecordHandler:
222
415
  dt: str = wrapper_type.get_wrapper_data_type_name()
223
416
  return self.wrap_models(self.dr_man.add_data_records(dt, num), wrapper_type)
224
417
 
225
- def create_models_with_data(self, wrapper_type: type[WrappedType], fields: list[dict[str, Any]]) \
418
+ def create_models_with_data(self, wrapper_type: type[WrappedType], fields: list[FieldIdentifierMap]) \
226
419
  -> list[WrappedType]:
227
420
  """
228
421
  Shorthand for creating new records via the data record manager with field data to initialize the records with
@@ -236,10 +429,12 @@ class RecordHandler:
236
429
  :return: The newly created record models.
237
430
  """
238
431
  dt: str = wrapper_type.get_wrapper_data_type_name()
432
+ fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(fields)
239
433
  return self.wrap_models(self.dr_man.add_data_records_with_data(dt, fields), wrapper_type)
240
434
 
241
- def find_or_create_model(self, wrapper_type: type[WrappedType], primary_identifier: str, id_value: Any,
242
- secondary_identifiers: dict[str, Any] | None = None) -> WrappedType:
435
+ def find_or_create_model(self, wrapper_type: type[WrappedType], primary_identifier: FieldIdentifier,
436
+ id_value: FieldValue, secondary_identifiers: FieldIdentifierMap | None = None) \
437
+ -> WrappedType:
243
438
  """
244
439
  Find a unique record that matches the given field values. If no such records exist, create one with the
245
440
  identifying fields set to the desired values. If more than one record with the identifying values exists,
@@ -263,24 +458,10 @@ class RecordHandler:
263
458
  if secondary_identifiers is None:
264
459
  secondary_identifiers = {}
265
460
 
266
- # Query for all records that match the primary identifier.
267
- results: list[WrappedType] = self.query_models(wrapper_type, primary_identifier, [id_value])
268
-
269
- # Find the one record, if any, that matches the secondary identifiers.
270
- unique_record: WrappedType | None = None
271
- for result in results:
272
- matches_all: bool = True
273
- for field, value in secondary_identifiers.items():
274
- if result.get_field_value(field) != value:
275
- matches_all = False
276
- break
277
- if matches_all:
278
- # If a previous record in the results already matched all identifiers, then throw an exception.
279
- if unique_record is not None:
280
- raise SapioException(f"More than one record of type {wrapper_type.get_wrapper_data_type_name()} "
281
- f"encountered in system that matches all provided identifiers.")
282
- unique_record = result
283
-
461
+ primary_identifier: str = AliasUtil.to_data_field_name(primary_identifier)
462
+ secondary_identifiers: FieldMap = AliasUtil.to_data_field_names_dict(secondary_identifiers)
463
+ unique_record: WrappedType | None = self.__find_model(wrapper_type, primary_identifier, id_value,
464
+ secondary_identifiers)
284
465
  # If a unique record matched the identifiers, return it.
285
466
  if unique_record is not None:
286
467
  return unique_record
@@ -292,7 +473,7 @@ class RecordHandler:
292
473
  return self.create_models_with_data(wrapper_type, [secondary_identifiers])[0]
293
474
 
294
475
  @staticmethod
295
- def map_to_parent(models: Iterable[RecordModel], parent_type: type[WrappedType]) -> dict:
476
+ def map_to_parent(models: Iterable[RecordModel], parent_type: type[WrappedType]) -> dict[RecordModel, WrappedType]:
296
477
  """
297
478
  Map a list of record models to a single parent of a given type. The parents must already be loaded.
298
479
 
@@ -307,7 +488,8 @@ class RecordHandler:
307
488
  return return_dict
308
489
 
309
490
  @staticmethod
310
- def map_to_parents(models: Iterable[RecordModel], parent_type: type[WrappedType]) -> dict:
491
+ def map_to_parents(models: Iterable[RecordModel], parent_type: type[WrappedType]) \
492
+ -> dict[RecordModel, list[WrappedType]]:
311
493
  """
312
494
  Map a list of record models to a list parents of a given type. The parents must already be loaded.
313
495
 
@@ -322,7 +504,31 @@ class RecordHandler:
322
504
  return return_dict
323
505
 
324
506
  @staticmethod
325
- def map_by_parents(models: Iterable[RecordModel], parent_type: type[WrappedType]) -> dict:
507
+ def map_by_parent(models: Iterable[RecordModel], parent_type: type[WrappedType]) \
508
+ -> dict[WrappedType, RecordModel]:
509
+ """
510
+ Take a list of record models and map them by their parent. Essentially an inversion of map_to_parent.
511
+ If two records share the same parent, an exception will be thrown. The parents must already be loaded.
512
+
513
+ :param models: A list of record models.
514
+ :param parent_type: The record model wrapper of the parents.
515
+ :return: A dict[ParentType, ModelType]. If an input model doesn't have a parent of the given parent type,
516
+ then it will not be in the resulting dictionary.
517
+ """
518
+ to_parent: dict[RecordModel, WrappedType] = RecordHandler.map_to_parent(models, parent_type)
519
+ by_parent: dict[WrappedType, RecordModel] = {}
520
+ for record, parent in to_parent.items():
521
+ if parent is None:
522
+ continue
523
+ if parent in by_parent:
524
+ raise SapioException(f"Parent {parent.data_type_name} {parent.record_id} encountered more than once "
525
+ f"in models list.")
526
+ by_parent[parent] = record
527
+ return by_parent
528
+
529
+ @staticmethod
530
+ def map_by_parents(models: Iterable[RecordModel], parent_type: type[WrappedType]) \
531
+ -> dict[WrappedType, list[RecordModel]]:
326
532
  """
327
533
  Take a list of record models and map them by their parents. Essentially an inversion of map_to_parents. Input
328
534
  models that share a parent will end up in the same list. The parents must already be loaded.
@@ -340,7 +546,7 @@ class RecordHandler:
340
546
  return by_parents
341
547
 
342
548
  @staticmethod
343
- def map_to_child(models: Iterable[RecordModel], child_type: type[WrappedType]) -> dict:
549
+ def map_to_child(models: Iterable[RecordModel], child_type: type[WrappedType]) -> dict[RecordModel, WrappedType]:
344
550
  """
345
551
  Map a list of record models to a single child of a given type. The children must already be loaded.
346
552
 
@@ -355,7 +561,8 @@ class RecordHandler:
355
561
  return return_dict
356
562
 
357
563
  @staticmethod
358
- def map_to_children(models: Iterable[RecordModel], child_type: type[WrappedType]) -> dict:
564
+ def map_to_children(models: Iterable[RecordModel], child_type: type[WrappedType]) \
565
+ -> dict[RecordModel, list[WrappedType]]:
359
566
  """
360
567
  Map a list of record models to a list children of a given type. The children must already be loaded.
361
568
 
@@ -370,14 +577,38 @@ class RecordHandler:
370
577
  return return_dict
371
578
 
372
579
  @staticmethod
373
- def map_by_children(models: Iterable[RecordModel], child_type: type[WrappedType]) -> dict:
580
+ def map_by_child(models: Iterable[RecordModel], child_type: type[WrappedType]) \
581
+ -> dict[WrappedType, RecordModel]:
582
+ """
583
+ Take a list of record models and map them by their children. Essentially an inversion of map_to_child.
584
+ If two records share the same child, an exception will be thrown. The children must already be loaded.
585
+
586
+ :param models: A list of record models.
587
+ :param child_type: The record model wrapper of the children.
588
+ :return: A dict[ChildType, ModelType]. If an input model doesn't have a child of the given child type,
589
+ then it will not be in the resulting dictionary.
590
+ """
591
+ to_child: dict[RecordModel, WrappedType] = RecordHandler.map_to_child(models, child_type)
592
+ by_child: dict[WrappedType, RecordModel] = {}
593
+ for record, child in to_child.items():
594
+ if child is None:
595
+ continue
596
+ if child in by_child:
597
+ raise SapioException(f"Child {child.data_type_name} {child.record_id} encountered more than once "
598
+ f"in models list.")
599
+ by_child[child] = record
600
+ return by_child
601
+
602
+ @staticmethod
603
+ def map_by_children(models: Iterable[RecordModel], child_type: type[WrappedType]) \
604
+ -> dict[WrappedType, list[RecordModel]]:
374
605
  """
375
606
  Take a list of record models and map them by their children. Essentially an inversion of map_to_children. Input
376
607
  models that share a child will end up in the same list. The children must already be loaded.
377
608
 
378
609
  :param models: A list of record models.
379
610
  :param child_type: The record model wrapper of the children.
380
- :return: A dict[ParentType, list[ModelType]]. If an input model doesn't have children of the given child type,
611
+ :return: A dict[ChildType, list[ModelType]]. If an input model doesn't have children of the given child type,
381
612
  then it will not be in the resulting dictionary.
382
613
  """
383
614
  to_children: dict[RecordModel, list[WrappedType]] = RecordHandler.map_to_children(models, child_type)
@@ -387,6 +618,171 @@ class RecordHandler:
387
618
  by_children.setdefault(child, []).append(record)
388
619
  return by_children
389
620
 
621
+ @staticmethod
622
+ def map_to_forward_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
623
+ side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, WrappedType]:
624
+ """
625
+ Map a list of record models to their forward side link. The forward side link must already be loaded.
626
+
627
+ :param models: A list of record models.
628
+ :param field_name: The field name on the record models where the side link is located.
629
+ :param side_link_type: The record model wrapper of the forward side link.
630
+ :return: A dict[ModelType, SlideLink]. If an input model doesn't have a forward side link of the given type,
631
+ then it will map to None.
632
+ """
633
+ field_name: str = AliasUtil.to_data_field_name(field_name)
634
+ return_dict: dict[WrappedRecordModel, WrappedType] = {}
635
+ for model in models:
636
+ return_dict[model] = model.get_forward_side_link(field_name, side_link_type)
637
+ return return_dict
638
+
639
+ @staticmethod
640
+ def map_by_forward_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
641
+ side_link_type: type[WrappedType]) -> dict[WrappedType, list[WrappedRecordModel]]:
642
+ """
643
+ Take a list of record models and map them by their forward side link. Essentially an inversion of
644
+ map_to_forward_side_link. Input models that share a forward side link will end up in the same list.
645
+ The forward side link must already be loaded.
646
+
647
+ :param models: A list of record models.
648
+ :param field_name: The field name on the record models where the side link is located.
649
+ :param side_link_type: The record model wrapper of the forward side links.
650
+ :return: A dict[SideLink, list[ModelType]]. If an input model doesn't have a forward side link of the given type
651
+ pointing to it, then it will not be in the resulting dictionary.
652
+ """
653
+ field_name: str = AliasUtil.to_data_field_name(field_name)
654
+ to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
655
+ .map_to_forward_side_link(models, field_name, side_link_type)
656
+ by_side_link: dict[WrappedType, list[WrappedRecordModel]] = {}
657
+ for record, side_link in to_side_link.items():
658
+ if side_link is None:
659
+ continue
660
+ by_side_link.setdefault(side_link, []).append(record)
661
+ return by_side_link
662
+
663
+ @staticmethod
664
+ def map_by_forward_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
665
+ side_link_type: type[WrappedType]) -> dict[WrappedType, WrappedRecordModel]:
666
+ """
667
+ Take a list of record models and map them by their forward side link. Essentially an inversion of
668
+ map_to_forward_side_link, but if two records share the same forward link, an exception is thrown.
669
+ The forward side link must already be loaded.
670
+
671
+ :param models: A list of record models.
672
+ :param field_name: The field name on the record models where the side link is located.
673
+ :param side_link_type: The record model wrapper of the forward side links.
674
+ :return: A dict[SideLink, ModelType]. If an input model doesn't have a forward side link of the given type
675
+ pointing to it, then it will not be in the resulting dictionary.
676
+ """
677
+ field_name: str = AliasUtil.to_data_field_name(field_name)
678
+ to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
679
+ .map_to_forward_side_link(models, field_name, side_link_type)
680
+ by_side_link: dict[WrappedType, WrappedRecordModel] = {}
681
+ for record, side_link in to_side_link.items():
682
+ if side_link is None:
683
+ continue
684
+ if side_link in by_side_link:
685
+ raise SapioException(f"Side link {side_link.data_type_name} {side_link.record_id} encountered more "
686
+ f"than once in models list.")
687
+ by_side_link[side_link] = record
688
+ return by_side_link
689
+
690
+ @staticmethod
691
+ def map_to_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
692
+ side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, list[WrappedType]]:
693
+ """
694
+ Map a list of record models to a list reverse side links of a given type. The reverse side links must already
695
+ be loaded.
696
+
697
+ :param models: A list of record models.
698
+ :param field_name: The field name on the side linked model where the side link to the given record models is
699
+ located.
700
+ :param side_link_type: The record model wrapper of the reverse side links.
701
+ :return: A dict[ModelType, list[SideLink]]. If an input model doesn't have reverse side links of the given type,
702
+ then it will map to an empty list.
703
+ """
704
+ field_name: str = AliasUtil.to_data_field_name(field_name)
705
+ return_dict: dict[WrappedRecordModel, list[WrappedType]] = {}
706
+ for model in models:
707
+ return_dict[model] = model.get_reverse_side_link(field_name, side_link_type)
708
+ return return_dict
709
+
710
+ @staticmethod
711
+ def map_to_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
712
+ side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, WrappedType]:
713
+ """
714
+ Map a list of record models to the reverse side link of a given type. If a given record has more than one
715
+ reverse side link of this type, an exception is thrown. The reverse side links must already be loaded.
716
+
717
+ :param models: A list of record models.
718
+ :param field_name: The field name on the side linked model where the side link to the given record models is
719
+ located.
720
+ :param side_link_type: The record model wrapper of the reverse side links.
721
+ :return: A dict[ModelType, SideLink]. If an input model doesn't have reverse side links of the given type,
722
+ then it will map to None.
723
+ """
724
+ field_name: str = AliasUtil.to_data_field_name(field_name)
725
+ return_dict: dict[WrappedRecordModel, WrappedType] = {}
726
+ for model in models:
727
+ links: list[WrappedType] = model.get_reverse_side_link(field_name, side_link_type)
728
+ if len(links) > 1:
729
+ raise SapioException(f"Model {model.data_type_name} {model.record_id} has more than one reverse link "
730
+ f"of type {side_link_type.get_wrapper_data_type_name()}.")
731
+ return_dict[model] = links[0] if links else None
732
+ return return_dict
733
+
734
+ @staticmethod
735
+ def map_by_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
736
+ side_link_type: type[WrappedType]) -> dict[WrappedType, list[WrappedRecordModel]]:
737
+ """
738
+ Take a list of record models and map them by their reverse side links. Essentially an inversion of
739
+ map_to_reverse_side_links. Input models that share a reverse side link will end up in the same list.
740
+ The reverse side links must already be loaded.
741
+
742
+ :param models: A list of record models.
743
+ :param field_name: The field name on the side linked model where the side link to the given record models is
744
+ located.
745
+ :param side_link_type: The record model wrapper of the reverse side links.
746
+ :return: A dict[SideLink, list[ModelType]]. If an input model doesn't have reverse side links of the given type
747
+ pointing to it, then it will not be in the resulting dictionary.
748
+ """
749
+ field_name: str = AliasUtil.to_data_field_name(field_name)
750
+ to_side_links: dict[WrappedRecordModel, list[WrappedType]] = RecordHandler\
751
+ .map_to_reverse_side_links(models, field_name, side_link_type)
752
+ by_side_links: dict[WrappedType, list[WrappedRecordModel]] = {}
753
+ for record, side_links in to_side_links.items():
754
+ for side_link in side_links:
755
+ by_side_links.setdefault(side_link, []).append(record)
756
+ return by_side_links
757
+
758
+ @staticmethod
759
+ def map_by_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
760
+ side_link_type: type[WrappedType]) -> dict[WrappedType, WrappedRecordModel]:
761
+ """
762
+ Take a list of record models and map them by their reverse side link. Essentially an inversion of
763
+ map_to_reverse_side_link. If two records share the same reverse side link, an exception is thrown.
764
+ The reverse side links must already be loaded.
765
+
766
+ :param models: A list of record models.
767
+ :param field_name: The field name on the side linked model where the side link to the given record models is
768
+ located.
769
+ :param side_link_type: The record model wrapper of the reverse side links.
770
+ :return: A dict[SideLink, ModelType]. If an input model doesn't have a reverse side link of the given type
771
+ pointing to it, then it will not be in the resulting dictionary.
772
+ """
773
+ field_name: str = AliasUtil.to_data_field_name(field_name)
774
+ to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
775
+ .map_to_reverse_side_link(models, field_name, side_link_type)
776
+ by_side_link: dict[WrappedType, WrappedRecordModel] = {}
777
+ for record, side_link in to_side_link.items():
778
+ if side_link is None:
779
+ continue
780
+ if side_link in by_side_link:
781
+ raise SapioException(f"Side link {side_link.data_type_name} {side_link.record_id} encountered more "
782
+ f"than once in models list.")
783
+ by_side_link[side_link] = record
784
+ return by_side_link
785
+
390
786
  @staticmethod
391
787
  def map_by_id(models: Iterable[SapioRecord]) -> dict[int, SapioRecord]:
392
788
  """
@@ -401,7 +797,8 @@ class RecordHandler:
401
797
  return ret_dict
402
798
 
403
799
  @staticmethod
404
- def map_by_field(models: Iterable[SapioRecord], field_name: str) -> dict[Any, list[SapioRecord]]:
800
+ def map_by_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) \
801
+ -> dict[FieldValue, list[SapioRecord]]:
405
802
  """
406
803
  Map the given records by one of their fields. If any two records share the same field value, they'll appear in
407
804
  the same value list.
@@ -410,14 +807,16 @@ class RecordHandler:
410
807
  :param field_name: The field name to map against.
411
808
  :return: A dict mapping field values to the records with that value.
412
809
  """
413
- ret_dict: dict[Any, list[SapioRecord]] = {}
810
+ field_name: str = AliasUtil.to_data_field_name(field_name)
811
+ ret_dict: dict[FieldValue, list[SapioRecord]] = {}
414
812
  for model in models:
415
- val: Any = model.get_field_value(field_name)
813
+ val: FieldValue = model.get_field_value(field_name)
416
814
  ret_dict.setdefault(val, []).append(model)
417
815
  return ret_dict
418
816
 
419
817
  @staticmethod
420
- def map_by_unique_field(models: Iterable[SapioRecord], field_name: str) -> dict[Any, SapioRecord]:
818
+ def map_by_unique_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) \
819
+ -> dict[FieldValue, SapioRecord]:
421
820
  """
422
821
  Uniquely map the given records by one of their fields. If any two records share the same field value, throws
423
822
  an exception.
@@ -426,16 +825,17 @@ class RecordHandler:
426
825
  :param field_name: The field name to map against.
427
826
  :return: A dict mapping field values to the record with that value.
428
827
  """
429
- ret_dict: dict[Any, SapioRecord] = {}
828
+ field_name: str = AliasUtil.to_data_field_name(field_name)
829
+ ret_dict: dict[FieldValue, SapioRecord] = {}
430
830
  for model in models:
431
- val: Any = model.get_field_value(field_name)
831
+ val: FieldValue = model.get_field_value(field_name)
432
832
  if val in ret_dict:
433
833
  raise SapioException(f"Value {val} encountered more than once in models list.")
434
834
  ret_dict.update({val: model})
435
835
  return ret_dict
436
836
 
437
837
  @staticmethod
438
- def sum_of_field(models: Iterable[SapioRecord], field_name: str) -> float:
838
+ def sum_of_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) -> float:
439
839
  """
440
840
  Sum up the numeric value of a given field across all input models. Excepts that all given models have a value.
441
841
  If the field is an integer field, the value will be converted to a float.
@@ -444,13 +844,14 @@ class RecordHandler:
444
844
  :param field_name: The name of the numeric field to sum.
445
845
  :return: The sum of the field values for the collection of models.
446
846
  """
847
+ field_name: str = AliasUtil.to_data_field_name(field_name)
447
848
  field_sum: float = 0
448
849
  for model in models:
449
850
  field_sum += float(model.get_field_value(field_name))
450
851
  return field_sum
451
852
 
452
853
  @staticmethod
453
- def mean_of_field(models: Iterable[SapioRecord], field_name: str) -> float:
854
+ def mean_of_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) -> float:
454
855
  """
455
856
  Calculate the mean of the numeric value of a given field across all input models. Excepts that all given models
456
857
  have a value. If the field is an integer field, the value will be converted to a float.
@@ -475,9 +876,24 @@ class RecordHandler:
475
876
  newest = record
476
877
  return newest
477
878
 
879
+ # FR-46696: Add a function for getting the oldest record in a list, just like we have one for the newest record.
880
+ @staticmethod
881
+ def get_oldest_record(records: Iterable[SapioRecord]) -> SapioRecord:
882
+ """
883
+ Get the oldest record from a list of records.
884
+
885
+ :param records: The list of records.
886
+ :return: The input record with the lowest record ID. None if the input list is empty.
887
+ """
888
+ oldest: SapioRecord | None = None
889
+ for record in records:
890
+ if oldest is None or record.record_id < oldest.record_id:
891
+ oldest = record
892
+ return oldest
893
+
478
894
  @staticmethod
479
- def values_to_field_maps(field_name: str, values: Iterable[Any], existing_fields: list[dict[str, Any]] | None = None) \
480
- -> list[dict[str, Any]]:
895
+ def values_to_field_maps(field_name: FieldIdentifier, values: Iterable[FieldValue],
896
+ existing_fields: list[FieldIdentifier] | None = None) -> list[FieldMap]:
481
897
  """
482
898
  Add a list of values for a specific field to a list of dictionaries pairing each value to that field name.
483
899
 
@@ -488,6 +904,8 @@ class RecordHandler:
488
904
  :return: A fields map list that contains the given values mapped by the given field name.
489
905
  """
490
906
  # Update the existing fields map list if one is given.
907
+ field_name: str = AliasUtil.to_data_field_name(field_name)
908
+ existing_fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(existing_fields)
491
909
  if existing_fields:
492
910
  values = list(values)
493
911
  # The number of new values must match the length of the existing fields list.
@@ -499,7 +917,7 @@ class RecordHandler:
499
917
  # Otherwise, create a new fields map list.
500
918
  return [{field_name: value} for value in values]
501
919
 
502
- # FR-46155: Update relationship path traversing functions to be non-const and take in a wrapper type so that the
920
+ # FR-46155: Update relationship path traversing functions to be non-static and take in a wrapper type so that the
503
921
  # output can be wrapped instead of requiring the user to wrap the output.
504
922
  def get_linear_path(self, models: Iterable[RecordModel], path: RelationshipPath, wrapper_type: type[WrappedType]) \
505
923
  -> dict[RecordModel, WrappedType | None]:
@@ -515,16 +933,49 @@ class RecordHandler:
515
933
  path couldn't be reached, the record will map to None.
516
934
  """
517
935
  ret_dict: dict[RecordModel, WrappedType | None] = {}
518
- path: list[tuple[RelationshipPathDir, str]] = path.path
936
+ # PR-46832: Update path traversal to account for changes to RelationshipPath in Sapiopylib.
937
+ path: list[RelationshipNode] = path.path
519
938
  for model in models:
520
- current: PyRecordModel = model if isinstance(model, PyRecordModel) else model.backing_model
521
- for direction, datatype in path:
939
+ current: PyRecordModel | None = model if isinstance(model, PyRecordModel) else model.backing_model
940
+ for node in path:
941
+ data_type: str = node.data_type_name
942
+ direction: RelationshipNodeType = node.direction
522
943
  if current is None:
523
944
  break
524
- if direction == RelationshipPathDir.CHILD:
525
- current = current.get_child_of_type(datatype)
526
- elif direction == RelationshipPathDir.PARENT:
527
- current = current.get_parent_of_type(datatype)
945
+ if direction == RelationshipNodeType.CHILD:
946
+ current = current.get_child_of_type(data_type)
947
+ elif direction == RelationshipNodeType.PARENT:
948
+ current = current.get_parent_of_type(data_type)
949
+ elif direction == RelationshipNodeType.ANCESTOR:
950
+ ancestors: list[PyRecordModel] = list(self.an_man.get_ancestors_of_type(current, data_type))
951
+ if not ancestors:
952
+ current = None
953
+ elif len(ancestors) > 1:
954
+ raise SapioException(f"Hierarchy contains multiple ancestors of type {data_type}.")
955
+ else:
956
+ current = ancestors[0]
957
+ elif direction == RelationshipNodeType.DESCENDANT:
958
+ descendants: list[PyRecordModel] = list(self.an_man.get_descendant_of_type(current, data_type))
959
+ if not descendants:
960
+ current = None
961
+ elif len(descendants) > 1:
962
+ raise SapioException(f"Hierarchy contains multiple descendants of type {data_type}.")
963
+ else:
964
+ current = descendants[0]
965
+ elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
966
+ current = current.get_forward_side_link(node.data_field_name)
967
+ elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
968
+ field_name: str = node.data_field_name
969
+ reverse_links: list[PyRecordModel] = current.get_reverse_side_link(data_type, field_name)
970
+ if not reverse_links:
971
+ current = None
972
+ elif len(reverse_links) > 1:
973
+ raise SapioException(f"Hierarchy contains multiple reverse links of type {data_type} on field "
974
+ f"{field_name}.")
975
+ else:
976
+ current = reverse_links[0]
977
+ else:
978
+ raise SapioException("Unsupported path direction.")
528
979
  ret_dict.update({model: self.inst_man.wrap(current, wrapper_type) if current else None})
529
980
  return ret_dict
530
981
 
@@ -542,19 +993,32 @@ class RecordHandler:
542
993
  path couldn't be reached, the record will map to an empty list.
543
994
  """
544
995
  ret_dict: dict[RecordModel, list[WrappedType]] = {}
545
- path: list[tuple[RelationshipPathDir, str]] = path.path
996
+ # PR-46832: Update path traversal to account for changes to RelationshipPath in Sapiopylib.
997
+ path: list[RelationshipNode] = path.path
546
998
  for model in models:
547
999
  current_search: set[PyRecordModel] = {model if isinstance(model, PyRecordModel) else model.backing_model}
548
1000
  next_search: set[PyRecordModel] = set()
549
1001
  # Exhaust the records at each step in the path, then use those records for the next step.
550
- for direction, datatype in path:
1002
+ for node in path:
1003
+ data_type: str = node.data_type_name
1004
+ direction: RelationshipNodeType = node.direction
551
1005
  if len(current_search) == 0:
552
1006
  break
553
1007
  for search in current_search:
554
- if direction == RelationshipPathDir.CHILD:
555
- next_search.update(search.get_children_of_type(datatype))
556
- elif direction == RelationshipPathDir.PARENT:
557
- next_search.update(search.get_parents_of_type(datatype))
1008
+ if direction == RelationshipNodeType.CHILD:
1009
+ next_search.update(search.get_children_of_type(data_type))
1010
+ elif direction == RelationshipNodeType.PARENT:
1011
+ next_search.update(search.get_parents_of_type(data_type))
1012
+ elif direction == RelationshipNodeType.ANCESTOR:
1013
+ next_search.update(self.an_man.get_ancestors_of_type(search, data_type))
1014
+ elif direction == RelationshipNodeType.DESCENDANT:
1015
+ next_search.update(self.an_man.get_descendant_of_type(search, data_type))
1016
+ elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
1017
+ next_search.add(search.get_forward_side_link(node.data_field_name))
1018
+ elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
1019
+ next_search.update(search.get_reverse_side_link(data_type, node.data_field_name))
1020
+ else:
1021
+ raise SapioException("Unsupported path direction.")
558
1022
  current_search = next_search
559
1023
  next_search = set()
560
1024
  ret_dict.update({model: self.inst_man.wrap_list(list(current_search), wrapper_type)})
@@ -570,7 +1034,7 @@ class RecordHandler:
570
1034
  relationship path must already be loaded.
571
1035
 
572
1036
  The path is "flattened" by only following the first record at each step. Useful for traversing 1-to-Many-to-1
573
- relationships (e.g. a sample with is aliquoted to a number of samples, then those aliquots are pooled back
1037
+ relationships (e.g. a sample which is aliquoted to a number of samples, then those aliquots are pooled back
574
1038
  together into a single sample).
575
1039
 
576
1040
  :param models: A list of record models.
@@ -580,60 +1044,69 @@ class RecordHandler:
580
1044
  path couldn't be reached, the record will map to None.
581
1045
  """
582
1046
  ret_dict: dict[RecordModel, WrappedType | None] = {}
583
- path: list[tuple[RelationshipPathDir, str]] = path.path
1047
+ # PR-46832: Update path traversal to account for changes to RelationshipPath in Sapiopylib.
1048
+ path: list[RelationshipNode] = path.path
584
1049
  for model in models:
585
1050
  current: list[PyRecordModel] = [model if isinstance(model, PyRecordModel) else model.backing_model]
586
- for direction, datatype in path:
1051
+ for node in path:
1052
+ data_type: str = node.data_type_name
1053
+ direction: RelationshipNodeType = node.direction
587
1054
  if len(current) == 0:
588
1055
  break
589
- if direction == RelationshipPathDir.CHILD:
590
- current = current[0].get_children_of_type(datatype)
591
- elif direction == RelationshipPathDir.PARENT:
592
- current = current[0].get_parents_of_type(datatype)
1056
+ if direction == RelationshipNodeType.CHILD:
1057
+ current = current[0].get_children_of_type(data_type)
1058
+ elif direction == RelationshipNodeType.PARENT:
1059
+ current = current[0].get_parents_of_type(data_type)
1060
+ elif direction == RelationshipNodeType.ANCESTOR:
1061
+ current = list(self.an_man.get_ancestors_of_type(current[0], data_type))
1062
+ elif direction == RelationshipNodeType.DESCENDANT:
1063
+ current = list(self.an_man.get_descendant_of_type(current[0], data_type))
1064
+ elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
1065
+ current = [current[0].get_forward_side_link(node.data_field_name)]
1066
+ elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
1067
+ current = current[0].get_reverse_side_link(data_type, node.data_field_name)
1068
+ else:
1069
+ raise SapioException("Unsupported path direction.")
593
1070
  ret_dict.update({model: self.inst_man.wrap(current[0], wrapper_type) if current else None})
594
1071
  return ret_dict
595
1072
 
596
- def __exhaust_query_pages(self, data_type_name: str, field: str, value_list: list[Any],
597
- paging_criteria: DataRecordPojoPageCriteria | None,
598
- page_limit: int | None) \
599
- -> tuple[list[DataRecord], DataRecordPojoPageCriteria | None]:
600
- has_next_page: bool = True
601
- records: list[DataRecord] = []
602
- cur_page: int = 1
603
- while has_next_page and (not page_limit or cur_page < page_limit):
604
- page_result = self.dr_man.query_data_records(data_type_name, field, value_list, paging_criteria)
605
- paging_criteria = page_result.next_page_criteria
606
- has_next_page = page_result.is_next_page_available
607
- records.extend(page_result.result_list)
608
- cur_page += 1
609
- return records, paging_criteria
610
-
611
- def __exhaust_query_id_pages(self, data_type_name: str, id_list: list[int],
612
- paging_criteria: DataRecordPojoPageCriteria | None,
613
- page_limit: int | None) \
614
- -> tuple[list[DataRecord], DataRecordPojoPageCriteria | None]:
615
- has_next_page: bool = True
616
- records: list[DataRecord] = []
617
- cur_page: int = 1
618
- while has_next_page and (not page_limit or cur_page < page_limit):
619
- page_result = self.dr_man.query_data_records_by_id(data_type_name, id_list, paging_criteria)
620
- paging_criteria = page_result.next_page_criteria
621
- has_next_page = page_result.is_next_page_available
622
- records.extend(page_result.result_list)
623
- cur_page += 1
624
- return records, paging_criteria
625
-
626
- def __exhaust_query_all_pages(self, data_type_name: str,
627
- paging_criteria: DataRecordPojoPageCriteria | None,
628
- page_limit: int | None) \
629
- -> tuple[list[DataRecord], DataRecordPojoPageCriteria | None]:
630
- has_next_page: bool = True
631
- records: list[DataRecord] = []
632
- cur_page: int = 1
633
- while has_next_page and (not page_limit or cur_page < page_limit):
634
- page_result = self.dr_man.query_all_records_of_type(data_type_name, paging_criteria)
635
- paging_criteria = page_result.next_page_criteria
636
- has_next_page = page_result.is_next_page_available
637
- records.extend(page_result.result_list)
638
- cur_page += 1
639
- return records, paging_criteria
1073
+ def __find_model(self, wrapper_type: type[WrappedType], primary_identifier: str, id_value: FieldValue,
1074
+ secondary_identifiers: FieldIdentifierMap | None = None) -> WrappedType | None:
1075
+ """
1076
+ Find a record from the system that matches the given field values. The primary identifier and value is used
1077
+ to query for the record, then the secondary identifiers may be optionally provided to further filter the
1078
+ returned results. If no record is found with these filters, returns None.
1079
+ """
1080
+ # Query for all records that match the primary identifier.
1081
+ results: list[WrappedType] = self.query_models(wrapper_type, primary_identifier, [id_value])
1082
+
1083
+ # Find the one record, if any, that matches the secondary identifiers.
1084
+ unique_record: WrappedType | None = None
1085
+ for result in results:
1086
+ matches_all: bool = True
1087
+ for field, value in secondary_identifiers.items():
1088
+ if result.get_field_value(field) != value:
1089
+ matches_all = False
1090
+ break
1091
+ if matches_all:
1092
+ # If a previous record in the results already matched all identifiers, then throw an exception.
1093
+ if unique_record is not None:
1094
+ raise SapioException(f"More than one record of type {wrapper_type.get_wrapper_data_type_name()} "
1095
+ f"encountered in system that matches all provided identifiers.")
1096
+ unique_record = result
1097
+ return unique_record
1098
+
1099
+ @staticmethod
1100
+ def __verify_data_type(records: Iterable[DataRecord], wrapper_type: type[WrappedType]) -> None:
1101
+ """
1102
+ Throw an exception if the data type of the given records and wrapper don't match.
1103
+ """
1104
+ model_type: str = wrapper_type.get_wrapper_data_type_name()
1105
+ for record in records:
1106
+ record_type: str = record.data_type_name
1107
+ # Account for ELN data type records.
1108
+ if ElnBaseDataType.is_eln_type(record_type):
1109
+ record_type = ElnBaseDataType.get_base_type(record_type).data_type_name
1110
+ if record_type != model_type:
1111
+ raise SapioException(f"Data record of type {record_type} cannot be wrapped by the record model wrapper "
1112
+ f"of type {model_type}")