clear-skies 2.0.0__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 (78) hide show
  1. {clear_skies-2.0.0.dist-info → clear_skies-2.0.2.dist-info}/METADATA +2 -2
  2. {clear_skies-2.0.0.dist-info → clear_skies-2.0.2.dist-info}/RECORD +78 -77
  3. clearskies/__init__.py +5 -8
  4. clearskies/authentication/authentication.py +4 -0
  5. clearskies/authentication/authorization.py +4 -0
  6. clearskies/authentication/jwks.py +8 -3
  7. clearskies/authentication/secret_bearer.py +3 -3
  8. clearskies/backends/api_backend.py +2 -2
  9. clearskies/backends/backend.py +13 -0
  10. clearskies/backends/secrets_backend.py +0 -1
  11. clearskies/column.py +9 -8
  12. clearskies/columns/audit.py +3 -2
  13. clearskies/columns/belongs_to_id.py +2 -2
  14. clearskies/columns/belongs_to_model.py +6 -2
  15. clearskies/columns/belongs_to_self.py +2 -2
  16. clearskies/columns/boolean.py +6 -2
  17. clearskies/columns/category_tree.py +2 -2
  18. clearskies/columns/category_tree_children.py +2 -2
  19. clearskies/columns/created.py +3 -2
  20. clearskies/columns/created_by_authorization_data.py +2 -2
  21. clearskies/columns/created_by_header.py +2 -2
  22. clearskies/columns/created_by_ip.py +2 -2
  23. clearskies/columns/created_by_routing_data.py +3 -2
  24. clearskies/columns/created_by_user_agent.py +2 -2
  25. clearskies/columns/date.py +6 -2
  26. clearskies/columns/datetime.py +6 -2
  27. clearskies/columns/float.py +6 -2
  28. clearskies/columns/has_many.py +2 -2
  29. clearskies/columns/has_many_self.py +2 -2
  30. clearskies/columns/integer.py +6 -2
  31. clearskies/columns/json.py +6 -2
  32. clearskies/columns/many_to_many_ids.py +6 -2
  33. clearskies/columns/many_to_many_ids_with_data.py +6 -2
  34. clearskies/columns/many_to_many_models.py +6 -2
  35. clearskies/columns/many_to_many_pivots.py +3 -2
  36. clearskies/columns/phone.py +3 -2
  37. clearskies/columns/select.py +3 -2
  38. clearskies/columns/string.py +4 -0
  39. clearskies/columns/timestamp.py +6 -2
  40. clearskies/columns/updated.py +2 -2
  41. clearskies/columns/uuid.py +2 -2
  42. clearskies/configs/__init__.py +4 -1
  43. clearskies/configs/config.py +4 -1
  44. clearskies/configs/endpoint_list.py +28 -0
  45. clearskies/contexts/cli.py +4 -0
  46. clearskies/contexts/context.py +13 -0
  47. clearskies/contexts/wsgi.py +4 -0
  48. clearskies/contexts/wsgi_ref.py +4 -0
  49. clearskies/{parameters_to_properties.py → decorators.py} +2 -0
  50. clearskies/di/di.py +2 -2
  51. clearskies/di/injectable_properties.py +2 -2
  52. clearskies/endpoint.py +7 -6
  53. clearskies/endpoint_group.py +14 -1
  54. clearskies/endpoints/callable.py +5 -4
  55. clearskies/endpoints/create.py +1 -1
  56. clearskies/endpoints/delete.py +1 -1
  57. clearskies/endpoints/get.py +1 -1
  58. clearskies/endpoints/health_check.py +1 -1
  59. clearskies/endpoints/list.py +1 -1
  60. clearskies/endpoints/restful_api.py +2 -2
  61. clearskies/endpoints/simple_search.py +1 -1
  62. clearskies/endpoints/update.py +1 -1
  63. clearskies/model.py +1214 -64
  64. clearskies/query/query.py +4 -4
  65. clearskies/security_header.py +7 -0
  66. clearskies/security_headers/cache_control.py +2 -2
  67. clearskies/security_headers/cors.py +2 -2
  68. clearskies/security_headers/csp.py +2 -2
  69. clearskies/security_headers/hsts.py +2 -2
  70. clearskies/validator.py +12 -0
  71. clearskies/validators/after_column.py +2 -2
  72. clearskies/validators/in_the_future.py +1 -1
  73. clearskies/validators/in_the_past.py +1 -1
  74. clearskies/validators/required.py +0 -1
  75. clearskies/validators/timedelta.py +2 -2
  76. clearskies/validators/unique.py +0 -1
  77. {clear_skies-2.0.0.dist-info → clear_skies-2.0.2.dist-info}/LICENSE +0 -0
  78. {clear_skies-2.0.0.dist-info → clear_skies-2.0.2.dist-info}/WHEEL +0 -0
clearskies/model.py CHANGED
@@ -19,13 +19,166 @@ class Model(Schema, InjectableProperties):
19
19
  """
20
20
  A clearskies model.
21
21
 
22
- To be useable, a model class needs three things:
22
+ To be useable, a model class needs four things:
23
23
 
24
- 1. Column definitions
25
- 2. The name of the id column
26
- 3. A backend
27
- 4. A destination name (equivalent to a table name for SQL backends)
24
+ 1. The name of the id column
25
+ 2. A backend
26
+ 3. A destination name (equivalent to a table name for SQL backends)
27
+ 4. Columns
28
28
 
29
+ In more detail:
30
+
31
+ ### Id Column Name
32
+
33
+ clearskies assumes that all models have a column that uniquely identifies each record. This id column is
34
+ provided where appropriate in the lifecycle of the model save process to help connect and find related records.
35
+ It's defined as a simple class attribute called `id_column_name`. There **MUST** be a column with the same name
36
+ in the column definitions. A simple approach to take is to use the Uuid column as an id column. This will
37
+ automatically provide a random UUID when the record is first created. If you are using auto-incrementing integers,
38
+ you can simply use an `Int` column type and define the column as auto-incrementing in your database.
39
+
40
+ ### Backend
41
+
42
+ Every model needs a backend, which is an object that extends clearskies.Backend and is attached to the
43
+ `backend` attribute of the model class. clearskies comes with a variety of backends in the `clearskies.backends`
44
+ module that you can use, and you can also define your own or import more from additional packages.
45
+
46
+ ### Destination Name
47
+
48
+ The destination name is the equivalent of a table name in other frameworks, but the name is more generic to
49
+ reflect the fact that clearskies is intended to work with a variety of backends - not just SQL databases.
50
+ The exact meaning of the destination name depends on the backend: for a cursor backend it is in fact used
51
+ as the table name when fetching/storing records. For the API backend it is frequently appended to a base
52
+ URL to reach the corect endpoint.
53
+
54
+ This is provided by a class function call `destination_name`. The base model class declares a generic method
55
+ for this which takes the class name, converts it from title case to snake case, and makes it plural. Hence,
56
+ a model class called `User` will have a default destination name of `users` and a model class of `OrderProduct`
57
+ will have a default destination name of `order_products`. Of course, this system isn't pefect: your backend
58
+ may have a different convention or you may have one of the many words in the english language that are
59
+ exceptions to the grammatical rules of making words plural. In this case you can simply extend the method
60
+ and change it according to your needs, e.g.:
61
+
62
+ ```
63
+ from typing import Self
64
+ import clearskies
65
+
66
+ class Fish(clearskies.Model):
67
+ @classmethod
68
+ def destination_name(cls: type[Self]) -> str:
69
+ return "fish"
70
+ ```
71
+
72
+ ### Columns
73
+
74
+ Finally, columns are defined by attaching attributes to your model class that extend clearskies.Column. A variety
75
+ are provided by default in the clearskies.columns module, and you can always create more or import them from
76
+ other packages.
77
+
78
+ ### Fetching From the Di Container
79
+
80
+ In order to use a model in your application you need to retrieve it from the dependency injection system. Like
81
+ everything, you can do this by either the name or with type hinting. Models do have a special rule for
82
+ injection-via-name: like all classes their dependency injection name is made by converting the class name from
83
+ title case to snake case, but they are also available via the pluralized name. Here's a quick example of all
84
+ three approaches for dependency injection:
85
+
86
+ ```
87
+ import clearskies
88
+
89
+ class User(clearskies.Model):
90
+ id_column_name = "id"
91
+ backend = clearskies.backends.MemoryBackend()
92
+
93
+ id = clearskies.columns.Uuid()
94
+ name = clearskies.columns.String()
95
+
96
+ def my_application(user, users, by_type_hint: User):
97
+ return {
98
+ "all_are_user_models": isinstance(user, User) and isinstance(users, User) and isinstance(by_type_hint, User)
99
+ }
100
+
101
+ cli = clearskies.contexts.Cli(my_application, classes=[User])
102
+ cli()
103
+ ```
104
+
105
+ Note that the `User` model class was provided in the `classes` list sent to the context: that's important as it
106
+ informs the dependency injection system that this is a class we want to provide. It's common (but not required)
107
+ to put all models for a clearskies application in their own separate python module and then provide those to
108
+ the depedency injection system via the `modules` argument to the context. So you may have a directory structure
109
+ like this:
110
+
111
+ ```
112
+ ├── app/
113
+ │ └── models/
114
+ │ ├── __init__.py
115
+ │ ├── category.py
116
+ │ ├── order.py
117
+ │ ├── product.py
118
+ │ ├── status.py
119
+ │ └── user.py
120
+ └── api.py
121
+ ```
122
+
123
+ Where `__init__.py` imports all the models:
124
+
125
+ ```
126
+ from app.models.category import Category
127
+ from app.models.order import Order
128
+ from app.models.proudct import Product
129
+ from app.models.status import Status
130
+ from app.models.user import User
131
+
132
+ __all__ = ["Category", "Order", "Product", "Status", "User"]
133
+ ```
134
+
135
+ Then in your main application you can just import the whole `models` module into your context:
136
+
137
+ ```
138
+ import app.models
139
+
140
+ cli = clearskies.contexts.cli(SomeApplication, modules=[app.models])
141
+ ```
142
+
143
+ ### Adding Dependencies
144
+
145
+ The base model class extends `clearskies.di.InjectableProperties` which means that you can inject dependencies into your model
146
+ using the `di.inject` classes. Here's an example that demonstrates dependency injection for models:
147
+
148
+ ```
149
+ import datetime
150
+ import clearskies
151
+
152
+ class SomeClass:
153
+ # Since this will be built by the DI system directly, we can declare dependencies in the __init__
154
+ def __init__(self, some_date):
155
+ self.some_date = some_date
156
+
157
+ class User(clearskies.Model):
158
+ id_column_name = "id"
159
+ backend = clearskies.backends.MemoryBackend()
160
+
161
+ utcnow = clearskies.di.inject.Utcnow()
162
+ some_class = clearskies.di.inject.ByClass(SomeClass)
163
+
164
+ id = clearskies.columns.Uuid()
165
+ name = clearskies.columns.String()
166
+
167
+ def some_date_in_the_past(self):
168
+ return self.some_class.some_date < self.utcnow
169
+
170
+ def my_application(user):
171
+ return user.some_date_in_the_past()
172
+
173
+ cli = clearskies.contexts.Cli(
174
+ my_application,
175
+ classes=[User],
176
+ bindings={
177
+ "some_date": datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1),
178
+ }
179
+ )
180
+ cli()
181
+ ```
29
182
  """
30
183
 
31
184
  _previous_data: dict[str, Any] = {}
@@ -110,29 +263,187 @@ class Model(Schema, InjectableProperties):
110
263
 
111
264
  def save(self: Self, data: dict[str, Any] | None = None, columns: dict[str, Column] = {}, no_data=False) -> bool:
112
265
  """
113
- Save data to the database and update the model.
114
-
115
- 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
116
318
 
117
319
  There are two supported flows. One is to pass in a dictionary of data to save:
118
320
 
119
321
  ```python
120
- model.save({
121
- "some_column": "New Value",
122
- "another_column": 5,
123
- })
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()
124
342
  ```
125
343
 
126
344
  And the other is to set new values on the columns attributes and then call save without data:
127
345
 
128
346
  ```python
129
- model.some_column = "New Value"
130
- model.another_column = 5
131
- 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
+
132
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")
133
393
 
134
- You cannot combine these methods. If you set a value on a column attribute and also pass
135
- in a dictionary of data to the save, then an exception will be raised.
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
+
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}
440
+
441
+ cli = clearskies.contexts.Cli(
442
+ my_application,
443
+ classes=[User],
444
+ )
445
+ cli()
446
+ ```
136
447
  """
137
448
  self.no_queries()
138
449
  if not data and not self._next_data and not no_data:
@@ -187,7 +498,76 @@ class Model(Schema, InjectableProperties):
187
498
  """
188
499
  Return True/False to denote if the given column is being modified by the active save operation.
189
500
 
190
- 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
+
191
571
  """
192
572
  self.no_queries()
193
573
  has_old_value = key in self._data
@@ -195,20 +575,73 @@ class Model(Schema, InjectableProperties):
195
575
 
196
576
  if not has_new_value:
197
577
  return False
578
+
198
579
  if not has_old_value:
199
580
  return True
200
581
 
201
- 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)
202
588
 
203
589
  def latest(self: Self, key: str, data: dict[str, Any]) -> Any:
204
590
  """
205
591
  Return the 'latest' value for a column during the save operation.
206
592
 
207
- Return either the column value from the data dictionary or the current value stored in the model
208
- Basically, shorthand for the optimized version of: `data.get(key, default=getattr(self, key))` (which is
209
- 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.
210
644
 
211
- Pass in the name of the column to check and the data dictionary from the save in progress
212
645
  """
213
646
  self.no_queries()
214
647
  if key in data:
@@ -216,7 +649,41 @@ class Model(Schema, InjectableProperties):
216
649
  return getattr(self, key)
217
650
 
218
651
  def was_changed(self: Self, key: str) -> bool:
219
- """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
+ """
220
687
  self.no_queries()
221
688
  if self._previous_data is None:
222
689
  raise ValueError("was_changed was called before a save was finished - you must save something first")
@@ -239,11 +706,86 @@ class Model(Schema, InjectableProperties):
239
706
  return old_value != new_value
240
707
  return not columns[key].values_match(old_value, new_value)
241
708
 
242
- def previous_value(self: Self, key: str):
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
+ """
243
741
  self.no_queries()
244
- 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))
245
747
 
246
748
  def delete(self: Self, except_if_not_exists=True) -> bool:
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
+ """
247
789
  self.no_queries()
248
790
  if not self:
249
791
  if except_if_not_exists:
@@ -311,26 +853,132 @@ class Model(Schema, InjectableProperties):
311
853
  for column in columns.values():
312
854
  column.save_finished(self)
313
855
 
314
- 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]:
315
857
  """
316
- 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.
317
920
 
318
- It is passed in the data being saved as well as the id. It should take action as needed and then return
319
- either the original data array or an adjusted one if appropriate.
320
921
  """
321
- pass
922
+ return data
322
923
 
323
- 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:
324
925
  """
325
- 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")
326
944
 
327
- It is passed in the data being saved and it should return the same data with adjustments as needed
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})
967
+
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
+ ```
328
976
  """
329
- return data
977
+ pass
330
978
 
331
979
  def save_finished(self: Self) -> None:
332
980
  """
333
- 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.
334
982
 
335
983
  It has no retrun value and is passed no data. By the time this fires the model has already been
336
984
  updated with the new data. You can decide on the necessary actions using the `was_changed` and
@@ -373,7 +1021,63 @@ class Model(Schema, InjectableProperties):
373
1021
  ### From here down is functionality related to list/search ###
374
1022
  ##############################################################
375
1023
  def has_query(self) -> bool:
376
- """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
+ """
377
1081
  return bool(self._query)
378
1082
 
379
1083
  def get_query(self) -> Query:
@@ -454,30 +1158,154 @@ class Model(Schema, InjectableProperties):
454
1158
 
455
1159
  def where(self: Self, where: str | Condition) -> Self:
456
1160
  """
457
- Add the given condition to the query.
1161
+ Add a condition to a query.
1162
+
1163
+ The `where` method (in combination with the `find` method) is typically the starting point for query records in
1164
+ a model. You don't *have* to add a condition to a model in order to fetch records, but of course it's a very
1165
+ common use case. Conditions in clearskies can be built from the columns or can be constructed as SQL-like
1166
+ string conditions, e.g. `model.where("name=Bob")` or `model.where(model.name.equals("Bob"))`. The latter
1167
+ provides strict type-checking, while the former does not. Either way they have the same result. The list of
1168
+ supported operators for a given column can be seen by checking the `_allowed_search_operators` attribute of the
1169
+ column class. Most columns accept all allowed operators, which are:
1170
+
1171
+ - "<=>"
1172
+ - "!="
1173
+ - "<="
1174
+ - ">="
1175
+ - ">"
1176
+ - "<"
1177
+ - "="
1178
+ - "in"
1179
+ - "is not null"
1180
+ - "is null"
1181
+ - "like"
1182
+
1183
+ When working with string conditions, it is safe to inject user input into the condition. The allowed
1184
+ format for conditions is very simple: `f"{column_name}\\s?{operator}\\s?{value}"`. This makes it possible to
1185
+ unambiguously separate all three pieces from eachother. It's not possible to inject malicious payloads into either
1186
+ the column names or operators because both are checked against a strict allow list (e.g. the columns declared in the
1187
+ model or the list of allowed operators above). The value is then extracted from the leftovers, and this is
1188
+ provided to the backend separately so it can use it appropriately (e.g. using prepared statements for the cursor
1189
+ backend). Of course, you generally shouldn't have to inject user input into conditions very often because, most
1190
+ often, the various list/search endpoints do this for you, but if you have to do it there are no security
1191
+ concerns.
1192
+
1193
+ You can include a table name before the column name, with the two separated by a period. As always, if you do this,
1194
+ ensure that you include a supporting join statement (via the `join` method - see it for examples).
1195
+
1196
+ When you call the `where` method it returns a new model object with it's query configured to include the additional
1197
+ condition. The original model object remains unchanged. Multiple conditions are always joined with AND. There is
1198
+ no explicit option for OR. The closest is using an IN condition.
1199
+
1200
+ To access the results you have to iterate over the resulting model. If you are only expecting one result
1201
+ and want to work directly with it, then you can use `model.find(condition)` or `model.where(condition).first()`.
1202
+
1203
+ Example:
1204
+ ```python
1205
+ import clearskies
458
1206
 
459
- This method returns a new object with the updated query. The original model object is unmodified.
1207
+ class Order(clearskies.Model):
1208
+ id_column_name = "id"
1209
+ backend = clearskies.backends.MemoryBackend()
460
1210
 
461
- Conditions should be an SQL-like string of the form [column][operator][value] with an optional table prefix.
462
- You can safely inject user input into the value. The column name will also be checked against the searchable
463
- columns for the model class, and an exception will be thrown if the column doesn't exist or is not searchable.
1211
+ id = clearskies.columns.Uuid()
1212
+ user_id = clearskies.columns.String()
1213
+ status = clearskies.columns.Select(["Pending", "In Progress"])
1214
+ total = clearskies.columns.Float()
464
1215
 
465
- Multiple conditions are always joined with AND. There is no explicit option for OR. The closest is using an
466
- IN condition.
1216
+ def my_application(orders):
1217
+ orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
1218
+ orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
1219
+ orders.create({"user_id": "Jane", "status": "Pending", "total": 30})
467
1220
 
468
- Examples:
469
- ```python
470
- for record in (
471
- models.where("order_id=5").where("status IN ('ACTIVE','PENDING')").where("other_table.id=asdf")
472
- ):
473
- print(record.id)
1221
+ return [order.user_id for order in orders.where("status=Pending").where(Order.total.greater_than(25))]
1222
+
1223
+ cli = clearskies.contexts.Cli(
1224
+ my_application,
1225
+ classes=[Order],
1226
+ )
1227
+ cli()
474
1228
  ```
1229
+
1230
+ Which, if ran, returns: `["Jane"]`
1231
+
475
1232
  """
476
1233
  self.no_single_model()
477
1234
  return self.with_query(self.get_query().add_where(where if isinstance(where, Condition) else Condition(where)))
478
1235
 
479
1236
  def join(self: Self, join: str) -> Self:
480
- """Add a join clause to the query."""
1237
+ """
1238
+ Add a join clause to the query.
1239
+
1240
+ As with the `where` method, this expects a string which is parsed accordingly. The syntax is not as flexible as
1241
+ SQL and expects a format of:
1242
+
1243
+ ```
1244
+ [left|right|inner]? join [right_table_name] ON [right_table_name].[right_column_name]=[left_table_name].[left_column_name].
1245
+ ```
1246
+
1247
+ This is case insensitive. Aliases are allowed. If you don't specify a join type it defaults to inner.
1248
+ Here are two examples of valid join statements:
1249
+
1250
+ - `join orders on orders.user_id=users.id`
1251
+ - `left join user_orders as orders on orders.id=users.id`
1252
+
1253
+ Note that joins are not strictly limited to SQL-like backends, but of course no all backends will support joining.
1254
+
1255
+ A basic example:
1256
+
1257
+ ```
1258
+ import clearskies
1259
+
1260
+ class User(clearskies.Model):
1261
+ id_column_name = "id"
1262
+ backend = clearskies.backends.MemoryBackend()
1263
+
1264
+ id = clearskies.columns.Uuid()
1265
+ name = clearskies.columns.String()
1266
+
1267
+ class Order(clearskies.Model):
1268
+ id_column_name = "id"
1269
+ backend = clearskies.backends.MemoryBackend()
1270
+
1271
+ id = clearskies.columns.Uuid()
1272
+ user_id = clearskies.columns.BelongsToId(User, readable_parent_columns=["id", "name"])
1273
+ user = clearskies.columns.BelongsToModel("user_id")
1274
+ status = clearskies.columns.Select(["Pending", "In Progress"])
1275
+ total = clearskies.columns.Float()
1276
+
1277
+ def my_application(users, orders):
1278
+ jane = users.create({"name": "Jane"})
1279
+ another_jane = users.create({"name": "Jane"})
1280
+ bob = users.create({"name": "Bob"})
1281
+
1282
+ # Jane's orders
1283
+ orders.create({"user_id": jane.id, "status": "Pending", "total": 25})
1284
+ orders.create({"user_id": jane.id, "status": "Pending", "total": 30})
1285
+ orders.create({"user_id": jane.id, "status": "In Progress", "total": 35})
1286
+
1287
+ # Another Jane's orders
1288
+ orders.create({"user_id": another_jane.id, "status": "Pending", "total": 15})
1289
+
1290
+ # Bob's orders
1291
+ orders.create({"user_id": bob.id, "status": "Pending", "total": 28})
1292
+ orders.create({"user_id": bob.id, "status": "In Progress", "total": 35})
1293
+
1294
+ # return all orders for anyone named Jane that have a status of Pending
1295
+ return orders.join("join users on users.id=orders.user_id").where("users.name=Jane").sort_by("total", "asc").where("status=Pending")
1296
+
1297
+ cli = clearskies.contexts.Cli(
1298
+ clearskies.endpoints.Callable(
1299
+ my_application,
1300
+ model_class=Order,
1301
+ readable_column_names=["user", "total"],
1302
+ ),
1303
+ classes=[Order, User],
1304
+ )
1305
+ cli()
1306
+
1307
+ ```
1308
+ """
481
1309
  self.no_single_model()
482
1310
  return self.with_query(self.get_query().add_join(Join(join)))
483
1311
 
@@ -498,6 +1326,11 @@ class Model(Schema, InjectableProperties):
498
1326
  return False
499
1327
 
500
1328
  def group_by(self: Self, group_by_column_name: str) -> Self:
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
+ """
501
1334
  self.no_single_model()
502
1335
  return self.with_query(self.get_query().set_group_by(group_by_column_name))
503
1336
 
@@ -510,6 +1343,41 @@ class Model(Schema, InjectableProperties):
510
1343
  secondary_direction: str = "",
511
1344
  secondary_table_name: str = "",
512
1345
  ) -> Self:
1346
+ """
1347
+ Add a sort by clause to the query. You can sort by up to two columns at once.
1348
+
1349
+ Example:
1350
+ ```
1351
+ import clearskies
1352
+
1353
+ class Order(clearskies.Model):
1354
+ id_column_name = "id"
1355
+ backend = clearskies.backends.MemoryBackend()
1356
+
1357
+ id = clearskies.columns.Uuid()
1358
+ user_id = clearskies.columns.String()
1359
+ status = clearskies.columns.Select(["Pending", "In Progress"])
1360
+ total = clearskies.columns.Float()
1361
+
1362
+ def my_application(orders):
1363
+ orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
1364
+ orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
1365
+ orders.create({"user_id": "Alice", "status": "Pending", "total": 30})
1366
+ orders.create({"user_id": "Bob", "status": "Pending", "total": 26})
1367
+
1368
+ return orders.sort_by("user_id", "asc", secondary_column_name="total", secondary_direction="desc")
1369
+
1370
+ cli = clearskies.contexts.Cli(
1371
+ clearskies.endpoints.Callable(
1372
+ my_application,
1373
+ model_class=Order,
1374
+ readable_column_names=["user_id", "total"],
1375
+ ),
1376
+ classes=[Order],
1377
+ )
1378
+ cli()
1379
+ ```
1380
+ """
513
1381
  self.no_single_model()
514
1382
  sort = Sort(primary_table_name, primary_column_name, primary_direction)
515
1383
  secondary_sort = None
@@ -518,10 +1386,96 @@ class Model(Schema, InjectableProperties):
518
1386
  return self.with_query(self.get_query().set_sort(sort, secondary_sort))
519
1387
 
520
1388
  def limit(self: Self, limit: int) -> Self:
1389
+ """
1390
+ Set the number of records to return.
1391
+
1392
+ ```
1393
+ import clearskies
1394
+
1395
+ class Order(clearskies.Model):
1396
+ id_column_name = "id"
1397
+ backend = clearskies.backends.MemoryBackend()
1398
+
1399
+ id = clearskies.columns.Uuid()
1400
+ user_id = clearskies.columns.String()
1401
+ status = clearskies.columns.Select(["Pending", "In Progress"])
1402
+ total = clearskies.columns.Float()
1403
+
1404
+ def my_application(orders):
1405
+ orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
1406
+ orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
1407
+ orders.create({"user_id": "Alice", "status": "Pending", "total": 30})
1408
+ orders.create({"user_id": "Bob", "status": "Pending", "total": 26})
1409
+
1410
+ return orders.limit(2)
1411
+
1412
+ cli = clearskies.contexts.Cli(
1413
+ clearskies.endpoints.Callable(
1414
+ my_application,
1415
+ model_class=Order,
1416
+ readable_column_names=["user_id", "total"],
1417
+ ),
1418
+ classes=[Order],
1419
+ )
1420
+ cli()
1421
+ ```
1422
+ """
521
1423
  self.no_single_model()
522
1424
  return self.with_query(self.get_query().set_limit(limit))
523
1425
 
524
1426
  def pagination(self: Self, **pagination_data) -> Self:
1427
+ """
1428
+ Set the pagination parameter(s) for the query.
1429
+
1430
+ The exact details of how pagination work depend on the backend. For instance, the cursor and memory backend
1431
+ expect to be given a `start` parameter, while an API backend will vary with the API, and the dynamodb backend
1432
+ expects a kwarg called `cursor`. As a result, it's necessary to check the backend documentation to understand
1433
+ how to properly set pagination. The endpoints automatically account for this because backends are required
1434
+ to declare pagination details via the `allowed_pagination_keys` method. If you attempt to set invalid
1435
+ pagination data via this method, clearskies will raise a ValueError.
1436
+
1437
+ Example:
1438
+ ```
1439
+ import clearskies
1440
+
1441
+ class Order(clearskies.Model):
1442
+ id_column_name = "id"
1443
+ backend = clearskies.backends.MemoryBackend()
1444
+
1445
+ id = clearskies.columns.Uuid()
1446
+ user_id = clearskies.columns.String()
1447
+ status = clearskies.columns.Select(["Pending", "In Progress"])
1448
+ total = clearskies.columns.Float()
1449
+
1450
+ def my_application(orders):
1451
+ orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
1452
+ orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
1453
+ orders.create({"user_id": "Alice", "status": "Pending", "total": 30})
1454
+ orders.create({"user_id": "Bob", "status": "Pending", "total": 26})
1455
+
1456
+ return orders.sort_by("total", "asc").pagination(start=2)
1457
+
1458
+ cli = clearskies.contexts.Cli(
1459
+ clearskies.endpoints.Callable(
1460
+ my_application,
1461
+ model_class=Order,
1462
+ readable_column_names=["user_id", "total"],
1463
+ ),
1464
+ classes=[Order],
1465
+ )
1466
+ cli()
1467
+ ```
1468
+
1469
+ However, if the return line in `my_application` is switched for either of these:
1470
+
1471
+ ```
1472
+ return orders.sort_by("total", "asc").pagination(start="asdf")
1473
+ return orders.sort_by("total", "asc").pagination(something_else=5)
1474
+ ```
1475
+
1476
+ Will result in an exception that explains exactly what is wrong.
1477
+
1478
+ """
525
1479
  self.no_single_model()
526
1480
  error = self.backend.validate_pagination_data(pagination_data, str)
527
1481
  if error:
@@ -538,8 +1492,36 @@ class Model(Schema, InjectableProperties):
538
1492
  This is just shorthand for `models.where("column=value").find()`. Example:
539
1493
 
540
1494
  ```python
541
- model = models.find("column=value")
542
- print(model.id)
1495
+ import clearskies
1496
+
1497
+ class Order(clearskies.Model):
1498
+ id_column_name = "id"
1499
+ backend = clearskies.backends.MemoryBackend()
1500
+
1501
+ id = clearskies.columns.Uuid()
1502
+ user_id = clearskies.columns.String()
1503
+ status = clearskies.columns.Select(["Pending", "In Progress"])
1504
+ total = clearskies.columns.Float()
1505
+
1506
+ def my_application(orders):
1507
+ orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
1508
+ orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
1509
+ orders.create({"user_id": "Jane", "status": "Pending", "total": 30})
1510
+
1511
+ jane = orders.find("user_id=Jane")
1512
+ jane.total = 35
1513
+ jane.save()
1514
+
1515
+ return {
1516
+ "user_id": jane.user_id,
1517
+ "total": jane.total,
1518
+ }
1519
+
1520
+ cli = clearskies.contexts.Cli(
1521
+ my_application,
1522
+ classes=[Order],
1523
+ )
1524
+ cli()
543
1525
  ```
544
1526
  """
545
1527
  self.no_single_model()
@@ -564,13 +1546,48 @@ class Model(Schema, InjectableProperties):
564
1546
  """
565
1547
  Loop through all available pages of results and returns a list of all models that match the query.
566
1548
 
567
- NOTE: this loads up all records in memory before returning (e.g. it isn't using generators yet), so
568
- expect delays for large record sets.
1549
+ If you don't set a limit on a query, some backends will return all records but some backends have a
1550
+ default maximum number of results that they will return. In the latter case, you can use `paginate_all`
1551
+ to fetch all records by instructing clearskies to iterate over all pages. This is possible because backends
1552
+ are required to define how pagination works in a way that clearskies can automatically understand and
1553
+ use. To demonstrate this, the following example sets a limit of 1 which stops the memory backend
1554
+ from returning everything, and then uses `paginate_all` to fetch all records. The memory backend
1555
+ doesn't have a default limit, so in practice the `paginate_all` is unnecessary here, but this is done
1556
+ for demonstration purposes.
569
1557
 
570
- ```python
571
- for model in models.where("column=value").paginate_all():
572
- print(model.id)
573
1558
  ```
1559
+ import clearskies
1560
+
1561
+ class Order(clearskies.Model):
1562
+ id_column_name = "id"
1563
+ backend = clearskies.backends.MemoryBackend()
1564
+
1565
+ id = clearskies.columns.Uuid()
1566
+ user_id = clearskies.columns.String()
1567
+ status = clearskies.columns.Select(["Pending", "In Progress"])
1568
+ total = clearskies.columns.Float()
1569
+
1570
+ def my_application(orders):
1571
+ orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
1572
+ orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
1573
+ orders.create({"user_id": "Alice", "status": "Pending", "total": 30})
1574
+ orders.create({"user_id": "Bob", "status": "Pending", "total": 26})
1575
+
1576
+ return orders.limit(1).paginate_all()
1577
+
1578
+ cli = clearskies.contexts.Cli(
1579
+ clearskies.endpoints.Callable(
1580
+ my_application,
1581
+ model_class=Order,
1582
+ readable_column_names=["user_id", "total"],
1583
+ ),
1584
+ classes=[Order],
1585
+ )
1586
+ cli()
1587
+ ```
1588
+
1589
+ NOTE: this loads up all records in memory before returning (e.g. it isn't using generators yet), so
1590
+ expect delays for large record sets.
574
1591
  """
575
1592
  self.no_single_model()
576
1593
  next_models = self.with_query(self.get_query())
@@ -587,7 +1604,39 @@ class Model(Schema, InjectableProperties):
587
1604
  Create a new model object and populates it with the data in `data`.
588
1605
 
589
1606
  NOTE: the difference between this and `model.create` is that model.create() actually saves a record in the backend,
590
- 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
+ ```
591
1640
  """
592
1641
  model = self._di.build(self.__class__, cache=False)
593
1642
  model.set_raw_data(data)
@@ -596,6 +1645,40 @@ class Model(Schema, InjectableProperties):
596
1645
  def empty(self: Self) -> Self:
597
1646
  """
598
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
+ ```
599
1682
  """
600
1683
  return self.model({})
601
1684
 
@@ -603,7 +1686,42 @@ class Model(Schema, InjectableProperties):
603
1686
  """
604
1687
  Create a new record in the backend using the information in `data`.
605
1688
 
606
- 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.
607
1725
  """
608
1726
  empty = self.model()
609
1727
  empty.save(data, columns=columns, no_data=no_data)
@@ -611,11 +1729,43 @@ class Model(Schema, InjectableProperties):
611
1729
 
612
1730
  def first(self: Self) -> Self:
613
1731
  """
614
- Return the first model matching the given query.
1732
+ Return the first model for a given query.
1733
+
1734
+ The `where` method returns an object meant to be iterated over. If you are expecting your query to return a single
1735
+ record, then you can use first to turn that directly into the matching model so you don't have to iterate over it:
1736
+
1737
+ ```
1738
+ import clearskies
1739
+
1740
+ class Order(clearskies.Model):
1741
+ id_column_name = "id"
1742
+ backend = clearskies.backends.MemoryBackend()
1743
+
1744
+ id = clearskies.columns.Uuid()
1745
+ user_id = clearskies.columns.String()
1746
+ status = clearskies.columns.Select(["Pending", "In Progress"])
1747
+ total = clearskies.columns.Float()
1748
+
1749
+ def my_application(orders):
1750
+ orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
1751
+ orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
1752
+ orders.create({"user_id": "Jane", "status": "Pending", "total": 30})
1753
+
1754
+ jane = orders.where("status=Pending").where(Order.total.greater_than(25)).first()
1755
+ jane.total = 35
1756
+ jane.save()
1757
+
1758
+ return {
1759
+ "user_id": jane.user_id,
1760
+ "total": jane.total,
1761
+ }
1762
+
1763
+ cli = clearskies.contexts.Cli(
1764
+ my_application,
1765
+ classes=[Order],
1766
+ )
1767
+ cli()
615
1768
 
616
- ```python
617
- model = models.where("column=value").sort_by("age", "DESC").first()
618
- print(model.id)
619
1769
  ```
620
1770
  """
621
1771
  self.no_single_model()