Skip to content

[Feature] ORM DataScope: Request-Level Data Scope as a Replacement for Global @[tenant_filter] #27154

@Jengro777

Description

@Jengro777

Problem

The current @[tenant_filter] ORM feature stores tenant state in a mutable
__global variable. This causes several concrete problems:

  1. Race conditions: concurrent requests/goroutines write to the same global
    state without locks or TLS.
  2. No request isolation: one request's tenant id can leak into another
    request in web servers.
  3. Single-purpose design: it only supports one tenant field, while real
    applications often need multiple automatic filters, such as:
    • tenant_id = ?
    • org_id = ?
    • shop_id = ?
    • deleted_at IS NULL
  4. Backend coupling: every database backend has to know about tenant
    filtering.

Proposal: orm.DB + DataScope

Introduce orm.DB, a small wrapper around any orm.Connection.

orm.DB carries a DataScope, and automatically applies its filters to ORM
queries before delegating to the wrapped connection.

mut db := orm.new_db(pool, orm.DataScope{
	filters: [
		orm.QueryFilter{
			field: 'tenant_id'
			value: orm.Primitive(5)
		},
	]
})

Then normal sql blocks work unchanged:

users := sql db {
	select from User
}!

The query is automatically scoped:

SELECT ... FROM user WHERE tenant_id = ?

Core API

pub enum QueryFilterMode {
	static
	dynamic
}

pub struct QueryFilter {
pub:
	field    string
	value    Primitive
	operator OperationKind = .eq
	mode     QueryFilterMode = .static
}

pub struct DataScope {
pub:
	enabled bool = true
	filters []QueryFilter
}

pub struct DB {
mut:
	conn Connection
pub:
	scope           DataScope
	skip_all_scopes bool
	skip_fields     []string
}

pub fn new_db(conn Connection, scope DataScope) DB

pub fn (db DB) unscoped(unscoped_fields ...string) DB

Static By Default, Dynamic When Needed

Most scope filters have a fixed SQL shape. For example, tenant_id = ? should
be known from the scope definition and can be treated as static.

orm.QueryFilter{
	field: 'tenant_id'
	value: orm.Primitive(tenant_id)
}

The value can still be runtime data. The static part is the filter shape:
field + operator.

For filters that need runtime behavior, use mode: .dynamic:

mut db := orm.new_db(pool, orm.DataScope{
	filters: [
		orm.QueryFilter{
			field: 'tenant_id'
			value: orm.Primitive(tenant_id)
		},
		orm.QueryFilter{
			field: 'shop_id'
			value: orm.Primitive(shop_id)
			mode: .dynamic
		},
	]
})

This allows mixed scopes:

  • tenant_id can be handled as a stable/default filter.
  • shop_id can be applied dynamically based on request role, shop access, or
    middleware logic.

Bypassing Scope Filters

db.unscoped() returns a new orm.DB value. It does not mutate the original DB.

Skip all filters:

unscoped_db := db.unscoped()

Skip one filter:

unscoped_db := db.unscoped('tenant_id')

Skip multiple filters:

unscoped_db := db.unscoped('tenant_id', 'shop_id')

This works for select, insert, update, and delete.

Table-Level Opt-Out

A table can opt out of all automatic scope filters:

@[unscoped]
struct Config {
	id int @[primary; sql: serial]
	key string
	value string
}

Queries for this table ignore DataScope.

Multi-Level Tenancy Example

mut db := orm.new_db(pool, orm.DataScope{
	filters: [
		orm.QueryFilter{
			field: 'tenant_id'
			value: orm.Primitive(5)
		},
		orm.QueryFilter{
			field: 'shop_id'
			value: orm.Primitive(shop_id)
			mode: .dynamic
		},
		orm.QueryFilter{
			field: 'deleted_at'
			value: orm.Primitive(orm.Null{})
			operator: .is_null
		},
	]
})

Generated behavior:

SELECT ... WHERE tenant_id = ? AND shop_id = ? AND deleted_at IS NULL
UPDATE ... WHERE user_condition AND tenant_id = ? AND shop_id = ? AND deleted_at IS NULL
DELETE ... WHERE user_condition AND tenant_id = ? AND shop_id = ? AND deleted_at IS NULL

For inserts, scope fields are auto-filled when they are not explicitly provided.

Transaction Support

orm.DB also implements orm.TransactionalConnection when the wrapped
connection supports transactions:

db.orm_begin()!
sql db {
	insert User{name: 'new'}
}!
db.orm_commit()!

Scope filters continue to apply inside transactions.

Why This Design

No global state

Scope data lives on the orm.DB value, so each request can carry its own scoped
connection.

No changes to the SQL DSL

sql db { ... } already works with any value implementing orm.Connection.
orm.DB implements the same interface, so the compiler-facing DSL does not need
a new syntax.

Supports more than tenant filtering

The same mechanism supports tenancy, soft delete, row-level access control, and
other automatic filters.

Explicit escape hatch

db.unscoped() gives a controlled way to bypass all or selected filters without
temporarily mutating global state.

Expected Benefits

  • Thread-safe request-level scope isolation
  • Multiple filters instead of one hardcoded tenant field
  • Works with existing sql blocks
  • Keeps raw database connections backward-compatible
  • Allows static default filters and explicit dynamic filters
  • Removes tenant-filter-specific logic from individual DB backends

Proposed Solution

No response

Other Information

No response

Acknowledgements

  • I may be able to implement this feature request
  • This feature might incur a breaking change

Version used

V 0.5.1 e02966e

Environment details (OS name and version, etc.)

V full version V 0.5.1 d45d40e.e02966e
OS linux, Deepin 25
Processor 20 cpus, 64bit, little endian, 13th Gen Intel(R) Core(TM) i5-13500
Memory 3.29GB/31.03GB
V executable /home/Jengro/opt/v/v
V last modified time 2026-05-12 07:12:05
V home dir OK, value: /home/Jengro/opt/v
VMODULES OK, value: /home/Jengro/.vmodules
VTMP OK, value: /tmp/v_1000
Current working dir OK, value: /home/Jengro
Git version git version 2.51.0
V git status 0.5.1-1548-ge02966e4
.git/config present true
cc version cc (Deepin 12.3.0-17deepin17) 12.3.0
gcc version gcc (Deepin 12.3.0-17deepin17) 12.3.0
clang version Deepin clang version 17.0.6 (5deepin7)
tcc version tcc version 0.9.28rc 2025-02-13 HEAD@f8bd136d (x86_64 Linux)
tcc git status thirdparty-linux-amd64 696c1d84
emcc version N/A
glibc version ldd (Debian GLIBC 2.38-6deepin21) 2.38

Note

You can use the 👍 reaction to increase the issue's priority for developers.

Please note that only the 👍 reaction to the issue itself counts as a vote.
Other reactions and those to comments will not be taken into account.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions