| 1 | module orm |
| 2 | |
| 3 | import 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. |
| 14 | pub enum QueryFilterMode { |
| 15 | unset // .static is not yet implemented — use .dynamic explicitly |
| 16 | static |
| 17 | dynamic |
| 18 | } |
| 19 | |
| 20 | fn 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, ...). |
| 32 | pub struct DB { |
| 33 | mut: |
| 34 | conn Connection |
| 35 | pub: |
| 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. |
| 42 | pub struct DataScope { |
| 43 | pub: |
| 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. |
| 57 | pub struct QueryFilter { |
| 58 | pub: |
| 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. |
| 66 | pub 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. |
| 77 | pub 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. |
| 96 | fn 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. |
| 107 | pub 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. |
| 112 | pub 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. |
| 120 | fn 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 | |
| 180 | fn 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 | |
| 187 | fn 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 | |
| 195 | fn 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 | |
| 293 | fn primitive_is_array(value Primitive) bool { |
| 294 | return primitive_array_len(value) >= 0 |
| 295 | } |
| 296 | |
| 297 | fn 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. |
| 312 | pub 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. |
| 327 | pub 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. |
| 337 | pub 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. |
| 347 | pub 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. |
| 357 | pub 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. |
| 362 | pub 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. |
| 367 | pub 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. |
| 374 | fn 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. |
| 380 | pub 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. |
| 391 | pub 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. |
| 402 | pub 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. |
| 413 | pub 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. |
| 424 | pub 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. |
| 435 | pub 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 | |