Skip to content

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

@Jengro777

Description

@Jengro777

Problem

The @[tenant_filter] feature stores its state in a __global mutable variable, causing three concrete issues:

  1. Race conditions — multiple goroutines/V coroutines writing to the same global variable with no locks or TLS
  2. No request isolation — in a web server, one request's tenant ID can leak into another
  3. Not extensible — the current design only supports a single tenant_id field, but real-world needs include multi-level tenancy (org_id + dept_id), soft-delete filtering (deleted_at IS NULL), and general row-level security

Solution: orm.DB + DataScope

Introduce orm.DB — a high-level DB wrapper that wraps a connection and carries a DataScope (a set of filter conditions automatically injected into every query).

Core Types

// QueryFilter represents a single filter condition in a DataScope.
// `field` should normally be a struct field name rather than a SQL column name.
// When `Table.fields`/`Table.columns` metadata is available, it is resolved to the
// corresponding SQL column name at query time.
pub struct QueryFilter {
pub:
	field    string
	value    Primitive
	operator OperationKind = .eq
}

// DataScope holds the per-connection data scope configuration for automatic filtering.
pub struct DataScope {
pub:
	enabled bool = true
	filters []QueryFilter
}

// DB implements orm.Connection with DataScope support.
// When the wrapped connection also implements TransactionalConnection,
// the DB will transparently proxy transaction methods (orm_begin, orm_commit, ...).
pub struct DB {
mut:
	conn Connection
pub:
	scope           DataScope
	skip_all_scopes bool
	skip_fields     []string
}

Constructor

// new_db creates a new DB with DataScope applied.
pub fn new_db(conn Connection, scope DataScope) DB

Bypassing Scope Filters

// unscoped returns a new DB with the specified fields excluded from DataScope filtering.
// Call without arguments to skip ALL scope filters.
pub fn (db DB) unscoped(unscoped_fields ...string) DB

Table-Level Opt-Out

The @[unscoped] struct attribute allows a specific table to completely ignore DataScope:

// table_ignores_data_scope checks for the @[unscoped] table attribute
fn table_ignores_data_scope(table Table) bool

Usage

Basic: Single-Field Tenancy

Create a scoped DB and use it directly in sql blocks — all queries are automatically filtered:

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

// All queries automatically filtered — thread-safe, request-isolated
users := sql db {
	select from User
}
// Generates: SELECT ... FROM User WHERE tenant_id = 5

Bypassing Scope Filters with db.unscoped()

db.unscoped() returns a new orm.DB instance that skips the specified scope filters.
The original db is unchanged. Three forms are available:

Skip a single fielddb.unscoped('tenant_id'):

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

// unscoped_db skips only the 'tenant_id' filter
unscoped_db := db.unscoped('tenant_id')
users_all := sql unscoped_db {
	select from NoScopeUser
}!
// All users visible regardless of tenant_id

Skip multiple fieldsdb.unscoped('tenant_id', 'org_id'):

mut db := orm.new_db(raw_db, orm.DataScope{
	filters: [
		orm.QueryFilter{field: 'tenant_id', value: orm.Primitive(1)},
		orm.QueryFilter{field: 'org_id',    value: orm.Primitive(10)},
	]
})

// unscoped_db skips both 'tenant_id' and 'org_id' filters
unscoped_db := db.unscoped('tenant_id', 'org_id')
users_all := sql unscoped_db {
	select from User
}!
// No scope filters applied

Skip all scopesdb.unscoped() (no arguments):

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

// unscoped_db skips ALL scope filters entirely
unscoped_db := db.unscoped()
users_all := sql unscoped_db {
	select from NoScopeUser
}!
assert users_all.len == 2

The pattern works for all CRUD operations — SELECT, INSERT, UPDATE, DELETE — not just SELECT:

unscoped_db := db.unscoped('tenant_id')

sql unscoped_db {
	update NoScopeUser set name = 'AdminUpdated' where name == 'Bob'
}!

sql unscoped_db {
	delete from NoScopeUser where name == 'Alice'
}!

users := sql unscoped_db {
	select from NoScopeUser
}!

Multi-Level Tenancy + Soft-Delete

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

// SELECT:  WHERE tenant_id = ? AND shop_id = ? AND deleted_at IS NULL
users := sql db {
	select from User
}

// UPDATE: WHERE tenant_id = ? AND shop_id = ? AND deleted_at IS NULL
sql db {
	update User set name = 'new' where id == 1
}

// DELETE: WHERE tenant_id = ? AND shop_id = ? AND deleted_at IS NULL
sql db {
	delete from User where id == 1
}

// INSERT: tenant_id = 5, shop_id = 10 auto-filled
sql db {
	insert User{name: 'new'}
}

Middleware Pattern (Role-Aware)

// Middleware layer: configure per-request DB by role
pub fn pool_acquire_scoped(mut ctx Context) !orm.DB {
	raw_conn := ctx.dbpool.acquire()!

	base_db := orm.new_db(raw_conn, orm.DataScope{
		filters: [
			orm.QueryFilter{
				field: 'tenant_id'
				value: orm.Primitive(ctx.user.tenant_id)
			},
		]
	})

	mut scoped_db := base_db
	match ctx.user.role {
		'admin' {
			scoped_db = scoped_db.unscoped() // skip all scopes
		}
		'manager' {
			scoped_db = scoped_db.unscoped('org_id') // only skip org_id
		}
		'normal' {} // tenant_id scope stays active
	}

	return scoped_db
}

// Business layer: completely unaware of DataScope
fn get_users(mut ctx Context) ![]SysUser {
	db := pool_acquire_scoped(mut ctx)!
	return sql db {
		select from SysUser
	}!
}

Transaction Support

orm.DB implements the orm.TransactionalConnection interface, transparently proxying all transaction operations:

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

db.orm_begin()!
// ... execute SQL inside transaction; scope still applies ...
db.orm_commit()!

// Savepoints also work
db.orm_savepoint('sp1')!
db.orm_rollback_to('sp1')!
db.orm_release_savepoint('sp1')!

Batch Insert Scope Value Replication

Scope values are automatically replicated per-row in batch inserts:

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

batch := [User{name: 'A'}, User{name: 'B'}]
sql db {
	insert batch into User
}
// All rows get tenant_id = 42 automatically

Core Implementation

Scope Filter Application

apply_scope_filters is the core function that applies DataScope filters to QueryData. It supports two modes:

  • .where mode (SELECT/UPDATE/DELETE): Appends scope filters as additional AND conditions. The original WHERE clause is wrapped in parentheses to ensure correct operator precedence.
  • .insert mode: Appends scope filter values as additional fields in the data. Does not override fields that are already explicitly set.

Two public entry points:

pub fn apply_data_scope(scope DataScope, table Table, where QueryData, scope_skip_fields []string) QueryData

pub fn apply_data_scope_insert(scope DataScope, table Table, data QueryData, scope_skip_fields []string) QueryData

DB Method Delegation

orm.DB's select/insert/update/delete methods conditionally apply DataScope filters before delegating to the underlying connection:

pub fn (mut db DB) select(config SelectConfig, data QueryData, where QueryData) ![][]Primitive {
	if db.scope.enabled && db.scope.filters.len > 0 && !db.skip_all_scopes
		&& !table_ignores_data_scope(config.table) {
		where_scoped := apply_data_scope(db.scope, config.table, where, db.skip_fields)
		if where_scoped.fields.len > where.fields.len {
			config.has_where = true
		}
		return db.conn.select(config, data, where_scoped)
	}
	return db.conn.select(config, data, where)
}

Column Name Resolution

table_field_to_column_map builds an O(1) lookup from struct field names to SQL column names, allowing QueryFilter.field to use struct field names and automatically resolve them to SQL column names.

Cloning

clone_query_data performs a deep copy of QueryData before modification, ensuring the original data is never mutated.

Syntax Origin and Design Rationale

Why sql db { ... } Works with orm.DB

The sql DSL accepts any value implementing the orm.Connection interface. orm.DB implements this interface by wrapping the real connection and intercepting the select/insert/update/delete methods. Each intercepted call applies the DataScope filters before delegating to the underlying connection:

sql db { select from User }
     │
     ▼
orm.DB.select(config, data, where)
     │
     ├─ apply_data_scope(scope, table, where, skip_fields)  ← injects filter conditions
     │
     └─ conn.select(config, data, where_scoped)             ← delegates to real connection

This is a Decorator patternorm.DB decorates a Connection with cross-cutting scope logic. The sql DSL does not need to know about DataScope at all; it simply calls the Connection interface it always has.

Why db.unscoped() Returns a New Value Rather Than Mutating

V structs are value types. db.unscoped() returns a new orm.DB value with modified skip_fields/skip_all_scopes, leaving the original unchanged. This is intentional:

  • The original scoped db remains usable as a "template" for multiple requests — configure once, derive per-request variants
  • No mutex or locking needed — each derived orm.DB value is independent
  • Falls naturally out of V's value semantics: no lifecycle management, no clone() calls, no reference counting

Why Not Just Use Global State or TLS

V compiles to C and runs across many platforms (Linux, Windows, macOS, WASM, embedded). Reliable thread-local storage (TLS) is not available in all target environments, and C's _Thread_local has portability issues with dynamically loaded libraries. Global mutable state in a server context introduces race conditions that are undetectable at compile time. The value-type orm.DB approach avoids all of these concerns by making scope a data property rather than a runtime context property.

Why Not Thread-Local Context (Go-style)

Go solves this with implicit context.Context passing through every function signature. V has no equivalent runtime type, and retrofitting one into the ORM DSL would require changing every sql block signature. The orm.DB wrapper achieves the same isolation with zero changes to the sql DSL itself — the only change is what value you pass to sql.

Why the Decorator Pattern Over Compiler-Inserted Filters

Compiler-inserted filters (e.g., automatically rewriting every SQL statement) would require the ORM code generator to be scope-aware, coupling the compiler to a runtime concept. The decorator pattern keeps the compiler and the sql DSL unchanged — all scope logic lives in the vlib/orm/ runtime library, making it testable, maintainable, and replaceable without compiler changes.

Origin of unscoped as a Term

The name unscoped is not arbitrary — it follows a well-established convention in the ORM ecosystem, originally popularized by Ruby on Rails' ActiveRecord and since adopted by multiple languages and frameworks:

Language / Framework API Since
Ruby on Rails (ActiveRecord) .unscoped { ... } Rails 3 (2010)
Laravel (Eloquent ORM, PHP) ->withoutGlobalScope() / ::unscoped() Laravel 5.x
Sequelize (Node.js / TypeScript) .unscoped() Sequelize 4.x
AdonisJS (Lucid ORM, Node.js) .withoutGlobalScopes() Adonis 5.x
LoopBack (IBM, Node.js) scope with { disabled: true } LoopBack 3.x
V ORM (this branch) .unscoped(...fields) 2025

Rails ActiveRecord introduced default_scope in 2009, and almost immediately the community needed a way to bypass it. The solution was Model.unscoped { ... }, which executes a block with all default scopes removed. The term "unscoped" was chosen as the natural antonym of "scoped" — clear, concise, and self-documenting.

Laravel Eloquent follows the same pattern with Model::unscoped() to temporarily remove global scopes. Its documentation explicitly describes it as "returning all rows regardless of any global scope."

Sequelize (Node.js ORM) adopted .unscoped() on model queries, providing the same semantics — remove all or specific scopes from a query.

V's db.unscoped() extends this convention in two ways that align with V's design philosophy:

  1. Value semantics, not blocks — Rails uses a block (Model.unscoped { ... }) because Ruby has mutable state and needs a scope guard. V uses value semantics: db.unscoped() returns a new orm.DB, naturally isolating the scope change without blocks or manual restore.

  2. Selective field skipping — In addition to db.unscoped() (skip all, matching the established convention), V adds db.unscoped('field1', 'field2') to skip specific filters. This is a natural extension of the same naming pattern.

By adopting unscoped, V's ORM signals its semantics clearly to developers familiar with any of these ecosystems — it means "temporarily bypass the active scope," the same as it does in Rails, Laravel, and Sequelize.

Design Principles

Principle Implementation
Thread-safe Scope state lives on the value-typed orm.DB struct, not global variables; each request has its own orm.DB instance
Request-level isolated Middleware configures orm.DB at request entry; business code extracts and uses it from context without knowing about scopes
Multi-field DataScope.filters []QueryFilter supports arbitrary filter conditions with various operators
Zero per-query overhead Configured once via orm.new_db(), automatically applied to all subsequent queries
Backward-compatible Existing code using raw connections continues unchanged; orm.DB implements the same orm.Connection interface
Runtime bypass flexibility db.unscoped() skips all or specific scope filters; @[unscoped] table attribute opts out entirely

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