sapiopycommons 2025.4.9a150__py3-none-any.whl → 2025.4.9a476__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 (42) hide show
  1. sapiopycommons/callbacks/callback_util.py +1262 -392
  2. sapiopycommons/callbacks/field_builder.py +2 -0
  3. sapiopycommons/chem/Molecules.py +0 -2
  4. sapiopycommons/customreport/auto_pagers.py +281 -0
  5. sapiopycommons/customreport/term_builder.py +1 -1
  6. sapiopycommons/datatype/attachment_util.py +4 -2
  7. sapiopycommons/datatype/data_fields.py +23 -1
  8. sapiopycommons/eln/experiment_cache.py +173 -0
  9. sapiopycommons/eln/experiment_handler.py +933 -279
  10. sapiopycommons/eln/experiment_report_util.py +15 -10
  11. sapiopycommons/eln/experiment_step_factory.py +474 -0
  12. sapiopycommons/eln/experiment_tags.py +7 -0
  13. sapiopycommons/eln/plate_designer.py +159 -59
  14. sapiopycommons/eln/step_creation.py +235 -0
  15. sapiopycommons/files/file_bridge.py +76 -0
  16. sapiopycommons/files/file_bridge_handler.py +325 -110
  17. sapiopycommons/files/file_data_handler.py +2 -2
  18. sapiopycommons/files/file_util.py +40 -15
  19. sapiopycommons/files/file_validator.py +6 -5
  20. sapiopycommons/files/file_writer.py +1 -1
  21. sapiopycommons/flowcyto/flow_cyto.py +1 -1
  22. sapiopycommons/general/accession_service.py +3 -3
  23. sapiopycommons/general/aliases.py +51 -28
  24. sapiopycommons/general/audit_log.py +2 -2
  25. sapiopycommons/general/custom_report_util.py +24 -1
  26. sapiopycommons/general/data_structure_util.py +115 -0
  27. sapiopycommons/general/directive_util.py +86 -0
  28. sapiopycommons/general/exceptions.py +41 -2
  29. sapiopycommons/general/popup_util.py +2 -2
  30. sapiopycommons/multimodal/multimodal.py +1 -0
  31. sapiopycommons/processtracking/custom_workflow_handler.py +46 -30
  32. sapiopycommons/recordmodel/record_handler.py +547 -159
  33. sapiopycommons/rules/eln_rule_handler.py +41 -30
  34. sapiopycommons/rules/on_save_rule_handler.py +41 -30
  35. sapiopycommons/samples/aliquot.py +48 -0
  36. sapiopycommons/webhook/webhook_handlers.py +448 -55
  37. sapiopycommons/webhook/webservice_handlers.py +2 -2
  38. {sapiopycommons-2025.4.9a150.dist-info → sapiopycommons-2025.4.9a476.dist-info}/METADATA +1 -1
  39. sapiopycommons-2025.4.9a476.dist-info/RECORD +67 -0
  40. sapiopycommons-2025.4.9a150.dist-info/RECORD +0 -59
  41. {sapiopycommons-2025.4.9a150.dist-info → sapiopycommons-2025.4.9a476.dist-info}/WHEEL +0 -0
  42. {sapiopycommons-2025.4.9a150.dist-info → sapiopycommons-2025.4.9a476.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import io
4
+ import warnings
3
5
  from collections.abc import Iterable
6
+ from typing import Collection
4
7
  from weakref import WeakValueDictionary
5
8
 
6
9
  from sapiopylib.rest.DataRecordManagerService import DataRecordManager
@@ -12,19 +15,28 @@ from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
12
15
  from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnBaseDataType
13
16
  from sapiopylib.rest.utils.autopaging import QueryDataRecordsAutoPager, QueryDataRecordByIdListAutoPager, \
14
17
  QueryAllRecordsOfTypeAutoPager
15
- from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
18
+ from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel, AbstractRecordModelPropertyGetter, \
19
+ RecordModelPropertyType, AbstractRecordModelPropertyAdder, AbstractRecordModelPropertySetter, \
20
+ AbstractRecordModelPropertyRemover
16
21
  from sapiopylib.rest.utils.recordmodel.RecordModelManager import RecordModelManager, RecordModelInstanceManager, \
17
22
  RecordModelRelationshipManager
18
23
  from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType, WrappedRecordModel
19
24
  from sapiopylib.rest.utils.recordmodel.RelationshipPath import RelationshipPath, RelationshipNode, \
20
25
  RelationshipNodeType
21
26
  from sapiopylib.rest.utils.recordmodel.ancestry import RecordModelAncestorManager
27
+ from sapiopylib.rest.utils.recordmodel.properties import Parents, Parent, Children, Child, ForwardSideLink
22
28
 
23
29
  from sapiopycommons.general.aliases import RecordModel, SapioRecord, FieldMap, FieldIdentifier, AliasUtil, \
24
- FieldIdentifierMap, FieldValue, UserIdentifier, FieldIdentifierKey
30
+ FieldIdentifierMap, FieldValue, UserIdentifier, FieldIdentifierKey, DataTypeIdentifier
25
31
  from sapiopycommons.general.custom_report_util import CustomReportUtil
26
32
  from sapiopycommons.general.exceptions import SapioException
27
33
 
34
+ # Aliases for longer name.
35
+ _PropertyGetter = AbstractRecordModelPropertyGetter
36
+ _PropertyAdder = AbstractRecordModelPropertyAdder
37
+ _PropertyRemover = AbstractRecordModelPropertyRemover
38
+ _PropertySetter = AbstractRecordModelPropertySetter
39
+ _PropertyType = RecordModelPropertyType
28
40
 
29
41
  # FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
30
42
  class RecordHandler:
@@ -57,226 +69,273 @@ class RecordHandler:
57
69
  """
58
70
  :param context: The current webhook context or a user object to send requests from.
59
71
  """
60
- self.user = AliasUtil.to_sapio_user(context)
61
72
  if self.__initialized:
62
73
  return
63
74
  self.__initialized = True
64
75
 
65
- self.user = context if isinstance(context, SapioUser) else context.user
76
+ self.user = AliasUtil.to_sapio_user(context)
66
77
  self.dr_man = DataRecordManager(self.user)
67
78
  self.rec_man = RecordModelManager(self.user)
68
79
  self.inst_man = self.rec_man.instance_manager
69
80
  self.rel_man = self.rec_man.relationship_manager
70
81
  self.an_man = RecordModelAncestorManager(self.rec_man)
71
82
 
72
- def wrap_model(self, record: DataRecord, wrapper_type: type[WrappedType]) -> WrappedType:
83
+ # CR-47491: Support not providing a wrapper type to receive PyRecordModels instead of WrappedRecordModels.
84
+ def wrap_model(self, record: DataRecord | PyRecordModel, wrapper_type: type[WrappedType] | None = None) \
85
+ -> WrappedType | PyRecordModel:
73
86
  """
74
- Shorthand for adding a single data record as a record model.
87
+ Shorthand for adding a single data record or PyRecordModel as a WrappedRecordModel.
75
88
 
76
- :param record: The data record to wrap.
77
- :param wrapper_type: The record model wrapper to use.
89
+ :param record: The data record or PyRecordModel to wrap.
90
+ :param wrapper_type: The record model wrapper to use. If not provided, the record is returned as a
91
+ PyRecordModel instead of a WrappedRecordModel.
78
92
  :return: The record model for the input.
79
93
  """
80
- self.__verify_data_type([record], wrapper_type)
81
- return self.inst_man.add_existing_record_of_type(record, wrapper_type)
94
+ if wrapper_type is not None:
95
+ self.__verify_data_type(record, wrapper_type)
96
+ if isinstance(record, PyRecordModel):
97
+ return self.inst_man.wrap(record, wrapper_type)
98
+ return self.inst_man.add_existing_record_of_type(record, wrapper_type)
99
+ if isinstance(record, PyRecordModel):
100
+ return record
101
+ return self.inst_man.add_existing_record(record)
82
102
 
83
- def wrap_models(self, records: Iterable[DataRecord], wrapper_type: type[WrappedType]) -> list[WrappedType]:
103
+ def wrap_models(self, records: Iterable[DataRecord | PyRecordModel],
104
+ wrapper_type: type[WrappedType] | None = None) \
105
+ -> list[WrappedType] | list[PyRecordModel]:
84
106
  """
85
- Shorthand for adding a list of data records as record models.
107
+ Shorthand for adding a list of data records or PyRecordModels as a WrappedRecordModels.
86
108
 
87
109
  :param records: The data records to wrap.
88
- :param wrapper_type: The record model wrapper to use.
110
+ :param wrapper_type: The record model wrapper to use. If not provided, the records are returned as
111
+ PyRecordModels instead of WrappedRecordModels.
89
112
  :return: The record models for the input.
90
113
  """
91
- self.__verify_data_type(records, wrapper_type)
92
- return self.inst_man.add_existing_records_of_type(list(records), wrapper_type)
114
+ return [self.wrap_model(x, wrapper_type) for x in records]
93
115
 
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]:
116
+ # CR-47491: Support providing a data type name string to receive PyRecordModels instead of requiring a WrapperType.
117
+ # CR-47523: Support a singular field value being provided for the value_list parameter.
118
+ def query_models(self, wrapper_type: type[WrappedType] | str, field: FieldIdentifier,
119
+ value_list: Iterable[FieldValue] | FieldValue,
120
+ page_limit: int | None = None, page_size: int | None = None) \
121
+ -> list[WrappedType] | list[PyRecordModel]:
96
122
  """
97
123
  Shorthand for using the data record manager to query for a list of data records by field value
98
124
  and then converting the results into a list of record models.
99
125
 
100
- :param wrapper_type: The record model wrapper to use.
126
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
101
127
  :param field: The field to query on.
102
- :param value_list: The values of the field to query on.
128
+ :param value_list: The values of the field to query on, or a singular field value that will be automatically
129
+ converted to a singleton list. Note that field values of None are not supported by this method and will be
130
+ ignored. If you need to query for records with a null field value, use a custom report.
103
131
  :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
104
132
  only functions if you set a page size or the platform enforces a page size.
105
133
  :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
106
- :return: The record models for the queried records.
134
+ :return: The record models for the queried records. If a data type name was used instead of a model wrapper,
135
+ then the returned records will be PyRecordModels instead of WrappedRecordModels.
107
136
  """
108
137
  criteria: DataRecordPojoPageCriteria | None = None
109
138
  if page_size is not None:
110
139
  criteria = DataRecordPojoPageCriteria(page_size=page_size)
111
140
  return self.query_models_with_criteria(wrapper_type, field, value_list, criteria, page_limit)[0]
112
141
 
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]]:
142
+ def query_and_map_models(self, wrapper_type: type[WrappedType] | str, field: FieldIdentifier,
143
+ value_list: Iterable[FieldValue] | FieldValue,
144
+ page_limit: int | None = None, page_size: int | None = None,
145
+ *,
146
+ mapping_field: FieldIdentifier | None = None) \
147
+ -> dict[FieldValue, list[WrappedType] | list[PyRecordModel]]:
117
148
  """
118
149
  Shorthand for using query_models to search for records given values on a specific field and then using
119
150
  map_by_field to turn the returned list into a dictionary mapping field values to records.
120
151
 
121
- :param wrapper_type: The record model wrapper to use.
152
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
122
153
  :param field: The field to query and map on.
123
- :param value_list: The values of the field to query on.
154
+ :param value_list: The values of the field to query on, or a singular field value that will be automatically
155
+ converted to a singleton list. Note that field values of None are not supported by this method and will be
156
+ ignored. If you need to query for records with a null field value, use a custom report.
124
157
  :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
125
158
  only functions if you set a page size or the platform enforces a page size.
126
159
  :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
127
160
  :param mapping_field: If provided, use this field to map against instead of the field that was queried on.
128
161
  :return: The record models for the queried records mapped by field values to the records with that value.
162
+ If a data type name was used instead of a model wrapper, then the returned records will be PyRecordModels
163
+ instead of WrappedRecordModels.
129
164
  """
130
165
  if mapping_field is None:
131
166
  mapping_field = field
132
167
  return self.map_by_field(self.query_models(wrapper_type, field, value_list, page_limit, page_size),
133
168
  mapping_field)
134
169
 
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]:
170
+ def query_and_unique_map_models(self, wrapper_type: type[WrappedType] | str, field: FieldIdentifier,
171
+ value_list: Iterable[FieldValue] | FieldValue,
172
+ page_limit: int | None = None, page_size: int | None = None,
173
+ *,
174
+ mapping_field: FieldIdentifier | None = None) \
175
+ -> dict[FieldValue, WrappedType | PyRecordModel]:
139
176
  """
140
177
  Shorthand for using query_models to search for records given values on a specific field and then using
141
178
  map_by_unique_field to turn the returned list into a dictionary mapping field values to records.
142
179
  If any two records share the same field value, throws an exception.
143
180
 
144
- :param wrapper_type: The record model wrapper to use.
181
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
145
182
  :param field: The field to query and map on.
146
- :param value_list: The values of the field to query on.
183
+ :param value_list: The values of the field to query on, or a singular field value that will be automatically
184
+ converted to a singleton list. Note that field values of None are not supported by this method and will be
185
+ ignored. If you need to query for records with a null field value, use a custom report.
147
186
  :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
148
187
  only functions if you set a page size or the platform enforces a page size.
149
188
  :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
150
189
  :param mapping_field: If provided, use this field to map against instead of the field that was queried on.
151
190
  :return: The record models for the queried records mapped by field values to the record with that value.
191
+ If a data type name was used instead of a model wrapper, then the returned records will be PyRecordModels
192
+ instead of WrappedRecordModels.
152
193
  """
153
194
  if mapping_field is None:
154
195
  mapping_field = field
155
196
  return self.map_by_unique_field(self.query_models(wrapper_type, field, value_list, page_limit, page_size),
156
197
  mapping_field)
157
198
 
158
- def query_models_with_criteria(self, wrapper_type: type[WrappedType], field: FieldIdentifier,
159
- value_list: Iterable[FieldValue],
199
+ def query_models_with_criteria(self, wrapper_type: type[WrappedType] | str, field: FieldIdentifier,
200
+ value_list: Iterable[FieldValue] | FieldValue,
160
201
  paging_criteria: DataRecordPojoPageCriteria | None = None,
161
202
  page_limit: int | None = None) \
162
- -> tuple[list[WrappedType], DataRecordPojoPageCriteria]:
203
+ -> tuple[list[WrappedType] | list[PyRecordModel], DataRecordPojoPageCriteria]:
163
204
  """
164
205
  Shorthand for using the data record manager to query for a list of data records by field value
165
206
  and then converting the results into a list of record models.
166
207
 
167
- :param wrapper_type: The record model wrapper to use.
208
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
168
209
  :param field: The field to query on.
169
- :param value_list: The values of the field to query on.
210
+ :param value_list: The values of the field to query on, or a singular field value that will be automatically
211
+ converted to a singleton list. Note that field values of None are not supported by this method and will be
212
+ ignored. If you need to query for records with a null field value, use a custom report.
170
213
  :param paging_criteria: The paging criteria to start the query with.
171
214
  :param page_limit: The maximum number of pages to query from the starting criteria. If None, exhausts all
172
215
  possible pages. This parameter only functions if you set a page size in the paging criteria or the platform
173
216
  enforces a page size.
174
- :return: The record models for the queried records and the final paging criteria.
217
+ :return: The record models for the queried records and the final paging criteria. If a data type name was used
218
+ instead of a model wrapper, then the returned records will be PyRecordModels instead of WrappedRecordModels.
175
219
  """
176
- dt: str = wrapper_type.get_wrapper_data_type_name()
220
+ dt: str = AliasUtil.to_data_type_name(wrapper_type)
221
+ if isinstance(wrapper_type, str):
222
+ wrapper_type = None
177
223
  field: str = AliasUtil.to_data_field_name(field)
224
+ if isinstance(value_list, FieldValue):
225
+ value_list: list[FieldValue] = [value_list]
178
226
  pager = QueryDataRecordsAutoPager(dt, field, list(value_list), self.user, paging_criteria)
179
227
  pager.max_page = page_limit
180
228
  return self.wrap_models(pager.get_all_at_once(), wrapper_type), pager.next_page_criteria
181
229
 
182
- def query_models_by_id(self, wrapper_type: type[WrappedType], ids: Iterable[int],
183
- page_limit: int | None = None, page_size: int | None = None) -> list[WrappedType]:
230
+ def query_models_by_id(self, wrapper_type: type[WrappedType] | str, ids: Iterable[int],
231
+ page_limit: int | None = None, page_size: int | None = None) \
232
+ -> list[WrappedType] | list[PyRecordModel]:
184
233
  """
185
234
  Shorthand for using the data record manager to query for a list of data records by record ID
186
235
  and then converting the results into a list of record models.
187
236
 
188
- :param wrapper_type: The record model wrapper to use.
237
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
189
238
  :param ids: The list of record IDs to query.
190
239
  :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
191
240
  only functions if you set a page size or the platform enforces a page size.
192
241
  :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
193
- :return: The record models for the queried records.
242
+ :return: The record models for the queried records. If a data type name was used instead of a model wrapper,
243
+ then the returned records will be PyRecordModels instead of WrappedRecordModels.
194
244
  """
195
245
  criteria: DataRecordPojoPageCriteria | None = None
196
246
  if page_size is not None:
197
247
  criteria = DataRecordPojoPageCriteria(page_size=page_size)
198
248
  return self.query_models_by_id_with_criteria(wrapper_type, ids, criteria, page_limit)[0]
199
249
 
200
- def query_models_by_id_with_criteria(self, wrapper_type: type[WrappedType], ids: Iterable[int],
250
+ def query_models_by_id_with_criteria(self, wrapper_type: type[WrappedType] | str, ids: Iterable[int],
201
251
  paging_criteria: DataRecordPojoPageCriteria | None = None,
202
252
  page_limit: int | None = None) \
203
- -> tuple[list[WrappedType], DataRecordPojoPageCriteria]:
253
+ -> tuple[list[WrappedType] | list[PyRecordModel], DataRecordPojoPageCriteria]:
204
254
  """
205
255
  Shorthand for using the data record manager to query for a list of data records by record ID
206
256
  and then converting the results into a list of record models.
207
257
 
208
- :param wrapper_type: The record model wrapper to use.
258
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
209
259
  :param ids: The list of record IDs to query.
210
260
  :param paging_criteria: The paging criteria to start the query with.
211
261
  :param page_limit: The maximum number of pages to query from the starting criteria. If None, exhausts all
212
262
  possible pages. This parameter only functions if you set a page size in the paging criteria or the platform
213
263
  enforces a page size.
214
- :return: The record models for the queried records and the final paging criteria.
264
+ :return: The record models for the queried records and the final paging criteria. If a data type name was used
265
+ instead of a model wrapper, then the returned records will be PyRecordModels instead of WrappedRecordModels.
215
266
  """
216
- dt: str = wrapper_type.get_wrapper_data_type_name()
267
+ dt: str = AliasUtil.to_data_type_name(wrapper_type)
268
+ if isinstance(wrapper_type, str):
269
+ wrapper_type = None
217
270
  pager = QueryDataRecordByIdListAutoPager(dt, list(ids), self.user, paging_criteria)
218
271
  pager.max_page = page_limit
219
272
  return self.wrap_models(pager.get_all_at_once(), wrapper_type), pager.next_page_criteria
220
273
 
221
- def query_models_by_id_and_map(self, wrapper_type: type[WrappedType], ids: Iterable[int],
274
+ def query_models_by_id_and_map(self, wrapper_type: type[WrappedType] | str, ids: Iterable[int],
222
275
  page_limit: int | None = None, page_size: int | None = None) \
223
- -> dict[int, WrappedType]:
276
+ -> dict[int, WrappedType | PyRecordModel]:
224
277
  """
225
278
  Shorthand for using the data record manager to query for a list of data records by record ID
226
279
  and then converting the results into a dictionary of record ID to the record model for that ID.
227
280
 
228
- :param wrapper_type: The record model wrapper to use.
281
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
229
282
  :param ids: The list of record IDs to query.
230
283
  :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
231
284
  only functions if you set a page size or the platform enforces a page size.
232
285
  :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
233
286
  :return: The record models for the queried records mapped in a dictionary by their record ID.
287
+ If a data type name was used instead of a model wrapper, then the returned records will be PyRecordModels
288
+ instead of WrappedRecordModels.
234
289
  """
235
- return {x.record_id: x for x in self.query_models_by_id(wrapper_type, ids, page_limit, page_size)}
290
+ return {AliasUtil.to_record_id(x): x for x in self.query_models_by_id(wrapper_type, ids, page_limit, page_size)}
236
291
 
237
- def query_all_models(self, wrapper_type: type[WrappedType], page_limit: int | None = None,
238
- page_size: int | None = None) -> list[WrappedType]:
292
+ def query_all_models(self, wrapper_type: type[WrappedType] | str, page_limit: int | None = None,
293
+ page_size: int | None = None) -> list[WrappedType] | list[PyRecordModel]:
239
294
  """
240
295
  Shorthand for using the data record manager to query for all data records of a given type
241
296
  and then converting the results into a list of record models.
242
297
 
243
- :param wrapper_type: The record model wrapper to use.
298
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
244
299
  :param page_limit: The maximum number of pages to query. If None, exhausts all possible pages. This parameter
245
300
  only functions if you set a page size or the platform enforces a page size.
246
301
  :param page_size: The size of the pages to query. If None, the page size may be limited by the platform.
247
- :return: The record models for the queried records.
302
+ :return: The record models for the queried records. If a data type name was used instead of a model wrapper,
303
+ then the returned records will be PyRecordModels instead of WrappedRecordModels.
248
304
  """
249
305
  criteria: DataRecordPojoPageCriteria | None = None
250
306
  if page_size is not None:
251
307
  criteria = DataRecordPojoPageCriteria(page_size=page_size)
252
308
  return self.query_all_models_with_criteria(wrapper_type, criteria, page_limit)[0]
253
309
 
254
- def query_all_models_with_criteria(self, wrapper_type: type[WrappedType],
310
+ def query_all_models_with_criteria(self, wrapper_type: type[WrappedType] | str,
255
311
  paging_criteria: DataRecordPojoPageCriteria | None = None,
256
312
  page_limit: int | None = None) \
257
- -> tuple[list[WrappedType], DataRecordPojoPageCriteria]:
313
+ -> tuple[list[WrappedType] | list[PyRecordModel], DataRecordPojoPageCriteria]:
258
314
  """
259
315
  Shorthand for using the data record manager to query for all data records of a given type
260
316
  and then converting the results into a list of record models.
261
317
 
262
- :param wrapper_type: The record model wrapper to use.
318
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
263
319
  :param paging_criteria: The paging criteria to start the query with.
264
320
  :param page_limit: The maximum number of pages to query from the starting criteria. If None, exhausts all
265
321
  possible pages. This parameter only functions if you set a page size in the paging criteria or the platform
266
322
  enforces a page size.
267
- :return: The record models for the queried records and the final paging criteria.
323
+ :return: The record models for the queried records and the final paging criteria. If a data type name was used
324
+ instead of a model wrapper, then the returned records will be PyRecordModels instead of WrappedRecordModels.
268
325
  """
269
- dt: str = wrapper_type.get_wrapper_data_type_name()
326
+ dt: str = AliasUtil.to_data_type_name(wrapper_type)
327
+ if isinstance(wrapper_type, str):
328
+ wrapper_type = None
270
329
  pager = QueryAllRecordsOfTypeAutoPager(dt, self.user, paging_criteria)
271
330
  pager.max_page = page_limit
272
331
  return self.wrap_models(pager.get_all_at_once(), wrapper_type), pager.next_page_criteria
273
332
 
274
- def query_models_by_report(self, wrapper_type: type[WrappedType],
333
+ def query_models_by_report(self, wrapper_type: type[WrappedType] | str,
275
334
  report_name: str | RawReportTerm | CustomReportCriteria,
276
335
  filters: dict[FieldIdentifierKey, Iterable[FieldValue]] | None = None,
277
336
  page_limit: int | None = None,
278
337
  page_size: int | None = None,
279
- page_number: int | None = None) -> list[WrappedType]:
338
+ page_number: int | None = None) -> list[WrappedType] | list[PyRecordModel]:
280
339
  """
281
340
  Run a report and use the results of that report to query for and return the records in the report results.
282
341
  First runs the report, then runs a data record manager query on the results of the custom report.
@@ -286,7 +345,7 @@ class RecordHandler:
286
345
 
287
346
  Any given custom report criteria should only have columns from a single data type.
288
347
 
289
- :param wrapper_type: The record model wrapper to use.
348
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
290
349
  :param report_name: The name of a system report, or a raw report term for a quick report, or custom report
291
350
  criteria for a custom report.
292
351
  :param filters: If provided, filter the results of the report using the given mapping of headers to values to
@@ -298,8 +357,10 @@ class RecordHandler:
298
357
  :param page_number: The page number to start the search from, If None, starts on the first page.
299
358
  If the input report is a custom report criteria, uses the value from the criteria, unless this value is
300
359
  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.
360
+ :return: The record models for the queried records that matched the given report. If a data type name was used
361
+ instead of a model wrapper, then the returned records will be PyRecordModels instead of WrappedRecordModels.
302
362
  """
363
+ warnings.warn("Deprecated in favor of the [System/Custom/Quick]ReportRecordAutoPager classes.", DeprecationWarning)
303
364
  if isinstance(report_name, str):
304
365
  results: list[dict[str, FieldValue]] = CustomReportUtil.run_system_report(self.user, report_name, filters,
305
366
  page_limit, page_size, page_number)
@@ -307,7 +368,7 @@ class RecordHandler:
307
368
  results: list[dict[str, FieldValue]] = CustomReportUtil.run_quick_report(self.user, report_name, filters,
308
369
  page_limit, page_size, page_number)
309
370
  elif isinstance(report_name, CustomReportCriteria):
310
- dt: str = wrapper_type.get_wrapper_data_type_name()
371
+ dt: str = AliasUtil.to_data_type_name(wrapper_type)
311
372
  # Ensure that the root data type is the one we're looking for.
312
373
  report_name.root_data_type = dt
313
374
  # Raise an exception if any column in the report doesn't match the given data type.
@@ -322,40 +383,45 @@ class RecordHandler:
322
383
  raise SapioException("Unrecognized report object.")
323
384
 
324
385
  # 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
386
+ # This should only possibly be the case with system reports, as quick reports will include the record ID, and
326
387
  # we forced any given custom report to have a record ID column.
327
388
  ids: list[int] = [row["RecordId"] for row in results]
328
389
  return self.query_models_by_id(wrapper_type, ids)
329
390
 
330
- def add_model(self, wrapper_type: type[WrappedType]) -> WrappedType:
391
+ def add_model(self, wrapper_type: type[WrappedType] | str) -> WrappedType | PyRecordModel:
331
392
  """
332
393
  Shorthand for using the instance manager to add a new record model of the given type.
333
394
 
334
- :param wrapper_type: The record model wrapper to use.
335
- :return: The newly added record model.
395
+ :param wrapper_type: The record model wrapper to use, or the data type name of the record.
396
+ :return: The newly added record model. If a data type name was used instead of a model wrapper, then the
397
+ returned record will be a PyRecordModel instead of a WrappedRecordModel.
336
398
  """
337
- return self.inst_man.add_new_record_of_type(wrapper_type)
399
+ return self.add_models(wrapper_type, 1)[0]
338
400
 
339
- def add_models(self, wrapper_type: type[WrappedType], num: int) -> list[WrappedType]:
401
+ def add_models(self, wrapper_type: type[WrappedType] | str, num: int) -> list[WrappedType] | list[PyRecordModel]:
340
402
  """
341
403
  Shorthand for using the instance manager to add new record models of the given type.
342
404
 
343
- :param wrapper_type: The record model wrapper to use.
405
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
344
406
  :param num: The number of models to create.
345
- :return: The newly added record models.
407
+ :return: The newly added record models. If a data type name was used instead of a model wrapper, then the
408
+ returned records will be PyRecordModels instead of WrappedRecordModels.
346
409
  """
410
+ if isinstance(wrapper_type, str):
411
+ return self.inst_man.add_new_records(wrapper_type, num)
347
412
  return self.inst_man.add_new_records_of_type(num, wrapper_type)
348
413
 
349
- def add_models_with_data(self, wrapper_type: type[WrappedType], fields: list[FieldIdentifierMap]) \
350
- -> list[WrappedType]:
414
+ def add_models_with_data(self, wrapper_type: type[WrappedType] | str, fields: list[FieldIdentifierMap]) \
415
+ -> list[WrappedType] | list[PyRecordModel]:
351
416
  """
352
417
  Shorthand for using the instance manager to add new models of the given type, and then initializing all those
353
418
  models with the given fields.
354
419
 
355
- :param wrapper_type: The record model wrapper to use.
420
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
356
421
  :param fields: A list of field maps to initialize the record models with.
357
422
  :return: The newly added record models with the provided fields set. The records will be in the same order as
358
- the fields in the fields list.
423
+ the fields in the fields list. If a data type name was used instead of a model wrapper, then the returned
424
+ records will be PyRecordModels instead of WrappedRecordModels.
359
425
  """
360
426
  fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(fields)
361
427
  models: list[WrappedType] = self.add_models(wrapper_type, len(fields))
@@ -363,8 +429,9 @@ class RecordHandler:
363
429
  model.set_field_values(field_list)
364
430
  return models
365
431
 
366
- def find_or_add_model(self, wrapper_type: type[WrappedType], primary_identifier: FieldIdentifier,
367
- id_value: FieldValue, secondary_identifiers: FieldIdentifierMap | None = None) -> WrappedType:
432
+ def find_or_add_model(self, wrapper_type: type[WrappedType] | str, primary_identifier: FieldIdentifier,
433
+ id_value: FieldValue, secondary_identifiers: FieldIdentifierMap | None = None) \
434
+ -> WrappedType | PyRecordModel:
368
435
  """
369
436
  Find a unique record that matches the given field values. If no such records exist, add a record model to the
370
437
  cache with the identifying fields set to the desired values. This record will be created in the system when
@@ -375,12 +442,14 @@ class RecordHandler:
375
442
 
376
443
  Makes a webservice call to query for the existing record.
377
444
 
378
- :param wrapper_type: The record model wrapper to use.
445
+ :param wrapper_type: The record model wrapper to use, or the data type name of the record.
379
446
  :param primary_identifier: The data field name of the field to search on.
380
447
  :param id_value: The value of the identifying field to search for.
381
448
  :param secondary_identifiers: Optional fields used to filter the records that are returned after searching on
382
449
  the primary identifier.
383
450
  :return: The record model with the identifying field value, either pulled from the system or newly created.
451
+ If a data type name was used instead of a model wrapper, then the returned record will be a PyRecordModel
452
+ instead of a WrappedRecordModel.
384
453
  """
385
454
  # PR-46335: Initialize the secondary identifiers parameter if None is provided to avoid an exception.
386
455
  # If no secondary identifiers were provided, use an empty dictionary.
@@ -401,22 +470,25 @@ class RecordHandler:
401
470
  secondary_identifiers.update({primary_identifier: id_value})
402
471
  return self.add_models_with_data(wrapper_type, [secondary_identifiers])[0]
403
472
 
404
- def create_models(self, wrapper_type: type[WrappedType], num: int) -> list[WrappedType]:
473
+ def create_models(self, wrapper_type: type[WrappedType] | str, num: int) -> list[WrappedType] | list[PyRecordModel]:
405
474
  """
406
475
  Shorthand for creating new records via the data record manager and then returning them as wrapped
407
476
  record models. Useful in cases where your record model needs to have a valid record ID.
408
477
 
409
478
  Makes a webservice call to create the data records.
410
479
 
411
- :param wrapper_type: The record model wrapper to use.
480
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
412
481
  :param num: The number of new records to create.
413
- :return: The newly created record models.
482
+ :return: The newly created record models. If a data type name was used instead of a model wrapper, then the
483
+ returned records will be PyRecordModels instead of WrappedRecordModels.
414
484
  """
415
- dt: str = wrapper_type.get_wrapper_data_type_name()
485
+ dt: str = AliasUtil.to_data_type_name(wrapper_type)
486
+ if isinstance(wrapper_type, str):
487
+ wrapper_type = None
416
488
  return self.wrap_models(self.dr_man.add_data_records(dt, num), wrapper_type)
417
489
 
418
- def create_models_with_data(self, wrapper_type: type[WrappedType], fields: list[FieldIdentifierMap]) \
419
- -> list[WrappedType]:
490
+ def create_models_with_data(self, wrapper_type: type[WrappedType] | str, fields: list[FieldIdentifierMap]) \
491
+ -> list[WrappedType] | list[PyRecordModel]:
420
492
  """
421
493
  Shorthand for creating new records via the data record manager with field data to initialize the records with
422
494
  and then returning them as wrapped record models. Useful in cases where your record model needs to have a valid
@@ -424,17 +496,20 @@ class RecordHandler:
424
496
 
425
497
  Makes a webservice call to create the data records.
426
498
 
427
- :param wrapper_type: The record model wrapper to use.
499
+ :param wrapper_type: The record model wrapper to use, or the data type name of the records.
428
500
  :param fields: The field map list to initialize the new data records with.
429
- :return: The newly created record models.
501
+ :return: The newly created record models. If a data type name was used instead of a model wrapper, then the
502
+ returned records will be PyRecordModels instead of WrappedRecordModels.
430
503
  """
431
- dt: str = wrapper_type.get_wrapper_data_type_name()
504
+ dt: str = AliasUtil.to_data_type_name(wrapper_type)
505
+ if isinstance(wrapper_type, str):
506
+ wrapper_type = None
432
507
  fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(fields)
433
508
  return self.wrap_models(self.dr_man.add_data_records_with_data(dt, fields), wrapper_type)
434
509
 
435
- def find_or_create_model(self, wrapper_type: type[WrappedType], primary_identifier: FieldIdentifier,
510
+ def find_or_create_model(self, wrapper_type: type[WrappedType] | str, primary_identifier: FieldIdentifier,
436
511
  id_value: FieldValue, secondary_identifiers: FieldIdentifierMap | None = None) \
437
- -> WrappedType:
512
+ -> WrappedType | PyRecordModel:
438
513
  """
439
514
  Find a unique record that matches the given field values. If no such records exist, create one with the
440
515
  identifying fields set to the desired values. If more than one record with the identifying values exists,
@@ -446,12 +521,14 @@ class RecordHandler:
446
521
  Makes a webservice call to query for the existing record. Makes an additional webservice call if the record
447
522
  needs to be created.
448
523
 
449
- :param wrapper_type: The record model wrapper to use.
524
+ :param wrapper_type: The record model wrapper to use, or the data type name of the record.
450
525
  :param primary_identifier: The data field name of the field to search on.
451
526
  :param id_value: The value of the identifying field to search for.
452
527
  :param secondary_identifiers: Optional fields used to filter the records that are returned after searching on
453
528
  the primary identifier.
454
529
  :return: The record model with the identifying field value, either pulled from the system or newly created.
530
+ If a data type name was used instead of a model wrapper, then the returned record will be a PyRecordModel
531
+ instead of a WrappedRecordModel.
455
532
  """
456
533
  # PR-46335: Initialize the secondary identifiers parameter if None is provided to avoid an exception.
457
534
  # If no secondary identifiers were provided, use an empty dictionary.
@@ -472,8 +549,281 @@ class RecordHandler:
472
549
  secondary_identifiers.update({primary_identifier: id_value})
473
550
  return self.create_models_with_data(wrapper_type, [secondary_identifiers])[0]
474
551
 
552
+ # FR-47525: Add functions for getting and setting record image bytes.
553
+ def get_record_image(self, record: SapioRecord) -> bytes:
554
+ """
555
+ Retrieve the record image for a given record.
556
+
557
+ :param record: The record model to retrieve the image of.
558
+ :return: The file bytes of the given record's image.
559
+ """
560
+ record: DataRecord = AliasUtil.to_data_record(record)
561
+ with io.BytesIO() as data_sink:
562
+ def consume_data(chunk: bytes):
563
+ data_sink.write(chunk)
564
+
565
+ self.dr_man.get_record_image(record, consume_data)
566
+ data_sink.flush()
567
+ data_sink.seek(0)
568
+ file_bytes = data_sink.read()
569
+ return file_bytes
570
+
571
+ def set_record_image(self, record: SapioRecord, file_data: str | bytes) -> None:
572
+ """
573
+ Set the record image for a given record.
574
+
575
+ :param record: The record model to set the image of.
576
+ :param file_data: The file data of the image to set on the record.
577
+ """
578
+ record: DataRecord = AliasUtil.to_data_record(record)
579
+ with io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data) as stream:
580
+ self.dr_man.set_record_image(record, stream)
581
+
582
+ # FR-47522: Add RecordHandler functions that copy from the RecordModelUtil class in our Java utilities.
583
+ @staticmethod
584
+ def get_values_list(records: list[RecordModel], field: FieldIdentifier) -> list[FieldValue]:
585
+ """
586
+ Get a list of field values from a list of record models.
587
+
588
+ :param records: The list of record models to get the field values from.
589
+ :param field: The field to get the values of.
590
+ :return: A list of field values from the input record models. The values are in the same order as the input
591
+ record models.
592
+ """
593
+ field: str = AliasUtil.to_data_field_name(field)
594
+ return [x.get_field_value(field) for x in records]
595
+
596
+ @staticmethod
597
+ def get_values_set(records: list[RecordModel], field: FieldIdentifier) -> set[FieldValue]:
598
+ """
599
+ Get a set of field values from a list of record models.
600
+
601
+ :param records: The list of record models to get the field values from.
602
+ :param field: The field to get the values of.
603
+ :return: A set of field values from the input record models.
604
+ """
605
+ field: str = AliasUtil.to_data_field_name(field)
606
+ return {x.get_field_value(field) for x in records}
607
+
608
+ @staticmethod
609
+ def set_values(records: list[RecordModel], field: FieldIdentifier, value: FieldValue) -> None:
610
+ """
611
+ Set the value of a field on a list of record models.
612
+
613
+ :param records: The list of record models to set the field value on.
614
+ :param field: The field to set the value of.
615
+ :param value: The value to set the field to for all input records.
616
+ """
617
+ field: str = AliasUtil.to_data_field_name(field)
618
+ for record in records:
619
+ record.set_field_value(field, value)
620
+
621
+ @staticmethod
622
+ def get_min_record(records: list[RecordModel], field: FieldIdentifier) -> RecordModel:
623
+ """
624
+ Get the record model with the minimum value of a given field from a list of record models.
625
+
626
+ :param records: The list of record models to search through.
627
+ :param field: The field to find the minimum value of.
628
+ :return: The record model with the minimum value of the given field.
629
+ """
630
+ field: str = AliasUtil.to_data_field_name(field)
631
+ return min(records, key=lambda x: x.get_field_value(field))
632
+
633
+ @staticmethod
634
+ def get_max_record(records: list[RecordModel], field: FieldIdentifier) -> RecordModel:
635
+ """
636
+ Get the record model with the maximum value of a given field from a list of record models.
637
+
638
+ :param records: The list of record models to search through.
639
+ :param field: The field to find the maximum value of.
640
+ :return: The record model with the maximum value of the given field.
641
+ """
642
+ field: str = AliasUtil.to_data_field_name(field)
643
+ return max(records, key=lambda x: x.get_field_value(field))
644
+
645
+ @staticmethod
646
+ def get_from_all(records: Iterable[RecordModel], getter: _PropertyGetter[_PropertyType]) -> list[_PropertyType]:
647
+ """
648
+ Use a getter property on all records in a list of record models. For example, you can iterate over a list of
649
+ record models using a getter of Ancestors.of_type(SampleModel) to get all the SampleModel ancestors from each
650
+ record.
651
+
652
+ :param records: The list of record models to get the property from.
653
+ :param getter: The getter to use to get the property from each record.
654
+ :return: A list of the property values from the input record models. The value at the matching index of the
655
+ input records is the results of using the getter on that record.
656
+ """
657
+ return [x.get(getter) for x in records]
658
+
659
+ @staticmethod
660
+ def set_on_all(records: Iterable[RecordModel], setter: _PropertySetter[_PropertyType]) -> list[_PropertyType]:
661
+ """
662
+ Use a setter property on all records in a list of record models. For example, you can iterate over a list of
663
+ record models user a setter of ForwardSideLink.ref(field_name, record) to set a forward side link on each
664
+ record.
665
+
666
+ :param records: The list of record models to set the property on.
667
+ :param setter: The setter to use to set the property on each record.
668
+ :return: A list of the property values that were set on the input record models. The value at the matching index
669
+ of the input records is the results of using the setter on that record.
670
+ """
671
+ return [x.set(setter) for x in records]
672
+
673
+ @staticmethod
674
+ def add_to_all(records: Iterable[RecordModel], adder: _PropertyAdder[_PropertyType]) -> list[_PropertyType]:
675
+ """
676
+ Use an adder property on all records in a list of record models. For example, you can iterate over a list of
677
+ record models using an adder of Child.create(SampleModel) to create a new SampleModel child on each record.
678
+
679
+ :param records: The list of record models to add the property to.
680
+ :param adder: The adder to use to add the property to each record.
681
+ :return: A list of the property values that were added to the input record models. The value at the matching
682
+ index of the input records is the results of using the adder on that record.
683
+ """
684
+ return [x.add(adder) for x in records]
685
+
686
+ @staticmethod
687
+ def remove_from_all(records: Iterable[RecordModel], remover: _PropertyRemover[_PropertyType]) -> list[_PropertyType]:
688
+ """
689
+ Use a remover property on all records in a list of record models. For example, you can iterate over a list of
690
+ record models using a remover of Parents.ref(records) to remove a list of parents from each record.
691
+
692
+ :param records: The list of record models to remove the property from.
693
+ :param remover: The remover to use to remove the property from each record.
694
+ :return: A list of the property values that were removed from the input record models. The value at the matching
695
+ index of the input records is the results of using the remover on that record.
696
+ """
697
+ return [x.remove(remover) for x in records]
698
+
699
+ # FR-47527: Created functions for manipulating relationships between records,
700
+ def get_extension(self, model: RecordModel, wrapper_type: type[WrappedType] | str) \
701
+ -> WrappedType | PyRecordModel | None:
702
+ """
703
+ Given a record with an extension record related to it, return the extension record as a record model.
704
+ This will retrieve an extension record without doing a webservice request to the server. The input record and
705
+ extension record will be considered related to one another if you later use load_child or load_parent on the
706
+ input record or extension record respectively.
707
+
708
+ :param model: The record model to get the extension for.
709
+ :param wrapper_type: The record model wrapper to use, or the data type name of the extension record. If a data
710
+ type name is provided, the returned record will be a PyRecordModel instead of a WrappedRecordModel.
711
+ :return: The extension record model for the input record model, or None if no extension record exists.
712
+ """
713
+ ext_dt: str = AliasUtil.to_data_type_name(wrapper_type)
714
+ ext_fields: FieldMap = {}
715
+ for field, value in AliasUtil.to_field_map(model).items():
716
+ if field.startswith(ext_dt + "."):
717
+ ext_fields[field.removeprefix(ext_dt + ".")] = value
718
+ if not ext_fields or ext_fields.get("RecordId") is None:
719
+ return None
720
+ ext_rec: DataRecord = DataRecord(ext_dt, ext_fields.get("RecordId"), ext_fields)
721
+ ext_model: WrappedType | PyRecordModel = self.wrap_model(ext_rec, wrapper_type)
722
+ self._spoof_child_load(model, ext_model)
723
+ self._spoof_parent_load(ext_model, model)
724
+ return ext_model
725
+
726
+ def get_or_add_parent(self, record: RecordModel, parent_type: type[WrappedType] | str) \
727
+ -> WrappedType | PyRecordModel:
728
+ """
729
+ Given a record model, retrieve the singular parent record model of a given type. If a parent of the given type
730
+ does not exist, a new one will be created. The parents of the given data type must already be loaded.
731
+
732
+ :param record: The record model to get the parent of.
733
+ :param parent_type: The record model wrapper of the parent, or the data type name of the parent. If a data type
734
+ name is provided, the returned record will be a PyRecordModel instead of a WrappedRecordModel.
735
+ :return: The parent record model of the given type.
736
+ """
737
+ parent_dt: str = AliasUtil.to_data_type_name(parent_type)
738
+ wrapper: type[WrappedType] | None = parent_type if isinstance(parent_type, type) else None
739
+ record: PyRecordModel = RecordModelInstanceManager.unwrap(record)
740
+ parent: PyRecordModel | None = record.get_parent_of_type(parent_dt)
741
+ if parent is not None:
742
+ return self.wrap_model(record, wrapper) if wrapper else parent
743
+ return record.add(Parent.create(wrapper)) if wrapper else record.add(Parent.create_by_name(parent_dt))
744
+
745
+ def get_or_add_child(self, record: RecordModel, child_type: type[WrappedType] | str) -> WrappedType | PyRecordModel:
746
+ """
747
+ Given a record model, retrieve the singular child record model of a given type. If a child of the given type
748
+ does not exist, a new one will be created. The children of the given data type must already be loaded.
749
+
750
+ :param record: The record model to get the child of.
751
+ :param child_type: The record model wrapper of the child, or the data type name of the child. If a data type
752
+ name is provided, the returned record will be a PyRecordModel instead of a WrappedRecordModel.
753
+ :return: The child record model of the given type.
754
+ """
755
+ child_dt: str = AliasUtil.to_data_type_name(child_type)
756
+ wrapper: type[WrappedType] | None = child_type if isinstance(child_type, type) else None
757
+ record: PyRecordModel = RecordModelInstanceManager.unwrap(record)
758
+ child: PyRecordModel | None = record.get_child_of_type(child_dt)
759
+ if child is not None:
760
+ return self.wrap_model(record, wrapper) if wrapper else child
761
+ return record.add(Child.create(wrapper)) if wrapper else record.add(Child.create_by_name(child_dt))
762
+
763
+ def get_or_add_side_link(self, record: RecordModel, side_link_field: FieldIdentifier,
764
+ side_link_type: type[WrappedType] | str) -> WrappedType | PyRecordModel:
765
+ """
766
+ Given a record model, retrieve the singular side link record model of a given type. If a side link of the given
767
+ type does not exist, a new one will be created. The side links of the given data type must already be loaded.
768
+
769
+ :param record: The record model to get the side link of.
770
+ :param side_link_field: The field name of the side link to get.
771
+ :param side_link_type: The record model wrapper of the side link, or the data type name of the side link. If a
772
+ data type name is provided, the returned record will be a PyRecordModel instead of a WrappedRecordModel.
773
+ :return: The side link record model of the given type.
774
+ """
775
+ side_link_field: str = AliasUtil.to_data_field_name(side_link_field)
776
+ wrapper: type[WrappedType] | None = side_link_type if isinstance(side_link_type, type) else None
777
+ record: PyRecordModel = RecordModelInstanceManager.unwrap(record)
778
+ side_link: PyRecordModel | None = record.get_forward_side_link(side_link_field)
779
+ if side_link is not None:
780
+ return self.wrap_model(record, wrapper) if wrapper else side_link
781
+ side_link: WrappedType | PyRecordModel = self.add_model(side_link_type)
782
+ return record.add(ForwardSideLink.ref(side_link_field, side_link))
783
+
784
+ @staticmethod
785
+ def set_parents(record: RecordModel, parents: Iterable[RecordModel], parent_type: DataTypeIdentifier) -> None:
786
+ """
787
+ Set the parents of a record model to a list of parent record models of a given type. The parents of the given
788
+ data type must already be loaded. This method will add the parents to the record model if they are not already
789
+ parents, and remove any existing parents that are not in the input list.
790
+
791
+ :param record: The record model to set the parents of.
792
+ :param parents: The list of parent record models to set as the parents of the input record model.
793
+ :param parent_type: The data type identifier of the parent record models.
794
+ """
795
+ parent_dt: str = AliasUtil.to_data_type_name(parent_type)
796
+ existing_parents: list[PyRecordModel] = record.get(Parents.of_type_name(parent_dt))
797
+ for parent in parents:
798
+ if parent not in existing_parents:
799
+ record.add(Parent.ref(parent))
800
+ for parent in existing_parents:
801
+ if parent not in parents:
802
+ record.remove(Parent.ref(parent))
803
+
804
+ @staticmethod
805
+ def set_children(record: RecordModel, children: Iterable[RecordModel], child_type: DataTypeIdentifier) -> None:
806
+ """
807
+ Set the children of a record model to a list of child record models of a given type. The children of the given
808
+ data type must already be loaded. This method will add the children to the record model if they are not already
809
+ children, and remove any existing children that are not in the input list.
810
+
811
+ :param record: The record model to set the children of.
812
+ :param children: The list of child record models to set as the children of the input record model.
813
+ :param child_type: The data type identifier of the child record models.
814
+ """
815
+ child_dt: str = AliasUtil.to_data_type_name(child_type)
816
+ existing_children: list[PyRecordModel] = record.get(Children.of_type_name(child_dt))
817
+ for child in children:
818
+ if child not in existing_children:
819
+ record.add(Child.ref(child))
820
+ for child in existing_children:
821
+ if child not in children:
822
+ record.remove(Child.ref(child))
823
+
475
824
  @staticmethod
476
- def map_to_parent(models: Iterable[RecordModel], parent_type: type[WrappedType]) -> dict[RecordModel, WrappedType]:
825
+ def map_to_parent(models: Iterable[WrappedRecordModel], parent_type: type[WrappedType])\
826
+ -> dict[WrappedRecordModel, WrappedType]:
477
827
  """
478
828
  Map a list of record models to a single parent of a given type. The parents must already be loaded.
479
829
 
@@ -482,14 +832,14 @@ class RecordHandler:
482
832
  :return: A dict[ModelType, ParentType]. If an input model doesn't have a parent of the given parent type, then
483
833
  it will map to None.
484
834
  """
485
- return_dict: dict[RecordModel, WrappedType] = {}
835
+ return_dict: dict[WrappedRecordModel, WrappedType] = {}
486
836
  for model in models:
487
837
  return_dict[model] = model.get_parent_of_type(parent_type)
488
838
  return return_dict
489
839
 
490
840
  @staticmethod
491
- def map_to_parents(models: Iterable[RecordModel], parent_type: type[WrappedType]) \
492
- -> dict[RecordModel, list[WrappedType]]:
841
+ def map_to_parents(models: Iterable[WrappedRecordModel], parent_type: type[WrappedType]) \
842
+ -> dict[WrappedRecordModel, list[WrappedType]]:
493
843
  """
494
844
  Map a list of record models to a list parents of a given type. The parents must already be loaded.
495
845
 
@@ -498,14 +848,14 @@ class RecordHandler:
498
848
  :return: A dict[ModelType, list[ParentType]]. If an input model doesn't have a parent of the given parent type,
499
849
  then it will map to an empty list.
500
850
  """
501
- return_dict: dict[RecordModel, list[WrappedType]] = {}
851
+ return_dict: dict[WrappedRecordModel, list[WrappedType]] = {}
502
852
  for model in models:
503
853
  return_dict[model] = model.get_parents_of_type(parent_type)
504
854
  return return_dict
505
855
 
506
856
  @staticmethod
507
- def map_by_parent(models: Iterable[RecordModel], parent_type: type[WrappedType]) \
508
- -> dict[WrappedType, RecordModel]:
857
+ def map_by_parent(models: Iterable[WrappedRecordModel], parent_type: type[WrappedType]) \
858
+ -> dict[WrappedType, WrappedRecordModel]:
509
859
  """
510
860
  Take a list of record models and map them by their parent. Essentially an inversion of map_to_parent.
511
861
  If two records share the same parent, an exception will be thrown. The parents must already be loaded.
@@ -515,8 +865,8 @@ class RecordHandler:
515
865
  :return: A dict[ParentType, ModelType]. If an input model doesn't have a parent of the given parent type,
516
866
  then it will not be in the resulting dictionary.
517
867
  """
518
- to_parent: dict[RecordModel, WrappedType] = RecordHandler.map_to_parent(models, parent_type)
519
- by_parent: dict[WrappedType, RecordModel] = {}
868
+ to_parent: dict[WrappedRecordModel, WrappedType] = RecordHandler.map_to_parent(models, parent_type)
869
+ by_parent: dict[WrappedType, WrappedRecordModel] = {}
520
870
  for record, parent in to_parent.items():
521
871
  if parent is None:
522
872
  continue
@@ -527,8 +877,8 @@ class RecordHandler:
527
877
  return by_parent
528
878
 
529
879
  @staticmethod
530
- def map_by_parents(models: Iterable[RecordModel], parent_type: type[WrappedType]) \
531
- -> dict[WrappedType, list[RecordModel]]:
880
+ def map_by_parents(models: Iterable[WrappedRecordModel], parent_type: type[WrappedType]) \
881
+ -> dict[WrappedType, list[WrappedRecordModel]]:
532
882
  """
533
883
  Take a list of record models and map them by their parents. Essentially an inversion of map_to_parents. Input
534
884
  models that share a parent will end up in the same list. The parents must already be loaded.
@@ -538,15 +888,16 @@ class RecordHandler:
538
888
  :return: A dict[ParentType, list[ModelType]]. If an input model doesn't have a parent of the given parent type,
539
889
  then it will not be in the resulting dictionary.
540
890
  """
541
- to_parents: dict[RecordModel, list[WrappedType]] = RecordHandler.map_to_parents(models, parent_type)
542
- by_parents: dict[WrappedType, list[RecordModel]] = {}
891
+ to_parents: dict[WrappedRecordModel, list[WrappedType]] = RecordHandler.map_to_parents(models, parent_type)
892
+ by_parents: dict[WrappedType, list[WrappedRecordModel]] = {}
543
893
  for record, parents in to_parents.items():
544
894
  for parent in parents:
545
895
  by_parents.setdefault(parent, []).append(record)
546
896
  return by_parents
547
897
 
548
898
  @staticmethod
549
- def map_to_child(models: Iterable[RecordModel], child_type: type[WrappedType]) -> dict[RecordModel, WrappedType]:
899
+ def map_to_child(models: Iterable[WrappedRecordModel], child_type: type[WrappedType])\
900
+ -> dict[WrappedRecordModel, WrappedType]:
550
901
  """
551
902
  Map a list of record models to a single child of a given type. The children must already be loaded.
552
903
 
@@ -555,14 +906,14 @@ class RecordHandler:
555
906
  :return: A dict[ModelType, ChildType]. If an input model doesn't have a child of the given child type, then
556
907
  it will map to None.
557
908
  """
558
- return_dict: dict[RecordModel, WrappedType] = {}
909
+ return_dict: dict[WrappedRecordModel, WrappedType] = {}
559
910
  for model in models:
560
911
  return_dict[model] = model.get_child_of_type(child_type)
561
912
  return return_dict
562
913
 
563
914
  @staticmethod
564
- def map_to_children(models: Iterable[RecordModel], child_type: type[WrappedType]) \
565
- -> dict[RecordModel, list[WrappedType]]:
915
+ def map_to_children(models: Iterable[WrappedRecordModel], child_type: type[WrappedType]) \
916
+ -> dict[WrappedRecordModel, list[WrappedType]]:
566
917
  """
567
918
  Map a list of record models to a list children of a given type. The children must already be loaded.
568
919
 
@@ -571,14 +922,14 @@ class RecordHandler:
571
922
  :return: A dict[ModelType, list[ChildType]]. If an input model doesn't have children of the given child type,
572
923
  then it will map to an empty list.
573
924
  """
574
- return_dict: dict[RecordModel, list[WrappedType]] = {}
925
+ return_dict: dict[WrappedRecordModel, list[WrappedType]] = {}
575
926
  for model in models:
576
927
  return_dict[model] = model.get_children_of_type(child_type)
577
928
  return return_dict
578
929
 
579
930
  @staticmethod
580
- def map_by_child(models: Iterable[RecordModel], child_type: type[WrappedType]) \
581
- -> dict[WrappedType, RecordModel]:
931
+ def map_by_child(models: Iterable[WrappedRecordModel], child_type: type[WrappedType]) \
932
+ -> dict[WrappedType, WrappedRecordModel]:
582
933
  """
583
934
  Take a list of record models and map them by their children. Essentially an inversion of map_to_child.
584
935
  If two records share the same child, an exception will be thrown. The children must already be loaded.
@@ -588,8 +939,8 @@ class RecordHandler:
588
939
  :return: A dict[ChildType, ModelType]. If an input model doesn't have a child of the given child type,
589
940
  then it will not be in the resulting dictionary.
590
941
  """
591
- to_child: dict[RecordModel, WrappedType] = RecordHandler.map_to_child(models, child_type)
592
- by_child: dict[WrappedType, RecordModel] = {}
942
+ to_child: dict[WrappedRecordModel, WrappedType] = RecordHandler.map_to_child(models, child_type)
943
+ by_child: dict[WrappedType, WrappedRecordModel] = {}
593
944
  for record, child in to_child.items():
594
945
  if child is None:
595
946
  continue
@@ -600,8 +951,8 @@ class RecordHandler:
600
951
  return by_child
601
952
 
602
953
  @staticmethod
603
- def map_by_children(models: Iterable[RecordModel], child_type: type[WrappedType]) \
604
- -> dict[WrappedType, list[RecordModel]]:
954
+ def map_by_children(models: Iterable[WrappedRecordModel], child_type: type[WrappedType]) \
955
+ -> dict[WrappedType, list[WrappedRecordModel]]:
605
956
  """
606
957
  Take a list of record models and map them by their children. Essentially an inversion of map_to_children. Input
607
958
  models that share a child will end up in the same list. The children must already be loaded.
@@ -611,8 +962,8 @@ class RecordHandler:
611
962
  :return: A dict[ChildType, list[ModelType]]. If an input model doesn't have children of the given child type,
612
963
  then it will not be in the resulting dictionary.
613
964
  """
614
- to_children: dict[RecordModel, list[WrappedType]] = RecordHandler.map_to_children(models, child_type)
615
- by_children: dict[WrappedType, list[RecordModel]] = {}
965
+ to_children: dict[WrappedRecordModel, list[WrappedType]] = RecordHandler.map_to_children(models, child_type)
966
+ by_children: dict[WrappedType, list[WrappedRecordModel]] = {}
616
967
  for record, children in to_children.items():
617
968
  for child in children:
618
969
  by_children.setdefault(child, []).append(record)
@@ -851,7 +1202,7 @@ class RecordHandler:
851
1202
  return field_sum
852
1203
 
853
1204
  @staticmethod
854
- def mean_of_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) -> float:
1205
+ def mean_of_field(models: Collection[SapioRecord], field_name: FieldIdentifier) -> float:
855
1206
  """
856
1207
  Calculate the mean of the numeric value of a given field across all input models. Excepts that all given models
857
1208
  have a value. If the field is an integer field, the value will be converted to a float.
@@ -860,7 +1211,7 @@ class RecordHandler:
860
1211
  :param field_name: The name of the numeric field to mean.
861
1212
  :return: The mean of the field values for the collection of models.
862
1213
  """
863
- return RecordHandler.sum_of_field(models, field_name) / len(list(models))
1214
+ return RecordHandler.sum_of_field(models, field_name) / len(models)
864
1215
 
865
1216
  @staticmethod
866
1217
  def get_newest_record(records: Iterable[SapioRecord]) -> SapioRecord:
@@ -870,11 +1221,7 @@ class RecordHandler:
870
1221
  :param records: The list of records.
871
1222
  :return: The input record with the highest record ID. None if the input list is empty.
872
1223
  """
873
- newest: SapioRecord | None = None
874
- for record in records:
875
- if newest is None or record.record_id > newest.record_id:
876
- newest = record
877
- return newest
1224
+ return max(records, key=lambda x: x.record_id)
878
1225
 
879
1226
  # FR-46696: Add a function for getting the oldest record in a list, just like we have one for the newest record.
880
1227
  @staticmethod
@@ -885,22 +1232,18 @@ class RecordHandler:
885
1232
  :param records: The list of records.
886
1233
  :return: The input record with the lowest record ID. None if the input list is empty.
887
1234
  """
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
1235
+ return min(records, key=lambda x: x.record_id)
893
1236
 
894
1237
  @staticmethod
895
1238
  def values_to_field_maps(field_name: FieldIdentifier, values: Iterable[FieldValue],
896
- existing_fields: list[FieldIdentifier] | None = None) -> list[FieldMap]:
1239
+ existing_fields: list[FieldMap] | None = None) -> list[FieldMap]:
897
1240
  """
898
1241
  Add a list of values for a specific field to a list of dictionaries pairing each value to that field name.
899
1242
 
900
1243
  :param field_name: The name of the field that the values are from.
901
1244
  :param values: A list of field values.
902
1245
  :param existing_fields: An optional existing fields map list to add the new values to. Values are added in the
903
- list in the same order that they appear. If no existing fields are provided, returns a new fields map list.
1246
+ list in the same order that they appear. If no existing fields are provided, returns a new fields map list.
904
1247
  :return: A fields map list that contains the given values mapped by the given field name.
905
1248
  """
906
1249
  # Update the existing fields map list if one is given.
@@ -919,8 +1262,9 @@ class RecordHandler:
919
1262
 
920
1263
  # FR-46155: Update relationship path traversing functions to be non-static and take in a wrapper type so that the
921
1264
  # output can be wrapped instead of requiring the user to wrap the output.
922
- def get_linear_path(self, models: Iterable[RecordModel], path: RelationshipPath, wrapper_type: type[WrappedType]) \
923
- -> dict[RecordModel, WrappedType | None]:
1265
+ def get_linear_path(self, models: Iterable[RecordModel], path: RelationshipPath,
1266
+ wrapper_type: type[WrappedType] | None = None) \
1267
+ -> dict[RecordModel, WrappedType | PyRecordModel | None]:
924
1268
  """
925
1269
  Given a relationship path, travel the path starting from the input models. Returns the record at the end of the
926
1270
  path, if any. The hierarchy must be linear (1:1 relationship between data types at every step) and the
@@ -928,7 +1272,8 @@ class RecordHandler:
928
1272
 
929
1273
  :param models: A list of record models.
930
1274
  :param path: The relationship path to follow.
931
- :param wrapper_type: The record model wrapper to use.
1275
+ :param wrapper_type: The record model wrapper to use on the record at the end of the path. If not provided,
1276
+ the record will be a PyRecordModel instead of a WrappedRecordModel.
932
1277
  :return: Each record model mapped to the record at the end of the path starting from itself. If the end of the
933
1278
  path couldn't be reached, the record will map to None.
934
1279
  """
@@ -976,11 +1321,12 @@ class RecordHandler:
976
1321
  current = reverse_links[0]
977
1322
  else:
978
1323
  raise SapioException("Unsupported path direction.")
979
- ret_dict.update({model: self.inst_man.wrap(current, wrapper_type) if current else None})
1324
+ ret_dict.update({model: self.wrap_model(current, wrapper_type) if current else None})
980
1325
  return ret_dict
981
1326
 
982
1327
  def get_branching_path(self, models: Iterable[RecordModel], path: RelationshipPath,
983
- wrapper_type: type[WrappedType]) -> dict[RecordModel, list[WrappedType]]:
1328
+ wrapper_type: type[WrappedType] | None = None)\
1329
+ -> dict[RecordModel, list[WrappedType] | list[PyRecordModel]]:
984
1330
  """
985
1331
  Given a relationship path, travel the path starting from the input models. Returns the record at the end of the
986
1332
  path, if any. The hierarchy may be non-linear (1:Many relationships between data types are allowed) and the
@@ -988,7 +1334,8 @@ class RecordHandler:
988
1334
 
989
1335
  :param models: A list of record models.
990
1336
  :param path: The relationship path to follow.
991
- :param wrapper_type: The record model wrapper to use.
1337
+ :param wrapper_type: The record model wrapper to use on the records at the end of the path. If not provided,
1338
+ the records will be PyRecordModels instead of WrappedRecordModels.
992
1339
  :return: Each record model mapped to the records at the end of the path starting from itself. If the end of the
993
1340
  path couldn't be reached, the record will map to an empty list.
994
1341
  """
@@ -1021,13 +1368,14 @@ class RecordHandler:
1021
1368
  raise SapioException("Unsupported path direction.")
1022
1369
  current_search = next_search
1023
1370
  next_search = set()
1024
- ret_dict.update({model: self.inst_man.wrap_list(list(current_search), wrapper_type)})
1371
+ ret_dict.update({model: self.wrap_models(current_search, wrapper_type)})
1025
1372
  return ret_dict
1026
1373
 
1027
1374
  # FR-46155: Create a relationship traversing function that returns a single function at the end of the path like
1028
1375
  # get_linear_path but can handle branching paths in the middle of the search like get_branching_path.
1029
- def get_flat_path(self, models: Iterable[RecordModel], path: RelationshipPath, wrapper_type: type[WrappedType]) \
1030
- -> dict[RecordModel, WrappedType | None]:
1376
+ def get_flat_path(self, models: Iterable[RecordModel], path: RelationshipPath,
1377
+ wrapper_type: type[WrappedType] | None = None) \
1378
+ -> dict[RecordModel, WrappedType | PyRecordModel | None]:
1031
1379
  """
1032
1380
  Given a relationship path, travel the path starting from the input models. Returns the record at the end of the
1033
1381
  path, if any. The hierarchy may be non-linear (1:Many relationships between data types are allowed) and the
@@ -1039,7 +1387,8 @@ class RecordHandler:
1039
1387
 
1040
1388
  :param models: A list of record models.
1041
1389
  :param path: The relationship path to follow.
1042
- :param wrapper_type: The record model wrapper to use.
1390
+ :param wrapper_type: The record model wrapper to use on the record at the end of the path. If not provided,
1391
+ the record will be a PyRecordModel instead of a WrappedRecordModel.
1043
1392
  :return: Each record model mapped to the record at the end of the path starting from itself. If the end of the
1044
1393
  path couldn't be reached, the record will map to None.
1045
1394
  """
@@ -1067,21 +1416,22 @@ class RecordHandler:
1067
1416
  current = current[0].get_reverse_side_link(data_type, node.data_field_name)
1068
1417
  else:
1069
1418
  raise SapioException("Unsupported path direction.")
1070
- ret_dict.update({model: self.inst_man.wrap(current[0], wrapper_type) if current else None})
1419
+ ret_dict.update({model: self.wrap_model(current[0], wrapper_type) if current else None})
1071
1420
  return ret_dict
1072
1421
 
1073
- def __find_model(self, wrapper_type: type[WrappedType], primary_identifier: str, id_value: FieldValue,
1074
- secondary_identifiers: FieldIdentifierMap | None = None) -> WrappedType | None:
1422
+ def __find_model(self, wrapper_type: type[WrappedType] | str, primary_identifier: str, id_value: FieldValue,
1423
+ secondary_identifiers: FieldIdentifierMap | None = None) -> WrappedType | PyRecordModel | None:
1075
1424
  """
1076
1425
  Find a record from the system that matches the given field values. The primary identifier and value is used
1077
1426
  to query for the record, then the secondary identifiers may be optionally provided to further filter the
1078
1427
  returned results. If no record is found with these filters, returns None.
1079
1428
  """
1080
1429
  # Query for all records that match the primary identifier.
1081
- results: list[WrappedType] = self.query_models(wrapper_type, primary_identifier, [id_value])
1430
+ results: list[WrappedType] | list[PyRecordModel] = self.query_models(wrapper_type, primary_identifier,
1431
+ [id_value])
1082
1432
 
1083
1433
  # Find the one record, if any, that matches the secondary identifiers.
1084
- unique_record: WrappedType | None = None
1434
+ unique_record: WrappedType | PyRecordModel | None = None
1085
1435
  for result in results:
1086
1436
  matches_all: bool = True
1087
1437
  for field, value in secondary_identifiers.items():
@@ -1091,22 +1441,60 @@ class RecordHandler:
1091
1441
  if matches_all:
1092
1442
  # If a previous record in the results already matched all identifiers, then throw an exception.
1093
1443
  if unique_record is not None:
1094
- raise SapioException(f"More than one record of type {wrapper_type.get_wrapper_data_type_name()} "
1444
+ raise SapioException(f"More than one record of type {AliasUtil.to_data_type_name(wrapper_type)} "
1095
1445
  f"encountered in system that matches all provided identifiers.")
1096
1446
  unique_record = result
1097
1447
  return unique_record
1098
1448
 
1099
1449
  @staticmethod
1100
- def __verify_data_type(records: Iterable[DataRecord], wrapper_type: type[WrappedType]) -> None:
1450
+ def __verify_data_type(record: DataRecord | PyRecordModel, wrapper_type: type[WrappedType]) -> None:
1101
1451
  """
1102
- Throw an exception if the data type of the given records and wrapper don't match.
1452
+ Throw an exception if the data type of the given record and wrapper don't match.
1103
1453
  """
1104
1454
  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}")
1455
+ record_type: str = AliasUtil.to_data_type_name(record)
1456
+ # Account for ELN data type records.
1457
+ if ElnBaseDataType.is_eln_type(record_type):
1458
+ record_type = ElnBaseDataType.get_base_type(record_type).data_type_name
1459
+ if record_type != model_type:
1460
+ raise SapioException(f"Data record of type {record_type} cannot be wrapped by the record model wrapper "
1461
+ f"of type {model_type}")
1462
+
1463
+ @staticmethod
1464
+ def _spoof_child_load(model: RecordModel, child: RecordModel) -> None:
1465
+ """
1466
+ Spoof the loading of a child record on a record model. This is useful for when you have records that you know
1467
+ are related but didn't use the relationship manager to load the relationship, which would make a webservice
1468
+ call.
1469
+ """
1470
+ RecordHandler._spoof_children_load(model, [child])
1471
+
1472
+ @staticmethod
1473
+ def _spoof_children_load(model: RecordModel, children: list[RecordModel]) -> None:
1474
+ """
1475
+ Spoof the loading of child records on a record model. This is useful for when you have records that you know
1476
+ are related but didn't use the relationship manager to load the relationship, which would make a webservice
1477
+ """
1478
+ model: PyRecordModel = RecordModelInstanceManager.unwrap(model)
1479
+ child_dt: str = AliasUtil.to_singular_data_type_name(children)
1480
+ # noinspection PyProtectedMember
1481
+ model._mark_children_loaded(child_dt, RecordModelInstanceManager.unwrap_list(children))
1482
+
1483
+ @staticmethod
1484
+ def _spoof_parent_load(model: RecordModel, parent: RecordModel) -> None:
1485
+ """
1486
+ Spoof the loading of a parent record 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
+ RecordHandler._spoof_parents_load(model, [parent])
1490
+
1491
+ @staticmethod
1492
+ def _spoof_parents_load(model: RecordModel, parents: list[RecordModel]) -> None:
1493
+ """
1494
+ Spoof the loading of parent records on a record model. This is useful for when you have records that you know
1495
+ are related but didn't use the relationship manager to load the relationship, which would make a webservice
1496
+ """
1497
+ model: PyRecordModel = RecordModelInstanceManager.unwrap(model)
1498
+ parent_dt: str = AliasUtil.to_singular_data_type_name(parents)
1499
+ # noinspection PyProtectedMember
1500
+ model._mark_children_loaded(parent_dt, RecordModelInstanceManager.unwrap_list(parents))