HasMany
A column to manage a “has many” relationship.
- Overview
- child_model_class
- foreign_column_name
- readable_child_column_names
- where
- is_readable
- on_change_pre_save
- on_change_post_save
- on_change_save_finished
Overview
In order to manage a has-many relationship, the child model needs a column that stores the id of the parent record it belongs to. Also remember that the reverse of a has-many relationship is a belongs-to relationship: the parent has many children, the child belongs to a parent.
There’s an automatic standard where the name of the column in thie child table that stores the parent id is made by converting the parent model class name into snake case and then appending _id
. For instance, if the parent model is called the DooHicky
class, the child model is expected to have a column named doo_hicky_id
. If you use a different column name for the id in your child model, then just update the foreign_column_name
property on the HasMany
column accordingly.
See the BelongsToId class for additional background and directions on avoiding circular dependency trees.
import clearskies
class Product(clearskies.Model):
id_column_name = "id"
backend = clearskies.backends.MemoryBackend()
id = clearskies.columns.Uuid()
name = clearskies.columns.String()
category_id = clearskies.columns.String()
class Category(clearskies.Model):
id_column_name = "id"
backend = clearskies.backends.MemoryBackend()
id = clearskies.columns.Uuid()
name = clearskies.columns.String()
products = clearskies.columns.HasMany(Product)
def test_has_many(products: Product, categories: Category):
toys = categories.create({"name": "Toys"})
auto = categories.create({"name": "Auto"})
# create some toys
ball = products.create({"name": "Ball", "category_id": toys.id})
fidget_spinner = products.create({"name": "Fidget Spinner", "category_id": toys.id})
crayon = products.create({"name": "Crayon", "category_id": toys.id})
# the HasMany column is an interable of matching records
toy_names = [product.name for product in toys.products]
# it specifically returns a models object so you can do more filtering/transformations
return toys.products.sort_by("name", "asc")
cli = clearskies.contexts.Cli(
clearskies.endpoints.Callable(
test_has_many,
model_class=Product,
readable_column_names=["id", "name"],
),
classes=[Category, Product],
)
if __name__ == "__main__":
cli()
And if you execute this it will return:
{
"status": "success",
"error": "",
"data": [
{"id": "edc68e8d-7fc8-45ce-98f0-9c6f883e4e7f", "name": "Ball"},
{"id": "b51a0de5-c784-4e0c-880c-56e5bf731dfd", "name": "Crayon"},
{"id": "06cec3af-d042-4d6b-a99c-b4a0072f188d", "name": "Fidget Spinner"},
],
"pagination": {},
"input_errors": {},
}
child_model_class
Required
foreign_column_name
Optional
The name of the column in the child table that connects it back to the parent.
By default this is populated by converting the model class name from TitleCase to snake_case and appending _id. So, if the model class is called ProductCategory
, this becomes product_category_id
. This MUST correspond to the actual name of a column in the child table. This is used so that the parent can find its child records.
Example:
import clearskies
class Product(clearskies.Model):
id_column_name = "id"
backend = clearskies.backends.MemoryBackend()
id = clearskies.columns.Uuid()
name = clearskies.columns.String()
my_parent_category_id = clearskies.columns.String()
class Category(clearskies.Model):
id_column_name = "id"
backend = clearskies.backends.MemoryBackend()
id = clearskies.columns.Uuid()
name = clearskies.columns.String()
products = clearskies.columns.HasMany(Product, foreign_column_name="my_parent_category_id")
def test_has_many(products: Product, categories: Category):
toys = categories.create({"name": "Toys"})
fidget_spinner = products.create({"name": "Fidget Spinner", "my_parent_category_id": toys.id})
crayon = products.create({"name": "Crayon", "my_parent_category_id": toys.id})
ball = products.create({"name": "Ball", "my_parent_category_id": toys.id})
return toys.products.sort_by("name", "asc")
cli = clearskies.contexts.Cli(
clearskies.endpoints.Callable(
test_has_many,
model_class=Product,
readable_column_names=["id", "name"],
),
classes=[Category, Product],
)
if __name__ == "__main__":
cli()
Compare to the first example for the HasMany class. In that case, the column in the product model which contained the category id was category_id
, and the products
column didn’t have to specify the foreign_column_name
(since the column name followed the naming rule). As a result, category.products
was able to find all children of a given category. In this example, the name of the column in the product model that contains the category id was changed to my_parent_category_id
. Since this no longer matches the naming convention, we had to specify foreign_column_name="my_parent_category_id"
in Category.products
, in order for the HasMany
column to find the children. Therefore, when invoked it returns the same thing:
{
"status": "success",
"error": "",
"data": [
{
"id": "3cdd06e0-b226-4a4a-962d-e8c5acc759ac",
"name": "Ball"
},
{
"id": "debc7968-976a-49cd-902c-d359a8abd032",
"name": "Crayon"
},
{
"id": "0afcd314-cdfc-4a27-ac6e-061b74ee5bf9",
"name": "Fidget Spinner"
}
],
"pagination": {},
"input_errors": {}
}
readable_child_column_names
Optional
where
Optional
Additional conditions to add to searches on the child table.
import clearskies
class Order(clearskies.Model):
id_column_name = "id"
backend = clearskies.backends.MemoryBackend()
id = clearskies.columns.Uuid()
total = clearskies.columns.Float()
status = clearskies.columns.Select(["Open", "In Progress", "Closed"])
user_id = clearskies.columns.String()
class User(clearskies.Model):
id_column_name = "id"
backend = clearskies.backends.MemoryBackend()
id = clearskies.columns.Uuid()
name = clearskies.columns.String()
orders = clearskies.columns.HasMany(Order, readable_child_column_names=["id", "status"])
large_open_orders = clearskies.columns.HasMany(
Order,
readable_child_column_names=["id", "status"],
where=[Order.status.equals("Open"), "total>100"],
)
def test_has_many(users: User, orders: Order):
user = users.create({"name": "Bob"})
order_1 = orders.create({"status": "Open", "total": 25.50, "user_id": user.id})
order_2 = orders.create({"status": "Closed", "total": 35.50, "user_id": user.id})
order_3 = orders.create({"status": "Open", "total": 125, "user_id": user.id})
order_4 = orders.create({"status": "In Progress", "total": 25.50, "user_id": user.id})
return user.large_open_orders
cli = clearskies.contexts.Cli(
clearskies.endpoints.Callable(
test_has_many,
model_class=Order,
readable_column_names=["id", "total", "status"],
return_records=True,
),
classes=[Order, User],
)
if __name__ == "__main__":
cli()
The above example shows two different ways of adding conditions. Note that where
can be either a list or a single condition. If you invoked this you would get:
{
"status": "success",
"error": "",
"data": [
{
"id": "6ad99935-ac9a-40ef-a1b2-f34538cc6529",
"total": 125.0,
"status": "Open"
}
],
"pagination": {},
"input_errors": {}
}
Finally, an individual condition can also be a callable that accepts the child model class, adds any desired conditions, and then returns the modified model class. Like usual, this callable can request any defined depenency. So, for instance, the following column definition is equivalent to the example above:
class User(clearskies.Model):
# removing unchanged part for brevity
large_open_orders = clearskies.columns.HasMany(
Order,
readable_child_column_names=["id", "status"],
where=lambda model: model.where("status=Open").where("total>100"),
)
is_readable
Optional
Whether or not this column can be converted to JSON and included in an API response.
If this is set to False for a column and you attempt to set that column as a readable_column in an endpoint, clearskies will throw an exception.
on_change_pre_save
Optional
Actions to take during the pre-save step of the save process if the column has changed during the active save operation.
Pre-save happens before the data is persisted to the backend. Actions/callables in this step must return a dictionary. The data in the dictionary will be included in the save operation. Since the save hasn’t completed, any data in the model itself reflects the model before the save operation started. Actions in the pre-save step must NOT make any changes directly, but should ONLY return modified data for the save operation. In addition, they must be idempotent - they should always return the same value when called with the same data. This is because clearskies can call them more than once. If a pre-save hook changes the save data, then clearskies will call all the pre-save hooks again in case this new data needs to trigger further changes. Stateful changes should be reserved for the post_save or save_finished stages.
Callables and actions can request any dependencies provided by the DI system. In addition, they can request two named parameters:
model
- the model involved in the save operationdata
- the new data being saved
The key here is that the defined actions will be invoked regardless of how the save happens. Whether the model.save() function is called directly or the model is creatd/modified via an endpoint, your business logic will always be executed. This makes for easy reusability and consistency throughout your application.
Here’s an example where we want to record a timestamp anytime an order status becomes a particular value:
import clearskies
class Order(clearskies.Model):
id_column_name = "id"
backend = clearskies.backends.MemoryBackend()
id = clearskies.columns.Uuid()
status = clearskies.columns.Select(
["Open", "On Hold", "Fulfilled"],
on_change_pre_save=[
lambda data, utcnow: {"fulfilled_at": utcnow} if data["status"] == "Fulfilled" else {},
],
)
fulfilled_at = clearskies.columns.Datetime()
wsgi = clearskies.contexts.WsgiRef(
clearskies.endpoints.Create(
model_class=Order,
writeable_column_names=["status"],
readable_column_names=["id", "status", "fulfilled_at"],
),
)
wsgi()
You can then see the difference depending on what you set the status to:
$ curl http://localhost:8080 -d '{"status":"Open"}' | jq
{
"status": "success",
"error": "",
"data": {
"id": "a732545f-51b3-4fd0-a6cf-576cf1b2872f",
"status": "Open",
"fulfilled_at": null
},
"pagination": {},
"input_errors": {}
}
$ curl http://localhost:8080 -d '{"status":"Fulfilled"}' | jq
{
"status": "success",
"error": "",
"data": {
"id": "c288bf43-2246-48e4-b168-f40cbf5376df",
"status": "Fulfilled",
"fulfilled_at": "2025-05-04T02:32:56+00:00"
},
"pagination": {},
"input_errors": {}
}
on_change_post_save
Optional
Actions to take during the post-save step of the process if the column has changed during the active save.
Post-save happens after the data is persisted to the backend but before the full save process has finished. Since the data has been persisted to the backend, any data returned by the callables/actions will be ignored. If you need to make data changes you’ll have to execute a separate save operation. Since the save hasn’t finished, the model is not yet updated with the new data, and any data you fetch out of the model will refelect the data in the model before the save started.
Callables and actions can request any dependencies provided by the DI system. In addition, they can request three named parameters:
model
- the model involved in the save operationdata
- the new data being savedid
- the id of the record being saved
Here’s an example of using a post-save action to record a simple audit trail when the order status changes:
import clearskies
class Order(clearskies.Model):
id_column_name = "id"
backend = clearskies.backends.MemoryBackend()
id = clearskies.columns.Uuid()
status = clearskies.columns.Select(
["Open", "On Hold", "Fulfilled"],
on_change_post_save=[
lambda model, data, order_histories: order_histories.create({
"order_id": model.latest("id", data),
"event": "Order status changed to " + data["status"]
}),
],
)
class OrderHistory(clearskies.Model):
id_column_name = "id"
backend = clearskies.backends.MemoryBackend()
id = clearskies.columns.Uuid()
event = clearskies.columns.String()
order_id = clearskies.columns.BelongsToId(Order)
# include microseconds in the created_at time so that we can sort our example by created_at
# and they come out in order (since, for our test program, they will all be created in the same second).
created_at = clearskies.columns.Created(date_format="%Y-%m-%d %H:%M:%S.%f")
def test_post_save(orders: Order, order_histories: OrderHistory):
my_order = orders.create({"status": "Open"})
my_order.status = "On Hold"
my_order.save()
my_order.save({"status": "Open"})
my_order.save({"status": "Fulfilled"})
return order_histories.where(OrderHistory.order_id.equals(my_order.id)).sort_by("created_at", "asc")
cli = clearskies.contexts.Cli(
clearskies.endpoints.Callable(
test_post_save,
model_class=OrderHistory,
return_records=True,
readable_column_names=["id", "event", "created_at"],
),
classes=[Order, OrderHistory],
)
cli()
Note that in our on_change_post_save
lambda function, we use model.latest("id", data)
. We can’t just use data["id"]
because data
is a dictionary containing the information present in the save. During the create operation data["id"]
will be populated, but during the subsequent edit operations it won’t be - only the status column is changing. model.latest("id", data)
is basically just short hand for: data.get("id", model.id)
. On the other hand, we can just use data["status"]
because the on_change
hook is attached to the status field, so it will only fire when status is being changed, which means that the status
key is guaranteed to be in the dictionary when the lambda is executed.
Finally, the post-save action has a named parameter called id
, so in this specific case we could use:
lambda data, id, order_histories: order_histories.create("order_id": id, "event": data["status"])
When we execute the above script it will return something like:
{
"status": "success",
"error": "",
"data": [
{
"id": "c550d714-839b-4f25-a9e1-bd7e977185ff",
"event": "Order status changed to Open",
"created_at": "2025-05-04T14:09:42.960119+00:00"
},
{
"id": "f393d7b0-da21-4117-a7a4-0359fab802bb",
"event": "Order status changed to On Hold",
"created_at": "2025-05-04T14:09:42.960275+00:00"
},
{
"id": "5b528a10-4a08-47ae-938c-fc7067603f8e",
"event": "Order status changed to Open",
"created_at": "2025-05-04T14:09:42.960395+00:00"
},
{
"id": "91f77a88-1c38-49f7-aa1e-7f97bd9f962f",
"event": "Order status changed to Fulfilled",
"created_at": "2025-05-04T14:09:42.960514+00:00"
}
],
"pagination": {},
"input_errors": {}
}
on_change_save_finished
Optional
Actions to take during the save-finished step of the save process if the column has changed in the save.
Save-finished happens after the save process has completely finished and the model is updated with the final data. Any data returned by these actions will be ignored, since the save has already finished. If you need to make data changes you’ll have to execute a separate save operation.
Callables and actions can request any dependencies provided by the DI system. In addition, they can request the following parameter:
model
- the model involved in the save operation
Unlike pre_save and post_save, data
is not provided because this data has already been merged into the model. If you need some context from the completed save operation, use methods like was_changed
and previous_value
.