In Python decorators are a useful tool for changing the behaviour of functions without modifying the original function. For a recent release of openapi-SQLAlchemy, which added support for references among table columns, I used decorators to de-reference columns. I needed a way of supporting cases where a reference to a column was just a reference to another column. The solution was to essentially keep applying the decorator until the column was actually found.
What is a Column Reference?
If you are not familiar with openapi specifications, a simple schema for an object might be the following:
components:
schemas:
Employee:
description: Person that works for a company.
type: object
properties:
id:
type: integer
description: Unique identifier for the employee.
example: 0
name:
type: string
description: The name of the employee.
example: David Andersson.
To be able to re-use the definition of the id property for another schema, you can do the following:
components:
schemas:
Id:
type: integer
description: Unique identifier for the employee.
example: 0
Employee:
description: Person that works for a company.
type: object
properties:
id:
$ref: "#/components/schemas/Id"
name:
type: string
description: The name of the employee.
example: David Andersson.
In this case, the id property just references the Id schema. This could be done for other property definitions where the same schema applies to reduce duplicate schema definitions.
The Simple Case
The openapi-SQLAlchemy package allows you to map openapi schemas to SQLAlchemy models where an object becomes a table and a property becomes a column. The architecture of the package is broken into a factory for tables which then calls a column factory for each property of an object. The problem I had to solve was that ,when a reference is encountered, the column factory gets called with the following dictionary as the schema for the column (for the id column in this case):
{"$ref": "#/components/schemas/Id"}
instead of the schema for the column. Apart from doing some basic checks, what needs to happen is that the schema of Id needs to be found and the column factory be called with that schema instead of the reference. A perfect case for a decorator! The following is the code (minus some input checks):
_REF_PATTER = re.compile(r"^#\/components\/schemas\/(\w+)$")
def resolve_ref(func):
"""Resolve $ref schemas."""
def inner(schema, schemas, **kwargs):
"""Replace function."""
# Checking for $ref
ref = schema.get("$ref")
if ref is None:
return func(schema=schema, **kwargs)
# Retrieving new schema
match = _REF_PATTER.match(ref)
schema_name = match.group(1)
ref_schema = schemas.get(schema_name)
return func(schema=ref_schema, **kwargs)
return inner
The first step is to check if the schema is a reference schema and call the column factory if not (lines 3-4). If it is a reference, then the referenced schema needs to be retrieved (lines 14-16) and the factory called with the referenced schema instead (line 18).
The Recursive Case
The problem with the simple decorator is that the following openapi specification is valid:
components:
schemas:
Id:
$ref: "#/components/schemas/RefId"
RefId:
type: integer
description: Unique identifier for the employee.
example: 0
Employee:
description: Person that works for a company.
type: object
x-tablename: employee
properties:
id:
$ref: "#/components/schemas/Id"
name:
type: string
description: The name of the employee.
example: David Andersson.
Noe that the Id schema just references the RefId schema. When this schema is used, the column factory would now be called with:
{"$ref": "#/components/schemas/RefId"}
That looks a lot like the original problem! The solution is that the decorator needs to be applied again. This also looks like a case for recursive programming. In recursive programming, you have the base case and the recursive case. The base case sounds a lot like the code that checks for whether the schema is a reference (lines 3-4 above). The recursive case needs to take a step towards the base case and apply the function again. In this case this is done by resolving one reference and then calling the decorator again. This means that on line 18 above we need to somehow apply the decorator again. The solution is actually quite simple, the code needs to be changed to the following:
_REF_PATTER = re.compile(r"^#\/components\/schemas\/(\w+)$")
def resolve_ref(func):
"""Resolve $ref schemas."""
def inner(schema, schemas, **kwargs):
"""Replace function."""
# Checking for $ref
ref = schema.get("$ref")
if ref is None:
return func(schema=schema, **kwargs)
# Retrieving new schema
match = _REF_PATTER.match(ref)
schema_name = match.group(1)
ref_schema = schemas.get(schema_name)
return inner(schema=ref_schema, schemas=schemas, **kwargs)
return inner
The change is that inner is called instead of the original function func. We then also need to pass in all the required arguments for inner, which in this case, means passing through the schemas which func does not need.
Conclusion
Recursive decorators combine the ideas behind decorators and recursive programming. If the problem a decorator solves can be broken into steps where, after each step, the decorator might need to be applied again, you might have a case for a recursive decorator. The decorator must then implement a base case where the decorator is not applied again and a recursive case where one step is taken towards the base case and the decorator is applied again.