Skip to content

Commit 7b724e9

Browse files
authored
orm: add DataScope support for per-instance request-level filtering (#27324)
1 parent d1b4f65 commit 7b724e9

8 files changed

Lines changed: 3068 additions & 12 deletions

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
// orm_scope_middleware.v - ORM middleware pattern demo
2+
//
3+
// Shows how middleware manages DataScope (multi-tenant) transparently.
4+
// Business code only changes acquire() -> pool_acquire_scoped().
5+
//
6+
// Run: v run orm_scope_middleware.v
7+
8+
module main
9+
10+
import db.sqlite
11+
import orm
12+
13+
// =============================================================================
14+
// Model
15+
// =============================================================================
16+
17+
@[table: 'sys_users']
18+
struct SysUser {
19+
id int @[primary; sql: serial]
20+
name string
21+
tenant_id int
22+
org_id int
23+
}
24+
25+
// =============================================================================
26+
// Request context
27+
// =============================================================================
28+
29+
struct UserInfo {
30+
mut:
31+
role string
32+
tenant_id int
33+
}
34+
35+
pub struct Context {
36+
pub mut:
37+
user UserInfo
38+
dbpool &DatabasePool
39+
}
40+
41+
// =============================================================================
42+
// Connection pool (SQLite version)
43+
// In real projects, use adapter.dbpool.DatabasePoolable
44+
// =============================================================================
45+
46+
pub struct DatabasePool {
47+
conn &sqlite.DB
48+
}
49+
50+
pub fn new_pool() &DatabasePool {
51+
mut db := sqlite.connect(':memory:') or { panic(err) }
52+
return &DatabasePool{
53+
conn: &db
54+
}
55+
}
56+
57+
// acquire gets a connection from pool
58+
pub fn (mut p DatabasePool) acquire() !&sqlite.DB {
59+
return p.conn
60+
}
61+
62+
// release returns a connection to pool
63+
pub fn (mut p DatabasePool) release(conn &sqlite.DB) ! {
64+
// no-op for single-connection SQLite
65+
// real pool: p.inner.put(conn)!
66+
}
67+
68+
// =============================================================================
69+
// Middleware layer -- pool_acquire_scoped
70+
//
71+
// Responsibilities:
72+
// 1. Acquire connection from pool
73+
// 2. Create orm.DB with DataScope injected
74+
// 3. Skip scope filters based on user role
75+
// 4. Return configured orm.DB
76+
//
77+
// Business code just replaces acquire() -> pool_acquire_scoped()
78+
// =============================================================================
79+
80+
pub fn pool_acquire_scoped(mut ctx Context) !(orm.DB, &sqlite.DB) {
81+
// 1. acquire raw connection
82+
raw_conn := ctx.dbpool.acquire()!
83+
84+
// 2. create base orm.DB with tenant scope
85+
base_db := orm.new_db(raw_conn, orm.DataScope{
86+
filters: [
87+
orm.QueryFilter{
88+
field: 'tenant_id'
89+
value: orm.Primitive(ctx.user.tenant_id)
90+
mode: .dynamic
91+
},
92+
]
93+
})
94+
95+
// 3. skip scope filters by role
96+
mut scoped_db := base_db
97+
match ctx.user.role {
98+
'admin' {
99+
scoped_db = scoped_db.unscoped()
100+
}
101+
'manager' {
102+
scoped_db = scoped_db.unscoped('org_id')
103+
}
104+
'normal' {}
105+
else {}
106+
}
107+
108+
// 4. return scoped DB
109+
return scoped_db, raw_conn
110+
}
111+
112+
// =============================================================================
113+
// Business layer -- Repository
114+
//
115+
// No awareness of DataScope at all.
116+
// Only change: acquire() -> pool_acquire_scoped()
117+
// =============================================================================
118+
119+
fn get_users(mut ctx Context) ![]SysUser {
120+
db, conn := pool_acquire_scoped(mut ctx)!
121+
defer {
122+
ctx.dbpool.release(conn) or {}
123+
}
124+
125+
return sql db {
126+
select from SysUser
127+
}!
128+
}
129+
130+
fn get_user_by_name(mut ctx Context, name string) ![]SysUser {
131+
db, conn := pool_acquire_scoped(mut ctx)!
132+
defer {
133+
ctx.dbpool.release(conn) or {}
134+
}
135+
136+
return sql db {
137+
select from SysUser where name == name
138+
}!
139+
}
140+
141+
fn count_users(mut ctx Context) !int {
142+
db, conn := pool_acquire_scoped(mut ctx)!
143+
defer {
144+
ctx.dbpool.release(conn) or {}
145+
}
146+
147+
return sql db {
148+
select count from SysUser
149+
}!
150+
}
151+
152+
// =============================================================================
153+
// Demo
154+
// =============================================================================
155+
156+
fn main() {
157+
// setup
158+
mut pool := new_pool()
159+
db := pool.conn
160+
161+
sql db {
162+
create table SysUser
163+
}!
164+
165+
users := [
166+
SysUser{
167+
name: 'Alice'
168+
tenant_id: 1
169+
org_id: 10
170+
},
171+
SysUser{
172+
name: 'Bob'
173+
tenant_id: 1
174+
org_id: 20
175+
},
176+
SysUser{
177+
name: 'Charlie'
178+
tenant_id: 2
179+
org_id: 10
180+
},
181+
SysUser{
182+
name: 'Diana'
183+
tenant_id: 2
184+
org_id: 20
185+
},
186+
]
187+
for u in users {
188+
sql db {
189+
insert u into SysUser
190+
}!
191+
}
192+
193+
println('=== test data ===')
194+
println('Alice : tenant=1, org=10')
195+
println('Bob : tenant=1, org=20')
196+
println('Charlie: tenant=2, org=10')
197+
println('Diana : tenant=2, org=20')
198+
199+
mut ctx := Context{
200+
user: UserInfo{
201+
role: 'normal'
202+
tenant_id: 1
203+
}
204+
dbpool: pool
205+
}
206+
207+
// scenario 1: normal user, tenant=1
208+
println('\n--- normal user (tenant=1) ---')
209+
users1 := get_users(mut ctx) or { panic(err) }
210+
println('got ${users1.len} rows:')
211+
for u in users1 {
212+
println(' - ${u.name} (tenant=${u.tenant_id})')
213+
}
214+
215+
// scenario 2: manager, tenant=1 (skip org_id, no effect since only tenant_id scope)
216+
println('\n--- manager user (tenant=1) ---')
217+
ctx.user.role = 'manager'
218+
219+
users2 := get_users(mut ctx) or { panic(err) }
220+
println('got ${users2.len} rows:')
221+
for u in users2 {
222+
println(' - ${u.name} (tenant=${u.tenant_id})')
223+
}
224+
225+
// scenario 3: admin (skip all scopes)
226+
println('\n--- admin user (skip all scopes) ---')
227+
ctx.user.role = 'admin'
228+
229+
users3 := get_users(mut ctx) or { panic(err) }
230+
println('got ${users3.len} rows:')
231+
for u in users3 {
232+
println(' - ${u.name} (tenant=${u.tenant_id})')
233+
}
234+
235+
// scenario 4: normal user, tenant=2
236+
println('\n--- normal user (tenant=2) ---')
237+
ctx.user.role = 'normal'
238+
ctx.user.tenant_id = 2
239+
240+
users4 := get_users(mut ctx) or { panic(err) }
241+
println('got ${users4.len} rows:')
242+
for u in users4 {
243+
println(' - ${u.name} (tenant=${u.tenant_id})')
244+
}
245+
246+
// scenario 5: get_user_by_name
247+
println('\n--- get_user_by_name (admin, name=Alice) ---')
248+
ctx.user.role = 'admin'
249+
users_by_name := get_user_by_name(mut ctx, 'Alice') or { panic(err) }
250+
println('got ${users_by_name.len} rows:')
251+
for u in users_by_name {
252+
println(' - ${u.name} (tenant=${u.tenant_id})')
253+
}
254+
255+
// scenario 6: count
256+
println('\n--- count (admin) ---')
257+
total := count_users(mut ctx) or { panic(err) }
258+
println('total: ${total}')
259+
260+
// assertions
261+
println('\n=== assertions ===')
262+
assert users1.len == 2 // normal/tenant=1: Alice + Bob
263+
assert users2.len == 2 // manager/tenant=1: Alice + Bob
264+
assert users3.len == 4 // admin: all 4
265+
assert users4.len == 2 // normal/tenant=2: Charlie + Diana
266+
assert users_by_name.len == 1 // admin: Alice
267+
assert total == 4 // admin: all 4
268+
println('all passed ✓')
269+
}

vlib/orm/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,46 @@ sql db {
9393
}!
9494
```
9595

96+
### Data Scope
97+
98+
`orm.DB` can wrap an `orm.Connection` with automatic scope filters. Scope filters
99+
are useful for request-level tenancy, soft deletes, and row-level access checks.
100+
101+
```v ignore
102+
mut db := orm.new_db(raw_db, orm.DataScope{
103+
filters: [
104+
orm.QueryFilter{
105+
field: 'tenant_id'
106+
value: orm.Primitive(tenant_id)
107+
mode: .dynamic
108+
},
109+
orm.QueryFilter{
110+
field: 'shop_id'
111+
value: orm.Primitive(shop_id)
112+
mode: .dynamic
113+
},
114+
orm.QueryFilter{
115+
field: 'deleted_at'
116+
operator: .is_null
117+
mode: .dynamic
118+
},
119+
]
120+
})
121+
122+
users := sql db {
123+
select from User
124+
}!
125+
```
126+
127+
`QueryFilter.mode` must be explicitly set to `.static` or `.dynamic` (there is
128+
no default — `.unset` causes a runtime error). Static filters are reserved for
129+
future compiler-generated scope clauses. The runtime `orm.DB` wrapper applies
130+
only filters explicitly marked with `mode: .dynamic`. Invalid dynamic filters
131+
return an error instead of being silently skipped.
132+
133+
Call `db.unscoped()` to return a new `orm.DB` value that skips all scope filters.
134+
Call `db.unscoped('tenant_id')` to skip only selected fields.
135+
96136
> [!TIP]
97137
> This guide uses the built-in `db.sqlite` module. If you want SQLite without first installing
98138
> system-level SQLite development files, the V team also maintains the

0 commit comments

Comments
 (0)