From 7b724e98ac187e233bd03716901c29f5e00f815f Mon Sep 17 00:00:00 2001 From: Jengro Date: Mon, 8 Jun 2026 03:29:37 +0800 Subject: [PATCH] orm: add DataScope support for per-instance request-level filtering (#27324) --- examples/orm/orm_scope_middleware.v | 269 ++++ vlib/orm/README.md | 40 + vlib/orm/orm.v | 206 ++- vlib/orm/orm_fn_test.v | 40 + vlib/orm/orm_func.v | 10 +- vlib/orm/orm_scope.v | 442 ++++++ vlib/orm/orm_scope_test.v | 1978 +++++++++++++++++++++++++++ vlib/v/gen/c/orm.v | 95 ++ 8 files changed, 3068 insertions(+), 12 deletions(-) create mode 100644 examples/orm/orm_scope_middleware.v create mode 100644 vlib/orm/orm_scope.v create mode 100644 vlib/orm/orm_scope_test.v diff --git a/examples/orm/orm_scope_middleware.v b/examples/orm/orm_scope_middleware.v new file mode 100644 index 000000000..6b8a4b6b6 --- /dev/null +++ b/examples/orm/orm_scope_middleware.v @@ -0,0 +1,269 @@ +// orm_scope_middleware.v - ORM middleware pattern demo +// +// Shows how middleware manages DataScope (multi-tenant) transparently. +// Business code only changes acquire() -> pool_acquire_scoped(). +// +// Run: v run orm_scope_middleware.v + +module main + +import db.sqlite +import orm + +// ============================================================================= +// Model +// ============================================================================= + +@[table: 'sys_users'] +struct SysUser { + id int @[primary; sql: serial] + name string + tenant_id int + org_id int +} + +// ============================================================================= +// Request context +// ============================================================================= + +struct UserInfo { +mut: + role string + tenant_id int +} + +pub struct Context { +pub mut: + user UserInfo + dbpool &DatabasePool +} + +// ============================================================================= +// Connection pool (SQLite version) +// In real projects, use adapter.dbpool.DatabasePoolable +// ============================================================================= + +pub struct DatabasePool { + conn &sqlite.DB +} + +pub fn new_pool() &DatabasePool { + mut db := sqlite.connect(':memory:') or { panic(err) } + return &DatabasePool{ + conn: &db + } +} + +// acquire gets a connection from pool +pub fn (mut p DatabasePool) acquire() !&sqlite.DB { + return p.conn +} + +// release returns a connection to pool +pub fn (mut p DatabasePool) release(conn &sqlite.DB) ! { + // no-op for single-connection SQLite + // real pool: p.inner.put(conn)! +} + +// ============================================================================= +// Middleware layer -- pool_acquire_scoped +// +// Responsibilities: +// 1. Acquire connection from pool +// 2. Create orm.DB with DataScope injected +// 3. Skip scope filters based on user role +// 4. Return configured orm.DB +// +// Business code just replaces acquire() -> pool_acquire_scoped() +// ============================================================================= + +pub fn pool_acquire_scoped(mut ctx Context) !(orm.DB, &sqlite.DB) { + // 1. acquire raw connection + raw_conn := ctx.dbpool.acquire()! + + // 2. create base orm.DB with tenant scope + base_db := orm.new_db(raw_conn, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(ctx.user.tenant_id) + mode: .dynamic + }, + ] + }) + + // 3. skip scope filters by role + mut scoped_db := base_db + match ctx.user.role { + 'admin' { + scoped_db = scoped_db.unscoped() + } + 'manager' { + scoped_db = scoped_db.unscoped('org_id') + } + 'normal' {} + else {} + } + + // 4. return scoped DB + return scoped_db, raw_conn +} + +// ============================================================================= +// Business layer -- Repository +// +// No awareness of DataScope at all. +// Only change: acquire() -> pool_acquire_scoped() +// ============================================================================= + +fn get_users(mut ctx Context) ![]SysUser { + db, conn := pool_acquire_scoped(mut ctx)! + defer { + ctx.dbpool.release(conn) or {} + } + + return sql db { + select from SysUser + }! +} + +fn get_user_by_name(mut ctx Context, name string) ![]SysUser { + db, conn := pool_acquire_scoped(mut ctx)! + defer { + ctx.dbpool.release(conn) or {} + } + + return sql db { + select from SysUser where name == name + }! +} + +fn count_users(mut ctx Context) !int { + db, conn := pool_acquire_scoped(mut ctx)! + defer { + ctx.dbpool.release(conn) or {} + } + + return sql db { + select count from SysUser + }! +} + +// ============================================================================= +// Demo +// ============================================================================= + +fn main() { + // setup + mut pool := new_pool() + db := pool.conn + + sql db { + create table SysUser + }! + + users := [ + SysUser{ + name: 'Alice' + tenant_id: 1 + org_id: 10 + }, + SysUser{ + name: 'Bob' + tenant_id: 1 + org_id: 20 + }, + SysUser{ + name: 'Charlie' + tenant_id: 2 + org_id: 10 + }, + SysUser{ + name: 'Diana' + tenant_id: 2 + org_id: 20 + }, + ] + for u in users { + sql db { + insert u into SysUser + }! + } + + println('=== test data ===') + println('Alice : tenant=1, org=10') + println('Bob : tenant=1, org=20') + println('Charlie: tenant=2, org=10') + println('Diana : tenant=2, org=20') + + mut ctx := Context{ + user: UserInfo{ + role: 'normal' + tenant_id: 1 + } + dbpool: pool + } + + // scenario 1: normal user, tenant=1 + println('\n--- normal user (tenant=1) ---') + users1 := get_users(mut ctx) or { panic(err) } + println('got ${users1.len} rows:') + for u in users1 { + println(' - ${u.name} (tenant=${u.tenant_id})') + } + + // scenario 2: manager, tenant=1 (skip org_id, no effect since only tenant_id scope) + println('\n--- manager user (tenant=1) ---') + ctx.user.role = 'manager' + + users2 := get_users(mut ctx) or { panic(err) } + println('got ${users2.len} rows:') + for u in users2 { + println(' - ${u.name} (tenant=${u.tenant_id})') + } + + // scenario 3: admin (skip all scopes) + println('\n--- admin user (skip all scopes) ---') + ctx.user.role = 'admin' + + users3 := get_users(mut ctx) or { panic(err) } + println('got ${users3.len} rows:') + for u in users3 { + println(' - ${u.name} (tenant=${u.tenant_id})') + } + + // scenario 4: normal user, tenant=2 + println('\n--- normal user (tenant=2) ---') + ctx.user.role = 'normal' + ctx.user.tenant_id = 2 + + users4 := get_users(mut ctx) or { panic(err) } + println('got ${users4.len} rows:') + for u in users4 { + println(' - ${u.name} (tenant=${u.tenant_id})') + } + + // scenario 5: get_user_by_name + println('\n--- get_user_by_name (admin, name=Alice) ---') + ctx.user.role = 'admin' + users_by_name := get_user_by_name(mut ctx, 'Alice') or { panic(err) } + println('got ${users_by_name.len} rows:') + for u in users_by_name { + println(' - ${u.name} (tenant=${u.tenant_id})') + } + + // scenario 6: count + println('\n--- count (admin) ---') + total := count_users(mut ctx) or { panic(err) } + println('total: ${total}') + + // assertions + println('\n=== assertions ===') + assert users1.len == 2 // normal/tenant=1: Alice + Bob + assert users2.len == 2 // manager/tenant=1: Alice + Bob + assert users3.len == 4 // admin: all 4 + assert users4.len == 2 // normal/tenant=2: Charlie + Diana + assert users_by_name.len == 1 // admin: Alice + assert total == 4 // admin: all 4 + println('all passed ✓') +} diff --git a/vlib/orm/README.md b/vlib/orm/README.md index aa6b6dd86..a2ec0d809 100644 --- a/vlib/orm/README.md +++ b/vlib/orm/README.md @@ -93,6 +93,46 @@ sql db { }! ``` +### Data Scope + +`orm.DB` can wrap an `orm.Connection` with automatic scope filters. Scope filters +are useful for request-level tenancy, soft deletes, and row-level access checks. + +```v ignore +mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(tenant_id) + mode: .dynamic + }, + orm.QueryFilter{ + field: 'shop_id' + value: orm.Primitive(shop_id) + mode: .dynamic + }, + orm.QueryFilter{ + field: 'deleted_at' + operator: .is_null + mode: .dynamic + }, + ] +}) + +users := sql db { + select from User +}! +``` + +`QueryFilter.mode` must be explicitly set to `.static` or `.dynamic` (there is +no default — `.unset` causes a runtime error). Static filters are reserved for +future compiler-generated scope clauses. The runtime `orm.DB` wrapper applies +only filters explicitly marked with `mode: .dynamic`. Invalid dynamic filters +return an error instead of being silently skipped. + +Call `db.unscoped()` to return a new `orm.DB` value that skips all scope filters. +Call `db.unscoped('tenant_id')` to skip only selected fields. + > [!TIP] > This guide uses the built-in `db.sqlite` module. If you want SQLite without first installing > system-level SQLite development files, the V team also maintains the diff --git a/vlib/orm/orm.v b/vlib/orm/orm.v index 2a48c3b98..4c0f1e672 100644 --- a/vlib/orm/orm.v +++ b/vlib/orm/orm.v @@ -227,8 +227,20 @@ pub: pub struct Table { pub mut: - name string - attrs []VAttribute + name string + attrs []VAttribute + fields []string // struct field names, used to skip scope filters that don't apply + columns []string // SQL column names (parallel to fields), used for SQL generation +} + +// new_table creates a Table with the given name and attributes. +// Prefer using this constructor over positional initialization, +// as new fields may be added to Table in future versions. +pub fn new_table(name string, attrs []VAttribute) Table { + return Table{ + name: name + attrs: attrs + } } pub struct TableField { @@ -619,6 +631,162 @@ fn parse_bool_attr(raw string) ?bool { } } +// primitive_type returns the type index for a Primitive value. +fn primitive_type(value Primitive) int { + return match value { + bool { + type_idx['bool'] + } + i8 { + type_idx['i8'] + } + i16 { + type_idx['i16'] + } + int { + type_idx['int'] + } + i64 { + type_idx['i64'] + } + u8 { + type_idx['u8'] + } + u16 { + type_idx['u16'] + } + u32 { + type_idx['u32'] + } + u64 { + type_idx['u64'] + } + f32 { + type_idx['f32'] + } + f64 { + type_idx['f64'] + } + string { + type_string + } + time.Time { + time_ + } + Null { + type_idx['int'] + } + InfixType { + primitive_type(value.right) + } + []Primitive { + if value.len > 0 { + primitive_type(value[0]) + } else { + type_idx['int'] + } + } + []bool { + if value.len > 0 { + type_idx['bool'] + } else { + type_idx['int'] + } + } + []f32 { + if value.len > 0 { + type_idx['f32'] + } else { + type_idx['int'] + } + } + []f64 { + if value.len > 0 { + type_idx['f64'] + } else { + type_idx['int'] + } + } + []i16 { + if value.len > 0 { + type_idx['i16'] + } else { + type_idx['int'] + } + } + []i64 { + if value.len > 0 { + type_idx['i64'] + } else { + type_idx['int'] + } + } + []i8 { + if value.len > 0 { + type_idx['i8'] + } else { + type_idx['int'] + } + } + []int { + if value.len > 0 { + type_idx['int'] + } else { + type_idx['int'] + } + } + []string { + if value.len > 0 { + type_string + } else { + type_idx['int'] + } + } + []time.Time { + if value.len > 0 { + time_ + } else { + type_idx['int'] + } + } + []u16 { + if value.len > 0 { + type_idx['u16'] + } else { + type_idx['int'] + } + } + []u32 { + if value.len > 0 { + type_idx['u32'] + } else { + type_idx['int'] + } + } + []u64 { + if value.len > 0 { + type_idx['u32'] + } else { + type_idx['int'] + } + } + []u8 { + if value.len > 0 { + type_idx['u8'] + } else { + type_idx['int'] + } + } + []InfixType { + if value.len > 0 { + primitive_type(value[0].right) + } else { + type_idx['int'] + } + } + } +} + fn clone_query_data(data QueryData) QueryData { return QueryData{ fields: data.fields.clone() @@ -1059,9 +1227,15 @@ pub fn orm_select_gen(cfg SelectConfig, q string, num bool, qm string, start_pos return str } +const table_qualified_field_separator = '::v_orm_table::' + +fn table_qualified_field(table_name string, column_name string) string { + return '${table_name}${table_qualified_field_separator}${column_name}' +} + fn gen_where_clause(where QueryData, q string, qm string, num bool, mut c &int) string { mut str := '' - + mut data_idx := 0 for i, field in where.fields { current_pre_par := where.parentheses.count(it[0] == i) current_post_par := where.parentheses.count(it[1] == i) @@ -1069,12 +1243,16 @@ fn gen_where_clause(where QueryData, q string, qm string, num bool, mut c &int) if current_pre_par > 0 { str += ' ( '.repeat(current_pre_par) } - str += '${q}${field}${q} ${where.kinds[i].to_str()}' + str += gen_qualified_field(field, q) + ' ${where.kinds[i].to_str()}' if !where.kinds[i].is_unary() { - if where.data.len > i && where.data[i] is []Primitive { - len := (where.data[i] as []Primitive).len - mut tmp := []string{len: len} - for j in 0 .. len { + array_len := if where.data.len > data_idx { + primitive_array_len(where.data[data_idx]) + } else { + -1 + } + if array_len >= 0 { + mut tmp := []string{len: array_len} + for j in 0 .. array_len { tmp[j] = '${qm}' if num { tmp[j] += '${c}' @@ -1089,6 +1267,7 @@ fn gen_where_clause(where QueryData, q string, qm string, num bool, mut c &int) c++ } } + data_idx++ } if current_post_par > 0 { str += ' ) '.repeat(current_post_par) @@ -1104,6 +1283,17 @@ fn gen_where_clause(where QueryData, q string, qm string, num bool, mut c &int) return str } +// gen_qualified_field renders a field name with the given quote character q. +// Table-qualified fields use an internal marker so embedded ORM column names +// containing dots (e.g. `Coordinates.latitude`) stay single quoted identifiers. +fn gen_qualified_field(field string, q string) string { + if idx := field.index(table_qualified_field_separator) { + column_start := idx + table_qualified_field_separator.len + return '${q}${field[..idx]}${q}.${q}${field[column_start..]}${q}' + } + return '${q}${field}${q}' +} + // Generates an sql table stmt, from universal parameter // table - Table struct // q - see orm_stmt_gen diff --git a/vlib/orm/orm_fn_test.v b/vlib/orm/orm_fn_test.v index 44d36941b..440ccc270 100644 --- a/vlib/orm/orm_fn_test.v +++ b/vlib/orm/orm_fn_test.v @@ -201,6 +201,31 @@ fn test_orm_stmt_gen_delete() { assert query_or == "DELETE FROM 'Test' WHERE 'id' >= ?0 OR 'name' = ?1;" } +fn test_orm_stmt_gen_where_unary_before_array() { + table := orm.Table{ + name: 'Test' + } + query, _ := orm.orm_stmt_gen(.default, table, "'", .delete, true, '?', 0, orm.QueryData{}, orm.QueryData{ + fields: ['deleted_at', 'tenant_id'] + data: [orm.Primitive([orm.Primitive(1), orm.Primitive(2)])] + kinds: [.is_null, .in] + is_and: [true] + }) + assert query == "DELETE FROM 'Test' WHERE 'deleted_at' IS NULL AND 'tenant_id' IN (?0, ?1);" +} + +fn test_orm_stmt_gen_where_typed_array() { + table := orm.Table{ + name: 'Test' + } + query, _ := orm.orm_stmt_gen(.default, table, "'", .delete, true, '?', 0, orm.QueryData{}, orm.QueryData{ + fields: ['tenant_id'] + data: [orm.Primitive([1, 2])] + kinds: [.in] + }) + assert query == "DELETE FROM 'Test' WHERE 'tenant_id' IN (?0, ?1);" +} + fn get_select_fields() []string { return ['id', 'test', 'abc'] } @@ -244,6 +269,21 @@ fn test_orm_select_gen_with_where() { assert query == "SELECT 'id', 'test', 'abc' FROM 'test_table' WHERE 'abc' = ?0 AND 'test' > ?1;" } +fn test_orm_select_gen_preserves_embedded_column_dot() { + query := orm.orm_select_gen(orm.SelectConfig{ + table: orm.Table{ + name: 'test_table' + } + fields: get_select_fields() + has_where: true + }, "'", true, '?', 0, orm.QueryData{ + fields: ['Coordinates.latitude'] + kinds: [.eq] + }) + + assert query == "SELECT 'id', 'test', 'abc' FROM 'test_table' WHERE 'Coordinates.latitude' = ?0;" +} + fn test_orm_select_gen_with_order() { query := orm.orm_select_gen(orm.SelectConfig{ table: orm.Table{ diff --git a/vlib/orm/orm_func.v b/vlib/orm/orm_func.v index 758d93097..1d1f8550b 100644 --- a/vlib/orm/orm_func.v +++ b/vlib/orm/orm_func.v @@ -31,7 +31,7 @@ pub fn new_query[T](conn Connection) &QueryBuilder[T] { valid_sql_field_names: meta.map(sql_field_name(it)) conn: conn config: SelectConfig{ - table: table_from_struct[T]() + table: table_from_struct[T](meta) } data: QueryData{} where: QueryData{} @@ -420,7 +420,7 @@ pub fn (qb_ &QueryBuilder[T]) set(assign string, values ...Primitive) !&QueryBui } // table_from_struct get table from struct -fn table_from_struct[T]() Table { +fn table_from_struct[T](meta []TableField) Table { mut table_name := T.name // Strip generic parameters from type name (e.g., Message[Payload] -> Message) if bracket_pos := table_name.index('[') { @@ -440,8 +440,10 @@ fn table_from_struct[T]() Table { table_name = table_name.to_lower() } return Table{ - name: table_name - attrs: attrs + name: table_name + attrs: attrs + fields: meta.map(it.name) + columns: meta.map(sql_field_name(it)) } } diff --git a/vlib/orm/orm_scope.v b/vlib/orm/orm_scope.v new file mode 100644 index 000000000..d84c93512 --- /dev/null +++ b/vlib/orm/orm_scope.v @@ -0,0 +1,442 @@ +module orm + +import time + +// DataScope provides per-instance request-level data filtering for ORM queries. +// It works with both `sql` block syntax and orm_func (QueryBuilder). +// +// Use `orm.new_db(raw_conn, scope)` to create a scoped connection, then pass it +// to `sql db { ... }` blocks or `orm.new_query[T](db)`. Call `db.unscoped()` or +// `db.unscoped('field')` to selectively skip scope filters. + +// QueryFilterMode describes whether a DataScope filter has a stable SQL shape or +// needs runtime handling. +pub enum QueryFilterMode { + unset // .static is not yet implemented — use .dynamic explicitly + static + dynamic +} + +fn table_ignores_data_scope(table Table) bool { + for attr in table.attrs { + if attr_name_matches(attr.name, 'unscoped') { + return true + } + } + return false +} + +// 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 // specific scope filter fields to skip, when skip_all_scopes is false +} + +// DataScope holds the per-connection data scope configuration for automatic filtering. +pub struct DataScope { +pub: + enabled bool = true + filters []QueryFilter +} + +// 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. If that metadata is unavailable, +// the ORM may fall back to using `field` directly as the SQL column name. In +// metadata-driven paths, unresolved fields are skipped for that table. +// `mode` must be explicitly set to .static or .dynamic. Static filters are +// reserved for future compiler-generated scope clauses. The runtime DB wrapper +// applies only filters explicitly marked with .dynamic. +pub struct QueryFilter { +pub: + field string + value Primitive + operator OperationKind = .eq + mode QueryFilterMode // must be explicitly set to .static or .dynamic +} + +// new_db creates a new DB with DataScope applied. +pub fn new_db(conn Connection, scope DataScope) DB { + return DB{ + conn: conn + scope: scope + skip_all_scopes: false + skip_fields: [] + } +} + +// 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 { + if unscoped_fields.len == 0 { + return DB{ + conn: db.conn + scope: db.scope + skip_all_scopes: true + skip_fields: [] + } + } + return DB{ + conn: db.conn + scope: db.scope + skip_all_scopes: false + skip_fields: unscoped_fields.map(it) + } +} + +// table_field_to_column_map builds an O(1) lookup from struct field names +// to SQL column names. +fn table_field_to_column_map(table Table) map[string]string { + mut m := map[string]string{} + if table.columns.len > 0 && table.columns.len == table.fields.len { + for j, field_name in table.fields { + m[field_name] = table.columns[j] + } + } + return m +} + +// apply_data_scope applies DataScope filters to a WHERE QueryData and returns the scoped query data. +pub fn apply_data_scope(scope DataScope, table Table, where QueryData, scope_skip_fields []string, has_joins bool) !QueryData { + return apply_scope_filters(scope, table, where, scope_skip_fields, has_joins) +} + +// apply_data_scope_insert applies DataScope filters to an INSERT QueryData and returns the scoped query data. +pub fn apply_data_scope_insert(scope DataScope, table Table, data QueryData, scope_skip_fields []string) !QueryData { + return apply_scope_insert_filters(scope, table, data, scope_skip_fields) +} + +// apply_scope_filters applies DataScope filters to WHERE data. It wraps original +// conditions in parentheses and appends is_and / kinds markers. +// When has_joins is true, scope filter column names are qualified with table.name +// to avoid ambiguity in JOIN queries where joined tables share column names. +fn apply_scope_filters(scope DataScope, table Table, qd QueryData, scope_skip_fields []string, has_joins bool) !QueryData { + if !scope.enabled || scope.filters.len == 0 { + return qd + } + if table_ignores_data_scope(table) { + return qd + } + mut result := clone_query_data(qd) + field_to_column := table_field_to_column_map(table) + // Wrap original WHERE clause in parentheses once, before adding scope filters + if result.fields.len > 1 { + result.parentheses << [0, result.fields.len - 1] + } + for filter in scope.filters { + if filter.mode == .unset { + return error('orm.DataScope: QueryFilter.mode must be explicitly set. .static is not yet implemented — use .dynamic. Got .unset for field `${filter.field}`') + } + if filter.mode != .dynamic { + continue + } + if filter.field == '' { + return error('orm.DataScope: dynamic filter field must not be empty') + } + if filter.field in scope_skip_fields { + continue + } + // Note: we do NOT skip when filter.field is already in result.fields. + // The scope filter is always appended as an additional AND condition. + // This prevents a user from bypassing tenant isolation by including the + // scoped field in their own WHERE clause. The resolved SQL column name is + // also appended without deduplication for the same reason. + if table.fields.len > 0 && filter.field !in table.fields { + continue + } + if !filter_value_matches_operator(filter) { + return invalid_scope_filter_error(filter) + } + // Resolve SQL column name from struct field name (O(1) via lookup map) + mut column_name := filter.field + if resolved := field_to_column[filter.field] { + column_name = resolved + } + // Qualify with table name when joins are present to avoid ambiguity + if has_joins && table.name != '' { + column_name = table_qualified_field(table.name, column_name) + } + // Note: we do NOT skip when column_name is already in result.fields. + // The scope filter is always appended as an additional AND condition + // to prevent bypassing tenant isolation. + result.is_and << true + result.fields << column_name.clone() + if !filter.operator.is_unary() { + result.data << filter.value + result.types << primitive_type(filter.value) + } + result.kinds << filter.operator + } + return result +} + +fn invalid_scope_filter_error(filter QueryFilter) IError { + if filter.operator in [.in, .not_in] { + return error('orm.DataScope: dynamic filter `${filter.field}` with `${filter.operator}` requires a non-empty array value') + } + return error('orm.DataScope: dynamic filter `${filter.field}` with `${filter.operator}` requires a scalar value') +} + +fn filter_value_matches_operator(filter QueryFilter) bool { + array_len := primitive_array_len(filter.value) + if filter.operator in [.in, .not_in] { + return array_len > 0 + } + return array_len < 0 +} + +fn apply_scope_insert_filters(scope DataScope, table Table, data QueryData, scope_skip_fields []string) !QueryData { + if !scope.enabled || scope.filters.len == 0 { + return data + } + if table_ignores_data_scope(table) { + return data + } + mut result := clone_query_data(data) + original_field_count := data.fields.len + field_to_column := table_field_to_column_map(table) + for filter in scope.filters { + if filter.mode == .unset { + return error('orm.DataScope: QueryFilter.mode must be explicitly set. .static is not yet implemented — use .dynamic. Got .unset for field `${filter.field}`') + } + if filter.mode != .dynamic { + continue + } + if filter.field == '' { + return error('orm.DataScope: dynamic filter field must not be empty') + } + if filter.field in scope_skip_fields { + continue + } + if table.fields.len > 0 && filter.field !in table.fields { + continue + } + if !filter_value_matches_operator(filter) { + return invalid_scope_filter_error(filter) + } + if filter.operator == .is_null { + continue + } + if filter.operator != .eq { + return error('orm.DataScope: dynamic filter `${filter.field}` with `${filter.operator}` cannot be applied to INSERT') + } + mut column_name := filter.field + if resolved := field_to_column[filter.field] { + column_name = resolved + } + field_index := result.fields.index(column_name) + if field_index >= 0 { + if result.batch_rows > 0 { + if field_index < original_field_count { + // Original field — data is per-row with original_field_count stride + for row in 0 .. result.batch_rows { + data_index := row * original_field_count + field_index + if data_index < result.data.len { + result.data[data_index] = filter.value + } + if data_index < result.types.len { + result.types[data_index] = primitive_type(filter.value) + } + } + } else { + // Scope field appended by a previous filter — single value at the end + data_index := original_field_count * result.batch_rows + + (field_index - original_field_count) + if data_index < result.data.len { + result.data[data_index] = filter.value + } + if data_index < result.types.len { + result.types[data_index] = primitive_type(filter.value) + } + } + } else { + // Single row — stride is irrelevant; directly index by field position + if field_index < result.data.len { + result.data[field_index] = filter.value + } + if field_index < result.types.len { + result.types[field_index] = primitive_type(filter.value) + } + } + continue + } + result.fields << column_name.clone() + result.data << filter.value + result.types << primitive_type(filter.value) + } + if result.batch_rows > 0 { + scope_field_count := result.fields.len - original_field_count + if scope_field_count > 0 { + mut new_data := []Primitive{cap: result.fields.len * result.batch_rows} + scope_data_start := original_field_count * result.batch_rows + for row in 0 .. result.batch_rows { + for col in 0 .. original_field_count { + new_data << result.data[row * original_field_count + col] + } + for s in 0 .. scope_field_count { + new_data << result.data[scope_data_start + s] + } + } + result.data = new_data + } + } + return result +} + +fn primitive_is_array(value Primitive) bool { + return primitive_array_len(value) >= 0 +} + +fn primitive_array_len(value Primitive) int { + return match value { + []Primitive, []bool, []f32, []f64, []i16, []i64, []i8, []int, []string, []time.Time, []u16, + []u32, []u64, []u8, []InfixType { + value.len + } + else { + -1 + } + } +} + +// DB implements orm.Connection ------------------------------------------------ + +// select fetches rows through the wrapped connection, with DataScope applied. +pub fn (mut db DB) select(config SelectConfig, data QueryData, where QueryData) ![][]Primitive { + mut cfg := config + if db.scope.enabled && db.scope.filters.len > 0 && !db.skip_all_scopes + && !table_ignores_data_scope(cfg.table) { + where_scoped := apply_data_scope(db.scope, cfg.table, where, db.skip_fields, + cfg.joins.len > 0)! + if where_scoped.fields.len > where.fields.len { + cfg.has_where = true + } + return db.conn.select(cfg, data, where_scoped) + } + return db.conn.select(cfg, data, where) +} + +// insert inserts rows through the wrapped connection, with DataScope applied. +pub fn (mut db DB) insert(table Table, data QueryData) ! { + mut data_scoped := data + if db.scope.enabled && db.scope.filters.len > 0 && !db.skip_all_scopes + && !table_ignores_data_scope(table) { + data_scoped = apply_data_scope_insert(db.scope, table, data, db.skip_fields)! + } + return db.conn.insert(table, data_scoped) +} + +// update updates rows through the wrapped connection, with DataScope applied. +pub fn (mut db DB) update(table Table, data QueryData, where QueryData) ! { + mut where_scoped := where + if db.scope.enabled && db.scope.filters.len > 0 && !db.skip_all_scopes + && !table_ignores_data_scope(table) { + where_scoped = apply_data_scope(db.scope, table, where, db.skip_fields, false)! + } + return db.conn.update(table, data, where_scoped) +} + +// delete deletes rows through the wrapped connection, with DataScope applied. +pub fn (mut db DB) delete(table Table, where QueryData) ! { + mut where_scoped := where + if db.scope.enabled && db.scope.filters.len > 0 && !db.skip_all_scopes + && !table_ignores_data_scope(table) { + where_scoped = apply_data_scope(db.scope, table, where, db.skip_fields, false)! + } + return db.conn.delete(table, where_scoped) +} + +// create creates a table through the wrapped connection. +pub fn (mut db DB) create(table Table, fields []TableField) ! { + return db.conn.create(table, fields) +} + +// drop drops a table through the wrapped connection. +pub fn (mut db DB) drop(table Table) ! { + return db.conn.drop(table) +} + +// last_id returns the last inserted id from the wrapped connection. +pub fn (mut db DB) last_id() int { + return db.conn.last_id() +} + +// DB implements orm.TransactionalConnection (decorator) ----------------------- + +// unwrap_to_tx extracts a TransactionalConnection from a Connection interface. +fn unwrap_to_tx(mut conn Connection) TransactionalConnection { + return conn as TransactionalConnection +} + +// orm_begin begins a transaction on the underlying connection. +// Returns an error if the underlying connection does not support transactions. +pub fn (mut db DB) orm_begin() ! { + if db.conn is TransactionalConnection { + mut tc := unwrap_to_tx(mut db.conn) + tc.orm_begin()! + } else { + return error('orm.DB: underlying connection does not support transactions') + } +} + +// orm_commit commits the current transaction on the underlying connection. +// Returns an error if the underlying connection does not support transactions. +pub fn (mut db DB) orm_commit() ! { + if db.conn is TransactionalConnection { + mut tc := unwrap_to_tx(mut db.conn) + tc.orm_commit()! + } else { + return error('orm.DB: underlying connection does not support transactions') + } +} + +// orm_rollback rolls back the current transaction on the underlying connection. +// Returns an error if the underlying connection does not support transactions. +pub fn (mut db DB) orm_rollback() ! { + if db.conn is TransactionalConnection { + mut tc := unwrap_to_tx(mut db.conn) + tc.orm_rollback()! + } else { + return error('orm.DB: underlying connection does not support transactions') + } +} + +// orm_savepoint creates a savepoint with the given name on the underlying connection. +// Returns an error if the underlying connection does not support transactions. +pub fn (mut db DB) orm_savepoint(name string) ! { + if db.conn is TransactionalConnection { + mut tc := unwrap_to_tx(mut db.conn) + tc.orm_savepoint(name)! + } else { + return error('orm.DB: underlying connection does not support transactions') + } +} + +// orm_rollback_to rolls back to the named savepoint on the underlying connection. +// Returns an error if the underlying connection does not support transactions. +pub fn (mut db DB) orm_rollback_to(name string) ! { + if db.conn is TransactionalConnection { + mut tc := unwrap_to_tx(mut db.conn) + tc.orm_rollback_to(name)! + } else { + return error('orm.DB: underlying connection does not support transactions') + } +} + +// orm_release_savepoint releases the named savepoint on the underlying connection. +// Returns an error if the underlying connection does not support transactions. +pub fn (mut db DB) orm_release_savepoint(name string) ! { + if db.conn is TransactionalConnection { + mut tc := unwrap_to_tx(mut db.conn) + tc.orm_release_savepoint(name)! + } else { + return error('orm.DB: underlying connection does not support transactions') + } +} diff --git a/vlib/orm/orm_scope_test.v b/vlib/orm/orm_scope_test.v new file mode 100644 index 000000000..328c685ca --- /dev/null +++ b/vlib/orm/orm_scope_test.v @@ -0,0 +1,1978 @@ +// vtest retry: 3 +// vtest build: present_sqlite3? && !sanitize-memory-clang +import db.sqlite +import orm + +struct NoScopeUser { + id int @[primary; sql: serial] + name string + tenant_id int +} + +@[table: 'scope_no_tenant_users'] +struct ScopeNoTenantUser { + id int @[primary; sql: serial] + name string +} + +@[table: 'unscoped_attr_users'] +@[unscoped] +struct UnscopedAttrUser { + id int @[primary; sql: serial] + name string + tenant_id int +} + +@[table: 'noscope_users2'] +struct NoScopeUserMulti { + id int @[primary; sql: serial] + name string + org_id int + deleted bool +} + +@[table: 'scope_users'] +struct ScopeUser { + id int @[primary; sql: serial] + name string + tenant_id int + shop_id int +} + +struct ScopeCoordinates { + latitude f64 + longitude f64 +} + +@[table: 'scope_embedded_locations'] +struct ScopeEmbeddedLocation { + ScopeCoordinates + id int @[primary; sql: serial] + name string +} + +fn test_unscoped_skips_tenant_filter_in_select() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + alice := NoScopeUser{ + name: 'Alice' + tenant_id: 1 + } + bob := NoScopeUser{ + name: 'Bob' + tenant_id: 2 + } + + sql raw_db { + insert alice into NoScopeUser + insert bob into NoScopeUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + ] + }) + + // Without unscoped - scope filter applies, only Alice (tenant_id=1) + users_filtered := sql db { + select from NoScopeUser + }! + assert users_filtered.len == 1 + assert users_filtered[0].name == 'Alice' + + // With db.unscoped('tenant_id') - scope skipped, all users visible + unscoped_db := db.unscoped('tenant_id') + users_all := sql unscoped_db { + select from NoScopeUser + }! + assert users_all.len == 2 +} + +fn test_table_unscoped_attr_skips_data_scope() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table UnscopedAttrUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(99) + mode: .dynamic + }, + ] + }) + + alice := UnscopedAttrUser{ + name: 'Alice' + tenant_id: 1 + } + sql db { + insert alice into UnscopedAttrUser + }! + + users := sql db { + select from UnscopedAttrUser + }! + assert users.len == 1 + assert users[0].tenant_id == 1 +} + +fn test_query_builder_skips_scope_filter_for_missing_table_field() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table ScopeNoTenantUser + }! + + alice := ScopeNoTenantUser{ + name: 'Alice' + } + sql raw_db { + insert alice into ScopeNoTenantUser + }! + + scoped_db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(99) + mode: .dynamic + }, + ] + }) + mut qb := orm.new_query[ScopeNoTenantUser](scoped_db) + users := qb.query()! + assert users.len == 1 + assert users[0].name == 'Alice' +} + +fn test_unscoped_skip_all_in_select() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + alice := NoScopeUser{ + name: 'Alice' + tenant_id: 1 + } + bob := NoScopeUser{ + name: 'Bob' + tenant_id: 2 + } + + sql raw_db { + insert alice into NoScopeUser + insert bob into NoScopeUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + ] + }) + + // db.unscoped() skips ALL scope filters + unscoped_db := db.unscoped() + users_all := sql unscoped_db { + select from NoScopeUser + }! + assert users_all.len == 2 +} + +fn test_unscoped_selective_skip_in_multi_field_scope() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUserMulti + }! + + u1 := NoScopeUserMulti{ + name: 'A' + org_id: 1 + deleted: false + } + u2 := NoScopeUserMulti{ + name: 'B' + org_id: 1 + deleted: true + } + u3 := NoScopeUserMulti{ + name: 'C' + org_id: 2 + deleted: false + } + u4 := NoScopeUserMulti{ + name: 'D' + org_id: 2 + deleted: true + } + + sql raw_db { + insert u1 into NoScopeUserMulti + insert u2 into NoScopeUserMulti + insert u3 into NoScopeUserMulti + insert u4 into NoScopeUserMulti + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'org_id' + value: orm.Primitive(1) + mode: .dynamic + }, + orm.QueryFilter{ + field: 'deleted' + value: orm.Primitive(false) + mode: .dynamic + }, + ] + }) + + // Both scope filters apply - only org_id=1 AND deleted=false + users_filtered := sql db { + select from NoScopeUserMulti + }! + assert users_filtered.len == 1 + assert users_filtered[0].name == 'A' + + // Skip only 'org_id' - 'deleted' filter still applies + unscoped_db := db.unscoped('org_id') + users := sql unscoped_db { + select from NoScopeUserMulti order by name + }! + // Should match deleted=false regardless of org_id + assert users.len == 2 + assert users[0].name == 'A' + assert users[1].name == 'C' +} + +fn test_unscoped_skips_tenant_in_insert() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(99) + mode: .dynamic + }, + ] + }) + + // With db.unscoped('tenant_id') - scope does NOT inject tenant_id=99 + alice := NoScopeUser{ + name: 'Alice' + } + unscoped_db := db.unscoped('tenant_id') + sql unscoped_db { + insert alice into NoScopeUser + }! + + // Alice was inserted with tenant_id=0 (no auto-inject), so scope won't find her + users := sql db { + select from NoScopeUser + }! + assert users.len == 0 +} + +fn test_data_scope_insert_overrides_default_scope_field() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(99) + mode: .dynamic + }, + ] + }) + + alice := NoScopeUser{ + name: 'Alice' + } + sql db { + insert alice into NoScopeUser + }! + + users := sql db { + select from NoScopeUser + }! + assert users.len == 1 + assert users[0].name == 'Alice' + assert users[0].tenant_id == 99 +} + +fn test_unscoped_skip_all_in_insert() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(99) + mode: .dynamic + }, + ] + }) + + bob := NoScopeUser{ + name: 'Bob' + } + + // db.unscoped() skips ALL scope field injection in insert + unscoped_db := db.unscoped() + sql unscoped_db { + insert bob into NoScopeUser + }! + + // Bob was inserted with tenant_id=0 (not auto-injected), not visible under scope + users := sql db { + select from NoScopeUser + }! + assert users.len == 0 +} + +fn test_unscoped_skips_tenant_in_update() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + alice := NoScopeUser{ + name: 'Alice' + tenant_id: 1 + } + bob := NoScopeUser{ + name: 'Bob' + tenant_id: 2 + } + + sql raw_db { + insert alice into NoScopeUser + insert bob into NoScopeUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + ] + }) + + // With scope tenant_id=1, update where name='Bob' normally won't match + // because scope ANDs tenant_id=1, making it (name='Bob' AND tenant_id=1) + sql db { + update NoScopeUser set name = 'UpdatedByScope' where name == 'Bob' + }! + + // Bob (tenant_id=2) should NOT have been updated + bob_check := sql raw_db { + select from NoScopeUser where name == 'Bob' && tenant_id == 2 + }! + assert bob_check.len == 1 + assert bob_check[0].name == 'Bob' + + // With db.unscoped('tenant_id'), the scope filter is skipped in the WHERE, + // so name='Bob' matches regardless of tenant_id + unscoped_db := db.unscoped('tenant_id') + sql unscoped_db { + update NoScopeUser set name = 'UpdatedByNoScope' where name == 'Bob' + }! + + bob_updated := sql raw_db { + select from NoScopeUser where tenant_id == 2 + }! + assert bob_updated.len == 1 + assert bob_updated[0].name == 'UpdatedByNoScope' +} + +fn test_unscoped_skip_all_in_update() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + alice := NoScopeUser{ + name: 'Alice' + tenant_id: 1 + } + bob := NoScopeUser{ + name: 'Bob' + tenant_id: 2 + } + + sql raw_db { + insert alice into NoScopeUser + insert bob into NoScopeUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + orm.QueryFilter{ + field: 'name' + value: orm.Primitive('Alice') + mode: .dynamic + }, + ] + }) + + // db.unscoped() skips ALL scope filters in the WHERE clause + // Without unscoped, the WHERE would include both tenant_id=1 AND name='Alice', + // making WHERE name='Bob' become (name='Bob' AND tenant_id=1 AND name='Alice'), which + // would not match anything due to the name conflict. + // With unscoped(), no scope filters are added, so name='Bob' matches directly. + unscoped_db := db.unscoped() + sql unscoped_db { + update NoScopeUser set name = 'UpdatedAll' where name == 'Bob' + }! + + bob_updated := sql raw_db { + select from NoScopeUser where tenant_id == 2 + }! + assert bob_updated.len == 1 + assert bob_updated[0].name == 'UpdatedAll' +} + +fn test_unscoped_skips_tenant_in_delete() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + alice := NoScopeUser{ + name: 'Alice' + tenant_id: 1 + } + bob := NoScopeUser{ + name: 'Bob' + tenant_id: 2 + } + + sql raw_db { + insert alice into NoScopeUser + insert bob into NoScopeUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + ] + }) + + // With scope tenant_id=1, delete where name='Bob' won't match + sql db { + delete from NoScopeUser where name == 'Bob' + }! + + // Bob should still exist (scope prevented deletion) + bob_check := sql raw_db { + select from NoScopeUser where tenant_id == 2 + }! + assert bob_check.len == 1 + + // With db.unscoped('tenant_id'), scope filter skipped, Bob gets deleted + unscoped_db := db.unscoped('tenant_id') + sql unscoped_db { + delete from NoScopeUser where name == 'Bob' + }! + + bob_gone := sql raw_db { + select from NoScopeUser where tenant_id == 2 + }! + assert bob_gone.len == 0 +} + +fn test_unscoped_skip_all_in_delete() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + alice := NoScopeUser{ + name: 'Alice' + tenant_id: 1 + } + bob := NoScopeUser{ + name: 'Bob' + tenant_id: 2 + } + + sql raw_db { + insert alice into NoScopeUser + insert bob into NoScopeUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + orm.QueryFilter{ + field: 'name' + value: orm.Primitive('Alice') + mode: .dynamic + }, + ] + }) + + // db.unscoped() skips ALL scope filters + // Without it, delete where name='Bob' becomes (name='Bob' AND tenant_id=1 AND name='Alice') + // which can't match (Bob != Alice). + // With unscoped(), delete where name='Bob' matches directly. + unscoped_db := db.unscoped() + sql unscoped_db { + delete from NoScopeUser where name == 'Bob' + }! + + bob_gone := sql raw_db { + select from NoScopeUser where tenant_id == 2 + }! + assert bob_gone.len == 0 +} + +fn test_unscoped_skip_multi_field_select() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table ScopeUser + }! + + alice := ScopeUser{ + name: 'Alice' + tenant_id: 1 + shop_id: 1 + } + bob := ScopeUser{ + name: 'Bob' + tenant_id: 2 + shop_id: 1 + } + carol := ScopeUser{ + name: 'Carol' + tenant_id: 2 + shop_id: 2 + } + + sql raw_db { + insert alice into ScopeUser + insert bob into ScopeUser + insert carol into ScopeUser + }! + + // Scope filters: tenant_id=1 AND shop_id=1 (only Alice matches) + db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + orm.QueryFilter{ + field: 'shop_id' + value: orm.Primitive(1) + mode: .dynamic + }, + ] + }) + + // Without unscoped - both filters apply, only Alice + users_filtered := sql db { + select from ScopeUser + }! + assert users_filtered.len == 1 + assert users_filtered[0].name == 'Alice' + + // Skip both tenant_id and shop_id - all users visible + unscoped_db := db.unscoped('tenant_id', 'shop_id') + users_all := sql unscoped_db { + select from ScopeUser + }! + assert users_all.len == 3 +} + +fn test_data_scope_filter_on_embedded_field() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table ScopeEmbeddedLocation + }! + + loc1 := ScopeEmbeddedLocation{ + name: 'North' + latitude: 10.5 + longitude: 20.0 + } + loc2 := ScopeEmbeddedLocation{ + name: 'South' + latitude: -3.25 + longitude: 30.0 + } + + sql raw_db { + insert loc1 into ScopeEmbeddedLocation + insert loc2 into ScopeEmbeddedLocation + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'ScopeCoordinates.latitude' + value: orm.Primitive(f64(10.5)) + mode: .dynamic + }, + ] + }) + + locations := sql db { + select from ScopeEmbeddedLocation + }! + assert locations.len == 1 + assert locations[0].name == 'North' + assert locations[0].latitude == 10.5 +} + +// ---- DataScope tests ------------------------------------------------- + +fn empty_scope() orm.DataScope { + return orm.DataScope{} +} + +fn scope_single_tenant(tenant_id int) orm.DataScope { + return orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(int(tenant_id)) + operator: .eq + mode: .dynamic + }, + ] + } +} + +fn scope_disabled() orm.DataScope { + return orm.DataScope{ + enabled: false + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(int(1)) + operator: .eq + mode: .dynamic + }, + ] + } +} + +fn test_apply_data_scope_single_filter() { + scope := scope_single_tenant(5) + where := orm.QueryData{} + table := orm.Table{ + name: 'users' + } + result := orm.apply_data_scope(scope, table, where, [], false)! + assert result.fields == ['tenant_id'] + assert result.data == [orm.Primitive(int(5))] + assert result.kinds == [.eq] +} + +fn test_apply_data_scope_appends_to_existing_where() { + scope := scope_single_tenant(42) + where := orm.QueryData{ + fields: ['id'] + data: [orm.Primitive(int(1))] + kinds: [.eq] + } + table := orm.Table{ + name: 'users' + } + result := orm.apply_data_scope(scope, table, where, [], false)! + assert result.fields == ['id', 'tenant_id'] + assert result.data == [orm.Primitive(int(1)), orm.Primitive(int(42))] + assert result.kinds == [.eq, .eq] + assert result.is_and == [true] +} + +fn test_apply_data_scope_appends_even_when_field_exists() { + // Scope filter is always appended as an additional AND condition, + // even when the field already exists in the user's WHERE clause. + // This prevents bypassing tenant isolation. + scope := scope_single_tenant(5) + where := orm.QueryData{ + fields: ['tenant_id'] + data: [orm.Primitive(int(10))] + kinds: [.eq] + } + table := orm.Table{ + name: 'users' + } + result := orm.apply_data_scope(scope, table, where, [], false)! + assert result.fields == ['tenant_id', 'tenant_id'] + assert result.data == [orm.Primitive(int(10)), orm.Primitive(int(5))] +} + +fn test_apply_data_scope_empty_or_disabled() { + where := orm.QueryData{ + fields: ['id'] + data: [orm.Primitive(int(1))] + kinds: [.eq] + } + table := orm.Table{ + name: 'users' + } + empty_result := orm.apply_data_scope(empty_scope(), table, where, [], false)! + assert empty_result.fields == ['id'] + disabled_result := orm.apply_data_scope(scope_disabled(), table, where, [], false)! + assert disabled_result.fields == ['id'] +} + +fn test_apply_data_scope_multi_field() { + scope := orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'org_id' + value: orm.Primitive(int(1)) + operator: .eq + mode: .dynamic + }, + orm.QueryFilter{ + field: 'deleted' + value: orm.Primitive(false) + operator: .eq + mode: .dynamic + }, + ] + } + where := orm.QueryData{} + table := orm.Table{ + name: 'users' + } + result := orm.apply_data_scope(scope, table, where, [], false)! + assert result.fields == ['org_id', 'deleted'] + assert result.data == [orm.Primitive(int(1)), orm.Primitive(false)] + assert result.kinds == [.eq, .eq] +} + +fn test_query_filter_mode_must_be_explicit() { + filter := orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(int(5)) + } + // mode defaults to .unset (the zero value); applying it must error + assert filter.mode == .unset + scope := orm.DataScope{ + filters: [filter] + } + orm.apply_data_scope(scope, orm.Table{ name: 't' }, orm.QueryData{}, [], false) or { + assert err.msg().contains('must be explicitly set') + return + } + assert false +} + +fn test_apply_data_scope_applies_only_dynamic_filters() { + scope := orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(int(5)) + mode: .static + }, + orm.QueryFilter{ + field: 'shop_id' + value: orm.Primitive(int(10)) + mode: .dynamic + }, + ] + } + where := orm.QueryData{} + table := orm.Table{ + name: 'users' + fields: ['tenant_id', 'shop_id'] + } + result := orm.apply_data_scope(scope, table, where, [], false)! + assert scope.filters[0].mode == .static + assert scope.filters[1].mode == .dynamic + assert result.fields == ['shop_id'] + assert result.data == [orm.Primitive(int(10))] + assert result.kinds == [.eq] +} + +fn test_apply_data_scope_wraps_parentheses() { + scope := scope_single_tenant(42) + where := orm.QueryData{ + fields: ['a', 'b'] + kinds: [.eq, .eq] + is_and: [false] + } + table := orm.Table{ + name: 'users' + } + result := orm.apply_data_scope(scope, table, where, [], false)! + assert result.fields == ['a', 'b', 'tenant_id'] + assert result.is_and == [false, true] + assert result.parentheses.len == 1 + assert result.parentheses[0] == [0, 1] +} + +fn test_apply_data_scope_with_unary_operator() { + // Unary operator (is_null) with existing WHERE — verifies is_and marker is appended + scope := orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'deleted_at' + operator: .is_null + mode: .dynamic + }, + ] + } + where := orm.QueryData{ + fields: ['tenant_id'] + data: [orm.Primitive(int(5))] + kinds: [.eq] + } + table := orm.Table{ + name: 'users' + fields: ['tenant_id', 'deleted_at'] + } + result := orm.apply_data_scope(scope, table, where, [], false)! + assert result.fields == ['tenant_id', 'deleted_at'] + assert result.kinds == [.eq, .is_null] + assert result.is_and == [true] + // Unary operators don't add data values + assert result.data == [orm.Primitive(int(5))] +} + +fn test_apply_data_scope_rejects_invalid_filter_values() { + scope := orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive([1, 2]) + mode: .dynamic + }, + ] + } + where := orm.QueryData{} + table := orm.Table{ + name: 'users' + fields: ['tenant_id'] + } + orm.apply_data_scope(scope, table, where, [], false) or { + assert err.msg().contains('requires a scalar value') + return + } + assert false +} + +fn test_apply_data_scope_rejects_scalar_value_for_in_operator() { + scope := orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'org_id' + value: orm.Primitive(1) + operator: .in + mode: .dynamic + }, + ] + } + where := orm.QueryData{} + table := orm.Table{ + name: 'users' + fields: ['org_id'] + } + orm.apply_data_scope(scope, table, where, [], false) or { + assert err.msg().contains('requires a non-empty array value') + return + } + assert false +} + +fn test_apply_data_scope_rejects_empty_array_for_in_operator() { + empty_ids := []int{} + scope := orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'region_id' + value: orm.Primitive(empty_ids) + operator: .in + mode: .dynamic + }, + ] + } + where := orm.QueryData{} + table := orm.Table{ + name: 'users' + fields: ['region_id'] + } + orm.apply_data_scope(scope, table, where, [], false) or { + assert err.msg().contains('requires a non-empty array value') + return + } + assert false +} + +fn test_apply_data_scope_accepts_non_empty_array_for_in_operator() { + scope := orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'shop_id' + value: orm.Primitive([3, 4]) + operator: .in + mode: .dynamic + }, + ] + } + where := orm.QueryData{} + table := orm.Table{ + name: 'users' + fields: ['shop_id'] + } + result := orm.apply_data_scope(scope, table, where, [], false)! + assert result.fields == ['shop_id'] + assert result.kinds == [.in] + assert result.data == [orm.Primitive([3, 4])] +} + +fn test_apply_data_scope_insert_adds_fields() { + scope := scope_single_tenant(99) + data := orm.QueryData{ + fields: ['name'] + data: [orm.Primitive('alice')] + } + table := orm.Table{ + name: 'users' + } + result := orm.apply_data_scope_insert(scope, table, data, [])! + assert result.fields == ['name', 'tenant_id'] + assert result.data == [orm.Primitive('alice'), orm.Primitive(int(99))] +} + +fn test_apply_data_scope_insert_skips_unary_operator() { + scope := orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(int(99)) + mode: .dynamic + }, + orm.QueryFilter{ + field: 'deleted_at' + operator: .is_null + mode: .dynamic + }, + ] + } + data := orm.QueryData{ + fields: ['name'] + data: [orm.Primitive('alice')] + } + table := orm.Table{ + name: 'users' + fields: ['name', 'tenant_id', 'deleted_at'] + } + result := orm.apply_data_scope_insert(scope, table, data, [])! + assert result.fields == ['name', 'tenant_id'] + assert result.data == [orm.Primitive('alice'), orm.Primitive(int(99))] +} + +fn test_apply_data_scope_insert_rejects_non_equality_operator() { + scope := orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive([orm.Primitive(1), orm.Primitive(2)]) + operator: .in + mode: .dynamic + }, + orm.QueryFilter{ + field: 'shop_id' + value: orm.Primitive(int(9)) + mode: .dynamic + }, + ] + } + data := orm.QueryData{ + fields: ['name'] + data: [orm.Primitive('alice')] + } + table := orm.Table{ + name: 'users' + fields: ['name', 'tenant_id', 'shop_id'] + } + orm.apply_data_scope_insert(scope, table, data, []) or { + assert err.msg().contains('cannot be applied to INSERT') + return + } + assert false +} + +fn test_apply_data_scope_insert_rejects_array_equality_value() { + scope := orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive([orm.Primitive(1), orm.Primitive(2)]) + mode: .dynamic + }, + orm.QueryFilter{ + field: 'shop_id' + value: orm.Primitive(int(9)) + mode: .dynamic + }, + ] + } + data := orm.QueryData{ + fields: ['name'] + data: [orm.Primitive('alice')] + } + table := orm.Table{ + name: 'users' + fields: ['name', 'tenant_id', 'shop_id'] + } + orm.apply_data_scope_insert(scope, table, data, []) or { + assert err.msg().contains('requires a scalar value') + return + } + assert false +} + +fn test_apply_data_scope_insert_overrides_existing_scope_field() { + scope := scope_single_tenant(99) + data := orm.QueryData{ + fields: ['tenant_id', 'name'] + data: [orm.Primitive(int(7)), orm.Primitive('bob')] + } + table := orm.Table{ + name: 'users' + } + result := orm.apply_data_scope_insert(scope, table, data, [])! + assert result.fields == ['tenant_id', 'name'] + assert result.data == [orm.Primitive(int(99)), orm.Primitive('bob')] +} + +fn test_apply_data_scope_insert_overrides_existing_scope_field_in_batch() { + scope := scope_single_tenant(99) + data := orm.QueryData{ + fields: ['name', 'tenant_id'] + data: [orm.Primitive('alice'), orm.Primitive(int(0)), orm.Primitive('bob'), + orm.Primitive(int(7))] + batch_rows: 2 + } + table := orm.Table{ + name: 'users' + } + result := orm.apply_data_scope_insert(scope, table, data, [])! + assert result.fields == ['name', 'tenant_id'] + assert result.data == [orm.Primitive('alice'), orm.Primitive(int(99)), orm.Primitive('bob'), + orm.Primitive(int(99))] +} + +fn test_apply_data_scope_insert_empty_or_disabled() { + data := orm.QueryData{ + fields: ['name'] + data: [orm.Primitive('alice')] + } + table := orm.Table{ + name: 'users' + } + empty_result := orm.apply_data_scope_insert(empty_scope(), table, data, [])! + assert empty_result.fields == ['name'] + disabled_result := orm.apply_data_scope_insert(scope_disabled(), table, data, [])! + assert disabled_result.fields == ['name'] +} + +// ---- scope_skip_fields unit tests ---------------------------------------- + +fn test_apply_data_scope_skip_single_field() { + scope := scope_single_tenant(5) + where := orm.QueryData{} + table := orm.Table{ + name: 'users' + } + // Skip 'tenant_id' - it should not be applied + result := orm.apply_data_scope(scope, table, where, ['tenant_id'], false)! + assert result.fields == [] + assert result.data == [] +} + +fn test_apply_data_scope_skip_field_still_applies_others() { + scope := orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'org_id' + value: orm.Primitive(int(1)) + operator: .eq + mode: .dynamic + }, + orm.QueryFilter{ + field: 'deleted' + value: orm.Primitive(false) + operator: .eq + mode: .dynamic + }, + ] + } + where := orm.QueryData{} + table := orm.Table{ + name: 'users' + } + // Skip only 'org_id', 'deleted' should still be applied + result := orm.apply_data_scope(scope, table, where, ['org_id'], false)! + assert result.fields == ['deleted'] + assert result.data == [orm.Primitive(false)] +} + +fn test_apply_data_scope_skip_non_existent_field() { + scope := scope_single_tenant(5) + where := orm.QueryData{} + table := orm.Table{ + name: 'users' + } + // Skip a non-existent field - all filters should still be applied + result := orm.apply_data_scope(scope, table, where, ['nonexistent'], false)! + assert result.fields == ['tenant_id'] + assert result.data == [orm.Primitive(int(5))] +} + +fn test_apply_data_scope_insert_skip_single_field() { + scope := scope_single_tenant(99) + data := orm.QueryData{ + fields: ['name'] + data: [orm.Primitive('alice')] + } + table := orm.Table{ + name: 'users' + } + // Skip 'tenant_id' in insert - should not inject it + result := orm.apply_data_scope_insert(scope, table, data, ['tenant_id'])! + assert result.fields == ['name'] + assert result.data == [orm.Primitive('alice')] +} + +// ---- Middleware pattern tests: db configured per-request, business code is scope-unaware ---- + +// Simulates a request context with a per-request db. +// In a real middleware, the db would be configured once at request entry, +// and all subsequent handlers use ctx.db without knowing about scopes. +struct RequestCtx { +mut: + db orm.DB +} + +fn test_middleware_admin_skips_all_scopes() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + alice := NoScopeUser{ + name: 'Alice' + tenant_id: 1 + } + bob := NoScopeUser{ + name: 'Bob' + tenant_id: 2 + } + + sql raw_db { + insert alice into NoScopeUser + insert bob into NoScopeUser + }! + + base_db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + ] + }) + + // --- Middleware: on request entry, configure per-request db by role --- + // Admin role: skip all scopes + mut ctx := RequestCtx{ + db: base_db.unscoped() + } + // --- End middleware --- + + // --- Business handler: just extract db from ctx, use it like always --- + db := ctx.db + users := sql db { + select from NoScopeUser + }! + // Admin sees all users because middleware configured no scopes + assert users.len == 2 + + users2 := sql db { + select from NoScopeUser + }! + // Second query also sees all - middleware config persists + assert users2.len == 2 +} + +fn test_middleware_manager_skips_specific_scope() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + alice := NoScopeUser{ + name: 'Alice' + tenant_id: 1 + } + bob := NoScopeUser{ + name: 'Bob' + tenant_id: 2 + } + + sql raw_db { + insert alice into NoScopeUser + insert bob into NoScopeUser + }! + + base_db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + ] + }) + + // --- Middleware: manager skips tenant_id filter --- + mut ctx := RequestCtx{ + db: base_db.unscoped('tenant_id') + } + + // --- Business handler: just extract db from ctx --- + db := ctx.db + users := sql db { + select from NoScopeUser + }! + // Manager sees all because tenant_id scope is skipped + assert users.len == 2 +} + +fn test_middleware_normal_user_has_full_scopes() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + alice := NoScopeUser{ + name: 'Alice' + tenant_id: 1 + } + bob := NoScopeUser{ + name: 'Bob' + tenant_id: 2 + } + + sql raw_db { + insert alice into NoScopeUser + insert bob into NoScopeUser + }! + + base_db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + ] + }) + + // --- Middleware: normal user, no scope skipping --- + mut ctx := RequestCtx{ + db: base_db + } + + // --- Business handler: just extract db from ctx --- + db := ctx.db + users := sql db { + select from NoScopeUser + }! + // Normal user sees only Alice (tenant_id=1) - scope is fully applied + assert users.len == 1 + assert users[0].name == 'Alice' +} + +fn test_middleware_mixed_roles_produce_isolated_results() { + // Simulates multiple concurrent requests with different role configurations. + // Each request has its own ctx.db, so they don't interfere. + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + alice := NoScopeUser{ + name: 'Alice' + tenant_id: 1 + } + bob := NoScopeUser{ + name: 'Bob' + tenant_id: 2 + } + + sql raw_db { + insert alice into NoScopeUser + insert bob into NoScopeUser + }! + + base_db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + ] + }) + + // Request 1: admin - no scopes + mut admin_ctx := RequestCtx{ + db: base_db.unscoped() + } + // Request 2: normal user - full scopes + mut normal_ctx := RequestCtx{ + db: base_db + } + + // Both "handlers" execute with their own ctx.db - results are isolated + db := admin_ctx.db + admin_users := sql db { + select from NoScopeUser + }! + assert admin_users.len == 2 // admin sees all + + normal_db := normal_ctx.db + normal_users := sql normal_db { + select from NoScopeUser + }! + assert normal_users.len == 1 // normal user scoped + assert normal_users[0].name == 'Alice' + + // Admin still sees all - persistent on per-request db + admin_users2 := sql db { + select from NoScopeUser + }! + assert admin_users2.len == 2 +} + +fn test_middleware_ignores_scope_affects_all_crud_operations() { + // Verifies that middleware-configured db.unscoped() works + // for all CRUD operations without business code awareness. + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + alice := NoScopeUser{ + name: 'Alice' + tenant_id: 1 + } + bob := NoScopeUser{ + name: 'Bob' + tenant_id: 2 + } + + sql raw_db { + insert alice into NoScopeUser + insert bob into NoScopeUser + }! + + base_db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + ] + }) + + // Middleware configures admin db at request entry + mut ctx := RequestCtx{ + db: base_db.unscoped('tenant_id') + } + + // Business handler: just extract db from ctx, use it like always + db := ctx.db + + // UPDATE, SELECT, DELETE are all scope-unaware + sql db { + update NoScopeUser set name = 'AdminUpdated' where name == 'Bob' + }! + + bob_updated := sql raw_db { + select from NoScopeUser where tenant_id == 2 + }! + assert bob_updated.len == 1 + assert bob_updated[0].name == 'AdminUpdated' + + // SELECT + users := sql db { + select from NoScopeUser + }! + assert users.len == 2 + + // DELETE: also scope-unaware + sql db { + delete from NoScopeUser where name == 'Alice' + }! + + alice_gone := sql raw_db { + select from NoScopeUser where name == 'Alice' + }! + assert alice_gone.len == 0 +} + +// ---- Transaction proxy tests ---- +// Verify orm.DB delegates orm_begin / orm_commit / orm_rollback / orm_savepoint +// to the underlying TransactionalConnection, so scoped DBs work in transactions. + +fn test_db_transaction_commit_through_proxy() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + ] + }) + + // begin via orm.DB proxy + db.orm_begin()! + + alice := NoScopeUser{ + name: 'Alice' + tenant_id: 1 + } + sql db { + insert alice into NoScopeUser + }! + + // commit via proxy + db.orm_commit()! + + // verify persisted + users := sql db { + select from NoScopeUser + }! + assert users.len == 1 + assert users[0].name == 'Alice' +} + +fn test_db_transaction_rollback_through_proxy() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + ] + }) + + db.orm_begin()! + + alice := NoScopeUser{ + name: 'Alice' + tenant_id: 1 + } + sql db { + insert alice into NoScopeUser + }! + + // rollback via proxy — inserted row should vanish + db.orm_rollback()! + + users := sql db { + select from NoScopeUser + }! + assert users.len == 0 +} + +fn test_db_transaction_with_data_scope() { + // Scope auto-injects tenant_id=1. Inside a transaction, same scope applies. + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + ] + }) + + // Transaction with scope-active DB + db.orm_begin()! + + alice := NoScopeUser{ + name: 'Alice' + tenant_id: 1 // matches scope + } + bob := NoScopeUser{ + name: 'Bob' + tenant_id: 2 // should NOT be visible under scope + } + sql raw_db { + insert alice into NoScopeUser + insert bob into NoScopeUser + }! + + db.orm_commit()! + + // Scope should filter: only Alice visible + users := sql db { + select from NoScopeUser + }! + assert users.len == 1 + assert users[0].name == 'Alice' +} + +fn test_db_transaction_unscoped_in_transaction() { + // unscoped DB in a transaction bypasses scope + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + ] + }) + + // unscoped before transaction + mut unscoped_db := db.unscoped('tenant_id') + + unscoped_db.orm_begin()! + + bob := NoScopeUser{ + name: 'Bob' + tenant_id: 2 + } + sql unscoped_db { + insert bob into NoScopeUser + }! + + unscoped_db.orm_commit()! + + // Bob inserted with tenant_id=2 + // Original scoped db can't see him (tenant_id=1 scope) + scoped_users := sql db { + select from NoScopeUser + }! + assert scoped_users.len == 0 + + // But raw DB sees him + raw_users := sql raw_db { + select from NoScopeUser + }! + assert raw_users.len == 1 + assert raw_users[0].name == 'Bob' +} + +fn test_db_savepoint_and_rollback_to() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{}) + + db.orm_begin()! + + // insert Alice + alice := NoScopeUser{ + name: 'Alice' + tenant_id: 1 + } + sql db { + insert alice into NoScopeUser + }! + + // create savepoint + db.orm_savepoint('sp1')! + + // insert Bob + bob := NoScopeUser{ + name: 'Bob' + tenant_id: 1 + } + sql db { + insert bob into NoScopeUser + }! + + // rollback to savepoint — Bob should vanish, Alice remains + db.orm_rollback_to('sp1')! + + // insert Carol + carol := NoScopeUser{ + name: 'Carol' + tenant_id: 1 + } + sql db { + insert carol into NoScopeUser + }! + + db.orm_release_savepoint('sp1')! + db.orm_commit()! + + users := sql db { + select from NoScopeUser order by id + }! + assert users.len == 2 + assert users[0].name == 'Alice' + assert users[1].name == 'Carol' +} + +fn test_db_satisfies_transactional_connection_interface() { + // Compile-time and runtime verification: orm.DB satisfies orm.TransactionalConnection. + // This function accepts a TransactionalConnection and exercises all its operations. + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table NoScopeUser + }! + + mut db := orm.new_db(raw_db, orm.DataScope{}) + + // Pass orm.DB as TransactionalConnection + transactional_crud(mut db, raw_db) or { panic(err) } + + // Verify data committed + users := sql db { + select from NoScopeUser where name == 'tx_test' + }! + assert users.len == 1 +} + +// transactional_crud accepts a TransactionalConnection and verifies all +// transaction primitives compile and execute correctly. +fn transactional_crud(mut db orm.TransactionalConnection, raw_db &sqlite.DB) ! { + db.orm_begin()! + + u := NoScopeUser{ + name: 'tx_test' + tenant_id: 7 + } + // Use raw_db to bypass scope: we're testing the interface, not scope + sql raw_db { + insert u into NoScopeUser + }! + + db.orm_commit()! +} + +// ---- Batch insert scope tests ---- +// Use a struct WITHOUT the scope fields — scope must inject them. +// This validates batch_rows > 1 correctly replicates scope values per row. + +@[table: 'batch_scope_rows'] +struct BatchScopeRow { + id int @[primary; sql: serial] + name string +} + +// Regression struct: has tenant_id but NOT shop_id, used to trigger the +// batch insert overwrite-after-append bug scenario. +@[table: 'batch_overwrite_rows'] +struct BatchOverwriteRow { + id int @[primary; sql: serial] + name string + tenant_id int +} + +fn test_data_scope_batch_insert_replicates_scope_values() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table BatchScopeRow + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(42) + mode: .dynamic + }, + ] + }) + + batch := [ + BatchScopeRow{ + name: 'Alice' + }, + BatchScopeRow{ + name: 'Bob' + }, + BatchScopeRow{ + name: 'Carol' + }, + ] + + sql db { + insert batch into BatchScopeRow + }! + + users := sql raw_db { + select from BatchScopeRow + }! + assert users.len == 3 +} + +fn test_data_scope_batch_insert_multi_field_scope() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table BatchScopeRow + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(7) + mode: .dynamic + }, + orm.QueryFilter{ + field: 'shop_id' + value: orm.Primitive(3) + mode: .dynamic + }, + ] + }) + + batch := [ + BatchScopeRow{ + name: 'X' + }, + BatchScopeRow{ + name: 'Y' + }, + ] + + sql db { + insert batch into BatchScopeRow + }! + + users := sql raw_db { + select from BatchScopeRow + }! + assert users.len == 2 +} + +fn test_data_scope_batch_insert_overwrite_after_append() { + // Regression test: when a batch insert has multiple dynamic scope filters + // and an earlier filter appends a new column, a later filter that overwrites + // an existing field must use the correct row stride. + // struct: id, name, tenant_id (table.fields may be empty) + // scope[0]: shop_id=3 (appended, not in struct) + // scope[1]: tenant_id=99 (overwrites existing value) + // Without the fix, the overwrite would index past the batch data due to + // using the inflated result.fields.len as row stride. + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table BatchOverwriteRow + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'shop_id' + value: orm.Primitive(3) + mode: .dynamic + }, + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(99) + mode: .dynamic + }, + ] + }) + + // Batch insert with tenant_id=1,2 — scope should overwrite both to 99 + batch := [ + BatchOverwriteRow{ + name: 'OverA' + tenant_id: 1 + }, + BatchOverwriteRow{ + name: 'OverB' + tenant_id: 2 + }, + ] + + sql db { + insert batch into BatchOverwriteRow + }! + + rows := sql raw_db { + select from BatchOverwriteRow order by id + }! + assert rows.len == 2 + assert rows[0].name == 'OverA' + assert rows[0].tenant_id == 99 + assert rows[1].name == 'OverB' + assert rows[1].tenant_id == 99 +} + +// ---- JOIN scope tests ---- +// Verify that scope filters are table-qualified when JOINs are present, +// avoiding ambiguity when joined tables share column names. + +// ScopeJoin table for testing scoped JOINs — must have distinct column +// names from JoinedItem to avoid ambiguity in SELECT *. +@[table: 'scope_join_main'] +struct ScopeJoinMain { + jid int @[primary; sql: serial] + jname string + tenant_id int + jrel_id int +} + +@[table: 'scope_join_rel'] +struct ScopeJoinRel { + rid int @[primary; sql: serial] + rname string +} + +fn test_data_scope_join_qualifies_table() { + mut raw_db := sqlite.connect(':memory:') or { panic(err) } + + sql raw_db { + create table ScopeJoinMain + create table ScopeJoinRel + }! + + m1 := ScopeJoinMain{ + jname: 'main1' + tenant_id: 1 + jrel_id: 1 + } + m2 := ScopeJoinMain{ + jname: 'main2' + tenant_id: 2 + jrel_id: 2 + } + r1 := ScopeJoinRel{ + rname: 'rel1' + } + r2 := ScopeJoinRel{ + rname: 'rel2' + } + + sql raw_db { + insert m1 into ScopeJoinMain + insert m2 into ScopeJoinMain + insert r1 into ScopeJoinRel + insert r2 into ScopeJoinRel + }! + + mut db := orm.new_db(raw_db, orm.DataScope{ + filters: [ + orm.QueryFilter{ + field: 'tenant_id' + value: orm.Primitive(1) + mode: .dynamic + }, + ] + }) + + // Verify scope filter is applied correctly with JOINs present. + rows := sql db { + select from ScopeJoinMain + join ScopeJoinRel on ScopeJoinMain.jrel_id == ScopeJoinRel.rid + }! + assert rows.len == 1 + assert rows[0].jname == 'main1' + assert rows[0].tenant_id == 1 +} diff --git a/vlib/v/gen/c/orm.v b/vlib/v/gen/c/orm.v index 92474118d..c8a98a1e9 100644 --- a/vlib/v/gen/c/orm.v +++ b/vlib/v/gen/c/orm.v @@ -756,9 +756,42 @@ fn (mut g Gen) write_orm_table_struct(typ ast.Type) { table_name := g.get_table_name_by_struct_type(typ) table_attrs := g.get_table_attrs_by_struct_type(typ) + mut fields := g.orm_table_field_names(typ) + mut columns := g.orm_table_column_names(typ) + g.writeln('((orm__Table){') g.indent++ g.writeln('.name = _S("${table_name}"),') + g.writeln('.fields = builtin__new_array_from_c_array(${fields.len}, ${fields.len}, sizeof(string),') + g.indent++ + if fields.len > 0 { + g.write('_MOV((string[${fields.len}]){') + g.indent++ + for field_name in fields { + g.write('_S("${field_name}"),') + } + g.indent-- + g.writeln('})') + } else { + g.writeln('NULL // No fields') + } + g.indent-- + g.writeln('),') + g.writeln('.columns = builtin__new_array_from_c_array(${columns.len}, ${columns.len}, sizeof(string),') + g.indent++ + if columns.len > 0 { + g.write('_MOV((string[${columns.len}]){') + g.indent++ + for column_name in columns { + g.write('_S("${column_name}"),') + } + g.indent-- + g.writeln('})') + } else { + g.writeln('NULL // No columns') + } + g.indent-- + g.writeln('),') g.writeln('.attrs = builtin__new_array_from_c_array(${table_attrs.len}, ${table_attrs.len}, sizeof(VAttribute),') g.indent++ @@ -2208,6 +2241,7 @@ fn (mut g Gen) write_orm_select(node ast.SqlExpr, connection_var_name string, re g.writeln('.kinds = builtin____new_array_with_default_noscan(0, 0, sizeof(orm__OperationKind), 0),') g.writeln('.is_and = builtin____new_array_with_default_noscan(0, 0, sizeof(bool), 0),') g.writeln('.parentheses = builtin____new_array_with_default_noscan(0, 0, sizeof(Array_${ast.int_type_name}), 0),') + g.writeln('.fields = builtin____new_array_with_default_noscan(0, 0, sizeof(string), 0),') if exprs.len > 0 { g.write('.data = builtin__new_array_from_c_array(${exprs.len}, ${exprs.len}, sizeof(orm__Primitive),') g.write(' _MOV((orm__Primitive[${exprs.len}]){') @@ -2234,6 +2268,7 @@ fn (mut g Gen) write_orm_select(node ast.SqlExpr, connection_var_name string, re g.writeln('.kinds = builtin____new_array_with_default_noscan(0, 0, sizeof(orm__OperationKind), 0),') g.writeln('.is_and = builtin____new_array_with_default_noscan(0, 0, sizeof(bool), 0),') g.writeln('.parentheses = builtin____new_array_with_default_noscan(0, 0, sizeof(Array_${ast.int_type_name}), 0),') + g.writeln('.fields = builtin____new_array_with_default_noscan(0, 0, sizeof(string), 0),') g.writeln('.data = builtin____new_array_with_default_noscan(0, 0, sizeof(orm__Primitive), 0)') g.indent-- g.writeln('}') @@ -2524,6 +2559,66 @@ fn (g &Gen) get_table_attrs_by_struct_type(typ ast.Type) []ast.Attr { return info.attrs } +// orm_table_field_names recursively collects field names from a struct type, +// including prefixed fields from embedded structs. Used to populate Table.fields +// for scope filter validation in apply_data_scope. +fn (g &Gen) orm_table_field_names(typ ast.Type) []string { + return g.orm_table_field_names_with_prefix(typ, '') +} + +fn (g &Gen) orm_table_field_names_with_prefix(typ ast.Type, prefix string) []string { + sym := g.table.sym(typ) + info := sym.struct_info() + mut names := []string{} + for field in info.fields { + if field.is_embed { + embed_sym := g.table.sym(field.typ) + if embed_sym.info is ast.Struct { + embed_prefix := prefixed_orm_field_name(prefix, embed_sym.embed_name()) + names << g.orm_table_field_names_with_prefix(field.typ, embed_prefix) + } + } else { + // Skip fields with @[skip] or @[sql:'-'] as they have no database column + if field.attrs.contains('skip') || field.attrs.contains_arg('sql', '-') { + continue + } + names << prefixed_orm_field_name(prefix, field.name) + } + } + return names +} + +// orm_table_column_names recursively collects SQL column names from a struct type, +// including columns from embedded structs. Used to populate Table.columns for +// SQL column name resolution in apply_data_scope. +fn (g &Gen) orm_table_column_names(typ ast.Type) []string { + sym := g.table.sym(typ) + info := sym.struct_info() + mut names := []string{} + for field in info.fields { + if field.is_embed { + embed_sym := g.table.sym(field.typ) + if embed_sym.info is ast.Struct { + names << g.orm_table_column_names(field.typ) + } + } else { + // Skip fields with @[skip] or @[sql:'-'] as they have no database column + if field.attrs.contains('skip') || field.attrs.contains_arg('sql', '-') { + continue + } + names << g.get_orm_column_name_from_struct_field(field) + } + } + return names +} + +fn prefixed_orm_field_name(prefix string, name string) string { + if prefix == '' { + return name + } + return '${prefix}.${name}' +} + // get_table_name_by_struct_type converts the struct type to a table name. // For generic types, uses ngname (name without generic params) to get the base table name. fn (g &Gen) get_table_name_by_struct_type(typ ast.Type) string { -- 2.39.5