v / vlib / orm / orm_scope.v
442 lines · 408 sloc · 15.19 KB · 7b724e98ac187e233bd03716901c29f5e00f815f
Raw
1module orm
2
3import time
4
5// DataScope provides per-instance request-level data filtering for ORM queries.
6// It works with both `sql` block syntax and orm_func (QueryBuilder).
7//
8// Use `orm.new_db(raw_conn, scope)` to create a scoped connection, then pass it
9// to `sql db { ... }` blocks or `orm.new_query[T](db)`. Call `db.unscoped()` or
10// `db.unscoped('field')` to selectively skip scope filters.
11
12// QueryFilterMode describes whether a DataScope filter has a stable SQL shape or
13// needs runtime handling.
14pub enum QueryFilterMode {
15 unset // .static is not yet implemented — use .dynamic explicitly
16 static
17 dynamic
18}
19
20fn table_ignores_data_scope(table Table) bool {
21 for attr in table.attrs {
22 if attr_name_matches(attr.name, 'unscoped') {
23 return true
24 }
25 }
26 return false
27}
28
29// DB implements orm.Connection with DataScope support.
30// When the wrapped connection also implements TransactionalConnection,
31// the DB will transparently proxy transaction methods (orm_begin, orm_commit, ...).
32pub struct DB {
33mut:
34 conn Connection
35pub:
36 scope DataScope
37 skip_all_scopes bool
38 skip_fields []string // specific scope filter fields to skip, when skip_all_scopes is false
39}
40
41// DataScope holds the per-connection data scope configuration for automatic filtering.
42pub struct DataScope {
43pub:
44 enabled bool = true
45 filters []QueryFilter
46}
47
48// QueryFilter represents a single filter condition in a DataScope.
49// `field` should normally be a struct field name rather than a SQL column name.
50// When `Table.fields`/`Table.columns` metadata is available, it is resolved to the
51// corresponding SQL column name at query time. If that metadata is unavailable,
52// the ORM may fall back to using `field` directly as the SQL column name. In
53// metadata-driven paths, unresolved fields are skipped for that table.
54// `mode` must be explicitly set to .static or .dynamic. Static filters are
55// reserved for future compiler-generated scope clauses. The runtime DB wrapper
56// applies only filters explicitly marked with .dynamic.
57pub struct QueryFilter {
58pub:
59 field string
60 value Primitive
61 operator OperationKind = .eq
62 mode QueryFilterMode // must be explicitly set to .static or .dynamic
63}
64
65// new_db creates a new DB with DataScope applied.
66pub fn new_db(conn Connection, scope DataScope) DB {
67 return DB{
68 conn: conn
69 scope: scope
70 skip_all_scopes: false
71 skip_fields: []
72 }
73}
74
75// unscoped returns a new DB with the specified fields excluded from DataScope filtering.
76// Call without arguments to skip ALL scope filters.
77pub fn (db DB) unscoped(unscoped_fields ...string) DB {
78 if unscoped_fields.len == 0 {
79 return DB{
80 conn: db.conn
81 scope: db.scope
82 skip_all_scopes: true
83 skip_fields: []
84 }
85 }
86 return DB{
87 conn: db.conn
88 scope: db.scope
89 skip_all_scopes: false
90 skip_fields: unscoped_fields.map(it)
91 }
92}
93
94// table_field_to_column_map builds an O(1) lookup from struct field names
95// to SQL column names.
96fn table_field_to_column_map(table Table) map[string]string {
97 mut m := map[string]string{}
98 if table.columns.len > 0 && table.columns.len == table.fields.len {
99 for j, field_name in table.fields {
100 m[field_name] = table.columns[j]
101 }
102 }
103 return m
104}
105
106// apply_data_scope applies DataScope filters to a WHERE QueryData and returns the scoped query data.
107pub fn apply_data_scope(scope DataScope, table Table, where QueryData, scope_skip_fields []string, has_joins bool) !QueryData {
108 return apply_scope_filters(scope, table, where, scope_skip_fields, has_joins)
109}
110
111// apply_data_scope_insert applies DataScope filters to an INSERT QueryData and returns the scoped query data.
112pub fn apply_data_scope_insert(scope DataScope, table Table, data QueryData, scope_skip_fields []string) !QueryData {
113 return apply_scope_insert_filters(scope, table, data, scope_skip_fields)
114}
115
116// apply_scope_filters applies DataScope filters to WHERE data. It wraps original
117// conditions in parentheses and appends is_and / kinds markers.
118// When has_joins is true, scope filter column names are qualified with table.name
119// to avoid ambiguity in JOIN queries where joined tables share column names.
120fn apply_scope_filters(scope DataScope, table Table, qd QueryData, scope_skip_fields []string, has_joins bool) !QueryData {
121 if !scope.enabled || scope.filters.len == 0 {
122 return qd
123 }
124 if table_ignores_data_scope(table) {
125 return qd
126 }
127 mut result := clone_query_data(qd)
128 field_to_column := table_field_to_column_map(table)
129 // Wrap original WHERE clause in parentheses once, before adding scope filters
130 if result.fields.len > 1 {
131 result.parentheses << [0, result.fields.len - 1]
132 }
133 for filter in scope.filters {
134 if filter.mode == .unset {
135 return error('orm.DataScope: QueryFilter.mode must be explicitly set. .static is not yet implemented — use .dynamic. Got .unset for field `${filter.field}`')
136 }
137 if filter.mode != .dynamic {
138 continue
139 }
140 if filter.field == '' {
141 return error('orm.DataScope: dynamic filter field must not be empty')
142 }
143 if filter.field in scope_skip_fields {
144 continue
145 }
146 // Note: we do NOT skip when filter.field is already in result.fields.
147 // The scope filter is always appended as an additional AND condition.
148 // This prevents a user from bypassing tenant isolation by including the
149 // scoped field in their own WHERE clause. The resolved SQL column name is
150 // also appended without deduplication for the same reason.
151 if table.fields.len > 0 && filter.field !in table.fields {
152 continue
153 }
154 if !filter_value_matches_operator(filter) {
155 return invalid_scope_filter_error(filter)
156 }
157 // Resolve SQL column name from struct field name (O(1) via lookup map)
158 mut column_name := filter.field
159 if resolved := field_to_column[filter.field] {
160 column_name = resolved
161 }
162 // Qualify with table name when joins are present to avoid ambiguity
163 if has_joins && table.name != '' {
164 column_name = table_qualified_field(table.name, column_name)
165 }
166 // Note: we do NOT skip when column_name is already in result.fields.
167 // The scope filter is always appended as an additional AND condition
168 // to prevent bypassing tenant isolation.
169 result.is_and << true
170 result.fields << column_name.clone()
171 if !filter.operator.is_unary() {
172 result.data << filter.value
173 result.types << primitive_type(filter.value)
174 }
175 result.kinds << filter.operator
176 }
177 return result
178}
179
180fn invalid_scope_filter_error(filter QueryFilter) IError {
181 if filter.operator in [.in, .not_in] {
182 return error('orm.DataScope: dynamic filter `${filter.field}` with `${filter.operator}` requires a non-empty array value')
183 }
184 return error('orm.DataScope: dynamic filter `${filter.field}` with `${filter.operator}` requires a scalar value')
185}
186
187fn filter_value_matches_operator(filter QueryFilter) bool {
188 array_len := primitive_array_len(filter.value)
189 if filter.operator in [.in, .not_in] {
190 return array_len > 0
191 }
192 return array_len < 0
193}
194
195fn apply_scope_insert_filters(scope DataScope, table Table, data QueryData, scope_skip_fields []string) !QueryData {
196 if !scope.enabled || scope.filters.len == 0 {
197 return data
198 }
199 if table_ignores_data_scope(table) {
200 return data
201 }
202 mut result := clone_query_data(data)
203 original_field_count := data.fields.len
204 field_to_column := table_field_to_column_map(table)
205 for filter in scope.filters {
206 if filter.mode == .unset {
207 return error('orm.DataScope: QueryFilter.mode must be explicitly set. .static is not yet implemented — use .dynamic. Got .unset for field `${filter.field}`')
208 }
209 if filter.mode != .dynamic {
210 continue
211 }
212 if filter.field == '' {
213 return error('orm.DataScope: dynamic filter field must not be empty')
214 }
215 if filter.field in scope_skip_fields {
216 continue
217 }
218 if table.fields.len > 0 && filter.field !in table.fields {
219 continue
220 }
221 if !filter_value_matches_operator(filter) {
222 return invalid_scope_filter_error(filter)
223 }
224 if filter.operator == .is_null {
225 continue
226 }
227 if filter.operator != .eq {
228 return error('orm.DataScope: dynamic filter `${filter.field}` with `${filter.operator}` cannot be applied to INSERT')
229 }
230 mut column_name := filter.field
231 if resolved := field_to_column[filter.field] {
232 column_name = resolved
233 }
234 field_index := result.fields.index(column_name)
235 if field_index >= 0 {
236 if result.batch_rows > 0 {
237 if field_index < original_field_count {
238 // Original field — data is per-row with original_field_count stride
239 for row in 0 .. result.batch_rows {
240 data_index := row * original_field_count + field_index
241 if data_index < result.data.len {
242 result.data[data_index] = filter.value
243 }
244 if data_index < result.types.len {
245 result.types[data_index] = primitive_type(filter.value)
246 }
247 }
248 } else {
249 // Scope field appended by a previous filter — single value at the end
250 data_index := original_field_count * result.batch_rows +
251 (field_index - original_field_count)
252 if data_index < result.data.len {
253 result.data[data_index] = filter.value
254 }
255 if data_index < result.types.len {
256 result.types[data_index] = primitive_type(filter.value)
257 }
258 }
259 } else {
260 // Single row — stride is irrelevant; directly index by field position
261 if field_index < result.data.len {
262 result.data[field_index] = filter.value
263 }
264 if field_index < result.types.len {
265 result.types[field_index] = primitive_type(filter.value)
266 }
267 }
268 continue
269 }
270 result.fields << column_name.clone()
271 result.data << filter.value
272 result.types << primitive_type(filter.value)
273 }
274 if result.batch_rows > 0 {
275 scope_field_count := result.fields.len - original_field_count
276 if scope_field_count > 0 {
277 mut new_data := []Primitive{cap: result.fields.len * result.batch_rows}
278 scope_data_start := original_field_count * result.batch_rows
279 for row in 0 .. result.batch_rows {
280 for col in 0 .. original_field_count {
281 new_data << result.data[row * original_field_count + col]
282 }
283 for s in 0 .. scope_field_count {
284 new_data << result.data[scope_data_start + s]
285 }
286 }
287 result.data = new_data
288 }
289 }
290 return result
291}
292
293fn primitive_is_array(value Primitive) bool {
294 return primitive_array_len(value) >= 0
295}
296
297fn primitive_array_len(value Primitive) int {
298 return match value {
299 []Primitive, []bool, []f32, []f64, []i16, []i64, []i8, []int, []string, []time.Time, []u16,
300 []u32, []u64, []u8, []InfixType {
301 value.len
302 }
303 else {
304 -1
305 }
306 }
307}
308
309// DB implements orm.Connection ------------------------------------------------
310
311// select fetches rows through the wrapped connection, with DataScope applied.
312pub fn (mut db DB) select(config SelectConfig, data QueryData, where QueryData) ![][]Primitive {
313 mut cfg := config
314 if db.scope.enabled && db.scope.filters.len > 0 && !db.skip_all_scopes
315 && !table_ignores_data_scope(cfg.table) {
316 where_scoped := apply_data_scope(db.scope, cfg.table, where, db.skip_fields,
317 cfg.joins.len > 0)!
318 if where_scoped.fields.len > where.fields.len {
319 cfg.has_where = true
320 }
321 return db.conn.select(cfg, data, where_scoped)
322 }
323 return db.conn.select(cfg, data, where)
324}
325
326// insert inserts rows through the wrapped connection, with DataScope applied.
327pub fn (mut db DB) insert(table Table, data QueryData) ! {
328 mut data_scoped := data
329 if db.scope.enabled && db.scope.filters.len > 0 && !db.skip_all_scopes
330 && !table_ignores_data_scope(table) {
331 data_scoped = apply_data_scope_insert(db.scope, table, data, db.skip_fields)!
332 }
333 return db.conn.insert(table, data_scoped)
334}
335
336// update updates rows through the wrapped connection, with DataScope applied.
337pub fn (mut db DB) update(table Table, data QueryData, where QueryData) ! {
338 mut where_scoped := where
339 if db.scope.enabled && db.scope.filters.len > 0 && !db.skip_all_scopes
340 && !table_ignores_data_scope(table) {
341 where_scoped = apply_data_scope(db.scope, table, where, db.skip_fields, false)!
342 }
343 return db.conn.update(table, data, where_scoped)
344}
345
346// delete deletes rows through the wrapped connection, with DataScope applied.
347pub fn (mut db DB) delete(table Table, where QueryData) ! {
348 mut where_scoped := where
349 if db.scope.enabled && db.scope.filters.len > 0 && !db.skip_all_scopes
350 && !table_ignores_data_scope(table) {
351 where_scoped = apply_data_scope(db.scope, table, where, db.skip_fields, false)!
352 }
353 return db.conn.delete(table, where_scoped)
354}
355
356// create creates a table through the wrapped connection.
357pub fn (mut db DB) create(table Table, fields []TableField) ! {
358 return db.conn.create(table, fields)
359}
360
361// drop drops a table through the wrapped connection.
362pub fn (mut db DB) drop(table Table) ! {
363 return db.conn.drop(table)
364}
365
366// last_id returns the last inserted id from the wrapped connection.
367pub fn (mut db DB) last_id() int {
368 return db.conn.last_id()
369}
370
371// DB implements orm.TransactionalConnection (decorator) -----------------------
372
373// unwrap_to_tx extracts a TransactionalConnection from a Connection interface.
374fn unwrap_to_tx(mut conn Connection) TransactionalConnection {
375 return conn as TransactionalConnection
376}
377
378// orm_begin begins a transaction on the underlying connection.
379// Returns an error if the underlying connection does not support transactions.
380pub fn (mut db DB) orm_begin() ! {
381 if db.conn is TransactionalConnection {
382 mut tc := unwrap_to_tx(mut db.conn)
383 tc.orm_begin()!
384 } else {
385 return error('orm.DB: underlying connection does not support transactions')
386 }
387}
388
389// orm_commit commits the current transaction on the underlying connection.
390// Returns an error if the underlying connection does not support transactions.
391pub fn (mut db DB) orm_commit() ! {
392 if db.conn is TransactionalConnection {
393 mut tc := unwrap_to_tx(mut db.conn)
394 tc.orm_commit()!
395 } else {
396 return error('orm.DB: underlying connection does not support transactions')
397 }
398}
399
400// orm_rollback rolls back the current transaction on the underlying connection.
401// Returns an error if the underlying connection does not support transactions.
402pub fn (mut db DB) orm_rollback() ! {
403 if db.conn is TransactionalConnection {
404 mut tc := unwrap_to_tx(mut db.conn)
405 tc.orm_rollback()!
406 } else {
407 return error('orm.DB: underlying connection does not support transactions')
408 }
409}
410
411// orm_savepoint creates a savepoint with the given name on the underlying connection.
412// Returns an error if the underlying connection does not support transactions.
413pub fn (mut db DB) orm_savepoint(name string) ! {
414 if db.conn is TransactionalConnection {
415 mut tc := unwrap_to_tx(mut db.conn)
416 tc.orm_savepoint(name)!
417 } else {
418 return error('orm.DB: underlying connection does not support transactions')
419 }
420}
421
422// orm_rollback_to rolls back to the named savepoint on the underlying connection.
423// Returns an error if the underlying connection does not support transactions.
424pub fn (mut db DB) orm_rollback_to(name string) ! {
425 if db.conn is TransactionalConnection {
426 mut tc := unwrap_to_tx(mut db.conn)
427 tc.orm_rollback_to(name)!
428 } else {
429 return error('orm.DB: underlying connection does not support transactions')
430 }
431}
432
433// orm_release_savepoint releases the named savepoint on the underlying connection.
434// Returns an error if the underlying connection does not support transactions.
435pub fn (mut db DB) orm_release_savepoint(name string) ! {
436 if db.conn is TransactionalConnection {
437 mut tc := unwrap_to_tx(mut db.conn)
438 tc.orm_release_savepoint(name)!
439 } else {
440 return error('orm.DB: underlying connection does not support transactions')
441 }
442}
443