You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The @[tenant_filter] feature stores its state in a __global mutable variable, causing three concrete issues:
Race conditions — multiple goroutines/V coroutines writing to the same global variable with no locks or TLS
No request isolation — in a web server, one request's tenant ID can leak into another
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.pubstructQueryFilter {
pub:
field string
value Primitive
operator OperationKind= .eq
}
// DataScope holds the per-connection data scope configuration for automatic filtering.pubstructDataScope {
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, ...).pubstructDB {
mut:
conn Connection
pub:
scope DataScope
skip_all_scopes bool
skip_fields []string
}
Constructor
// new_db creates a new DB with DataScope applied.pub fnnew_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 attributefntable_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:
mutdb:= orm.new_db(pool, orm.DataScope{
filters: [
orm.QueryFilter{
field: 'tenant_id'
value: orm.Primitive(5)
},
]
})
// All queries automatically filtered — thread-safe, request-isolatedusers:= sql db {
select from User
}
// Generates: SELECT ... FROM User WHERE tenant_id = 5
Bypassing Scope Filters with db.unscoped()
db.unscoped() returns a neworm.DB instance that skips the specified scope filters.
The original db is unchanged. Three forms are available:
Skip a single field — db.unscoped('tenant_id'):
mutdb:= orm.new_db(raw_db, orm.DataScope{
filters: [orm.QueryFilter{field: 'tenant_id', value: orm.Primitive(1)}]
})
// unscoped_db skips only the 'tenant_id' filterunscoped_db:= db.unscoped('tenant_id')
users_all:= sql unscoped_db {
select from NoScopeUser
}!// All users visible regardless of tenant_id
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
mutdb:= 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 NULLusers:= 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 rolepub fnpool_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)
},
]
})
mutscoped_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 DataScopefnget_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:
mutdb:= 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:
mutdb:= 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 fnapply_data_scope(scope DataScope, table Table, where QueryData, scope_skip_fields []string) QueryData
pub fnapply_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 pattern — orm.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 neworm.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:
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.
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
Problem
The
@[tenant_filter]feature stores its state in a__globalmutable variable, causing three concrete issues:tenant_idfield, but real-world needs include multi-level tenancy (org_id+dept_id), soft-delete filtering (deleted_at IS NULL), and general row-level securitySolution:
orm.DB+DataScopeIntroduce
orm.DB— a high-level DB wrapper that wraps a connection and carries aDataScope(a set of filter conditions automatically injected into every query).Core Types
Constructor
Bypassing Scope Filters
Table-Level Opt-Out
The
@[unscoped]struct attribute allows a specific table to completely ignore DataScope:Usage
Basic: Single-Field Tenancy
Create a scoped DB and use it directly in
sqlblocks — all queries are automatically filtered:Bypassing Scope Filters with
db.unscoped()db.unscoped()returns a neworm.DBinstance that skips the specified scope filters.The original
dbis unchanged. Three forms are available:Skip a single field —
db.unscoped('tenant_id'):Skip multiple fields —
db.unscoped('tenant_id', 'org_id'):Skip all scopes —
db.unscoped()(no arguments):The pattern works for all CRUD operations — SELECT, INSERT, UPDATE, DELETE — not just SELECT:
Multi-Level Tenancy + Soft-Delete
Middleware Pattern (Role-Aware)
Transaction Support
orm.DBimplements theorm.TransactionalConnectioninterface, transparently proxying all transaction operations:Batch Insert Scope Value Replication
Scope values are automatically replicated per-row in batch inserts:
Core Implementation
Scope Filter Application
apply_scope_filtersis the core function that applies DataScope filters to QueryData. It supports two modes:.wheremode (SELECT/UPDATE/DELETE): Appends scope filters as additional AND conditions. The original WHERE clause is wrapped in parentheses to ensure correct operator precedence..insertmode: Appends scope filter values as additional fields in the data. Does not override fields that are already explicitly set.Two public entry points:
DB Method Delegation
orm.DB'sselect/insert/update/deletemethods conditionally apply DataScope filters before delegating to the underlying connection:Column Name Resolution
table_field_to_column_mapbuilds an O(1) lookup from struct field names to SQL column names, allowingQueryFilter.fieldto use struct field names and automatically resolve them to SQL column names.Cloning
clone_query_dataperforms a deep copy of QueryData before modification, ensuring the original data is never mutated.Syntax Origin and Design Rationale
Why
sql db { ... }Works withorm.DBThe
sqlDSL accepts any value implementing theorm.Connectioninterface.orm.DBimplements this interface by wrapping the real connection and intercepting theselect/insert/update/deletemethods. Each intercepted call applies theDataScopefilters before delegating to the underlying connection:This is a Decorator pattern —
orm.DBdecorates aConnectionwith cross-cutting scope logic. ThesqlDSL does not need to know aboutDataScopeat all; it simply calls theConnectioninterface it always has.Why
db.unscoped()Returns a New Value Rather Than MutatingV structs are value types.
db.unscoped()returns a neworm.DBvalue with modifiedskip_fields/skip_all_scopes, leaving the original unchanged. This is intentional:dbremains usable as a "template" for multiple requests — configure once, derive per-request variantsorm.DBvalue is independentWhy 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_localhas portability issues with dynamically loaded libraries. Global mutable state in a server context introduces race conditions that are undetectable at compile time. The value-typeorm.DBapproach 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.Contextpassing through every function signature. V has no equivalent runtime type, and retrofitting one into the ORM DSL would require changing everysqlblock signature. Theorm.DBwrapper achieves the same isolation with zero changes to thesqlDSL itself — the only change is what value you pass tosql.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
sqlDSL unchanged — all scope logic lives in thevlib/orm/runtime library, making it testable, maintainable, and replaceable without compiler changes.Origin of
unscopedas a TermThe name
unscopedis 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:.unscoped { ... }->withoutGlobalScope()/::unscoped().unscoped().withoutGlobalScopes()scopewith{ disabled: true }.unscoped(...fields)Rails ActiveRecord introduced
default_scopein 2009, and almost immediately the community needed a way to bypass it. The solution wasModel.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: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 neworm.DB, naturally isolating the scope change without blocks or manual restore.Selective field skipping — In addition to
db.unscoped()(skip all, matching the established convention), V addsdb.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
orm.DBstruct, not global variables; each request has its ownorm.DBinstanceorm.DBat request entry; business code extracts and uses it from context without knowing about scopesDataScope.filters []QueryFiltersupports arbitrary filter conditions with various operatorsorm.new_db(), automatically applied to all subsequent queriesorm.DBimplements the sameorm.Connectioninterfacedb.unscoped()skips all or specific scope filters;@[unscoped]table attribute opts out entirelyProposed Solution
No response
Other Information
No response
Acknowledgements
Version used
V 0.5.1 e02966e
Environment details (OS name and version, etc.)
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.