Skip to content

Static analyzer internals ​

Source location

src/sqlmodel_ext/relation_load_checker.py — RelationLoadChecker, 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.