Skip to content

Static analyzer internals

Source location

src/sqlmodel_ext/relation_load_checker.pyRelationLoadChecker, RelationLoadCheckMiddleware, run_model_checks

This is the most complex module in the entire project (~2000 lines). It uses AST static analysis to surface potential MissingGreenlet problems at application startup.

Disabled by default since 0.3

Starting with 0.3.0 this module is experimental and requires rlc.check_on_startup = True to opt in. This chapter explains how it works; for steps to enable it in your project, see Prevent MissingGreenlet errors.

Core class

python
class RelationLoadChecker:
    def __init__(self, model_base_class=None):
        self.model_base_class = model_base_class
        self.warnings: list[RelationLoadWarning] = []

Uses Python's ast module to parse source code's abstract syntax tree, rather than executing code:

  • No database connection needed
  • No business logic execution required
  • Can be completed during the import phase

Analysis flow

Detection rules

RLC001: response_model contains relation fields but endpoint doesn't preload

The analyzer:

  1. Parses response_model=UserResponse, finds it contains a profile field
  2. Checks query calls in the endpoint function body, finds no load= parameter
  3. Generates a warning
python
@router.get("/user/{id}", response_model=UserResponse)
async def get_user(session: SessionDep, id: UUID):
    return await User.get_exist_one(session, id) 
    # ⚠ RLC001: response_model contains profile, but query has no load=

RLC002: accessing relations after save/update

Tracks variable "expiration state" — after a save() or update() call, all relations on the object are considered expired.

python
user = await User.get_exist_one(session, id, load=User.profile)
user = await user.update(session, data)   # All relations expire after this
return user.profile                        # RLC002

RLC003: accessing unloaded relations (local variables)

Tracks the object types and loaded relations bound to local variables, detecting attribute access on unloaded relations.

RLC007: accessing expired object column attributes after commit

Tracks session.commit() calls — accessing any attribute on related objects afterward is considered dangerous.

RLC008: calling methods on expired objects after commit

Similar to RLC007, but detects method calls rather than attribute access.

RLC009: type annotation resolution errors

Detects issues caused by mixing resolved types with string forward references.

RelationLoadWarning

python
class RelationLoadWarning:
    code: str          # "RLC001"
    message: str       # Human-readable description
    location: str      # "module.py:42 in function_name"
    severity: str      # "warning" or "error"

mark_app_check_completed()

The middleware check executes only once. After analysis is complete on the first request, mark_app_check_completed() marks it as done, and subsequent requests don't repeat the check.

Why AST instead of runtime checks?

ApproachProsCons
AST static analysisFinds issues at startup, no code execution, covers all pathsPossible false positives, can't analyze dynamic code
Runtime checks100% accurateOnly checks executed paths

The static analyzer serves as the "first line of defense" alongside runtime @requires_relations and lazy='raise_on_sql', forming multi-layer protection.

Limitations

  • False positives: Static analysis cannot track runtime dynamic behavior (e.g., getattr, conditional loading)
  • Coroutines only: Synchronous functions are not analyzed (no MissingGreenlet issue in sync environments)
  • Module scope: Only analyzes imported modules; unimported code is not scanned

False positives & project assumptions

The analyzer's AST rules are tuned to a specific project layout (FastAPI endpoints, STI inheritance conventions, save/update/delete naming). On other projects it may produce false positives or fail to parse. The module API is not in the semver stability promise.