sapiopycommons 2025.5.20a539__py3-none-any.whl → 2025.5.30a548__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.

@@ -39,6 +39,7 @@ _PropertySetter = AbstractRecordModelPropertySetter
39
39
  _PropertyType = RecordModelPropertyType
40
40
 
41
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.
42
43
  class RecordHandler:
43
44
  """
44
45
  A collection of shorthand methods for dealing with the various record managers.
@@ -113,6 +114,167 @@ class RecordHandler:
113
114
  """
114
115
  return [self.wrap_model(x, wrapper_type) for x in records]
115
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
+
116
278
  # CR-47491: Support providing a data type name string to receive PyRecordModels instead of requiring a WrapperType.
117
279
  # CR-47523: Support a singular field value being provided for the value_list parameter.
118
280
  def query_models(self, wrapper_type: type[WrappedType] | str, field: FieldIdentifier,
@@ -388,196 +550,161 @@ class RecordHandler:
388
550
  ids: list[int] = [row["RecordId"] for row in results]
389
551
  return self.query_models_by_id(wrapper_type, ids)
390
552
 
391
- def add_model(self, wrapper_type: type[WrappedType] | str) -> WrappedType | PyRecordModel:
553
+ @staticmethod
554
+ def map_by_id(models: Iterable[SapioRecord]) -> dict[int, SapioRecord]:
392
555
  """
393
- Shorthand for using the instance manager to add a new record model of the given type.
556
+ Map the given records their record IDs.
394
557
 
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.
558
+ :param models: The records to map.
559
+ :return: A dict mapping the record ID to each record.
398
560
  """
399
- 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
400
565
 
401
- 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]]:
402
569
  """
403
- 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.
404
572
 
405
- :param wrapper_type: The record model wrapper to use, or the data type name of the records.
406
- :param num: The number of models to create.
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.
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.
409
576
  """
410
- if isinstance(wrapper_type, str):
411
- return self.inst_man.add_new_records(wrapper_type, num)
412
- 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
413
583
 
414
- def add_models_with_data(self, wrapper_type: type[WrappedType] | str, fields: list[FieldIdentifierMap]) \
415
- -> list[WrappedType] | list[PyRecordModel]:
584
+ @staticmethod
585
+ def map_by_unique_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) \
586
+ -> dict[FieldValue, SapioRecord]:
416
587
  """
417
- Shorthand for using the instance manager to add new models of the given type, and then initializing all those
418
- 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.
419
590
 
420
- :param wrapper_type: The record model wrapper to use, or the data type name of the records.
421
- :param fields: A list of field maps to initialize the record models with.
422
- :return: The newly added record models with the provided fields set. The records will be in the same order as
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.
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.
425
594
  """
426
- fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(fields)
427
- models: list[WrappedType] = self.add_models(wrapper_type, len(fields))
428
- for model, field_list in zip(models, fields):
429
- model.set_field_values(field_list)
430
- 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
431
603
 
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:
604
+ # FR-47525: Add functions for getting and setting record image bytes.
605
+ def get_record_image(self, record: SapioRecord) -> bytes:
435
606
  """
436
- Find a unique record that matches the given field values. If no such records exist, add a record model to the
437
- cache with the identifying fields set to the desired values. This record will be created in the system when
438
- you store and commit changes. If more than one record with the identifying values exists, throws an exception.
439
-
440
- The record is searched for using the primary identifier field name and value. If multiple records are returned
441
- by the query on this primary identifier, then the secondary identifiers are used to filter the results.
442
-
443
- Makes a webservice call to query for the existing record.
607
+ Retrieve the record image for a given record.
444
608
 
445
- :param wrapper_type: The record model wrapper to use, or the data type name of the record.
446
- :param primary_identifier: The data field name of the field to search on.
447
- :param id_value: The value of the identifying field to search for.
448
- :param secondary_identifiers: Optional fields used to filter the records that are returned after searching on
449
- the primary identifier.
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.
609
+ :param record: The record model to retrieve the image of.
610
+ :return: The file bytes of the given record's image.
453
611
  """
454
- # PR-46335: Initialize the secondary identifiers parameter if None is provided to avoid an exception.
455
- # If no secondary identifiers were provided, use an empty dictionary.
456
- if secondary_identifiers is None:
457
- secondary_identifiers = {}
458
-
459
- primary_identifier: str = AliasUtil.to_data_field_name(primary_identifier)
460
- secondary_identifiers: FieldMap = AliasUtil.to_data_field_names_dict(secondary_identifiers)
461
- unique_record: WrappedType | None = self.__find_model(wrapper_type, primary_identifier, id_value,
462
- secondary_identifiers)
463
- # If a unique record matched the identifiers, return it.
464
- if unique_record is not None:
465
- return unique_record
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)
466
616
 
467
- # If none of the results matched the identifiers, create a new record with all identifiers set.
468
- # Put the primary identifier and value into the secondary identifiers list and use that as the fields map
469
- # for this new record.
470
- secondary_identifiers.update({primary_identifier: id_value})
471
- return self.add_models_with_data(wrapper_type, [secondary_identifiers])[0]
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
472
622
 
473
- def create_models(self, wrapper_type: type[WrappedType] | str, num: int) -> list[WrappedType] | list[PyRecordModel]:
623
+ def set_record_image(self, record: SapioRecord, file_data: str | bytes) -> None:
474
624
  """
475
- Shorthand for creating new records via the data record manager and then returning them as wrapped
476
- record models. Useful in cases where your record model needs to have a valid record ID.
477
-
478
- Makes a webservice call to create the data records.
625
+ Set the record image for a given record.
479
626
 
480
- :param wrapper_type: The record model wrapper to use, or the data type name of the records.
481
- :param num: The number of new records to create.
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.
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.
484
629
  """
485
- dt: str = AliasUtil.to_data_type_name(wrapper_type)
486
- if isinstance(wrapper_type, str):
487
- wrapper_type = None
488
- return self.wrap_models(self.dr_man.add_data_records(dt, num), wrapper_type)
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)
489
633
 
490
- def create_models_with_data(self, wrapper_type: type[WrappedType] | str, fields: list[FieldIdentifierMap]) \
491
- -> list[WrappedType] | list[PyRecordModel]:
634
+ @staticmethod
635
+ def sum_of_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) -> float:
492
636
  """
493
- Shorthand for creating new records via the data record manager with field data to initialize the records with
494
- and then returning them as wrapped record models. Useful in cases where your record model needs to have a valid
495
- record ID.
496
-
497
- Makes a webservice call to create the data records.
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.
498
639
 
499
- :param wrapper_type: The record model wrapper to use, or the data type name of the records.
500
- :param fields: The field map list to initialize the new data records with.
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.
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.
503
643
  """
504
- dt: str = AliasUtil.to_data_type_name(wrapper_type)
505
- if isinstance(wrapper_type, str):
506
- wrapper_type = None
507
- fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(fields)
508
- return self.wrap_models(self.dr_man.add_data_records_with_data(dt, fields), wrapper_type)
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
509
651
 
510
- def find_or_create_model(self, wrapper_type: type[WrappedType] | str, primary_identifier: FieldIdentifier,
511
- id_value: FieldValue, secondary_identifiers: FieldIdentifierMap | None = None) \
512
- -> WrappedType | PyRecordModel:
652
+ @staticmethod
653
+ def mean_of_field(models: Collection[SapioRecord], field_name: FieldIdentifier) -> float:
513
654
  """
514
- Find a unique record that matches the given field values. If no such records exist, create one with the
515
- identifying fields set to the desired values. If more than one record with the identifying values exists,
516
- throws an exception.
517
-
518
- The record is searched for using the primary identifier field name and value. If multiple records are returned
519
- by the query on this primary identifier, then the secondary identifiers are used to filter the results.
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.
520
657
 
521
- Makes a webservice call to query for the existing record. Makes an additional webservice call if the record
522
- needs to be created.
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.
661
+ """
662
+ return RecordHandler.sum_of_field(models, field_name) / len(models)
523
663
 
524
- :param wrapper_type: The record model wrapper to use, or the data type name of the record.
525
- :param primary_identifier: The data field name of the field to search on.
526
- :param id_value: The value of the identifying field to search for.
527
- :param secondary_identifiers: Optional fields used to filter the records that are returned after searching on
528
- the primary identifier.
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.
664
+ @staticmethod
665
+ def get_newest_record(records: Iterable[SapioRecord]) -> SapioRecord:
532
666
  """
533
- # PR-46335: Initialize the secondary identifiers parameter if None is provided to avoid an exception.
534
- # If no secondary identifiers were provided, use an empty dictionary.
535
- if secondary_identifiers is None:
536
- secondary_identifiers = {}
667
+ Get the newest record from a list of records.
537
668
 
538
- primary_identifier: str = AliasUtil.to_data_field_name(primary_identifier)
539
- secondary_identifiers: FieldMap = AliasUtil.to_data_field_names_dict(secondary_identifiers)
540
- unique_record: WrappedType | None = self.__find_model(wrapper_type, primary_identifier, id_value,
541
- secondary_identifiers)
542
- # If a unique record matched the identifiers, return it.
543
- if unique_record is not None:
544
- return unique_record
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)
545
673
 
546
- # If none of the results matched the identifiers, create a new record with all identifiers set.
547
- # Put the primary identifier and value into the secondary identifiers list and use that as the fields map
548
- # for this new record.
549
- secondary_identifiers.update({primary_identifier: id_value})
550
- return self.create_models_with_data(wrapper_type, [secondary_identifiers])[0]
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:
677
+ """
678
+ Get the oldest record from a list of records.
551
679
 
552
- # FR-47525: Add functions for getting and setting record image bytes.
553
- def get_record_image(self, record: SapioRecord) -> bytes:
680
+ :param records: The list of records.
681
+ :return: The input record with the lowest record ID. None if the input list is empty.
554
682
  """
555
- Retrieve the record image for a given record.
683
+ return min(records, key=lambda x: x.record_id)
556
684
 
557
- :param record: The record model to retrieve the image of.
558
- :return: The file bytes of the given record's image.
685
+ @staticmethod
686
+ def get_min_record(records: list[RecordModel], field: FieldIdentifier) -> RecordModel:
559
687
  """
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)
688
+ Get the record model with the minimum value of a given field from a list of record models.
564
689
 
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
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))
570
696
 
571
- def set_record_image(self, record: SapioRecord, file_data: str | bytes) -> None:
697
+ @staticmethod
698
+ def get_max_record(records: list[RecordModel], field: FieldIdentifier) -> RecordModel:
572
699
  """
573
- Set the record image for a given record.
700
+ Get the record model with the maximum value of a given field from a list of record models.
574
701
 
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.
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.
577
705
  """
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)
706
+ field: str = AliasUtil.to_data_field_name(field)
707
+ return max(records, key=lambda x: x.get_field_value(field))
581
708
 
582
709
  # FR-47522: Add RecordHandler functions that copy from the RecordModelUtil class in our Java utilities.
583
710
  @staticmethod
@@ -619,31 +746,34 @@ class RecordHandler:
619
746
  record.set_field_value(field, value)
620
747
 
621
748
  @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:
749
+ def values_to_field_maps(field_name: FieldIdentifier, values: Iterable[FieldValue],
750
+ existing_fields: list[FieldMap] | None = None) -> list[FieldMap]:
635
751
  """
636
- Get the record model with the maximum value of a given field from a list of record models.
752
+ Add a list of values for a specific field to a list of dictionaries pairing each value to that field name.
637
753
 
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.
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.
641
759
  """
642
- field: str = AliasUtil.to_data_field_name(field)
643
- return max(records, key=lambda x: x.get_field_value(field))
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]
644
773
 
645
774
  @staticmethod
646
- def get_from_all(records: Iterable[RecordModel], getter: _PropertyGetter[_PropertyType]) -> list[_PropertyType]:
775
+ def get_from_all(records: Iterable[RecordModel], getter: _PropertyGetter[_PropertyType]) \
776
+ -> list[RecordModelPropertyType]:
647
777
  """
648
778
  Use a getter property on all records in a list of record models. For example, you can iterate over a list of
649
779
  record models using a getter of Ancestors.of_type(SampleModel) to get all the SampleModel ancestors from each
@@ -657,7 +787,8 @@ class RecordHandler:
657
787
  return [x.get(getter) for x in records]
658
788
 
659
789
  @staticmethod
660
- def set_on_all(records: Iterable[RecordModel], setter: _PropertySetter[_PropertyType]) -> list[_PropertyType]:
790
+ def set_on_all(records: Iterable[RecordModel], setter: _PropertySetter[_PropertyType]) \
791
+ -> list[RecordModelPropertyType]:
661
792
  """
662
793
  Use a setter property on all records in a list of record models. For example, you can iterate over a list of
663
794
  record models user a setter of ForwardSideLink.ref(field_name, record) to set a forward side link on each
@@ -671,7 +802,8 @@ class RecordHandler:
671
802
  return [x.set(setter) for x in records]
672
803
 
673
804
  @staticmethod
674
- def add_to_all(records: Iterable[RecordModel], adder: _PropertyAdder[_PropertyType]) -> list[_PropertyType]:
805
+ def add_to_all(records: Iterable[RecordModel], adder: _PropertyAdder[_PropertyType]) \
806
+ -> list[RecordModelPropertyType]:
675
807
  """
676
808
  Use an adder property on all records in a list of record models. For example, you can iterate over a list of
677
809
  record models using an adder of Child.create(SampleModel) to create a new SampleModel child on each record.
@@ -684,7 +816,8 @@ class RecordHandler:
684
816
  return [x.add(adder) for x in records]
685
817
 
686
818
  @staticmethod
687
- def remove_from_all(records: Iterable[RecordModel], remover: _PropertyRemover[_PropertyType]) -> list[_PropertyType]:
819
+ def remove_from_all(records: Iterable[RecordModel], remover: _PropertyRemover[_PropertyType]) \
820
+ -> list[RecordModelPropertyType]:
688
821
  """
689
822
  Use a remover property on all records in a list of record models. For example, you can iterate over a list of
690
823
  record models using a remover of Parents.ref(records) to remove a list of parents from each record.
@@ -989,76 +1122,56 @@ class RecordHandler:
989
1122
  return return_dict
990
1123
 
991
1124
  @staticmethod
992
- def map_by_forward_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
993
- 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]:
994
1127
  """
995
1128
  Take a list of record models and map them by their forward side link. Essentially an inversion of
996
- 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.
997
1130
  The forward side link must already be loaded.
998
1131
 
999
1132
  :param models: A list of record models.
1000
1133
  :param field_name: The field name on the record models where the side link is located.
1001
1134
  :param side_link_type: The record model wrapper of the forward side links.
1002
- :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
1003
1136
  pointing to it, then it will not be in the resulting dictionary.
1004
1137
  """
1005
1138
  field_name: str = AliasUtil.to_data_field_name(field_name)
1006
1139
  to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
1007
1140
  .map_to_forward_side_link(models, field_name, side_link_type)
1008
- by_side_link: dict[WrappedType, list[WrappedRecordModel]] = {}
1141
+ by_side_link: dict[WrappedType, WrappedRecordModel] = {}
1009
1142
  for record, side_link in to_side_link.items():
1010
1143
  if side_link is None:
1011
1144
  continue
1012
- 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
1013
1149
  return by_side_link
1014
1150
 
1015
1151
  @staticmethod
1016
- def map_by_forward_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
1017
- 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]]:
1018
1154
  """
1019
1155
  Take a list of record models and map them by their forward side link. Essentially an inversion of
1020
- 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.
1021
1157
  The forward side link must already be loaded.
1022
1158
 
1023
1159
  :param models: A list of record models.
1024
1160
  :param field_name: The field name on the record models where the side link is located.
1025
1161
  :param side_link_type: The record model wrapper of the forward side links.
1026
- :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
1027
1163
  pointing to it, then it will not be in the resulting dictionary.
1028
1164
  """
1029
1165
  field_name: str = AliasUtil.to_data_field_name(field_name)
1030
1166
  to_side_link: dict[WrappedRecordModel, WrappedType] = RecordHandler\
1031
1167
  .map_to_forward_side_link(models, field_name, side_link_type)
1032
- by_side_link: dict[WrappedType, WrappedRecordModel] = {}
1168
+ by_side_link: dict[WrappedType, list[WrappedRecordModel]] = {}
1033
1169
  for record, side_link in to_side_link.items():
1034
1170
  if side_link is None:
1035
1171
  continue
1036
- if side_link in by_side_link:
1037
- raise SapioException(f"Side link {side_link.data_type_name} {side_link.record_id} encountered more "
1038
- f"than once in models list.")
1039
- by_side_link[side_link] = record
1172
+ by_side_link.setdefault(side_link, []).append(record)
1040
1173
  return by_side_link
1041
1174
 
1042
- @staticmethod
1043
- def map_to_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
1044
- side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, list[WrappedType]]:
1045
- """
1046
- Map a list of record models to a list reverse side links of a given type. The reverse side links must already
1047
- be loaded.
1048
-
1049
- :param models: A list of record models.
1050
- :param field_name: The field name on the side linked model where the side link to the given record models is
1051
- located.
1052
- :param side_link_type: The record model wrapper of the reverse side links.
1053
- :return: A dict[ModelType, list[SideLink]]. If an input model doesn't have reverse side links of the given type,
1054
- then it will map to an empty list.
1055
- """
1056
- field_name: str = AliasUtil.to_data_field_name(field_name)
1057
- return_dict: dict[WrappedRecordModel, list[WrappedType]] = {}
1058
- for model in models:
1059
- return_dict[model] = model.get_reverse_side_link(field_name, side_link_type)
1060
- return return_dict
1061
-
1062
1175
  @staticmethod
1063
1176
  def map_to_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
1064
1177
  side_link_type: type[WrappedType]) -> dict[WrappedRecordModel, WrappedType]:
@@ -1084,28 +1197,24 @@ class RecordHandler:
1084
1197
  return return_dict
1085
1198
 
1086
1199
  @staticmethod
1087
- def map_by_reverse_side_links(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
1088
- 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]]:
1089
1202
  """
1090
- Take a list of record models and map them by their reverse side links. Essentially an inversion of
1091
- map_to_reverse_side_links. Input models that share a reverse side link will end up in the same list.
1092
- 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.
1093
1205
 
1094
1206
  :param models: A list of record models.
1095
1207
  :param field_name: The field name on the side linked model where the side link to the given record models is
1096
1208
  located.
1097
1209
  :param side_link_type: The record model wrapper of the reverse side links.
1098
- :return: A dict[SideLink, list[ModelType]]. If an input model doesn't have reverse side links of the given type
1099
- 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.
1100
1212
  """
1101
1213
  field_name: str = AliasUtil.to_data_field_name(field_name)
1102
- to_side_links: dict[WrappedRecordModel, list[WrappedType]] = RecordHandler\
1103
- .map_to_reverse_side_links(models, field_name, side_link_type)
1104
- by_side_links: dict[WrappedType, list[WrappedRecordModel]] = {}
1105
- for record, side_links in to_side_links.items():
1106
- for side_link in side_links:
1107
- by_side_links.setdefault(side_link, []).append(record)
1108
- 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
1109
1218
 
1110
1219
  @staticmethod
1111
1220
  def map_by_reverse_side_link(models: Iterable[WrappedRecordModel], field_name: FieldIdentifier,
@@ -1136,130 +1245,28 @@ class RecordHandler:
1136
1245
  return by_side_link
1137
1246
 
1138
1247
  @staticmethod
1139
- def map_by_id(models: Iterable[SapioRecord]) -> dict[int, SapioRecord]:
1140
- """
1141
- Map the given records their record IDs.
1142
-
1143
- :param models: The records to map.
1144
- :return: A dict mapping the record ID to each record.
1145
- """
1146
- ret_dict: dict[int, SapioRecord] = {}
1147
- for model in models:
1148
- ret_dict.update({model.record_id: model})
1149
- return ret_dict
1150
-
1151
- @staticmethod
1152
- def map_by_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) \
1153
- -> dict[FieldValue, list[SapioRecord]]:
1154
- """
1155
- Map the given records by one of their fields. If any two records share the same field value, they'll appear in
1156
- the same value list.
1157
-
1158
- :param models: The records to map.
1159
- :param field_name: The field name to map against.
1160
- :return: A dict mapping field values to the records with that value.
1161
- """
1162
- field_name: str = AliasUtil.to_data_field_name(field_name)
1163
- ret_dict: dict[FieldValue, list[SapioRecord]] = {}
1164
- for model in models:
1165
- val: FieldValue = model.get_field_value(field_name)
1166
- ret_dict.setdefault(val, []).append(model)
1167
- return ret_dict
1168
-
1169
- @staticmethod
1170
- def map_by_unique_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) \
1171
- -> dict[FieldValue, SapioRecord]:
1172
- """
1173
- Uniquely map the given records by one of their fields. If any two records share the same field value, throws
1174
- an exception.
1175
-
1176
- :param models: The records to map.
1177
- :param field_name: The field name to map against.
1178
- :return: A dict mapping field values to the record with that value.
1179
- """
1180
- field_name: str = AliasUtil.to_data_field_name(field_name)
1181
- ret_dict: dict[FieldValue, SapioRecord] = {}
1182
- for model in models:
1183
- val: FieldValue = model.get_field_value(field_name)
1184
- if val in ret_dict:
1185
- raise SapioException(f"Value {val} encountered more than once in models list.")
1186
- ret_dict.update({val: model})
1187
- return ret_dict
1188
-
1189
- @staticmethod
1190
- def sum_of_field(models: Iterable[SapioRecord], field_name: FieldIdentifier) -> float:
1191
- """
1192
- Sum up the numeric value of a given field across all input models. Excepts that all given models have a value.
1193
- If the field is an integer field, the value will be converted to a float.
1194
-
1195
- :param models: The models to calculate the sum of.
1196
- :param field_name: The name of the numeric field to sum.
1197
- :return: The sum of the field values for the collection of models.
1198
- """
1199
- field_name: str = AliasUtil.to_data_field_name(field_name)
1200
- field_sum: float = 0
1201
- for model in models:
1202
- field_sum += float(model.get_field_value(field_name))
1203
- return field_sum
1204
-
1205
- @staticmethod
1206
- def mean_of_field(models: Collection[SapioRecord], field_name: FieldIdentifier) -> float:
1207
- """
1208
- Calculate the mean of the numeric value of a given field across all input models. Excepts that all given models
1209
- have a value. If the field is an integer field, the value will be converted to a float.
1210
-
1211
- :param models: The models to calculate the mean of.
1212
- :param field_name: The name of the numeric field to mean.
1213
- :return: The mean of the field values for the collection of models.
1214
- """
1215
- return RecordHandler.sum_of_field(models, field_name) / len(models)
1216
-
1217
- @staticmethod
1218
- def get_newest_record(records: Iterable[SapioRecord]) -> SapioRecord:
1219
- """
1220
- Get the newest record from a list of records.
1221
-
1222
- :param records: The list of records.
1223
- :return: The input record with the highest record ID. None if the input list is empty.
1224
- """
1225
- return max(records, key=lambda x: x.record_id)
1226
-
1227
- # FR-46696: Add a function for getting the oldest record in a list, just like we have one for the newest record.
1228
- @staticmethod
1229
- def get_oldest_record(records: Iterable[SapioRecord]) -> SapioRecord:
1230
- """
1231
- Get the oldest record from a list of records.
1232
-
1233
- :param records: The list of records.
1234
- :return: The input record with the lowest record ID. None if the input list is empty.
1235
- """
1236
- return min(records, key=lambda x: x.record_id)
1237
-
1238
- @staticmethod
1239
- def values_to_field_maps(field_name: FieldIdentifier, values: Iterable[FieldValue],
1240
- 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]]:
1241
1250
  """
1242
- 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.
1243
1254
 
1244
- :param field_name: The name of the field that the values are from.
1245
- :param values: A list of field values.
1246
- :param existing_fields: An optional existing fields map list to add the new values to. Values are added in the
1247
- list in the same order that they appear. If no existing fields are provided, returns a new fields map list.
1248
- :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.
1249
1261
  """
1250
- # Update the existing fields map list if one is given.
1251
1262
  field_name: str = AliasUtil.to_data_field_name(field_name)
1252
- existing_fields: list[FieldMap] = AliasUtil.to_data_field_names_list_dict(existing_fields)
1253
- if existing_fields:
1254
- values = list(values)
1255
- # The number of new values must match the length of the existing fields list.
1256
- if len(values) != len(existing_fields):
1257
- raise SapioException(f"Length of \"{field_name}\" values does not match the existing fields length.")
1258
- for field, value in zip(existing_fields, values):
1259
- field.update({field_name: value})
1260
- return existing_fields
1261
- # Otherwise, create a new fields map list.
1262
- 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
1263
1270
 
1264
1271
  # FR-46155: Update relationship path traversing functions to be non-static and take in a wrapper type so that the
1265
1272
  # output can be wrapped instead of requiring the user to wrap the output.
@@ -1362,7 +1369,9 @@ class RecordHandler:
1362
1369
  elif direction == RelationshipNodeType.DESCENDANT:
1363
1370
  next_search.update(self.an_man.get_descendant_of_type(search, data_type))
1364
1371
  elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
1365
- 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)
1366
1375
  elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
1367
1376
  next_search.update(search.get_reverse_side_link(data_type, node.data_field_name))
1368
1377
  else:
@@ -1412,7 +1421,8 @@ class RecordHandler:
1412
1421
  elif direction == RelationshipNodeType.DESCENDANT:
1413
1422
  current = list(self.an_man.get_descendant_of_type(current[0], data_type))
1414
1423
  elif direction == RelationshipNodeType.FORWARD_SIDE_LINK:
1415
- 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 []
1416
1426
  elif direction == RelationshipNodeType.REVERSE_SIDE_LINK:
1417
1427
  current = current[0].get_reverse_side_link(data_type, node.data_field_name)
1418
1428
  else: