Skip to content

Gel model casts #934

@vpetrovykh

Description

@vpetrovykh

In EdgeQL we use the query shape to control what data is returned to us. The shape may introduce new computeds or hide existing fields. This, however, does not affect our ability to access the existing fields in the rest of the query. Thus making queries like this possible:

xxx:main> select Person {
    id,
    name,
}
filter .email = '[email protected]';
{default::Person {id: e4ccc24e-9470-11f0-8a4f-7f1bc03910dd, name: 'Alice'}}

Which works fine if the goal is to only show id and name in the serialized output.

In Python we have reflected models, __shapes__, and __defs__ available to build a custom model for querying, but we cannot easily hide fields from showing up in the serialized output. We can control the fields by explicitly selecting them in the query:

person = await db.get(
    default.Person.select(
        id=True,
        name=True,
    ).filter(
        email='[email protected]'
    )
)

But this approach relies on merging the inner logic of what operation needs to be done (which might require extra fields being fetched in Python) and the serialization logic (which may specifically need to ensure a limited and strict interface). It would be a better and more flexible solution to allow casting compatible types into each other.

The main requirement for the cast would have to be that the two Python types are representing the same Gel type (or maybe related Gel types, like Named and Person extending Named).

We can use a Pythonic way of casting via a constructor. If a model is constructed by passing a single positional argument and that argument is another model, we know this is meant to be a cast. When casting, the new model object will copy and validate the fields based on the fields of the same name in the original object.

Casting must be recursive, meaning that if the cast target type has links and the links are of different type than the casting source model, the link values must be cast as well.

class MyProfile(default.Profile.__shapes__.RequiredId):
    public_name: defualt.Profile.__defs__.public_name

class MyPerson(default.Person.__shapes__.RequiredId):
    name: default.Person.__defs__.name
    profile: MyProfile


async def foo() -> MyPerson:
    person = db.get(
        default.Person.select(
            '*',
            profile=True,
        ).filter(
            email='[email protected]'
        )
    )

    # the person and profile are expected to be cast into MyPerson and MyProfile respectively
    return MyPerson(person)

Casting will raise an exception if any of the fields are either unset or modified. The intended use of this is to make fetched valid data conform to some specific serialization requirements, thus it is not a valid use case to cast an unsaved or unsynchronized object. This is similar to the logic that we use for not allowing model_dump to work on unsaved or modified objects. In fact, this casting mechanism is related to the model_dump because dumping and filtering the data before serializing would be the natural alternative.

Optimization considerations

During casting we can copy the __dict__ entirely if all the fields are immutable. Otherwise we'll have to copy field-by-field and deep-copy the mutable scalars (like arrays).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions