sapiopycommons 2025.7.2a578__py3-none-any.whl → 2025.7.7a580__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 (60) hide show
  1. sapiopycommons/callbacks/callback_util.py +665 -332
  2. sapiopycommons/callbacks/field_builder.py +2 -0
  3. sapiopycommons/chem/IndigoMolecules.py +31 -1
  4. sapiopycommons/chem/Molecules.py +3 -3
  5. sapiopycommons/chem/ps_commons.py +455 -0
  6. sapiopycommons/customreport/auto_pagers.py +26 -1
  7. sapiopycommons/customreport/term_builder.py +1 -1
  8. sapiopycommons/datatype/pseudo_data_types.py +349 -326
  9. sapiopycommons/eln/experiment_cache.py +188 -0
  10. sapiopycommons/eln/experiment_handler.py +408 -767
  11. sapiopycommons/eln/experiment_report_util.py +11 -6
  12. sapiopycommons/eln/experiment_step_factory.py +476 -0
  13. sapiopycommons/eln/plate_designer.py +7 -2
  14. sapiopycommons/eln/step_creation.py +236 -0
  15. sapiopycommons/files/file_util.py +7 -5
  16. sapiopycommons/general/accession_service.py +2 -2
  17. sapiopycommons/general/aliases.py +3 -1
  18. sapiopycommons/general/audit_log.py +7 -0
  19. sapiopycommons/general/custom_report_util.py +12 -0
  20. sapiopycommons/general/data_structure_util.py +115 -0
  21. sapiopycommons/processtracking/custom_workflow_handler.py +11 -1
  22. sapiopycommons/processtracking/endpoints.py +27 -0
  23. sapiopycommons/recordmodel/record_handler.py +657 -317
  24. sapiopycommons/rules/eln_rule_handler.py +8 -1
  25. sapiopycommons/rules/on_save_rule_handler.py +8 -1
  26. sapiopycommons/webhook/webhook_handlers.py +3 -0
  27. sapiopycommons/webhook/webservice_handlers.py +2 -2
  28. {sapiopycommons-2025.7.2a578.dist-info → sapiopycommons-2025.7.7a580.dist-info}/METADATA +2 -2
  29. sapiopycommons-2025.7.7a580.dist-info/RECORD +69 -0
  30. sapiopycommons/ai/__init__.py +0 -0
  31. sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2.py +0 -43
  32. sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2.pyi +0 -31
  33. sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2_grpc.py +0 -24
  34. sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2.py +0 -123
  35. sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2.pyi +0 -598
  36. sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2_grpc.py +0 -24
  37. sapiopycommons/ai/api/plan/proto/step_output_pb2.py +0 -45
  38. sapiopycommons/ai/api/plan/proto/step_output_pb2.pyi +0 -42
  39. sapiopycommons/ai/api/plan/proto/step_output_pb2_grpc.py +0 -24
  40. sapiopycommons/ai/api/plan/proto/step_pb2.py +0 -43
  41. sapiopycommons/ai/api/plan/proto/step_pb2.pyi +0 -43
  42. sapiopycommons/ai/api/plan/proto/step_pb2_grpc.py +0 -24
  43. sapiopycommons/ai/api/plan/script/proto/script_pb2.py +0 -53
  44. sapiopycommons/ai/api/plan/script/proto/script_pb2.pyi +0 -99
  45. sapiopycommons/ai/api/plan/script/proto/script_pb2_grpc.py +0 -153
  46. sapiopycommons/ai/api/plan/tool/proto/entry_pb2.py +0 -57
  47. sapiopycommons/ai/api/plan/tool/proto/entry_pb2.pyi +0 -96
  48. sapiopycommons/ai/api/plan/tool/proto/entry_pb2_grpc.py +0 -24
  49. sapiopycommons/ai/api/plan/tool/proto/tool_pb2.py +0 -67
  50. sapiopycommons/ai/api/plan/tool/proto/tool_pb2.pyi +0 -220
  51. sapiopycommons/ai/api/plan/tool/proto/tool_pb2_grpc.py +0 -154
  52. sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2.py +0 -39
  53. sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2.pyi +0 -32
  54. sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2_grpc.py +0 -24
  55. sapiopycommons/ai/protobuf_utils.py +0 -508
  56. sapiopycommons/ai/test_client.py +0 -251
  57. sapiopycommons/ai/tool_service_base.py +0 -798
  58. sapiopycommons-2025.7.2a578.dist-info/RECORD +0 -92
  59. {sapiopycommons-2025.7.2a578.dist-info → sapiopycommons-2025.7.7a580.dist-info}/WHEEL +0 -0
  60. {sapiopycommons-2025.7.2a578.dist-info → sapiopycommons-2025.7.7a580.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import io
3
4
  import warnings
4
5
  from collections.abc import Iterable
6
+ from typing import Collection
5
7
  from weakref import WeakValueDictionary
6
8
 
7
9
  from sapiopylib.rest.DataRecordManagerService import DataRecordManager
@@ -13,21 +15,31 @@ from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
13
15
  from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnBaseDataType
14
16
  from sapiopylib.rest.utils.autopaging import QueryDataRecordsAutoPager, QueryDataRecordByIdListAutoPager, \
15
17
  QueryAllRecordsOfTypeAutoPager
16
- from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
18
+ from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel, AbstractRecordModelPropertyGetter, \
19
+ RecordModelPropertyType, AbstractRecordModelPropertyAdder, AbstractRecordModelPropertySetter, \
20
+ AbstractRecordModelPropertyRemover
17
21
  from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelManager, RecordModelInstanceManager, \
18
22
  RecordModelRelationshipManager
19
23
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType, WrappedRecordModel
20
24
  from sapiopylib.rest.utils.recordmodel.RelationshipPath import RelationshipPath, RelationshipNode, \
21
25
  RelationshipNodeType
22
26
  from sapiopylib.rest.utils.recordmodel.ancestry import RecordModelAncestorManager
27
+ from sapiopylib.rest.utils.recordmodel.properties import Parents, Parent, Children, Child, ForwardSideLink
23
28
 
24
29
  from sapiopycommons.general.aliases import RecordModel, SapioRecord, FieldMap, FieldIdentifier, AliasUtil, \
25
- FieldIdentifierMap, FieldValue, UserIdentifier, FieldIdentifierKey
30
+ FieldIdentifierMap, FieldValue, UserIdentifier, FieldIdentifierKey, DataTypeIdentifier
26
31
  from sapiopycommons.general.custom_report_util import CustomReportUtil
27
32
  from sapiopycommons.general.exceptions import SapioException
28
33
 
34
+ # Aliases for longer name.
35
+ _PropertyGetter = AbstractRecordModelPropertyGetter
36
+ _PropertyAdder = AbstractRecordModelPropertyAdder
37
+ _PropertyRemover = AbstractRecordModelPropertyRemover
38
+ _PropertySetter = AbstractRecordModelPropertySetter
39
+ _PropertyType = RecordModelPropertyType
29
40
 
30
41
  # FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
42
+ # FR-47575 - Reordered functions so that the Java and Python versions are as close to each other as possible.
31
43
  class RecordHandler:
32
44
  """
33
45
  A collection of shorthand methods for dealing with the various record managers.
@@ -58,12 +70,11 @@ class RecordHandler:
58
70
  """
59
71
  :param context: The current webhook context or a user object to send requests from.
60
72
  """
61
- self.user = AliasUtil.to_sapio_user(context)
62
73
  if self.__initialized:
63
74
  return
64
75
  self.__initialized = True
65
76
 
66
- self.user = context if isinstance(context, SapioUser) else context.user
77
+ self.user = AliasUtil.to_sapio_user(context)
67
78
  self.dr_man = DataRecordManager(self.user)
68
79
  self.rec_man = RecordModelManager(self.user)
69
80
  self.inst_man = self.rec_man.instance_manager
@@ -103,9 +114,172 @@ class RecordHandler:
103
114
  """
104
115
  return [self.wrap_model(x, wrapper_type) for x in records]
105
116
 
117
+ def add_model(self, wrapper_type: type[WrappedType] | str) -> WrappedType | PyRecordModel:
118
+ """
119
+ Shorthand for using the instance manager to add a new record model of the given type.
120
+
121
+ :param wrapper_type: The record model wrapper to use, or the data type name of the record.
122
+ :return: The newly added record model. If a data type name was used instead of a model wrapper, then the
123
+ returned record will be a PyRecordModel instead of a WrappedRecordModel.
124
+ """
125
+ return self.add_models(wrapper_type, 1)[0]
126
+
127
+ def add_models(self, wrapper_type: type[WrappedType] | str, num: int) -> list[WrappedType] | list[PyRecordModel]:
128
+ """
129
+ Shorthand for using the instance manager to add new record models of the given type.
130
+
131
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
132
+ :param num: The number of models to create.
133
+ :return: The newly added record models. If a data type name was used instead of a model wrapper, then the
134
+ returned records will be PyRecordModels instead of WrappedRecordModels.
135
+ """
136
+ if isinstance(wrapper_type, str):
137
+ return self.inst_man.add_new_records(wrapper_type, num)
138
+ return self.inst_man.add_new_records_of_type(num, wrapper_type)
139
+
140
+ def add_models_with_data(self, wrapper_type: type[WrappedType] | str, fields: list[FieldIdentifierMap]) \
141
+ -> list[WrappedType] | list[PyRecordModel]:
142
+ """
143
+ Shorthand for using the instance manager to add new models of the given type, and then initializing all those
144
+ models with the given fields.
145
+
146
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
147
+ :param fields: A list of field maps to initialize the record models with.
148
+ :return: The newly added record models with the provided fields set. The records will be in the same order as
149
+ the fields in the fields list. If a data type name was used instead of a model wrapper, then the returned
150
+ records will be PyRecordModels instead of WrappedRecordModels.
151
+ """
152
+ fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(fields)
153
+ models: list[WrappedType] = self.add_models(wrapper_type, len(fields))
154
+ for model, field_list in zip(models, fields):
155
+ model.set_field_values(field_list)
156
+ return models
157
+
158
+ def find_or_add_model(self, wrapper_type: type[WrappedType] | str, primary_identifier: FieldIdentifier,
159
+ id_value: FieldValue, secondary_identifiers: FieldIdentifierMap | None = None) \
160
+ -> WrappedType | PyRecordModel:
161
+ """
162
+ Find a unique record that matches the given field values. If no such records exist, add a record model to the
163
+ cache with the identifying fields set to the desired values. This record will be created in the system when
164
+ you store and commit changes. If more than one record with the identifying values exists, throws an exception.
165
+
166
+ The record is searched for using the primary identifier field name and value. If multiple records are returned
167
+ by the query on this primary identifier, then the secondary identifiers are used to filter the results.
168
+
169
+ Makes a webservice call to query for the existing record.
170
+
171
+ :param wrapper_type: The record model wrapper to use, or the data type name of the record.
172
+ :param primary_identifier: The data field name of the field to search on.
173
+ :param id_value: The value of the identifying field to search for.
174
+ :param secondary_identifiers: Optional fields used to filter the records that are returned after searching on
175
+ the primary identifier.
176
+ :return: The record model with the identifying field value, either pulled from the system or newly created.
177
+ If a data type name was used instead of a model wrapper, then the returned record will be a PyRecordModel
178
+ instead of a WrappedRecordModel.
179
+ """
180
+ # PR-46335: Initialize the secondary identifiers parameter if None is provided to avoid an exception.
181
+ # If no secondary identifiers were provided, use an empty dictionary.
182
+ if secondary_identifiers is None:
183
+ secondary_identifiers = {}
184
+
185
+ primary_identifier: str = AliasUtil.to_data_field_name(primary_identifier)
186
+ secondary_identifiers: FieldMap = AliasUtil.to_data_field_names_dict(secondary_identifiers)
187
+ unique_record: WrappedType | None = self.__find_model(wrapper_type, primary_identifier, id_value,
188
+ secondary_identifiers)
189
+ # If a unique record matched the identifiers, return it.
190
+ if unique_record is not None:
191
+ return unique_record
192
+
193
+ # If none of the results matched the identifiers, create a new record with all identifiers set.
194
+ # Put the primary identifier and value into the secondary identifiers list and use that as the fields map
195
+ # for this new record.
196
+ secondary_identifiers.update({primary_identifier: id_value})
197
+ return self.add_models_with_data(wrapper_type, [secondary_identifiers])[0]
198
+
199
+ def create_models(self, wrapper_type: type[WrappedType] | str, num: int) -> list[WrappedType] | list[PyRecordModel]:
200
+ """
201
+ Shorthand for creating new records via the data record manager and then returning them as wrapped
202
+ record models. Useful in cases where your record model needs to have a valid record ID.
203
+
204
+ Makes a webservice call to create the data records.
205
+
206
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
207
+ :param num: The number of new records to create.
208
+ :return: The newly created record models. If a data type name was used instead of a model wrapper, then the
209
+ returned records will be PyRecordModels instead of WrappedRecordModels.
210
+ """
211
+ dt: str = AliasUtil.to_data_type_name(wrapper_type)
212
+ if isinstance(wrapper_type, str):
213
+ wrapper_type = None
214
+ return self.wrap_models(self.dr_man.add_data_records(dt, num), wrapper_type)
215
+
216
+ def create_models_with_data(self, wrapper_type: type[WrappedType] | str, fields: list[FieldIdentifierMap]) \
217
+ -> list[WrappedType] | list[PyRecordModel]:
218
+ """
219
+ Shorthand for creating new records via the data record manager with field data to initialize the records with
220
+ and then returning them as wrapped record models. Useful in cases where your record model needs to have a valid
221
+ record ID.
222
+
223
+ Makes a webservice call to create the data records.
224
+
225
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
226
+ :param fields: The field map list to initialize the new data records with.
227
+ :return: The newly created record models. If a data type name was used instead of a model wrapper, then the
228
+ returned records will be PyRecordModels instead of WrappedRecordModels.
229
+ """
230
+ dt: str = AliasUtil.to_data_type_name(wrapper_type)
231
+ if isinstance(wrapper_type, str):
232
+ wrapper_type = None
233
+ fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(fields)
234
+ return self.wrap_models(self.dr_man.add_data_records_with_data(dt, fields), wrapper_type)
235
+
236
+ def find_or_create_model(self, wrapper_type: type[WrappedType] | str, primary_identifier: FieldIdentifier,
237
+ id_value: FieldValue, secondary_identifiers: FieldIdentifierMap | None = None) \
238
+ -> WrappedType | PyRecordModel:
239
+ """
240
+ Find a unique record that matches the given field values. If no such records exist, create one with the
241
+ identifying fields set to the desired values. If more than one record with the identifying values exists,
242
+ throws an exception.
243
+
244
+ The record is searched for using the primary identifier field name and value. If multiple records are returned
245
+ by the query on this primary identifier, then the secondary identifiers are used to filter the results.
246
+
247
+ Makes a webservice call to query for the existing record. Makes an additional webservice call if the record
248
+ needs to be created.
249
+
250
+ :param wrapper_type: The record model wrapper to use, or the data type name of the record.
251
+ :param primary_identifier: The data field name of the field to search on.
252
+ :param id_value: The value of the identifying field to search for.
253
+ :param secondary_identifiers: Optional fields used to filter the records that are returned after searching on
254
+ the primary identifier.
255
+ :return: The record model with the identifying field value, either pulled from the system or newly created.
256
+ If a data type name was used instead of a model wrapper, then the returned record will be a PyRecordModel
257
+ instead of a WrappedRecordModel.
258
+ """
259
+ # PR-46335: Initialize the secondary identifiers parameter if None is provided to avoid an exception.
260
+ # If no secondary identifiers were provided, use an empty dictionary.
261
+ if secondary_identifiers is None:
262
+ secondary_identifiers = {}
263
+
264
+ primary_identifier: str = AliasUtil.to_data_field_name(primary_identifier)
265
+ secondary_identifiers: FieldMap = AliasUtil.to_data_field_names_dict(secondary_identifiers)
266
+ unique_record: WrappedType | None = self.__find_model(wrapper_type, primary_identifier, id_value,
267
+ secondary_identifiers)
268
+ # If a unique record matched the identifiers, return it.
269
+ if unique_record is not None:
270
+ return unique_record
271
+
272
+ # If none of the results matched the identifiers, create a new record with all identifiers set.
273
+ # Put the primary identifier and value into the secondary identifiers list and use that as the fields map
274
+ # for this new record.
275
+ secondary_identifiers.update({primary_identifier: id_value})
276
+ return self.create_models_with_data(wrapper_type, [secondary_identifiers])[0]
277
+
106
278
  # CR-47491: Support providing a data type name string to receive PyRecordModels instead of requiring a WrapperType.
279
+ # CR-47523: Support a singular field value being provided for the value_list parameter.
107
280
  def query_models(self, wrapper_type: type[WrappedType] | str, field: FieldIdentifier,
108
- value_list: Iterable[FieldValue], page_limit: int | None = None, page_size: int | None = None) \
281
+ value_list: Iterable[FieldValue] | FieldValue,
282
+ page_limit: int | None = None, page_size: int | None = None) \
109
283
  -> list[WrappedType] | list[PyRecordModel]:
110
284
  """
111
285
  Shorthand for using the data record manager to query for a list of data records by field value
@@ -113,7 +287,9 @@ class RecordHandler:
113
287
 
114
288
  :param wrapper_type: The record model wrapper to use, or the data type name of the records.
115
289
  :param field: The field to query on.
116
- :param value_list: The values of the field to query on.
290
+ :param value_list: The values of the field to query on, or a singular field value that will be automatically
291
+ converted to a singleton list. Note that field values of None are not supported by this method and will be
292
+ ignored. If you need to query for records with a null field value, use a custom report.
117
293
  :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
118
294
  only functions if you set a page size or the platform enforces a page size.
119
295
  :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
@@ -126,8 +302,10 @@ class RecordHandler:
126
302
  return self.query_models_with_criteria(wrapper_type, field, value_list, criteria, page_limit)[0]
127
303
 
128
304
  def query_and_map_models(self, wrapper_type: type[WrappedType] | str, field: FieldIdentifier,
129
- value_list: Iterable[FieldValue], page_limit: int | None = None,
130
- page_size: int | None = None, *, mapping_field: FieldIdentifier | None = None) \
305
+ value_list: Iterable[FieldValue] | FieldValue,
306
+ page_limit: int | None = None, page_size: int | None = None,
307
+ *,
308
+ mapping_field: FieldIdentifier | None = None) \
131
309
  -> dict[FieldValue, list[WrappedType] | list[PyRecordModel]]:
132
310
  """
133
311
  Shorthand for using query_models to search for records given values on a specific field and then using
@@ -135,7 +313,9 @@ class RecordHandler:
135
313
 
136
314
  :param wrapper_type: The record model wrapper to use, or the data type name of the records.
137
315
  :param field: The field to query and map on.
138
- :param value_list: The values of the field to query on.
316
+ :param value_list: The values of the field to query on, or a singular field value that will be automatically
317
+ converted to a singleton list. Note that field values of None are not supported by this method and will be
318
+ ignored. If you need to query for records with a null field value, use a custom report.
139
319
  :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
140
320
  only functions if you set a page size or the platform enforces a page size.
141
321
  :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
@@ -150,8 +330,10 @@ class RecordHandler:
150
330
  mapping_field)
151
331
 
152
332
  def query_and_unique_map_models(self, wrapper_type: type[WrappedType] | str, field: FieldIdentifier,
153
- value_list: Iterable[FieldValue], page_limit: int | None = None,
154
- page_size: int | None = None, *, mapping_field: FieldIdentifier | None = None) \
333
+ value_list: Iterable[FieldValue] | FieldValue,
334
+ page_limit: int | None = None, page_size: int | None = None,
335
+ *,
336
+ mapping_field: FieldIdentifier | None = None) \
155
337
  -> dict[FieldValue, WrappedType | PyRecordModel]:
156
338
  """
157
339
  Shorthand for using query_models to search for records given values on a specific field and then using
@@ -160,7 +342,9 @@ class RecordHandler:
160
342
 
161
343
  :param wrapper_type: The record model wrapper to use, or the data type name of the records.
162
344
  :param field: The field to query and map on.
163
- :param value_list: The values of the field to query on.
345
+ :param value_list: The values of the field to query on, or a singular field value that will be automatically
346
+ converted to a singleton list. Note that field values of None are not supported by this method and will be
347
+ ignored. If you need to query for records with a null field value, use a custom report.
164
348
  :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
165
349
  only functions if you set a page size or the platform enforces a page size.
166
350
  :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
@@ -175,7 +359,7 @@ class RecordHandler:
175
359
  mapping_field)
176
360
 
177
361
  def query_models_with_criteria(self, wrapper_type: type[WrappedType] | str, field: FieldIdentifier,
178
- value_list: Iterable[FieldValue],
362
+ value_list: Iterable[FieldValue] | FieldValue,
179
363
  paging_criteria: DataRecordPojoPageCriteria | None = None,
180
364
  page_limit: int | None = None) \
181
365
  -> tuple[list[WrappedType] | list[PyRecordModel], DataRecordPojoPageCriteria]:
@@ -185,7 +369,9 @@ class RecordHandler:
185
369
 
186
370
  :param wrapper_type: The record model wrapper to use, or the data type name of the records.
187
371
  :param field: The field to query on.
188
- :param value_list: The values of the field to query on.
372
+ :param value_list: The values of the field to query on, or a singular field value that will be automatically
373
+ converted to a singleton list. Note that field values of None are not supported by this method and will be
374
+ ignored. If you need to query for records with a null field value, use a custom report.
189
375
  :param paging_criteria: The paging criteria to start the query with.
190
376
  :param page_limit: The maximum number of pages to query from the starting criteria. If None, exhausts all
191
377
  possible pages. This parameter only functions if you set a page size in the paging criteria or the platform
@@ -197,6 +383,8 @@ class RecordHandler:
197
383
  if isinstance(wrapper_type, str):
198
384
  wrapper_type = None
199
385
  field: str = AliasUtil.to_data_field_name(field)
386
+ if isinstance(value_list, FieldValue):
387
+ value_list: list[FieldValue] = [value_list]
200
388
  pager = QueryDataRecordsAutoPager(dt, field, list(value_list), self.user, paging_criteria)
201
389
  pager.max_page = page_limit
202
390
  return self.wrap_models(pager.get_all_at_once(), wrapper_type), pager.next_page_criteria
@@ -357,171 +545,415 @@ class RecordHandler:
357
545
  raise SapioException("Unrecognized report object.")
358
546
 
359
547
  # Using the bracket accessor because we want to throw an exception if RecordId doesn't exist in the report.
360
- # This should only possibly be the case with system reports, as quick reports will include the record ID and
548
+ # This should only possibly be the case with system reports, as quick reports will include the record ID, and
361
549
  # we forced any given custom report to have a record ID column.
362
550
  ids: list[int] = [row["RecordId"] for row in results]
363
551
  return self.query_models_by_id(wrapper_type, ids)
364
552
 
365
- def add_model(self, wrapper_type: type[WrappedType] | str) -> WrappedType | PyRecordModel:
553
+ @staticmethod
554
+ def map_by_id(models: Iterable[SapioRecord]) -> dict[int, SapioRecord]:
366
555
  """
367
- Shorthand for using the instance manager to add a new record model of the given type.
556
+ Map the given records their record IDs.
368
557
 
369
- :param wrapper_type: The record model wrapper to use, or the data type name of the record.
370
- :return: The newly added record model. If a data type name was used instead of a model wrapper, then the
371
- returned record will be a PyRecordModel instead of a WrappedRecordModel.
558
+ :param models: The records to map.
559
+ :return: A dict mapping the record ID to each record.
372
560
  """
373
- return self.add_models(wrapper_type, 1)[0]
561
+ ret_dict: dict[int, SapioRecord] = {}
562
+ for model in models:
563
+ ret_dict.update({model.record_id: model})
564
+ return ret_dict
374
565
 
375
- def add_models(self, wrapper_type: type[WrappedType] | str, num: int) -> list[WrappedType] | list[PyRecordModel]:
566
+ @staticmethod
567
+ def map_by_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) \
568
+ -> dict[FieldValue, list[SapioRecord]]:
376
569
  """
377
- Shorthand for using the instance manager to add new record models of the given type.
570
+ Map the given records by one of their fields. If any two records share the same field value, they'll appear in
571
+ the same value list.
378
572
 
379
- :param wrapper_type: The record model wrapper to use, or the data type name of the records.
380
- :param num: The number of models to create.
381
- :return: The newly added record models. If a data type name was used instead of a model wrapper, then the
382
- returned records will be PyRecordModels instead of WrappedRecordModels.
573
+ :param models: The records to map.
574
+ :param field_name: The field name to map against.
575
+ :return: A dict mapping field values to the records with that value.
383
576
  """
384
- if isinstance(wrapper_type, str):
385
- return self.inst_man.add_new_records(wrapper_type, num)
386
- return self.inst_man.add_new_records_of_type(num, wrapper_type)
577
+ field_name: str = AliasUtil.to_data_field_name(field_name)
578
+ ret_dict: dict[FieldValue, list[SapioRecord]] = {}
579
+ for model in models:
580
+ val: FieldValue = model.get_field_value(field_name)
581
+ ret_dict.setdefault(val, []).append(model)
582
+ return ret_dict
387
583
 
388
- def add_models_with_data(self, wrapper_type: type[WrappedType] | str, fields: list[FieldIdentifierMap]) \
389
- -> list[WrappedType] | list[PyRecordModel]:
584
+ @staticmethod
585
+ def map_by_unique_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) \
586
+ -> dict[FieldValue, SapioRecord]:
390
587
  """
391
- Shorthand for using the instance manager to add new models of the given type, and then initializing all those
392
- models with the given fields.
588
+ Uniquely map the given records by one of their fields. If any two records share the same field value, throws
589
+ an exception.
393
590
 
394
- :param wrapper_type: The record model wrapper to use, or the data type name of the records.
395
- :param fields: A list of field maps to initialize the record models with.
396
- :return: The newly added record models with the provided fields set. The records will be in the same order as
397
- the fields in the fields list. If a data type name was used instead of a model wrapper, then the returned
398
- records will be PyRecordModels instead of WrappedRecordModels.
591
+ :param models: The records to map.
592
+ :param field_name: The field name to map against.
593
+ :return: A dict mapping field values to the record with that value.
399
594
  """
400
- fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(fields)
401
- models: list[WrappedType] = self.add_models(wrapper_type, len(fields))
402
- for model, field_list in zip(models, fields):
403
- model.set_field_values(field_list)
404
- return models
595
+ field_name: str = AliasUtil.to_data_field_name(field_name)
596
+ ret_dict: dict[FieldValue, SapioRecord] = {}
597
+ for model in models:
598
+ val: FieldValue = model.get_field_value(field_name)
599
+ if val in ret_dict:
600
+ raise SapioException(f"Value {val} encountered more than once in models list.")
601
+ ret_dict.update({val: model})
602
+ return ret_dict
405
603
 
406
- def find_or_add_model(self, wrapper_type: type[WrappedType] | str, primary_identifier: FieldIdentifier,
407
- id_value: FieldValue, secondary_identifiers: FieldIdentifierMap | None = None) \
408
- -> WrappedType | PyRecordModel:
604
+ # FR-47525: Add functions for getting and setting record image bytes.
605
+ def get_record_image(self, record: SapioRecord) -> bytes:
409
606
  """
410
- Find a unique record that matches the given field values. If no such records exist, add a record model to the
411
- cache with the identifying fields set to the desired values. This record will be created in the system when
412
- you store and commit changes. If more than one record with the identifying values exists, throws an exception.
607
+ Retrieve the record image for a given record.
413
608
 
414
- The record is searched for using the primary identifier field name and value. If multiple records are returned
415
- by the query on this primary identifier, then the secondary identifiers are used to filter the results.
609
+ :param record: The record model to retrieve the image of.
610
+ :return: The file bytes of the given record's image.
611
+ """
612
+ record: DataRecord = AliasUtil.to_data_record(record)
613
+ with io.BytesIO() as data_sink:
614
+ def consume_data(chunk: bytes):
615
+ data_sink.write(chunk)
416
616
 
417
- Makes a webservice call to query for the existing record.
617
+ self.dr_man.get_record_image(record, consume_data)
618
+ data_sink.flush()
619
+ data_sink.seek(0)
620
+ file_bytes = data_sink.read()
621
+ return file_bytes
418
622
 
419
- :param wrapper_type: The record model wrapper to use, or the data type name of the record.
420
- :param primary_identifier: The data field name of the field to search on.
421
- :param id_value: The value of the identifying field to search for.
422
- :param secondary_identifiers: Optional fields used to filter the records that are returned after searching on
423
- the primary identifier.
424
- :return: The record model with the identifying field value, either pulled from the system or newly created.
425
- If a data type name was used instead of a model wrapper, then the returned record will be a PyRecordModel
426
- instead of a WrappedRecordModel.
623
+ def set_record_image(self, record: SapioRecord, file_data: str | bytes) -> None:
427
624
  """
428
- # PR-46335: Initialize the secondary identifiers parameter if None is provided to avoid an exception.
429
- # If no secondary identifiers were provided, use an empty dictionary.
430
- if secondary_identifiers is None:
431
- secondary_identifiers = {}
625
+ Set the record image for a given record.
432
626
 
433
- primary_identifier: str = AliasUtil.to_data_field_name(primary_identifier)
434
- secondary_identifiers: FieldMap = AliasUtil.to_data_field_names_dict(secondary_identifiers)
435
- unique_record: WrappedType | None = self.__find_model(wrapper_type, primary_identifier, id_value,
436
- secondary_identifiers)
437
- # If a unique record matched the identifiers, return it.
438
- if unique_record is not None:
439
- return unique_record
627
+ :param record: The record model to set the image of.
628
+ :param file_data: The file data of the image to set on the record.
629
+ """
630
+ record: DataRecord = AliasUtil.to_data_record(record)
631
+ with io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data) as stream:
632
+ self.dr_man.set_record_image(record, stream)
440
633
 
441
- # If none of the results matched the identifiers, create a new record with all identifiers set.
442
- # Put the primary identifier and value into the secondary identifiers list and use that as the fields map
443
- # for this new record.
444
- secondary_identifiers.update({primary_identifier: id_value})
445
- return self.add_models_with_data(wrapper_type, [secondary_identifiers])[0]
634
+ @staticmethod
635
+ def sum_of_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) -> float:
636
+ """
637
+ Sum up the numeric value of a given field across all input models. Excepts that all given models have a value.
638
+ If the field is an integer field, the value will be converted to a float.
446
639
 
447
- def create_models(self, wrapper_type: type[WrappedType] | str, num: int) -> list[WrappedType] | list[PyRecordModel]:
640
+ :param models: The models to calculate the sum of.
641
+ :param field_name: The name of the numeric field to sum.
642
+ :return: The sum of the field values for the collection of models.
448
643
  """
449
- Shorthand for creating new records via the data record manager and then returning them as wrapped
450
- record models. Useful in cases where your record model needs to have a valid record ID.
644
+ field_name: str = AliasUtil.to_data_field_name(field_name)
645
+ field_sum: float = 0
646
+ for model in models:
647
+ val = model.get_field_value(field_name)
648
+ if isinstance(val, (int, float)):
649
+ field_sum += float(model.get_field_value(field_name))
650
+ return field_sum
451
651
 
452
- Makes a webservice call to create the data records.
652
+ @staticmethod
653
+ def mean_of_field(models: Collection[SapioRecord], field_name: FieldIdentifier) -> float:
654
+ """
655
+ Calculate the average (arithmetic mean) of the numeric value of a given field across all input models. Excepts
656
+ that all given models have a value. If the field is an integer field, the value will be converted to a float.
453
657
 
454
- :param wrapper_type: The record model wrapper to use, or the data type name of the records.
455
- :param num: The number of new records to create.
456
- :return: The newly created record models. If a data type name was used instead of a model wrapper, then the
457
- returned records will be PyRecordModels instead of WrappedRecordModels.
658
+ :param models: The models to calculate the mean of.
659
+ :param field_name: The name of the numeric field to mean.
660
+ :return: The mean of the field values for the collection of models.
458
661
  """
459
- dt: str = AliasUtil.to_data_type_name(wrapper_type)
460
- if isinstance(wrapper_type, str):
461
- wrapper_type = None
462
- return self.wrap_models(self.dr_man.add_data_records(dt, num), wrapper_type)
662
+ return RecordHandler.sum_of_field(models, field_name) / len(models)
463
663
 
464
- def create_models_with_data(self, wrapper_type: type[WrappedType] | str, fields: list[FieldIdentifierMap]) \
465
- -> list[WrappedType] | list[PyRecordModel]:
664
+ @staticmethod
665
+ def get_newest_record(records: Iterable[SapioRecord]) -> SapioRecord:
466
666
  """
467
- Shorthand for creating new records via the data record manager with field data to initialize the records with
468
- and then returning them as wrapped record models. Useful in cases where your record model needs to have a valid
469
- record ID.
667
+ Get the newest record from a list of records.
470
668
 
471
- Makes a webservice call to create the data records.
669
+ :param records: The list of records.
670
+ :return: The input record with the highest record ID. None if the input list is empty.
671
+ """
672
+ return max(records, key=lambda x: x.record_id)
472
673
 
473
- :param wrapper_type: The record model wrapper to use, or the data type name of the records.
474
- :param fields: The field map list to initialize the new data records with.
475
- :return: The newly created record models. If a data type name was used instead of a model wrapper, then the
476
- returned records will be PyRecordModels instead of WrappedRecordModels.
674
+ # FR-46696: Add a function for getting the oldest record in a list, just like we have one for the newest record.
675
+ @staticmethod
676
+ def get_oldest_record(records: Iterable[SapioRecord]) -> SapioRecord:
477
677
  """
478
- dt: str = AliasUtil.to_data_type_name(wrapper_type)
479
- if isinstance(wrapper_type, str):
480
- wrapper_type = None
481
- fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(fields)
482
- return self.wrap_models(self.dr_man.add_data_records_with_data(dt, fields), wrapper_type)
678
+ Get the oldest record from a list of records.
483
679
 
484
- def find_or_create_model(self, wrapper_type: type[WrappedType] | str, primary_identifier: FieldIdentifier,
485
- id_value: FieldValue, secondary_identifiers: FieldIdentifierMap | None = None) \
486
- -> WrappedType | PyRecordModel:
680
+ :param records: The list of records.
681
+ :return: The input record with the lowest record ID. None if the input list is empty.
487
682
  """
488
- Find a unique record that matches the given field values. If no such records exist, create one with the
489
- identifying fields set to the desired values. If more than one record with the identifying values exists,
490
- throws an exception.
683
+ return min(records, key=lambda x: x.record_id)
491
684
 
492
- The record is searched for using the primary identifier field name and value. If multiple records are returned
493
- by the query on this primary identifier, then the secondary identifiers are used to filter the results.
685
+ @staticmethod
686
+ def get_min_record(records: list[RecordModel], field: FieldIdentifier) -> RecordModel:
687
+ """
688
+ Get the record model with the minimum value of a given field from a list of record models.
689
+
690
+ :param records: The list of record models to search through.
691
+ :param field: The field to find the minimum value of.
692
+ :return: The record model with the minimum value of the given field.
693
+ """
694
+ field: str = AliasUtil.to_data_field_name(field)
695
+ return min(records, key=lambda x: x.get_field_value(field))
696
+
697
+ @staticmethod
698
+ def get_max_record(records: list[RecordModel], field: FieldIdentifier) -> RecordModel:
699
+ """
700
+ Get the record model with the maximum value of a given field from a list of record models.
701
+
702
+ :param records: The list of record models to search through.
703
+ :param field: The field to find the maximum value of.
704
+ :return: The record model with the maximum value of the given field.
705
+ """
706
+ field: str = AliasUtil.to_data_field_name(field)
707
+ return max(records, key=lambda x: x.get_field_value(field))
708
+
709
+ # FR-47522: Add RecordHandler functions that copy from the RecordModelUtil class in our Java utilities.
710
+ @staticmethod
711
+ def get_values_list(records: list[RecordModel], field: FieldIdentifier) -> list[FieldValue]:
712
+ """
713
+ Get a list of field values from a list of record models.
714
+
715
+ :param records: The list of record models to get the field values from.
716
+ :param field: The field to get the values of.
717
+ :return: A list of field values from the input record models. The values are in the same order as the input
718
+ record models.
719
+ """
720
+ field: str = AliasUtil.to_data_field_name(field)
721
+ return [x.get_field_value(field) for x in records]
722
+
723
+ @staticmethod
724
+ def get_values_set(records: list[RecordModel], field: FieldIdentifier) -> set[FieldValue]:
725
+ """
726
+ Get a set of field values from a list of record models.
727
+
728
+ :param records: The list of record models to get the field values from.
729
+ :param field: The field to get the values of.
730
+ :return: A set of field values from the input record models.
731
+ """
732
+ field: str = AliasUtil.to_data_field_name(field)
733
+ return {x.get_field_value(field) for x in records}
734
+
735
+ @staticmethod
736
+ def set_values(records: list[RecordModel], field: FieldIdentifier, value: FieldValue) -> None:
737
+ """
738
+ Set the value of a field on a list of record models.
739
+
740
+ :param records: The list of record models to set the field value on.
741
+ :param field: The field to set the value of.
742
+ :param value: The value to set the field to for all input records.
743
+ """
744
+ field: str = AliasUtil.to_data_field_name(field)
745
+ for record in records:
746
+ record.set_field_value(field, value)
747
+
748
+ @staticmethod
749
+ def values_to_field_maps(field_name: FieldIdentifier, values: Iterable[FieldValue],
750
+ existing_fields: list[FieldMap] | None = None) -> list[FieldMap]:
751
+ """
752
+ Add a list of values for a specific field to a list of dictionaries pairing each value to that field name.
753
+
754
+ :param field_name: The name of the field that the values are from.
755
+ :param values: A list of field values.
756
+ :param existing_fields: An optional existing fields map list to add the new values to. Values are added in the
757
+ list in the same order that they appear. If no existing fields are provided, returns a new fields map list.
758
+ :return: A fields map list that contains the given values mapped by the given field name.
759
+ """
760
+ # Update the existing fields map list if one is given.
761
+ field_name: str = AliasUtil.to_data_field_name(field_name)
762
+ existing_fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(existing_fields)
763
+ if existing_fields:
764
+ values = list(values)
765
+ # The number of new values must match the length of the existing fields list.
766
+ if len(values) != len(existing_fields):
767
+ raise SapioException(f"Length of \"{field_name}\" values does not match the existing fields length.")
768
+ for field, value in zip(existing_fields, values):
769
+ field.update({field_name: value})
770
+ return existing_fields
771
+ # Otherwise, create a new fields map list.
772
+ return [{field_name: value} for value in values]
773
+
774
+ @staticmethod
775
+ def get_from_all(records: Iterable[RecordModel], getter: _PropertyGetter[_PropertyType]) \
776
+ -> list[RecordModelPropertyType]:
777
+ """
778
+ Use a getter property on all records in a list of record models. For example, you can iterate over a list of
779
+ record models using a getter of Ancestors.of_type(SampleModel) to get all the SampleModel ancestors from each
780
+ record.
781
+
782
+ :param records: The list of record models to get the property from.
783
+ :param getter: The getter to use to get the property from each record.
784
+ :return: A list of the property values from the input record models. The value at the matching index of the
785
+ input records is the results of using the getter on that record.
786
+ """
787
+ return [x.get(getter) for x in records]
788
+
789
+ @staticmethod
790
+ def set_on_all(records: Iterable[RecordModel], setter: _PropertySetter[_PropertyType]) \
791
+ -> list[RecordModelPropertyType]:
792
+ """
793
+ Use a setter property on all records in a list of record models. For example, you can iterate over a list of
794
+ record models user a setter of ForwardSideLink.ref(field_name, record) to set a forward side link on each
795
+ record.
796
+
797
+ :param records: The list of record models to set the property on.
798
+ :param setter: The setter to use to set the property on each record.
799
+ :return: A list of the property values that were set on the input record models. The value at the matching index
800
+ of the input records is the results of using the setter on that record.
801
+ """
802
+ return [x.set(setter) for x in records]
803
+
804
+ @staticmethod
805
+ def add_to_all(records: Iterable[RecordModel], adder: _PropertyAdder[_PropertyType]) \
806
+ -> list[RecordModelPropertyType]:
807
+ """
808
+ Use an adder property on all records in a list of record models. For example, you can iterate over a list of
809
+ record models using an adder of Child.create(SampleModel) to create a new SampleModel child on each record.
810
+
811
+ :param records: The list of record models to add the property to.
812
+ :param adder: The adder to use to add the property to each record.
813
+ :return: A list of the property values that were added to the input record models. The value at the matching
814
+ index of the input records is the results of using the adder on that record.
815
+ """
816
+ return [x.add(adder) for x in records]
817
+
818
+ @staticmethod
819
+ def remove_from_all(records: Iterable[RecordModel], remover: _PropertyRemover[_PropertyType]) \
820
+ -> list[RecordModelPropertyType]:
821
+ """
822
+ Use a remover property on all records in a list of record models. For example, you can iterate over a list of
823
+ record models using a remover of Parents.ref(records) to remove a list of parents from each record.
824
+
825
+ :param records: The list of record models to remove the property from.
826
+ :param remover: The remover to use to remove the property from each record.
827
+ :return: A list of the property values that were removed from the input record models. The value at the matching
828
+ index of the input records is the results of using the remover on that record.
829
+ """
830
+ return [x.remove(remover) for x in records]
831
+
832
+ # FR-47527: Created functions for manipulating relationships between records,
833
+ def get_extension(self, model: RecordModel, wrapper_type: type[WrappedType] | str) \
834
+ -> WrappedType | PyRecordModel | None:
835
+ """
836
+ Given a record with an extension record related to it, return the extension record as a record model.
837
+ This will retrieve an extension record without doing a webservice request to the server. The input record and
838
+ extension record will be considered related to one another if you later use load_child or load_parent on the
839
+ input record or extension record respectively.
840
+
841
+ :param model: The record model to get the extension for.
842
+ :param wrapper_type: The record model wrapper to use, or the data type name of the extension record. If a data
843
+ type name is provided, the returned record will be a PyRecordModel instead of a WrappedRecordModel.
844
+ :return: The extension record model for the input record model, or None if no extension record exists.
845
+ """
846
+ ext_dt: str = AliasUtil.to_data_type_name(wrapper_type)
847
+ ext_fields: FieldMap = {}
848
+ for field, value in AliasUtil.to_field_map(model).items():
849
+ if field.startswith(ext_dt + "."):
850
+ ext_fields[field.removeprefix(ext_dt + ".")] = value
851
+ if not ext_fields or ext_fields.get("RecordId") is None:
852
+ return None
853
+ ext_rec: DataRecord = DataRecord(ext_dt, ext_fields.get("RecordId"), ext_fields)
854
+ ext_model: WrappedType | PyRecordModel = self.wrap_model(ext_rec, wrapper_type)
855
+ self._spoof_child_load(model, ext_model)
856
+ self._spoof_parent_load(ext_model, model)
857
+ return ext_model
858
+
859
+ def get_or_add_parent(self, record: RecordModel, parent_type: type[WrappedType] | str) \
860
+ -> WrappedType | PyRecordModel:
861
+ """
862
+ Given a record model, retrieve the singular parent record model of a given type. If a parent of the given type
863
+ does not exist, a new one will be created. The parents of the given data type must already be loaded.
864
+
865
+ :param record: The record model to get the parent of.
866
+ :param parent_type: The record model wrapper of the parent, or the data type name of the parent. If a data type
867
+ name is provided, the returned record will be a PyRecordModel instead of a WrappedRecordModel.
868
+ :return: The parent record model of the given type.
869
+ """
870
+ parent_dt: str = AliasUtil.to_data_type_name(parent_type)
871
+ wrapper: type[WrappedType] | None = parent_type if isinstance(parent_type, type) else None
872
+ record: PyRecordModel = RecordModelInstanceManager.unwrap(record)
873
+ parent: PyRecordModel | None = record.get_parent_of_type(parent_dt)
874
+ if parent is not None:
875
+ return self.wrap_model(parent, wrapper) if wrapper else parent
876
+ return record.add(Parent.create(wrapper)) if wrapper else record.add(Parent.create_by_name(parent_dt))
877
+
878
+ def get_or_add_child(self, record: RecordModel, child_type: type[WrappedType] | str) -> WrappedType | PyRecordModel:
879
+ """
880
+ Given a record model, retrieve the singular child record model of a given type. If a child of the given type
881
+ does not exist, a new one will be created. The children of the given data type must already be loaded.
882
+
883
+ :param record: The record model to get the child of.
884
+ :param child_type: The record model wrapper of the child, or the data type name of the child. If a data type
885
+ name is provided, the returned record will be a PyRecordModel instead of a WrappedRecordModel.
886
+ :return: The child record model of the given type.
887
+ """
888
+ child_dt: str = AliasUtil.to_data_type_name(child_type)
889
+ wrapper: type[WrappedType] | None = child_type if isinstance(child_type, type) else None
890
+ record: PyRecordModel = RecordModelInstanceManager.unwrap(record)
891
+ child: PyRecordModel | None = record.get_child_of_type(child_dt)
892
+ if child is not None:
893
+ return self.wrap_model(child, wrapper) if wrapper else child
894
+ return record.add(Child.create(wrapper)) if wrapper else record.add(Child.create_by_name(child_dt))
895
+
896
+ def get_or_add_side_link(self, record: RecordModel, side_link_field: FieldIdentifier,
897
+ side_link_type: type[WrappedType] | str) -> WrappedType | PyRecordModel:
898
+ """
899
+ Given a record model, retrieve the singular side link record model of a given type. If a side link of the given
900
+ type does not exist, a new one will be created. The side links of the given data type must already be loaded.
901
+
902
+ :param record: The record model to get the side link of.
903
+ :param side_link_field: The field name of the side link to get.
904
+ :param side_link_type: The record model wrapper of the side link, or the data type name of the side link. If a
905
+ data type name is provided, the returned record will be a PyRecordModel instead of a WrappedRecordModel.
906
+ :return: The side link record model of the given type.
907
+ """
908
+ side_link_field: str = AliasUtil.to_data_field_name(side_link_field)
909
+ wrapper: type[WrappedType] | None = side_link_type if isinstance(side_link_type, type) else None
910
+ record: PyRecordModel = RecordModelInstanceManager.unwrap(record)
911
+ side_link: PyRecordModel | None = record.get_forward_side_link(side_link_field)
912
+ if side_link is not None:
913
+ return self.wrap_model(side_link, wrapper) if wrapper else side_link
914
+ side_link: WrappedType | PyRecordModel = self.add_model(side_link_type)
915
+ record.set(ForwardSideLink.ref(side_link_field, side_link))
916
+ return side_link
494
917
 
495
- Makes a webservice call to query for the existing record. Makes an additional webservice call if the record
496
- needs to be created.
918
+ @staticmethod
919
+ def set_parents(record: RecordModel, parents: Iterable[RecordModel], parent_type: DataTypeIdentifier) -> None:
920
+ """
921
+ Set the parents of a record model to a list of parent record models of a given type. The parents of the given
922
+ data type must already be loaded. This method will add the parents to the record model if they are not already
923
+ parents, and remove any existing parents that are not in the input list.
497
924
 
498
- :param wrapper_type: The record model wrapper to use, or the data type name of the record.
499
- :param primary_identifier: The data field name of the field to search on.
500
- :param id_value: The value of the identifying field to search for.
501
- :param secondary_identifiers: Optional fields used to filter the records that are returned after searching on
502
- the primary identifier.
503
- :return: The record model with the identifying field value, either pulled from the system or newly created.
504
- If a data type name was used instead of a model wrapper, then the returned record will be a PyRecordModel
505
- instead of a WrappedRecordModel.
925
+ :param record: The record model to set the parents of.
926
+ :param parents: The list of parent record models to set as the parents of the input record model.
927
+ :param parent_type: The data type identifier of the parent record models.
506
928
  """
507
- # PR-46335: Initialize the secondary identifiers parameter if None is provided to avoid an exception.
508
- # If no secondary identifiers were provided, use an empty dictionary.
509
- if secondary_identifiers is None:
510
- secondary_identifiers = {}
929
+ parent_dt: str = AliasUtil.to_data_type_name(parent_type)
930
+ existing_parents: list[PyRecordModel] = record.get(Parents.of_type_name(parent_dt))
931
+ for parent in parents:
932
+ if parent not in existing_parents:
933
+ record.add(Parent.ref(parent))
934
+ for parent in existing_parents:
935
+ if parent not in parents:
936
+ record.remove(Parent.ref(parent))
511
937
 
512
- primary_identifier: str = AliasUtil.to_data_field_name(primary_identifier)
513
- secondary_identifiers: FieldMap = AliasUtil.to_data_field_names_dict(secondary_identifiers)
514
- unique_record: WrappedType | None = self.__find_model(wrapper_type, primary_identifier, id_value,
515
- secondary_identifiers)
516
- # If a unique record matched the identifiers, return it.
517
- if unique_record is not None:
518
- return unique_record
938
+ @staticmethod
939
+ def set_children(record: RecordModel, children: Iterable[RecordModel], child_type: DataTypeIdentifier) -> None:
940
+ """
941
+ Set the children of a record model to a list of child record models of a given type. The children of the given
942
+ data type must already be loaded. This method will add the children to the record model if they are not already
943
+ children, and remove any existing children that are not in the input list.
519
944
 
520
- # If none of the results matched the identifiers, create a new record with all identifiers set.
521
- # Put the primary identifier and value into the secondary identifiers list and use that as the fields map
522
- # for this new record.
523
- secondary_identifiers.update({primary_identifier: id_value})
524
- return self.create_models_with_data(wrapper_type, [secondary_identifiers])[0]
945
+ :param record: The record model to set the children of.
946
+ :param children: The list of child record models to set as the children of the input record model.
947
+ :param child_type: The data type identifier of the child record models.
948
+ """
949
+ child_dt: str = AliasUtil.to_data_type_name(child_type)
950
+ existing_children: list[PyRecordModel] = record.get(Children.of_type_name(child_dt))
951
+ for child in children:
952
+ if child not in existing_children:
953
+ record.add(Child.ref(child))
954
+ for child in existing_children:
955
+ if child not in children:
956
+ record.remove(Child.ref(child))
525
957
 
526
958
  @staticmethod
527
959
  def map_to_parent(models: Iterable[WrappedRecordModel], parent_type: type[WrappedType])\
@@ -690,76 +1122,56 @@ class RecordHandler:
690
1122
  return return_dict
691
1123
 
692
1124
  @staticmethod
693
- def map_by_forward_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
694
- side_link_type: type[WrappedType]) -> dict[WrappedType, list[WrappedRecordModel]]:
1125
+ def map_by_forward_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
1126
+ side_link_type: type[WrappedType]) -> dict[WrappedType, WrappedRecordModel]:
695
1127
  """
696
1128
  Take a list of record models and map them by their forward side link. Essentially an inversion of
697
- map_to_forward_side_link. Input models that share a forward side link will end up in the same list.
1129
+ map_to_forward_side_link, but if two records share the same forward link, an exception is thrown.
698
1130
  The forward side link must already be loaded.
699
1131
 
700
1132
  :param models: A list of record models.
701
1133
  :param field_name: The field name on the record models where the side link is located.
702
1134
  :param side_link_type: The record model wrapper of the forward side links.
703
- :return: A dict[SideLink, list[ModelType]]. If an input model doesn't have a forward side link of the given type
1135
+ :return: A dict[SideLink, ModelType]. If an input model doesn't have a forward side link of the given type
704
1136
  pointing to it, then it will not be in the resulting dictionary.
705
1137
  """
706
1138
  field_name: str = AliasUtil.to_data_field_name(field_name)
707
1139
  to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
708
1140
  .map_to_forward_side_link(models, field_name, side_link_type)
709
- by_side_link: dict[WrappedType, list[WrappedRecordModel]] = {}
1141
+ by_side_link: dict[WrappedType, WrappedRecordModel] = {}
710
1142
  for record, side_link in to_side_link.items():
711
1143
  if side_link is None:
712
1144
  continue
713
- by_side_link.setdefault(side_link, []).append(record)
1145
+ if side_link in by_side_link:
1146
+ raise SapioException(f"Side link {side_link.data_type_name} {side_link.record_id} encountered more "
1147
+ f"than once in models list.")
1148
+ by_side_link[side_link] = record
714
1149
  return by_side_link
715
1150
 
716
1151
  @staticmethod
717
- def map_by_forward_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
718
- side_link_type: type[WrappedType]) -> dict[WrappedType, WrappedRecordModel]:
1152
+ def map_by_forward_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
1153
+ side_link_type: type[WrappedType]) -> dict[WrappedType, list[WrappedRecordModel]]:
719
1154
  """
720
1155
  Take a list of record models and map them by their forward side link. Essentially an inversion of
721
- map_to_forward_side_link, but if two records share the same forward link, an exception is thrown.
1156
+ map_to_forward_side_link. Input models that share a forward side link will end up in the same list.
722
1157
  The forward side link must already be loaded.
723
1158
 
724
1159
  :param models: A list of record models.
725
1160
  :param field_name: The field name on the record models where the side link is located.
726
1161
  :param side_link_type: The record model wrapper of the forward side links.
727
- :return: A dict[SideLink, ModelType]. If an input model doesn't have a forward side link of the given type
1162
+ :return: A dict[SideLink, list[ModelType]]. If an input model doesn't have a forward side link of the given type
728
1163
  pointing to it, then it will not be in the resulting dictionary.
729
1164
  """
730
1165
  field_name: str = AliasUtil.to_data_field_name(field_name)
731
1166
  to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
732
1167
  .map_to_forward_side_link(models, field_name, side_link_type)
733
- by_side_link: dict[WrappedType, WrappedRecordModel] = {}
1168
+ by_side_link: dict[WrappedType, list[WrappedRecordModel]] = {}
734
1169
  for record, side_link in to_side_link.items():
735
1170
  if side_link is None:
736
1171
  continue
737
- if side_link in by_side_link:
738
- raise SapioException(f"Side link {side_link.data_type_name} {side_link.record_id} encountered more "
739
- f"than once in models list.")
740
- by_side_link[side_link] = record
1172
+ by_side_link.setdefault(side_link, []).append(record)
741
1173
  return by_side_link
742
1174
 
743
- @staticmethod
744
- def map_to_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
745
- side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, list[WrappedType]]:
746
- """
747
- Map a list of record models to a list reverse side links of a given type. The reverse side links must already
748
- be loaded.
749
-
750
- :param models: A list of record models.
751
- :param field_name: The field name on the side linked model where the side link to the given record models is
752
- located.
753
- :param side_link_type: The record model wrapper of the reverse side links.
754
- :return: A dict[ModelType, list[SideLink]]. If an input model doesn't have reverse side links of the given type,
755
- then it will map to an empty list.
756
- """
757
- field_name: str = AliasUtil.to_data_field_name(field_name)
758
- return_dict: dict[WrappedRecordModel, list[WrappedType]] = {}
759
- for model in models:
760
- return_dict[model] = model.get_reverse_side_link(field_name, side_link_type)
761
- return return_dict
762
-
763
1175
  @staticmethod
764
1176
  def map_to_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
765
1177
  side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, WrappedType]:
@@ -785,28 +1197,24 @@ class RecordHandler:
785
1197
  return return_dict
786
1198
 
787
1199
  @staticmethod
788
- def map_by_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
789
- side_link_type: type[WrappedType]) -> dict[WrappedType, list[WrappedRecordModel]]:
1200
+ def map_to_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
1201
+ side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, list[WrappedType]]:
790
1202
  """
791
- Take a list of record models and map them by their reverse side links. Essentially an inversion of
792
- map_to_reverse_side_links. Input models that share a reverse side link will end up in the same list.
793
- The reverse side links must already be loaded.
1203
+ Map a list of record models to a list reverse side links of a given type. The reverse side links must already
1204
+ be loaded.
794
1205
 
795
1206
  :param models: A list of record models.
796
1207
  :param field_name: The field name on the side linked model where the side link to the given record models is
797
1208
  located.
798
1209
  :param side_link_type: The record model wrapper of the reverse side links.
799
- :return: A dict[SideLink, list[ModelType]]. If an input model doesn't have reverse side links of the given type
800
- pointing to it, then it will not be in the resulting dictionary.
1210
+ :return: A dict[ModelType, list[SideLink]]. If an input model doesn't have reverse side links of the given type,
1211
+ then it will map to an empty list.
801
1212
  """
802
1213
  field_name: str = AliasUtil.to_data_field_name(field_name)
803
- to_side_links: dict[WrappedRecordModel, list[WrappedType]] = RecordHandler\
804
- .map_to_reverse_side_links(models, field_name, side_link_type)
805
- by_side_links: dict[WrappedType, list[WrappedRecordModel]] = {}
806
- for record, side_links in to_side_links.items():
807
- for side_link in side_links:
808
- by_side_links.setdefault(side_link, []).append(record)
809
- return by_side_links
1214
+ return_dict: dict[WrappedRecordModel, list[WrappedType]] = {}
1215
+ for model in models:
1216
+ return_dict[model] = model.get_reverse_side_link(field_name, side_link_type)
1217
+ return return_dict
810
1218
 
811
1219
  @staticmethod
812
1220
  def map_by_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
@@ -837,138 +1245,28 @@ class RecordHandler:
837
1245
  return by_side_link
838
1246
 
839
1247
  @staticmethod
840
- def map_by_id(models: Iterable[SapioRecord]) -> dict[int, SapioRecord]:
841
- """
842
- Map the given records their record IDs.
843
-
844
- :param models: The records to map.
845
- :return: A dict mapping the record ID to each record.
846
- """
847
- ret_dict: dict[int, SapioRecord] = {}
848
- for model in models:
849
- ret_dict.update({model.record_id: model})
850
- return ret_dict
851
-
852
- @staticmethod
853
- def map_by_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) \
854
- -> dict[FieldValue, list[SapioRecord]]:
855
- """
856
- Map the given records by one of their fields. If any two records share the same field value, they'll appear in
857
- the same value list.
858
-
859
- :param models: The records to map.
860
- :param field_name: The field name to map against.
861
- :return: A dict mapping field values to the records with that value.
862
- """
863
- field_name: str = AliasUtil.to_data_field_name(field_name)
864
- ret_dict: dict[FieldValue, list[SapioRecord]] = {}
865
- for model in models:
866
- val: FieldValue = model.get_field_value(field_name)
867
- ret_dict.setdefault(val, []).append(model)
868
- return ret_dict
869
-
870
- @staticmethod
871
- def map_by_unique_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) \
872
- -> dict[FieldValue, SapioRecord]:
873
- """
874
- Uniquely map the given records by one of their fields. If any two records share the same field value, throws
875
- an exception.
876
-
877
- :param models: The records to map.
878
- :param field_name: The field name to map against.
879
- :return: A dict mapping field values to the record with that value.
880
- """
881
- field_name: str = AliasUtil.to_data_field_name(field_name)
882
- ret_dict: dict[FieldValue, SapioRecord] = {}
883
- for model in models:
884
- val: FieldValue = model.get_field_value(field_name)
885
- if val in ret_dict:
886
- raise SapioException(f"Value {val} encountered more than once in models list.")
887
- ret_dict.update({val: model})
888
- return ret_dict
889
-
890
- @staticmethod
891
- def sum_of_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) -> float:
892
- """
893
- Sum up the numeric value of a given field across all input models. Excepts that all given models have a value.
894
- If the field is an integer field, the value will be converted to a float.
895
-
896
- :param models: The models to calculate the sum of.
897
- :param field_name: The name of the numeric field to sum.
898
- :return: The sum of the field values for the collection of models.
899
- """
900
- field_name: str = AliasUtil.to_data_field_name(field_name)
901
- field_sum: float = 0
902
- for model in models:
903
- field_sum += float(model.get_field_value(field_name))
904
- return field_sum
905
-
906
- @staticmethod
907
- def mean_of_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) -> float:
908
- """
909
- Calculate the mean of the numeric value of a given field across all input models. Excepts that all given models
910
- have a value. If the field is an integer field, the value will be converted to a float.
911
-
912
- :param models: The models to calculate the mean of.
913
- :param field_name: The name of the numeric field to mean.
914
- :return: The mean of the field values for the collection of models.
915
- """
916
- return RecordHandler.sum_of_field(models, field_name) / len(list(models))
917
-
918
- @staticmethod
919
- def get_newest_record(records: Iterable[SapioRecord]) -> SapioRecord:
920
- """
921
- Get the newest record from a list of records.
922
-
923
- :param records: The list of records.
924
- :return: The input record with the highest record ID. None if the input list is empty.
925
- """
926
- newest: SapioRecord | None = None
927
- for record in records:
928
- if newest is None or record.record_id > newest.record_id:
929
- newest = record
930
- return newest
931
-
932
- # FR-46696: Add a function for getting the oldest record in a list, just like we have one for the newest record.
933
- @staticmethod
934
- def get_oldest_record(records: Iterable[SapioRecord]) -> SapioRecord:
935
- """
936
- Get the oldest record from a list of records.
937
-
938
- :param records: The list of records.
939
- :return: The input record with the lowest record ID. None if the input list is empty.
940
- """
941
- oldest: SapioRecord | None = None
942
- for record in records:
943
- if oldest is None or record.record_id < oldest.record_id:
944
- oldest = record
945
- return oldest
946
-
947
- @staticmethod
948
- def values_to_field_maps(field_name: FieldIdentifier, values: Iterable[FieldValue],
949
- existing_fields: list[FieldMap] | None = None) -> list[FieldMap]:
1248
+ def map_by_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
1249
+ side_link_type: type[WrappedType]) -> dict[WrappedType, list[WrappedRecordModel]]:
950
1250
  """
951
- Add a list of values for a specific field to a list of dictionaries pairing each value to that field name.
1251
+ Take a list of record models and map them by their reverse side links. Essentially an inversion of
1252
+ map_to_reverse_side_links. Input models that share a reverse side link will end up in the same list.
1253
+ The reverse side links must already be loaded.
952
1254
 
953
- :param field_name: The name of the field that the values are from.
954
- :param values: A list of field values.
955
- :param existing_fields: An optional existing fields map list to add the new values to. Values are added in the
956
- list in the same order that they appear. If no existing fields are provided, returns a new fields map list.
957
- :return: A fields map list that contains the given values mapped by the given field name.
1255
+ :param models: A list of record models.
1256
+ :param field_name: The field name on the side linked model where the side link to the given record models is
1257
+ located.
1258
+ :param side_link_type: The record model wrapper of the reverse side links.
1259
+ :return: A dict[SideLink, list[ModelType]]. If an input model doesn't have reverse side links of the given type
1260
+ pointing to it, then it will not be in the resulting dictionary.
958
1261
  """
959
- # Update the existing fields map list if one is given.
960
1262
  field_name: str = AliasUtil.to_data_field_name(field_name)
961
- existing_fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(existing_fields)
962
- if existing_fields:
963
- values = list(values)
964
- # The number of new values must match the length of the existing fields list.
965
- if len(values) != len(existing_fields):
966
- raise SapioException(f"Length of \"{field_name}\" values does not match the existing fields length.")
967
- for field, value in zip(existing_fields, values):
968
- field.update({field_name: value})
969
- return existing_fields
970
- # Otherwise, create a new fields map list.
971
- return [{field_name: value} for value in values]
1263
+ to_side_links: dict[WrappedRecordModel, list[WrappedType]] = RecordHandler\
1264
+ .map_to_reverse_side_links(models, field_name, side_link_type)
1265
+ by_side_links: dict[WrappedType, list[WrappedRecordModel]] = {}
1266
+ for record, side_links in to_side_links.items():
1267
+ for side_link in side_links:
1268
+ by_side_links.setdefault(side_link, []).append(record)
1269
+ return by_side_links
972
1270
 
973
1271
  # FR-46155: Update relationship path traversing functions to be non-static and take in a wrapper type so that the
974
1272
  # output can be wrapped instead of requiring the user to wrap the output.
@@ -1071,7 +1369,9 @@ class RecordHandler:
1071
1369
  elif direction == RelationshipNodeType.DESCENDANT:
1072
1370
  next_search.update(self.an_man.get_descendant_of_type(search, data_type))
1073
1371
  elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
1074
- next_search.add(search.get_forward_side_link(node.data_field_name))
1372
+ side_link: RecordModel | None = search.get_forward_side_link(node.data_field_name)
1373
+ if side_link:
1374
+ next_search.add(side_link)
1075
1375
  elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
1076
1376
  next_search.update(search.get_reverse_side_link(data_type, node.data_field_name))
1077
1377
  else:
@@ -1121,7 +1421,8 @@ class RecordHandler:
1121
1421
  elif direction == RelationshipNodeType.DESCENDANT:
1122
1422
  current = list(self.an_man.get_descendant_of_type(current[0], data_type))
1123
1423
  elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
1124
- current = [current[0].get_forward_side_link(node.data_field_name)]
1424
+ side_link: RecordModel | None = current[0].get_forward_side_link(node.data_field_name)
1425
+ current = [side_link] if side_link else []
1125
1426
  elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
1126
1427
  current = current[0].get_reverse_side_link(data_type, node.data_field_name)
1127
1428
  else:
@@ -1169,3 +1470,42 @@ class RecordHandler:
1169
1470
  if record_type != model_type:
1170
1471
  raise SapioException(f"Data record of type {record_type} cannot be wrapped by the record model wrapper "
1171
1472
  f"of type {model_type}")
1473
+
1474
+ @staticmethod
1475
+ def _spoof_child_load(model: RecordModel, child: RecordModel) -> None:
1476
+ """
1477
+ Spoof the loading of a child record on a record model. This is useful for when you have records that you know
1478
+ are related but didn't use the relationship manager to load the relationship, which would make a webservice
1479
+ call.
1480
+ """
1481
+ RecordHandler._spoof_children_load(model, [child])
1482
+
1483
+ @staticmethod
1484
+ def _spoof_children_load(model: RecordModel, children: list[RecordModel]) -> None:
1485
+ """
1486
+ Spoof the loading of child records on a record model. This is useful for when you have records that you know
1487
+ are related but didn't use the relationship manager to load the relationship, which would make a webservice
1488
+ """
1489
+ model: PyRecordModel = RecordModelInstanceManager.unwrap(model)
1490
+ child_dt: str = AliasUtil.to_singular_data_type_name(children)
1491
+ # noinspection PyProtectedMember
1492
+ model._mark_children_loaded(child_dt, RecordModelInstanceManager.unwrap_list(children))
1493
+
1494
+ @staticmethod
1495
+ def _spoof_parent_load(model: RecordModel, parent: RecordModel) -> None:
1496
+ """
1497
+ Spoof the loading of a parent record on a record model. This is useful for when you have records that you know
1498
+ are related but didn't use the relationship manager to load the relationship, which would make a webservice
1499
+ """
1500
+ RecordHandler._spoof_parents_load(model, [parent])
1501
+
1502
+ @staticmethod
1503
+ def _spoof_parents_load(model: RecordModel, parents: list[RecordModel]) -> None:
1504
+ """
1505
+ Spoof the loading of parent records on a record model. This is useful for when you have records that you know
1506
+ are related but didn't use the relationship manager to load the relationship, which would make a webservice
1507
+ """
1508
+ model: PyRecordModel = RecordModelInstanceManager.unwrap(model)
1509
+ parent_dt: str = AliasUtil.to_singular_data_type_name(parents)
1510
+ # noinspection PyProtectedMember
1511
+ model._mark_children_loaded(parent_dt, RecordModelInstanceManager.unwrap_list(parents))