Scopes#

A scope is the clause the server imposes on every read of a model and on every reference to it: the row-visibility rule. Scope is a role a clause plays, not a kind of clause; the material is a plain query clause. The word completes a three-way vocabulary:

Word

Who decides

What it is

clause

the declaration

a named, reusable query fragment: ItemClauses.visible

scope

the server

the clause every read and every reference check applies

filter

the client

what the request asks for through query parameters

A filter narrows within the scope; it can never widen past it.

The examples below use an Item model and an ItemClauses namespace for soft deletion. Query Clauses defines the clause operations:

class ItemClauses(fr.ClauseNamespace):
    model = Item

    is_deleted = fr.where_clause(Item.deleted_at.is_not(None))
    visible = fr.none_of(is_deleted)
    trashed = is_deleted

    default_scope = visible

The model’s default scope#

default_scope is a reserved attribute name on fr.ClauseNamespace: the clause under that name is the scope for the model. The one line default_scope = visible arms two surfaces at once:

  • Every view read on the model. List, count, and retrieve apply the scope, so a row outside it is absent from pages and totals and returns 404 from GET /{id}. Update and delete load through the same scoped retrieve, so a deleted row cannot be changed through the live surface.

  • Every reference to the model. A fr.MustExist / fr.IDRef / fr.IDSchema field targeting the model checks existence inside the scope, so referencing a deleted row on a write returns 404.

Every model namespace has a default_scope, and its default is fr.clauses.UNSCOPED: a model that declares none reads unscoped, and its reference checks are bare primary-key lookups. UNSCOPED is the one explicit unscoped spelling everywhere a scope can appear. Searching for UNSCOPED finds where unscoping is requested directly. Follow variables and composed clauses to find the views and references that receive it. Composition can absorb or propagate the sentinel, so the search does not identify every resulting unscoped read.

In clause composition, UNSCOPED means accepting every row: it is a no-op in all_of, makes any_of unscoped, and makes none_of match no rows. It contributes nothing to apply_clauses and does not remove other filters on a statement. See the full composition rules for object identity, return types, and validation.

The scope guards reads of the model and references to it; it does not reach through relationships. A scoped model serialized inside another model’s response (ItemSchema.comments embedding a soft-deleted comment, say) is loaded through the relationship, unscoped, and a dotted query-parameter filter or sort (?items.name=x) joins the related table without its scope. Shape the view’s schema, or override get_relationship_loader_options, where embedded rows must be filtered.

default_scope is a WhereClause: a predicate, with EXISTS (.any()/.has()) for a rule that depends on a related table. A raw expression is rejected at class definition.

A model subclass inherits the nearest declared default_scope along its MRO. A namespace on the subclass that says nothing about default_scope leaves the inherited scope in force; declaring one replaces it for that subclass, and the opt-out is explicit: default_scope = fr.clauses.UNSCOPED. A subclass can never drop the base scope by omission, and default_scope = None is rejected: it says nothing.

An unbound scope raises at request time, naming the missing value: a scope with a request-bound value cannot be read silently unfiltered.

Views: replacing the scope#

A view that should see something other than the default declares its own scope:

@fr.include_view(app)
class ItemView(fr.AsyncRestView):
    prefix = "/items"
    model = Item
    schema = ItemSchema
    # no scope declared: reads apply ItemClauses.default_scope


@fr.include_view(app)
class TrashView(fr.AsyncRestView):
    prefix = "/trash"
    model = Item
    schema = ItemSchema
    scope = ItemClauses.trashed          # the deviating view declares

Declaring a scope replaces the default; it does not stack on it. ItemClauses.trashed is the complete alternate surface for deleted rows.

A tenant rule must hold under every scope a view or a route can name, so it is not a scope. SQLAlchemy’s with_loader_criteria, added from a do_orm_execute listener, puts the predicate on every ORM SELECT that selects or joins the class: view reads under any scope, reference checks, lazy loads and hand-written selects alike. Restly’s reads and reference checks are ORM statements, so the rule reaches them. The tenant row scoping recipe shows the listener, the column it restricts, and the statements it does not reach. The SaaS example runs that recipe.

The explicit opt-out is fr.clauses.UNSCOPED: it reads past the model’s default_scope, where None would fall back to it. Reserve it for a view that needs every model row. UNSCOPED removes only the model’s default scope. It does not disable session-level criteria. The tenant listener decides separately whether an admin may cross tenants:

@fr.include_view(admin_router)      # router with admin auth
class AdminItemView(fr.AsyncRestView):
    prefix = "/admin/items"
    model = Item
    schema = ItemSchema
    scope = fr.clauses.UNSCOPED

There is no per-request hook, deliberately: a clause is already a function. A per-request value (the tenant id) is bound around the request; a per-request choice is a clause function branching on a bound value, which fails loudly when the value is missing:

from sqlalchemy import ColumnElement, true


class RoleContext(fr.ContextNamespace):
    include_deleted: fr.ContextParam[bool]

@fr.where_clause
def role_visibility() -> ColumnElement[bool]:
    if RoleContext.include_deleted():
        return true()
    return Item.deleted_at.is_(None)

class ItemView(fr.AsyncRestView):
    ...
    scope = role_visibility  # a dependency binds RoleContext.include_deleted

A policy clause like this names a request-boundary value, so it lives beside the view that applies it, not in the model’s namespace; the namespace holds model facts such as is_deleted, and views compose policy from them. The framework applies the declared clause itself, on every read; there is no apply-side override point, and a scope that is not a clause is rejected as the view class is defined. Reading the resolved scope hands a route the clause without opening one.

A route names its own scope#

A custom route that reads another surface of the same model passes a clause as scope= to the handler, and that clause replaces the resolved scope for that one read. The trash list and the restore action are then routes on the view rather than a second view class:

ItemResponse = fr.schemas.derive_schema_response(ItemSchema)
ItemListResponse = fr.schemas.derive_schema_list_response(ItemResponse)


@fr.include_view(app)
class ItemView(fr.AsyncRestView):
    prefix = "/items"
    model = Item
    schema = ItemSchema
    # no scope declared: reads apply ItemClauses.default_scope

    @fr.get("/trash", response_model=ItemListResponse)
    async def trash(self, list_params):
        result = await self.handle_get_many(list_params, scope=ItemClauses.trashed)
        return self.to_response(result, fr.ResponseShape.LIST)

    @fr.post("/{id}/restore", response_model=ItemResponse, status_code=200)
    async def restore(self, id: int):
        item = await self.get_one(id, scope=ItemClauses.trashed)
        async with self.write_action("restore", obj=item):
            item.deleted_at = None
        return self.to_response(item)

handle_get_many runs authorize and forwards scope= to get_many; declaring list_params gives the route the list params of GET / (Filter, Sort, and Paginate Lists). The restore loads with get_one(id, scope=...) rather than handle_get_one, because a write action gates its own action inside write_action; a live item is a 404 here, since the trash is the surface this route reads. fr.clauses.UNSCOPED is the per-read opt-out, in the same spelling as everywhere else. The two routes name the response classes of the view, ItemListResponse and ItemResponse, so OpenAPI shows the same types as for the CRUD routes. A view with other pagination passes it, as in derive_schema_list_response(ItemResponse, pagination=AppView.pagination); see A custom route names the same classes.

The argument replaces the scope, it does not narrow it. Put rules that may never be replaced, such as tenant isolation, at the session level. A get_one or get_many override declares the scope parameter and passes it on to super(). The handlers always pass it, so an override without the parameter fails on its first call instead of serving the wrong rows.

Narrowing inside the scope#

handle_get_many also takes where=, which narrows the read and keeps the scope. A list of one collection’s items still hides deleted items:

@fr.get("/in-collection/{collection_id}", response_model=ItemListResponse)
async def in_collection(self, collection_id: int, list_params):
    result = await self.handle_get_many(
        list_params, where=Item.collection_id == collection_id
    )
    return self.to_response(result, fr.ResponseShape.LIST)

where= accepts a SQLAlchemy boolean expression or a clause. It is ANDed into the scope the read would apply, as fr.all_of would: the resolved scope by default, or the scope= argument when both are given. The page and total_count both apply it, and the client’s filters, sort and page apply on top.

Passing the same expression as scope=fr.where_clause(...) replaces the scope, so that list would include deleted items.

The handler folds where= into the scope it forwards. A get_many override receives one combined clause and declares no where parameter. get_many itself does not take where=; a route that calls it directly composes fr.all_of(fr.resolve_scope(self), ...). A Python bool, such as a comparison on a loaded object, raises TypeError.

Reading the resolved scope#

fr.resolve_scope returns the clause a view’s reads apply, so a route that builds its own query sees the rows GET / and GET /{id} see. A count route applies the client’s filters to the scoped select:

@fr.get("/count")
async def total(self, list_params) -> int:
    await self.authorize(fr.Action.GET_MANY)
    query = fr.apply_clauses(sa.select(Task), fr.resolve_scope(self))
    return await self.count(self.apply_list_params(query, list_params))

Do not name that route method count: it would shadow the count seam it calls.

Pass another view to follow that view’s visibility in code that builds its own query. The parent lookup of a nested view does this, so a project that GET /projects/{id} hides also answers 404 under /projects/{project_id}/tasks:

async def project_from_path(project_id: int, session: fr.AsyncSessionDep) -> int:
    query = fr.apply_clauses(
        sa.select(Project.id).where(Project.id == project_id),
        fr.resolve_scope(ProjectView),
    )
    found = await session.scalar(query)
    if found is None:
        raise fr.exc.NotFound(f"Project with id {project_id} was not found")
    return found

Pass a mapped model class, fr.resolve_scope(Task), for the model rung alone: the default_scope every reference check applies and every view without its own scope inherits. The two answers differ wherever a view declares a scope, so code off the request path that wants what the API shows passes the view.

The result is a clause or fr.clauses.UNSCOPED, never None, so it drops into fr.apply_clauses or a composition without a branch. The function resolves the scope; applying it stays with the framework.

References: overriding per field#

A reference field checks against the target model’s default_scope by default. fr.RefExists names the target explicitly and carries the per-field override in its scope argument, with three states:

class OrderCreate(fr.BaseSchema):
    item_id: fr.MustExist[int]                # default_scope of Item (FK-inferred)

    restore_id: Annotated[int, fr.RefExists(Item, scope=ItemClauses.trashed)]

    audit_item_id: Annotated[int, fr.RefExists(Item, scope=fr.clauses.UNSCOPED)]
  • Not given: the target’s default_scope applies.

  • scope=<WhereClause>: that predicate applies instead; the restore endpoint above accepts exactly the ids the trash view shows.

  • scope=fr.clauses.UNSCOPED: the check is explicitly unscoped, in the same loud spelling as everywhere else. scope=None is rejected, so a variable that happens to be None can never silently unscope the check or escape the grep.

IDRef / IDSchema relationship references always use the target’s default_scope; the per-field override exists on the scalar marker only.

Coming from Rails#

This is default_scope without the parts that earned Rails’ default_scope its reputation. default_scope controls which rows are visible to reads and reference checks. It does not set column values when creating rows. A view escapes it by declaring a replacement or fr.clauses.UNSCOPED, both visible in the class body, and the reference escape is the same greppable word. Nothing escapes it implicitly.

Migrating from build_query#

Restly’s earlier read seam, overriding build_query(), is removed: a view class that still defines one, itself or through a mixin, fails at class definition with a pointer here. Nothing it did is lost, and the mechanics differ in one point: build_query overrides composed through super() chains, scopes replace, so a chain of filters becomes one composed clause:

# before
class ItemView(fr.AsyncRestView):
    def build_query(self):
        return super().build_query().where(Item.deleted_at.is_(None))

# after: the rule becomes a clause on the model, and the override disappears
class ItemClauses(fr.ClauseNamespace):
    model = Item
    default_scope = fr.where_clause(Item.deleted_at.is_(None))

Relationship loading that lived in build_query belongs in get_relationship_loader_options. Other Select changes to a list, such as a join or a default ordering, belong in an apply_list_params override:

class ItemView(fr.AsyncRestView):
    ...

    def apply_list_params(self, query, list_params):
        query = query.order_by(Item.created_at.desc())
        return super().apply_list_params(query, list_params)

An ordering added before super() comes first, so a client ?sort= orders rows within it. A to-many join added here does not repeat rows or inflate total_count.

Binding scope values#

Scopes are clauses, so their values come from context members: Query Clauses, Supplying values. Bind request-wide values with a generated dependency, which Current context describes:

app = FastAPI(dependencies=[
    Current.depends(user_id=get_user_id),
    RoleContext.depends(include_deleted=get_include_deleted),
])

Read Current.explain() when a query filters unexpectedly; the origin names your depends() line. The scope’s promise is structural: the framework guarantees the clause is applied and its values are bound, or the request fails loudly. That the bound value is the right user is the source dependency’s job; assert it there.