clear-skies 2.0.1__py3-none-any.whl → 2.0.2__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 clear-skies might be problematic. Click here for more details.

Files changed (65) hide show
  1. {clear_skies-2.0.1.dist-info → clear_skies-2.0.2.dist-info}/METADATA +1 -1
  2. {clear_skies-2.0.1.dist-info → clear_skies-2.0.2.dist-info}/RECORD +65 -65
  3. clearskies/__init__.py +5 -8
  4. clearskies/authentication/jwks.py +2 -2
  5. clearskies/authentication/secret_bearer.py +2 -2
  6. clearskies/backends/api_backend.py +2 -2
  7. clearskies/backends/secrets_backend.py +0 -1
  8. clearskies/column.py +6 -2
  9. clearskies/columns/audit.py +3 -2
  10. clearskies/columns/belongs_to_id.py +2 -2
  11. clearskies/columns/belongs_to_model.py +6 -2
  12. clearskies/columns/belongs_to_self.py +2 -2
  13. clearskies/columns/boolean.py +6 -2
  14. clearskies/columns/category_tree.py +2 -2
  15. clearskies/columns/category_tree_children.py +2 -2
  16. clearskies/columns/created.py +3 -2
  17. clearskies/columns/created_by_authorization_data.py +2 -2
  18. clearskies/columns/created_by_header.py +2 -2
  19. clearskies/columns/created_by_ip.py +2 -2
  20. clearskies/columns/created_by_routing_data.py +3 -2
  21. clearskies/columns/created_by_user_agent.py +2 -2
  22. clearskies/columns/date.py +6 -2
  23. clearskies/columns/datetime.py +6 -2
  24. clearskies/columns/float.py +6 -2
  25. clearskies/columns/has_many.py +2 -2
  26. clearskies/columns/has_many_self.py +2 -2
  27. clearskies/columns/integer.py +6 -2
  28. clearskies/columns/json.py +6 -2
  29. clearskies/columns/many_to_many_ids.py +6 -2
  30. clearskies/columns/many_to_many_ids_with_data.py +6 -2
  31. clearskies/columns/many_to_many_models.py +6 -2
  32. clearskies/columns/many_to_many_pivots.py +3 -2
  33. clearskies/columns/phone.py +3 -2
  34. clearskies/columns/select.py +3 -2
  35. clearskies/columns/string.py +4 -0
  36. clearskies/columns/timestamp.py +6 -2
  37. clearskies/columns/updated.py +2 -2
  38. clearskies/columns/uuid.py +2 -2
  39. clearskies/configs/__init__.py +1 -1
  40. clearskies/{parameters_to_properties.py → decorators.py} +2 -0
  41. clearskies/di/injectable_properties.py +2 -2
  42. clearskies/endpoint.py +2 -2
  43. clearskies/endpoint_group.py +1 -1
  44. clearskies/endpoints/callable.py +1 -1
  45. clearskies/endpoints/create.py +1 -1
  46. clearskies/endpoints/delete.py +1 -1
  47. clearskies/endpoints/get.py +1 -1
  48. clearskies/endpoints/health_check.py +1 -1
  49. clearskies/endpoints/list.py +1 -1
  50. clearskies/endpoints/restful_api.py +2 -2
  51. clearskies/endpoints/simple_search.py +1 -1
  52. clearskies/endpoints/update.py +1 -1
  53. clearskies/model.py +691 -37
  54. clearskies/security_headers/cache_control.py +2 -2
  55. clearskies/security_headers/cors.py +2 -2
  56. clearskies/security_headers/csp.py +2 -2
  57. clearskies/security_headers/hsts.py +2 -2
  58. clearskies/validators/after_column.py +2 -2
  59. clearskies/validators/in_the_future.py +1 -1
  60. clearskies/validators/in_the_past.py +1 -1
  61. clearskies/validators/required.py +0 -1
  62. clearskies/validators/timedelta.py +2 -2
  63. clearskies/validators/unique.py +0 -1
  64. {clear_skies-2.0.1.dist-info → clear_skies-2.0.2.dist-info}/LICENSE +0 -0
  65. {clear_skies-2.0.1.dist-info → clear_skies-2.0.2.dist-info}/WHEEL +0 -0
clearskies/model.py CHANGED
@@ -263,29 +263,187 @@ class Model(Schema, InjectableProperties):
263
263
 
264
264
  def save(self: Self, data: dict[str, Any] | None = None, columns: dict[str, Column] = {}, no_data=False) -> bool:
265
265
  """
266
- Save data to the database and update the model.
267
-
268
- Executes an update if the model corresponds to a record already, or an insert if not.
266
+ Save data to the database and create/update the underlying record.
267
+
268
+ ### Lifecycle of a Save
269
+
270
+ Before discussing the mechanics of how to save a model, it helps to understand the full lifecycle of a save
271
+ operation. Of course you can ignore this lifecycle and simply use the save process to send data to a
272
+ backend, but then you miss out on one of the key advantages of clearskies - supporting a state machine
273
+ flow for defining your applications. The save process is controlled not just by the model but also by
274
+ the columns, with equivalent hooks for both. This creates a lot of flexibility for how to control and
275
+ organize an application. The overall save process looks like this:
276
+
277
+ 1. The `pre_save` hook in each column is called (including the `on_change_pre_save` actions attached to the columns)
278
+ 2. The `pre_save` hook for the model is called
279
+ 3. The `to_backend` hook for each column is called and temporary data is removed from the save dictionary
280
+ 4. The `to_backend` hook for the model is called
281
+ 5. The data is persisted to the backend via a create or update call as appropriate
282
+ 6. The `post_save` hook in each column is called (including the `on_change_post_save` actions attached to the columns)
283
+ 7. The `post_save` hook in the model is called
284
+ 8. Any data returned by the backend during the create/update operation is saved to the model along with the temporary data
285
+ 9. The `save_finished` hook in each column is called (including the `on_change_save_finished` actions attached to the columns)
286
+ 10. The `save_finished` hook in the model is called
287
+
288
+ Note that pre/post/finished hooks for all columns are called - not just the ones with data in the save.
289
+ Thus, any column attached to a model can always influence the save process.
290
+
291
+ From this we can see how to use these hooks. In particular:
292
+
293
+ 1. The `pre_save` hook is used to modify the data before it is persisted to the backend. This means that changes
294
+ can be made to the data dictionary in the `pre_save` step and there will still only be a single save operation
295
+ with the backend. For columns, the `on_change_pre_save` methods *MUST* be stateless - they can return data to
296
+ change the save but should not make any changes themselves. This is because they may be called more than once
297
+ in a given save operation.
298
+ 2. `to_backend` is used to modify data on its way to the backend. Consider dates: in python these are typically represented
299
+ by datetime objects but, to persist this to (for instance) an SQL database, it usually has to be converted to a string
300
+ format first. That happens in the `to_backend` method of the datetime column.
301
+ 3. The `post_save` hook is called after the backend is updated. Therefore, if you are using auto-incrementing ids,
302
+ the id will only be available in ths hook. For consistency with this, clearskies doesn't directly provide the record id
303
+ until the `post_save` hook. If you need to make more data changes in this hook, an additional operation will
304
+ be required. Since the backend has already been updated, this hook does not require a return value (and anything
305
+ returned will be ignored).
306
+ 4. The save finished hook happens after the save is fully completed. The backend is updated and the model has been
307
+ updated and the model state reflects the new backend state.
308
+
309
+ The following table summarizes some key details of these hooks:
310
+
311
+ | Name | Stateful | Return Value | Id Present | Backend Updated | Model Updated |
312
+ |-----------------|----------|----------------|------------|-----------------|---------------|
313
+ | `pre_save` | No | dict[str, Any] | No | No | No |
314
+ | `post_save` | Yes | None | Yes | Yes | No |
315
+ | `save_finished` | Yes | None | Yes | Yes | Yes |
316
+
317
+ ### How to Create/Update a Model
269
318
 
270
319
  There are two supported flows. One is to pass in a dictionary of data to save:
271
320
 
272
321
  ```python
273
- model.save({
274
- "some_column": "New Value",
275
- "another_column": 5,
276
- })
322
+ import clearskies
323
+
324
+ class User(clearskies.Model):
325
+ id_column_name = "id"
326
+ backend = clearskies.backends.MemoryBackend()
327
+
328
+ id = clearskies.columns.Uuid()
329
+ name = clearskies.columns.String()
330
+
331
+ def my_application(user):
332
+ user.save({
333
+ "name": "Awesome Person",
334
+ })
335
+ return {"id": user.id, "name": user.name}
336
+
337
+ cli = clearskies.contexts.Cli(
338
+ my_application,
339
+ classes=[User],
340
+ )
341
+ cli()
277
342
  ```
278
343
 
279
344
  And the other is to set new values on the columns attributes and then call save without data:
280
345
 
281
346
  ```python
282
- model.some_column = "New Value"
283
- model.another_column = 5
284
- model.save()
347
+ import clearskies
348
+
349
+ class User(clearskies.Model):
350
+ id_column_name = "id"
351
+ backend = clearskies.backends.MemoryBackend()
352
+
353
+ id = clearskies.columns.Uuid()
354
+ name = clearskies.columns.String()
355
+
356
+ def my_application(user):
357
+ user.name = "Awesome Person"
358
+ user.save()
359
+ return {"id": user.id, "name": user.name}
360
+
361
+ cli = clearskies.contexts.Cli(
362
+ my_application,
363
+ classes=[User],
364
+ )
365
+ cli()
366
+ ```
367
+
368
+ The primray difference is that setting attributes provides strict type checking capabilities, while passing a
369
+ dictionary can be done in one line. Note that you cannot combine these methods: if you set a value on a
370
+ column attribute and also pass in a dictionary of data to the save, then an exception will be raised.
371
+ In either case the save operation acts in place on the model object. The return value is always True - in
372
+ the event of an error an exception will be raised.
373
+
374
+ If a record already exists in the model being saved, then an update operation will be executed. Otherwise,
375
+ a new record will be inserted. To understand the difference yourself, you can convert a model to a boolean
376
+ value - it will return True if a record has been loaded and false otherwise. You can see that with this
377
+ example, where all the `if` statements will evaluate to `True`:
378
+
379
+ ```
380
+ import clearskies
381
+
382
+ class User(clearskies.Model):
383
+ id_column_name = "id"
384
+ backend = clearskies.backends.MemoryBackend()
385
+
386
+ id = clearskies.columns.Uuid()
387
+ name = clearskies.columns.String()
388
+
389
+ def my_application(user):
390
+
391
+ if not user:
392
+ print("We will execute a create operation")
393
+
394
+ user.save({"name": "Test One"})
395
+ new_id = user.id
396
+
397
+ if user:
398
+ print("We will execute an update operation")
399
+
400
+ user.save({"name": "Test Two"})
401
+
402
+ final_id = user.id
403
+
404
+ if new_id == final_id:
405
+ print("The id did not chnage because the second save performed an update")
406
+
407
+ return {"id": user.id, "name": user.name}
408
+
409
+ cli = clearskies.contexts.Cli(
410
+ my_application,
411
+ classes=[User],
412
+ )
413
+ cli()
414
+ ```
415
+
416
+ occassionaly, you may want to execute a save operation without actually providing any data. This may happen,
417
+ for instance, if you want to create a record in the database that will be filled in later, and so just need
418
+ an auto-generated id. By default if you call save without setting attributes on the model and without
419
+ providing data to the `save` call, this will raise an exception, but you can make this happen with the
420
+ `no_data` kwarg:
421
+
285
422
  ```
423
+ import clearskies
424
+
425
+ class User(clearskies.Model):
426
+ id_column_name = "id"
427
+ backend = clearskies.backends.MemoryBackend()
428
+
429
+ id = clearskies.columns.Uuid()
430
+ name = clearskies.columns.String()
431
+
432
+ def my_application(user):
433
+ # create a record with just an id
434
+ user.save(no_data=True)
435
+
436
+ # and now we can set the name
437
+ user.save({"name": "Test"})
438
+
439
+ return {"id": user.id, "name": user.name}
286
440
 
287
- You cannot combine these methods. If you set a value on a column attribute and also pass
288
- in a dictionary of data to the save, then an exception will be raised.
441
+ cli = clearskies.contexts.Cli(
442
+ my_application,
443
+ classes=[User],
444
+ )
445
+ cli()
446
+ ```
289
447
  """
290
448
  self.no_queries()
291
449
  if not data and not self._next_data and not no_data:
@@ -340,7 +498,76 @@ class Model(Schema, InjectableProperties):
340
498
  """
341
499
  Return True/False to denote if the given column is being modified by the active save operation.
342
500
 
343
- Pass in the name of the column to check and the data dictionary from the save in progress
501
+ A column is considered to be changing if:
502
+
503
+ - During a create operation
504
+ - It is present in the data array, even if a null value
505
+ - During an update operation
506
+ - It is present in the data array and the value is changing
507
+
508
+ Note whether or not the value is changing is typically evaluated with a simple `=` comparison,
509
+ but columns can optionally implement their own custom logic.
510
+
511
+ Pass in the name of the column to check and the data dictionary from the save in progress. This only
512
+ returns meaningful results during a save, which typically happens in the pre-save/post-save hooks
513
+ (either on the model class itself or in a column). Here's an examle that extends the `pre_save` hook
514
+ on the model to demonstrate how `is_changing` works:
515
+
516
+ ```
517
+ from typing import Any, Self
518
+ import clearskies
519
+
520
+ class User(clearskies.Model):
521
+ id_column_name = "id"
522
+ backend = clearskies.backends.MemoryBackend()
523
+
524
+ id = clearskies.columns.Uuid()
525
+ name = clearskies.columns.String()
526
+ age = clearskies.columns.Integer()
527
+
528
+ def pre_save(self: Self, data: dict[str, Any]) -> dict[str, Any]:
529
+ if self.is_changing("name", data) and self.is_changing("age", data):
530
+ print("My name and age have changed!")
531
+ elif self.is_changing("name", data):
532
+ print("Only my name is changing")
533
+ elif self.is_changing("age", data):
534
+ print("Only my age is changing")
535
+ else:
536
+ print("Nothing changed")
537
+ return data
538
+
539
+ def my_application(users):
540
+ jane = users.create({"name": "Jane"})
541
+ jane.save({"age": 22})
542
+ jane.save({"name": "Anon", "age": 23})
543
+ jane.save({"name": "Anon", "age": 23})
544
+
545
+ return {"id": jane.id, "name": jane.name}
546
+
547
+ cli = clearskies.contexts.Cli(
548
+ my_application,
549
+ classes=[User],
550
+ )
551
+ cli()
552
+ ```
553
+
554
+ If you run the above example it will print out:
555
+
556
+ ```
557
+ Only my name is changing
558
+ Only my age is changing
559
+ My name and age have changed
560
+ Nothing changed
561
+ ```
562
+
563
+ The first message is printed out when the record is created - during a create operation, any column that
564
+ is being set to a non-null value is considered to be changing. We then set the age, and since it changes
565
+ from a null value (we didn't originally set an age with the create operation, so the age was null) to a
566
+ non-null value, `is_changed` returns True. We perform another update operation and set both
567
+ name and age to new values, so both change. Finally we repeat the same save operation. This will result
568
+ in another update operation on the backend, but `is_changed` reflects the fact that the values haven't
569
+ actually changed from their previous values.
570
+
344
571
  """
345
572
  self.no_queries()
346
573
  has_old_value = key in self._data
@@ -348,20 +575,73 @@ class Model(Schema, InjectableProperties):
348
575
 
349
576
  if not has_new_value:
350
577
  return False
578
+
351
579
  if not has_old_value:
352
580
  return True
353
581
 
354
- return getattr(self, key) != data[key]
582
+ columns = self.get_columns()
583
+ new_value = data[key]
584
+ old_value = self._data[key]
585
+ if key not in columns:
586
+ return old_value != new_value
587
+ return not columns[key].values_match(old_value, new_value)
355
588
 
356
589
  def latest(self: Self, key: str, data: dict[str, Any]) -> Any:
357
590
  """
358
591
  Return the 'latest' value for a column during the save operation.
359
592
 
360
- Return either the column value from the data dictionary or the current value stored in the model
361
- Basically, shorthand for the optimized version of: `data.get(key, default=getattr(self, key))` (which is
362
- less than ideal because it always builds the default value, even when not necessary)
593
+ During the pre_save and post_save hooks, the model is not yet updated with the latest data.
594
+ In these hooks, it's common to want the "latest" data for the model - e.g. either the column value
595
+ from the model or from the data dictionary (if the column is being updated in the save). This happens
596
+ via slightly verbose lines like: `data.get(column_name, getattr(self, column_name))`. The `latest`
597
+ method is just a substitue for this:
598
+
599
+ ```
600
+ from typing import Any, Self
601
+ import clearskies
602
+
603
+ class User(clearskies.Model):
604
+ id_column_name = "id"
605
+ backend = clearskies.backends.MemoryBackend()
606
+
607
+ id = clearskies.columns.Uuid()
608
+ name = clearskies.columns.String()
609
+ age = clearskies.columns.Integer()
610
+
611
+ def pre_save(self: Self, data: dict[str, Any]) -> dict[str, Any]:
612
+ if not self:
613
+ print("Create operation in progress!")
614
+ else:
615
+ print("Update operation in progress!")
616
+
617
+ print("Latest name: " + str(self.latest("name", data)))
618
+ print("Latest age: " + str(self.latest("age", data)))
619
+ return data
620
+
621
+ def my_application(users):
622
+ jane = users.create({"name": "Jane"})
623
+ jane.save({"age": 25})
624
+ return {"id": jane.id, "name": jane.name}
625
+
626
+ cli = clearskies.contexts.Cli(
627
+ my_application,
628
+ classes=[User],
629
+ )
630
+ cli()
631
+ ```
632
+ The above example will print:
633
+
634
+ ```
635
+ Create operation in progress!
636
+ Latest name: Jane
637
+ Latest age: None
638
+ Update operation in progress!
639
+ Latest name: Jane
640
+ Latest age: 25
641
+ ```
642
+
643
+ e.g. `latest` returns the value in the data array (if present), the value for the column in the model, or None.
363
644
 
364
- Pass in the name of the column to check and the data dictionary from the save in progress
365
645
  """
366
646
  self.no_queries()
367
647
  if key in data:
@@ -369,7 +649,41 @@ class Model(Schema, InjectableProperties):
369
649
  return getattr(self, key)
370
650
 
371
651
  def was_changed(self: Self, key: str) -> bool:
372
- """Return True/False to denote if a column was changed in the last save."""
652
+ """
653
+ Return True/False to denote if a column was changed in the last save.
654
+
655
+ To emphasize, the difference between this and `is_changing` is that `is_changing` is available during
656
+ the save prcess while `was_changed` is available after the save has finished. Otherwise, the logic for
657
+ deciding if a column has changed is identical as for `is_changing`.
658
+
659
+ ```
660
+ import clearskies
661
+
662
+ class User(clearskies.Model):
663
+ id_column_name = "id"
664
+ backend = clearskies.backends.MemoryBackend()
665
+
666
+ id = clearskies.columns.Uuid()
667
+ name = clearskies.columns.String()
668
+ age = clearskies.columns.Integer()
669
+
670
+ def my_application(users):
671
+ jane = users.create({"name": "Jane"})
672
+ return {
673
+ "name_changed": jane.was_changed("name"),
674
+ "age_changed": jane.was_changed("age"),
675
+ }
676
+
677
+ cli = clearskies.contexts.Cli(
678
+ my_application,
679
+ classes=[User],
680
+ )
681
+ cli()
682
+ ```
683
+
684
+ In the above example the name is changed while the age is not.
685
+
686
+ """
373
687
  self.no_queries()
374
688
  if self._previous_data is None:
375
689
  raise ValueError("was_changed was called before a save was finished - you must save something first")
@@ -392,13 +706,86 @@ class Model(Schema, InjectableProperties):
392
706
  return old_value != new_value
393
707
  return not columns[key].values_match(old_value, new_value)
394
708
 
395
- def previous_value(self: Self, key: str):
396
- """Return the value of a column from before the most recent save."""
709
+ def previous_value(self: Self, key: str, silent=False):
710
+ """
711
+ Return the value of a column from before the most recent save.
712
+
713
+ ```
714
+ import clearskies
715
+
716
+ class User(clearskies.Model):
717
+ id_column_name = "id"
718
+ backend = clearskies.backends.MemoryBackend()
719
+
720
+ id = clearskies.columns.Uuid()
721
+ name = clearskies.columns.String()
722
+
723
+ def my_application(users):
724
+ jane = users.create({"name": "Jane"})
725
+ jane.save({"name": "Jane Doe"})
726
+ return {"name": jane.name, "previous_name": jane.previous_value("name")}
727
+
728
+ cli = clearskies.contexts.Cli(
729
+ my_application,
730
+ classes=[User],
731
+ )
732
+ cli()
733
+ ```
734
+
735
+ The above example returns `{"name": "Jane Doe", "previous_name": "Jane"}`
736
+
737
+ If you request a key that is neither a column nor was present in the previous data array,
738
+ then you'll receive a key error. You can suppress this by setting `silent=True` in your call to
739
+ previous_value.
740
+ """
397
741
  self.no_queries()
398
- return getattr(self.__class__, key).transform(self._previous_data.get(key))
742
+ if key not in self.get_columns() and key not in self._previous_data:
743
+ raise KeyError(f"Unknown previous data key: {key}")
744
+ if key not in self.get_columns():
745
+ return self._previous_data.get(key)
746
+ return getattr(self.__class__, key).from_backend(self._previous_data.get(key))
399
747
 
400
748
  def delete(self: Self, except_if_not_exists=True) -> bool:
401
- """Delete a record."""
749
+ """
750
+ Delete a record.
751
+
752
+ If you try to delete a record that doesn't exist, an exception will be thrown unless you set
753
+ `except_if_not_exists=False`. After the record is deleted from the backend, the model instance
754
+ is left unchanged and can be used to fetch the data previously stored. In the following example
755
+ both statements will be printed and the id and name in the "Alice" record will be returned,
756
+ even though the record no longer exists:
757
+
758
+ ```
759
+ import clearskies
760
+
761
+ class User(clearskies.Model):
762
+ id_column_name = "id"
763
+ backend = clearskies.backends.MemoryBackend()
764
+
765
+ id = clearskies.columns.Uuid()
766
+ name = clearskies.columns.String()
767
+
768
+ def my_application(users):
769
+ alice = users.create({"name": "Alice"})
770
+
771
+ if users.find("name=Alice"):
772
+ print("Alice exists")
773
+
774
+ alice.delete()
775
+
776
+ if not users.find("name=Alice"):
777
+ print("No more Alice")
778
+
779
+ return {"id": alice.id, "name": alice.name}
780
+
781
+ cli = clearskies.contexts.Cli(
782
+ my_application,
783
+ classes=[User],
784
+ )
785
+ cli()
786
+
787
+ ```
788
+ """
402
789
  self.no_queries()
403
790
  if not self:
404
791
  if except_if_not_exists:
@@ -466,26 +853,132 @@ class Model(Schema, InjectableProperties):
466
853
  for column in columns.values():
467
854
  column.save_finished(self)
468
855
 
469
- def post_save(self: Self, data: dict[str, Any], id: str | int) -> None:
856
+ def pre_save(self: Self, data: dict[str, Any]) -> dict[str, Any]:
470
857
  """
471
- Create a hook to extend so you can provide additional pre-save logic as needed.
858
+ A hook to add additional logic in the pre-save step of the save process.
859
+
860
+ The pre/post/finished steps of the model are directly analogous to the pre/post/finished steps for the columns.
861
+
862
+ pre-save is inteneded to be a stateless hook (e.g. you should not make changes to the backend) where you can
863
+ adjust the data being saved to the model. It is called before any data is persisted to the backend and
864
+ must return a dictionary of data that will be added to the save, potentially over-writing the save data.
865
+ Since pre-save happens before communicating with the backend, the record itself will not yet exist in the
866
+ event of a create operation, and so the id will not be-present for auto-incrementing ids. As a result, the
867
+ record id is not provided during the pre-save hook. See the breakdown of the save lifecycle in the `save`
868
+ documentation above for more details.
869
+
870
+ An here's an example of using it to set some additional data during a save:
871
+
872
+ ```
873
+ from typing import Any, Self
874
+ import clearskies
875
+
876
+ class User(clearskies.Model):
877
+ id_column_name = "id"
878
+ backend = clearskies.backends.MemoryBackend()
879
+
880
+ id = clearskies.columns.Uuid()
881
+ name = clearskies.columns.String()
882
+ is_anonymous = clearskies.columns.Boolean()
883
+
884
+ def pre_save(self: Self, data: dict[str, Any]) -> dict[str, Any]:
885
+ additional_data = {}
886
+
887
+ if self.is_changing("name", data):
888
+ additional_data["is_anonymous"] = not bool(data["name"])
889
+
890
+ return additional_data
891
+
892
+ def my_application(users):
893
+ jane = users.create({"name": "Jane"})
894
+ is_anonymous_after_create = jane.is_anonymous
895
+
896
+ jane.save({"name":""})
897
+ is_anonymous_after_first_update = jane.is_anonymous
898
+
899
+ jane.save({"name": "Jane Doe"})
900
+ is_anonymous_after_last_update = jane.is_anonymous
901
+
902
+ return {
903
+ "is_anonymous_after_create": is_anonymous_after_create,
904
+ "is_anonymous_after_first_update": is_anonymous_after_first_update,
905
+ "is_anonymous_after_last_update": is_anonymous_after_last_update,
906
+ }
907
+
908
+ cli = clearskies.contexts.Cli(
909
+ my_application,
910
+ classes=[User],
911
+ )
912
+ cli()
913
+ ```
914
+
915
+ In our pre-save hook we set the `is_anonymous` field to either True or False depending on whether or
916
+ not there is a value in the incoming `name` column. As a result, after the original create operation
917
+ (when the `name` is `"Jane"`, `is_anonymous` is False. We then update the name and set it to an empty
918
+ string, and `is_anonymous` becomes True. We then update one last time to set a name again and
919
+ `is_anonymous` becomes False.
472
920
 
473
- It is passed in the data being saved as well as the id. It should take action as needed and then return
474
- either the original data array or an adjusted one if appropriate.
475
921
  """
476
- pass
922
+ return data
477
923
 
478
- def pre_save(self: Self, data: dict[str, Any]) -> dict[str, Any]:
924
+ def post_save(self: Self, data: dict[str, Any], id: str | int) -> None:
479
925
  """
480
- Create a hook to extend so you can provide additional pre-save logic as needed.
926
+ A hook to add additional logic in the post-save step of the save process.
927
+
928
+ It is passed in the data being saved as well as the id of the record. Keep in mind that the post save
929
+ hook happens after the backend has been updated (but before the model is updated) so if you need to make
930
+ any changes to the backend you must execute another save operation. Since the backend is already updated,
931
+ the return value from this function is ignored (it should return None):
932
+
933
+ ```
934
+ from typing import Any, Self
935
+ import clearskies
936
+
937
+ class History(clearskies.Model):
938
+ id_column_name = "id"
939
+ backend = clearskies.backends.MemoryBackend()
940
+
941
+ id = clearskies.columns.Uuid()
942
+ message = clearskies.columns.String()
943
+ created_at = clearskies.columns.Created(date_format="%Y-%m-%d %H:%M:%S.%f")
944
+
945
+ class User(clearskies.Model):
946
+ id_column_name = "id"
947
+ backend = clearskies.backends.MemoryBackend()
948
+ histories = clearskies.di.inject.ByClass(History)
949
+
950
+ id = clearskies.columns.Uuid()
951
+ age = clearskies.columns.Integer()
952
+ name = clearskies.columns.String()
953
+
954
+ def post_save(self: Self, data: dict[str, Any], id: str | int) -> None:
955
+ if not self.is_changing("age", data):
956
+ return
957
+
958
+ name = self.latest("name", data)
959
+ age = self.latest("age", data)
960
+ self.histories.create({"message": f"My name is {name} and I am {age} years old"})
961
+
962
+ def my_application(users, histories):
963
+ jane = users.create({"name": "Jane"})
964
+ jane.save({"age": 25})
965
+ jane.save({"age": 26})
966
+ jane.save({"age": 30})
481
967
 
482
- It is passed in the data being saved and it should return the same data with adjustments as needed
968
+ return [history.message for history in histories.sort_by("created_at", "ASC")]
969
+
970
+ cli = clearskies.contexts.Cli(
971
+ my_application,
972
+ classes=[User, History],
973
+ )
974
+ cli()
975
+ ```
483
976
  """
484
- return data
977
+ pass
485
978
 
486
979
  def save_finished(self: Self) -> None:
487
980
  """
488
- Create a hook to extend so you can provide additional logic after a save operation has fully completed.
981
+ A hook to add additional logicin the save_finished step of the save process.
489
982
 
490
983
  It has no retrun value and is passed no data. By the time this fires the model has already been
491
984
  updated with the new data. You can decide on the necessary actions using the `was_changed` and
@@ -528,7 +1021,63 @@ class Model(Schema, InjectableProperties):
528
1021
  ### From here down is functionality related to list/search ###
529
1022
  ##############################################################
530
1023
  def has_query(self) -> bool:
531
- """Whether or not this model instance represents a query."""
1024
+ """
1025
+ Whether or not this model instance represents a query.
1026
+
1027
+ The model class is used for both querying records and modifying individual records. As a result, each model class instance
1028
+ keeps track of whether it is being used to query things, or whether it represents an individual record. This distinction
1029
+ is not usually very important to the developer (because there's no good reason to use one model for both), but it may
1030
+ occassionaly be useful to tell how a given model is being used. Clearskies itself does use this to ensure that you
1031
+ can't accidentally use a single model instance for both purposes, mostly because when this happens it's usually a sign
1032
+ of a bug.
1033
+
1034
+ ```
1035
+ import clearskies
1036
+
1037
+ class User(clearskies.Model):
1038
+ id_column_name = "id"
1039
+ backend = clearskies.backends.MemoryBackend()
1040
+
1041
+ id = clearskies.columns.Uuid()
1042
+ name = clearskies.columns.String()
1043
+
1044
+ def my_application(users):
1045
+ jane = users.create({"name": "Jane"})
1046
+ jane_instance_has_query = jane.has_query()
1047
+
1048
+ some_search = users.where("name=Jane")
1049
+ some_search_has_query = some_search.has_query()
1050
+
1051
+ invalid_request_error = ""
1052
+ try:
1053
+ some_search.save({"not": "valid"})
1054
+ except ValueError as e:
1055
+ invalid_request_error = str(e)
1056
+
1057
+ return {
1058
+ "jane_instance_has_query": jane_instance_has_query,
1059
+ "some_search_has_query": some_search_has_query,
1060
+ "invalid_request_error": invalid_request_error,
1061
+ }
1062
+
1063
+ cli = clearskies.contexts.Cli(
1064
+ my_application,
1065
+ classes=[User],
1066
+ )
1067
+ cli()
1068
+ ```
1069
+
1070
+ Which if you run will return:
1071
+
1072
+ ```
1073
+ {
1074
+ "jane_instance_has_query": false,
1075
+ "some_search_has_query": true,
1076
+ "invalid_request_error": "You attempted to save/read record data for a model being used to make a query. This is not allowed, as it is typically a sign of a bug in your application code."
1077
+ }
1078
+ ```
1079
+
1080
+ """
532
1081
  return bool(self._query)
533
1082
 
534
1083
  def get_query(self) -> Query:
@@ -777,7 +1326,11 @@ class Model(Schema, InjectableProperties):
777
1326
  return False
778
1327
 
779
1328
  def group_by(self: Self, group_by_column_name: str) -> Self:
780
- """Add a group by clause to the query."""
1329
+ """
1330
+ Add a group by clause to the query.
1331
+
1332
+ You just provide the name of the column to group by. Of course, not all backends support a group by clause.
1333
+ """
781
1334
  self.no_single_model()
782
1335
  return self.with_query(self.get_query().set_group_by(group_by_column_name))
783
1336
 
@@ -1051,7 +1604,39 @@ class Model(Schema, InjectableProperties):
1051
1604
  Create a new model object and populates it with the data in `data`.
1052
1605
 
1053
1606
  NOTE: the difference between this and `model.create` is that model.create() actually saves a record in the backend,
1054
- while this method just creates a model object populated with the given data.
1607
+ while this method just creates a model object populated with the given data. This can be helpful if you have record
1608
+ data loaded up in some alternate way and want to wrap a model around it. Calling the `model` method does not result
1609
+ in any interactions with the backend.
1610
+
1611
+ In the following example we create a record in the backend and then make a new model instance using `model`, which
1612
+ we then use to udpate the record. The returned name will be `Jane Doe`.
1613
+
1614
+ ```
1615
+ import clearskies
1616
+
1617
+ class User(clearskies.Model):
1618
+ id_column_name = "id"
1619
+ backend = clearskies.backends.MemoryBackend()
1620
+
1621
+ id = clearskies.columns.Uuid()
1622
+ name = clearskies.columns.String()
1623
+
1624
+ def my_application(users):
1625
+ jane = users.create({"name": "Jane"})
1626
+
1627
+ # This effectively makes a new model instance that points to the jane record in the backend
1628
+ another_jane_object = users.model({"id": jane.id, "name": jane.name})
1629
+ # and we can perform an update operation like usual
1630
+ another_jane_object.save({"name": "Jane Doe"})
1631
+
1632
+ return {"id": another_jane_object.id, "name": another_jane_object.name}
1633
+
1634
+ cli = clearskies.contexts.Cli(
1635
+ my_application,
1636
+ classes=[User],
1637
+ )
1638
+ cli()
1639
+ ```
1055
1640
  """
1056
1641
  model = self._di.build(self.__class__, cache=False)
1057
1642
  model.set_raw_data(data)
@@ -1060,6 +1645,40 @@ class Model(Schema, InjectableProperties):
1060
1645
  def empty(self: Self) -> Self:
1061
1646
  """
1062
1647
  An alias for self.model({})
1648
+
1649
+ This just provides you a fresh, empty model instance that you can use for populating with data or creating
1650
+ a new record. Here's a simple exmaple. Both print statements will be printed and it will return the id
1651
+ for the Alice record, and then null for `blank_id`:
1652
+
1653
+ ```
1654
+ import clearskies
1655
+
1656
+ class User(clearskies.Model):
1657
+ id_column_name = "id"
1658
+ backend = clearskies.backends.MemoryBackend()
1659
+
1660
+ id = clearskies.columns.Uuid()
1661
+ name = clearskies.columns.String()
1662
+
1663
+ def my_application(users):
1664
+ alice = users.create({"name": "Alice"})
1665
+
1666
+ if users.find("name=Alice"):
1667
+ print("Alice exists")
1668
+
1669
+ blank = alice.empty()
1670
+
1671
+ if not blank:
1672
+ print("Fresh instance, ready to go")
1673
+
1674
+ return {"alice_id": alice.id, "blank_id": blank.id}
1675
+
1676
+ cli = clearskies.contexts.Cli(
1677
+ my_application,
1678
+ classes=[User],
1679
+ )
1680
+ cli()
1681
+ ```
1063
1682
  """
1064
1683
  return self.model({})
1065
1684
 
@@ -1067,7 +1686,42 @@ class Model(Schema, InjectableProperties):
1067
1686
  """
1068
1687
  Create a new record in the backend using the information in `data`.
1069
1688
 
1070
- new_model = models.create({"column": "value"})
1689
+ The `save` method always operates changes the model directly rather than creating a new model instance.
1690
+ Often, when creating a new record, you will need to both create a new (empty) model instance and save
1691
+ data to it. You can do this via `model.empty().save({"data": "here"})`, and this method provides a simple,
1692
+ unambiguous shortcut to do exactly that. So, you pass your save data to the `create` method and you will get
1693
+ back a new model:
1694
+
1695
+ ```
1696
+ import clearskies
1697
+
1698
+ class User(clearskies.Model):
1699
+ id_column_name = "id"
1700
+ backend = clearskies.backends.MemoryBackend()
1701
+
1702
+ id = clearskies.columns.Uuid()
1703
+ name = clearskies.columns.String()
1704
+
1705
+ def my_application(user):
1706
+ # let's create a new record
1707
+ user.save({"name": "Alice"})
1708
+
1709
+ # and now use `create` to both create a new record and get a new model instance
1710
+ bob = user.create({"name": "Bob"})
1711
+
1712
+ return {
1713
+ "Alice": user.name,
1714
+ "Bob": bob.name,
1715
+ }
1716
+
1717
+ cli = clearskies.contexts.Cli(
1718
+ my_application,
1719
+ classes=[User],
1720
+ )
1721
+ cli()
1722
+ ```
1723
+
1724
+ Like with `save`, you can set `no_data=True` to create a record without specifying any model data.
1071
1725
  """
1072
1726
  empty = self.model()
1073
1727
  empty.save(data, columns=columns, no_data=no_data)