v / vlib / orm / orm_scope_test.v
1978 lines · 1727 sloc · 41.64 KB · 7b724e98ac187e233bd03716901c29f5e00f815f
Raw
1// vtest retry: 3
2// vtest build: present_sqlite3? && !sanitize-memory-clang
3import db.sqlite
4import orm
5
6struct NoScopeUser {
7 id int @[primary; sql: serial]
8 name string
9 tenant_id int
10}
11
12@[table: 'scope_no_tenant_users']
13struct ScopeNoTenantUser {
14 id int @[primary; sql: serial]
15 name string
16}
17
18@[table: 'unscoped_attr_users']
19@[unscoped]
20struct UnscopedAttrUser {
21 id int @[primary; sql: serial]
22 name string
23 tenant_id int
24}
25
26@[table: 'noscope_users2']
27struct NoScopeUserMulti {
28 id int @[primary; sql: serial]
29 name string
30 org_id int
31 deleted bool
32}
33
34@[table: 'scope_users']
35struct ScopeUser {
36 id int @[primary; sql: serial]
37 name string
38 tenant_id int
39 shop_id int
40}
41
42struct ScopeCoordinates {
43 latitude f64
44 longitude f64
45}
46
47@[table: 'scope_embedded_locations']
48struct ScopeEmbeddedLocation {
49 ScopeCoordinates
50 id int @[primary; sql: serial]
51 name string
52}
53
54fn test_unscoped_skips_tenant_filter_in_select() {
55 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
56
57 sql raw_db {
58 create table NoScopeUser
59 }!
60
61 alice := NoScopeUser{
62 name: 'Alice'
63 tenant_id: 1
64 }
65 bob := NoScopeUser{
66 name: 'Bob'
67 tenant_id: 2
68 }
69
70 sql raw_db {
71 insert alice into NoScopeUser
72 insert bob into NoScopeUser
73 }!
74
75 mut db := orm.new_db(raw_db, orm.DataScope{
76 filters: [
77 orm.QueryFilter{
78 field: 'tenant_id'
79 value: orm.Primitive(1)
80 mode: .dynamic
81 },
82 ]
83 })
84
85 // Without unscoped - scope filter applies, only Alice (tenant_id=1)
86 users_filtered := sql db {
87 select from NoScopeUser
88 }!
89 assert users_filtered.len == 1
90 assert users_filtered[0].name == 'Alice'
91
92 // With db.unscoped('tenant_id') - scope skipped, all users visible
93 unscoped_db := db.unscoped('tenant_id')
94 users_all := sql unscoped_db {
95 select from NoScopeUser
96 }!
97 assert users_all.len == 2
98}
99
100fn test_table_unscoped_attr_skips_data_scope() {
101 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
102
103 sql raw_db {
104 create table UnscopedAttrUser
105 }!
106
107 mut db := orm.new_db(raw_db, orm.DataScope{
108 filters: [
109 orm.QueryFilter{
110 field: 'tenant_id'
111 value: orm.Primitive(99)
112 mode: .dynamic
113 },
114 ]
115 })
116
117 alice := UnscopedAttrUser{
118 name: 'Alice'
119 tenant_id: 1
120 }
121 sql db {
122 insert alice into UnscopedAttrUser
123 }!
124
125 users := sql db {
126 select from UnscopedAttrUser
127 }!
128 assert users.len == 1
129 assert users[0].tenant_id == 1
130}
131
132fn test_query_builder_skips_scope_filter_for_missing_table_field() {
133 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
134
135 sql raw_db {
136 create table ScopeNoTenantUser
137 }!
138
139 alice := ScopeNoTenantUser{
140 name: 'Alice'
141 }
142 sql raw_db {
143 insert alice into ScopeNoTenantUser
144 }!
145
146 scoped_db := orm.new_db(raw_db, orm.DataScope{
147 filters: [
148 orm.QueryFilter{
149 field: 'tenant_id'
150 value: orm.Primitive(99)
151 mode: .dynamic
152 },
153 ]
154 })
155 mut qb := orm.new_query[ScopeNoTenantUser](scoped_db)
156 users := qb.query()!
157 assert users.len == 1
158 assert users[0].name == 'Alice'
159}
160
161fn test_unscoped_skip_all_in_select() {
162 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
163
164 sql raw_db {
165 create table NoScopeUser
166 }!
167
168 alice := NoScopeUser{
169 name: 'Alice'
170 tenant_id: 1
171 }
172 bob := NoScopeUser{
173 name: 'Bob'
174 tenant_id: 2
175 }
176
177 sql raw_db {
178 insert alice into NoScopeUser
179 insert bob into NoScopeUser
180 }!
181
182 mut db := orm.new_db(raw_db, orm.DataScope{
183 filters: [
184 orm.QueryFilter{
185 field: 'tenant_id'
186 value: orm.Primitive(1)
187 mode: .dynamic
188 },
189 ]
190 })
191
192 // db.unscoped() skips ALL scope filters
193 unscoped_db := db.unscoped()
194 users_all := sql unscoped_db {
195 select from NoScopeUser
196 }!
197 assert users_all.len == 2
198}
199
200fn test_unscoped_selective_skip_in_multi_field_scope() {
201 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
202
203 sql raw_db {
204 create table NoScopeUserMulti
205 }!
206
207 u1 := NoScopeUserMulti{
208 name: 'A'
209 org_id: 1
210 deleted: false
211 }
212 u2 := NoScopeUserMulti{
213 name: 'B'
214 org_id: 1
215 deleted: true
216 }
217 u3 := NoScopeUserMulti{
218 name: 'C'
219 org_id: 2
220 deleted: false
221 }
222 u4 := NoScopeUserMulti{
223 name: 'D'
224 org_id: 2
225 deleted: true
226 }
227
228 sql raw_db {
229 insert u1 into NoScopeUserMulti
230 insert u2 into NoScopeUserMulti
231 insert u3 into NoScopeUserMulti
232 insert u4 into NoScopeUserMulti
233 }!
234
235 mut db := orm.new_db(raw_db, orm.DataScope{
236 filters: [
237 orm.QueryFilter{
238 field: 'org_id'
239 value: orm.Primitive(1)
240 mode: .dynamic
241 },
242 orm.QueryFilter{
243 field: 'deleted'
244 value: orm.Primitive(false)
245 mode: .dynamic
246 },
247 ]
248 })
249
250 // Both scope filters apply - only org_id=1 AND deleted=false
251 users_filtered := sql db {
252 select from NoScopeUserMulti
253 }!
254 assert users_filtered.len == 1
255 assert users_filtered[0].name == 'A'
256
257 // Skip only 'org_id' - 'deleted' filter still applies
258 unscoped_db := db.unscoped('org_id')
259 users := sql unscoped_db {
260 select from NoScopeUserMulti order by name
261 }!
262 // Should match deleted=false regardless of org_id
263 assert users.len == 2
264 assert users[0].name == 'A'
265 assert users[1].name == 'C'
266}
267
268fn test_unscoped_skips_tenant_in_insert() {
269 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
270
271 sql raw_db {
272 create table NoScopeUser
273 }!
274
275 mut db := orm.new_db(raw_db, orm.DataScope{
276 filters: [
277 orm.QueryFilter{
278 field: 'tenant_id'
279 value: orm.Primitive(99)
280 mode: .dynamic
281 },
282 ]
283 })
284
285 // With db.unscoped('tenant_id') - scope does NOT inject tenant_id=99
286 alice := NoScopeUser{
287 name: 'Alice'
288 }
289 unscoped_db := db.unscoped('tenant_id')
290 sql unscoped_db {
291 insert alice into NoScopeUser
292 }!
293
294 // Alice was inserted with tenant_id=0 (no auto-inject), so scope won't find her
295 users := sql db {
296 select from NoScopeUser
297 }!
298 assert users.len == 0
299}
300
301fn test_data_scope_insert_overrides_default_scope_field() {
302 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
303
304 sql raw_db {
305 create table NoScopeUser
306 }!
307
308 mut db := orm.new_db(raw_db, orm.DataScope{
309 filters: [
310 orm.QueryFilter{
311 field: 'tenant_id'
312 value: orm.Primitive(99)
313 mode: .dynamic
314 },
315 ]
316 })
317
318 alice := NoScopeUser{
319 name: 'Alice'
320 }
321 sql db {
322 insert alice into NoScopeUser
323 }!
324
325 users := sql db {
326 select from NoScopeUser
327 }!
328 assert users.len == 1
329 assert users[0].name == 'Alice'
330 assert users[0].tenant_id == 99
331}
332
333fn test_unscoped_skip_all_in_insert() {
334 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
335
336 sql raw_db {
337 create table NoScopeUser
338 }!
339
340 mut db := orm.new_db(raw_db, orm.DataScope{
341 filters: [
342 orm.QueryFilter{
343 field: 'tenant_id'
344 value: orm.Primitive(99)
345 mode: .dynamic
346 },
347 ]
348 })
349
350 bob := NoScopeUser{
351 name: 'Bob'
352 }
353
354 // db.unscoped() skips ALL scope field injection in insert
355 unscoped_db := db.unscoped()
356 sql unscoped_db {
357 insert bob into NoScopeUser
358 }!
359
360 // Bob was inserted with tenant_id=0 (not auto-injected), not visible under scope
361 users := sql db {
362 select from NoScopeUser
363 }!
364 assert users.len == 0
365}
366
367fn test_unscoped_skips_tenant_in_update() {
368 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
369
370 sql raw_db {
371 create table NoScopeUser
372 }!
373
374 alice := NoScopeUser{
375 name: 'Alice'
376 tenant_id: 1
377 }
378 bob := NoScopeUser{
379 name: 'Bob'
380 tenant_id: 2
381 }
382
383 sql raw_db {
384 insert alice into NoScopeUser
385 insert bob into NoScopeUser
386 }!
387
388 mut db := orm.new_db(raw_db, orm.DataScope{
389 filters: [
390 orm.QueryFilter{
391 field: 'tenant_id'
392 value: orm.Primitive(1)
393 mode: .dynamic
394 },
395 ]
396 })
397
398 // With scope tenant_id=1, update where name='Bob' normally won't match
399 // because scope ANDs tenant_id=1, making it (name='Bob' AND tenant_id=1)
400 sql db {
401 update NoScopeUser set name = 'UpdatedByScope' where name == 'Bob'
402 }!
403
404 // Bob (tenant_id=2) should NOT have been updated
405 bob_check := sql raw_db {
406 select from NoScopeUser where name == 'Bob' && tenant_id == 2
407 }!
408 assert bob_check.len == 1
409 assert bob_check[0].name == 'Bob'
410
411 // With db.unscoped('tenant_id'), the scope filter is skipped in the WHERE,
412 // so name='Bob' matches regardless of tenant_id
413 unscoped_db := db.unscoped('tenant_id')
414 sql unscoped_db {
415 update NoScopeUser set name = 'UpdatedByNoScope' where name == 'Bob'
416 }!
417
418 bob_updated := sql raw_db {
419 select from NoScopeUser where tenant_id == 2
420 }!
421 assert bob_updated.len == 1
422 assert bob_updated[0].name == 'UpdatedByNoScope'
423}
424
425fn test_unscoped_skip_all_in_update() {
426 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
427
428 sql raw_db {
429 create table NoScopeUser
430 }!
431
432 alice := NoScopeUser{
433 name: 'Alice'
434 tenant_id: 1
435 }
436 bob := NoScopeUser{
437 name: 'Bob'
438 tenant_id: 2
439 }
440
441 sql raw_db {
442 insert alice into NoScopeUser
443 insert bob into NoScopeUser
444 }!
445
446 mut db := orm.new_db(raw_db, orm.DataScope{
447 filters: [
448 orm.QueryFilter{
449 field: 'tenant_id'
450 value: orm.Primitive(1)
451 mode: .dynamic
452 },
453 orm.QueryFilter{
454 field: 'name'
455 value: orm.Primitive('Alice')
456 mode: .dynamic
457 },
458 ]
459 })
460
461 // db.unscoped() skips ALL scope filters in the WHERE clause
462 // Without unscoped, the WHERE would include both tenant_id=1 AND name='Alice',
463 // making WHERE name='Bob' become (name='Bob' AND tenant_id=1 AND name='Alice'), which
464 // would not match anything due to the name conflict.
465 // With unscoped(), no scope filters are added, so name='Bob' matches directly.
466 unscoped_db := db.unscoped()
467 sql unscoped_db {
468 update NoScopeUser set name = 'UpdatedAll' where name == 'Bob'
469 }!
470
471 bob_updated := sql raw_db {
472 select from NoScopeUser where tenant_id == 2
473 }!
474 assert bob_updated.len == 1
475 assert bob_updated[0].name == 'UpdatedAll'
476}
477
478fn test_unscoped_skips_tenant_in_delete() {
479 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
480
481 sql raw_db {
482 create table NoScopeUser
483 }!
484
485 alice := NoScopeUser{
486 name: 'Alice'
487 tenant_id: 1
488 }
489 bob := NoScopeUser{
490 name: 'Bob'
491 tenant_id: 2
492 }
493
494 sql raw_db {
495 insert alice into NoScopeUser
496 insert bob into NoScopeUser
497 }!
498
499 mut db := orm.new_db(raw_db, orm.DataScope{
500 filters: [
501 orm.QueryFilter{
502 field: 'tenant_id'
503 value: orm.Primitive(1)
504 mode: .dynamic
505 },
506 ]
507 })
508
509 // With scope tenant_id=1, delete where name='Bob' won't match
510 sql db {
511 delete from NoScopeUser where name == 'Bob'
512 }!
513
514 // Bob should still exist (scope prevented deletion)
515 bob_check := sql raw_db {
516 select from NoScopeUser where tenant_id == 2
517 }!
518 assert bob_check.len == 1
519
520 // With db.unscoped('tenant_id'), scope filter skipped, Bob gets deleted
521 unscoped_db := db.unscoped('tenant_id')
522 sql unscoped_db {
523 delete from NoScopeUser where name == 'Bob'
524 }!
525
526 bob_gone := sql raw_db {
527 select from NoScopeUser where tenant_id == 2
528 }!
529 assert bob_gone.len == 0
530}
531
532fn test_unscoped_skip_all_in_delete() {
533 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
534
535 sql raw_db {
536 create table NoScopeUser
537 }!
538
539 alice := NoScopeUser{
540 name: 'Alice'
541 tenant_id: 1
542 }
543 bob := NoScopeUser{
544 name: 'Bob'
545 tenant_id: 2
546 }
547
548 sql raw_db {
549 insert alice into NoScopeUser
550 insert bob into NoScopeUser
551 }!
552
553 mut db := orm.new_db(raw_db, orm.DataScope{
554 filters: [
555 orm.QueryFilter{
556 field: 'tenant_id'
557 value: orm.Primitive(1)
558 mode: .dynamic
559 },
560 orm.QueryFilter{
561 field: 'name'
562 value: orm.Primitive('Alice')
563 mode: .dynamic
564 },
565 ]
566 })
567
568 // db.unscoped() skips ALL scope filters
569 // Without it, delete where name='Bob' becomes (name='Bob' AND tenant_id=1 AND name='Alice')
570 // which can't match (Bob != Alice).
571 // With unscoped(), delete where name='Bob' matches directly.
572 unscoped_db := db.unscoped()
573 sql unscoped_db {
574 delete from NoScopeUser where name == 'Bob'
575 }!
576
577 bob_gone := sql raw_db {
578 select from NoScopeUser where tenant_id == 2
579 }!
580 assert bob_gone.len == 0
581}
582
583fn test_unscoped_skip_multi_field_select() {
584 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
585
586 sql raw_db {
587 create table ScopeUser
588 }!
589
590 alice := ScopeUser{
591 name: 'Alice'
592 tenant_id: 1
593 shop_id: 1
594 }
595 bob := ScopeUser{
596 name: 'Bob'
597 tenant_id: 2
598 shop_id: 1
599 }
600 carol := ScopeUser{
601 name: 'Carol'
602 tenant_id: 2
603 shop_id: 2
604 }
605
606 sql raw_db {
607 insert alice into ScopeUser
608 insert bob into ScopeUser
609 insert carol into ScopeUser
610 }!
611
612 // Scope filters: tenant_id=1 AND shop_id=1 (only Alice matches)
613 db := orm.new_db(raw_db, orm.DataScope{
614 filters: [
615 orm.QueryFilter{
616 field: 'tenant_id'
617 value: orm.Primitive(1)
618 mode: .dynamic
619 },
620 orm.QueryFilter{
621 field: 'shop_id'
622 value: orm.Primitive(1)
623 mode: .dynamic
624 },
625 ]
626 })
627
628 // Without unscoped - both filters apply, only Alice
629 users_filtered := sql db {
630 select from ScopeUser
631 }!
632 assert users_filtered.len == 1
633 assert users_filtered[0].name == 'Alice'
634
635 // Skip both tenant_id and shop_id - all users visible
636 unscoped_db := db.unscoped('tenant_id', 'shop_id')
637 users_all := sql unscoped_db {
638 select from ScopeUser
639 }!
640 assert users_all.len == 3
641}
642
643fn test_data_scope_filter_on_embedded_field() {
644 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
645
646 sql raw_db {
647 create table ScopeEmbeddedLocation
648 }!
649
650 loc1 := ScopeEmbeddedLocation{
651 name: 'North'
652 latitude: 10.5
653 longitude: 20.0
654 }
655 loc2 := ScopeEmbeddedLocation{
656 name: 'South'
657 latitude: -3.25
658 longitude: 30.0
659 }
660
661 sql raw_db {
662 insert loc1 into ScopeEmbeddedLocation
663 insert loc2 into ScopeEmbeddedLocation
664 }!
665
666 mut db := orm.new_db(raw_db, orm.DataScope{
667 filters: [
668 orm.QueryFilter{
669 field: 'ScopeCoordinates.latitude'
670 value: orm.Primitive(f64(10.5))
671 mode: .dynamic
672 },
673 ]
674 })
675
676 locations := sql db {
677 select from ScopeEmbeddedLocation
678 }!
679 assert locations.len == 1
680 assert locations[0].name == 'North'
681 assert locations[0].latitude == 10.5
682}
683
684// ---- DataScope tests -------------------------------------------------
685
686fn empty_scope() orm.DataScope {
687 return orm.DataScope{}
688}
689
690fn scope_single_tenant(tenant_id int) orm.DataScope {
691 return orm.DataScope{
692 filters: [
693 orm.QueryFilter{
694 field: 'tenant_id'
695 value: orm.Primitive(int(tenant_id))
696 operator: .eq
697 mode: .dynamic
698 },
699 ]
700 }
701}
702
703fn scope_disabled() orm.DataScope {
704 return orm.DataScope{
705 enabled: false
706 filters: [
707 orm.QueryFilter{
708 field: 'tenant_id'
709 value: orm.Primitive(int(1))
710 operator: .eq
711 mode: .dynamic
712 },
713 ]
714 }
715}
716
717fn test_apply_data_scope_single_filter() {
718 scope := scope_single_tenant(5)
719 where := orm.QueryData{}
720 table := orm.Table{
721 name: 'users'
722 }
723 result := orm.apply_data_scope(scope, table, where, [], false)!
724 assert result.fields == ['tenant_id']
725 assert result.data == [orm.Primitive(int(5))]
726 assert result.kinds == [.eq]
727}
728
729fn test_apply_data_scope_appends_to_existing_where() {
730 scope := scope_single_tenant(42)
731 where := orm.QueryData{
732 fields: ['id']
733 data: [orm.Primitive(int(1))]
734 kinds: [.eq]
735 }
736 table := orm.Table{
737 name: 'users'
738 }
739 result := orm.apply_data_scope(scope, table, where, [], false)!
740 assert result.fields == ['id', 'tenant_id']
741 assert result.data == [orm.Primitive(int(1)), orm.Primitive(int(42))]
742 assert result.kinds == [.eq, .eq]
743 assert result.is_and == [true]
744}
745
746fn test_apply_data_scope_appends_even_when_field_exists() {
747 // Scope filter is always appended as an additional AND condition,
748 // even when the field already exists in the user's WHERE clause.
749 // This prevents bypassing tenant isolation.
750 scope := scope_single_tenant(5)
751 where := orm.QueryData{
752 fields: ['tenant_id']
753 data: [orm.Primitive(int(10))]
754 kinds: [.eq]
755 }
756 table := orm.Table{
757 name: 'users'
758 }
759 result := orm.apply_data_scope(scope, table, where, [], false)!
760 assert result.fields == ['tenant_id', 'tenant_id']
761 assert result.data == [orm.Primitive(int(10)), orm.Primitive(int(5))]
762}
763
764fn test_apply_data_scope_empty_or_disabled() {
765 where := orm.QueryData{
766 fields: ['id']
767 data: [orm.Primitive(int(1))]
768 kinds: [.eq]
769 }
770 table := orm.Table{
771 name: 'users'
772 }
773 empty_result := orm.apply_data_scope(empty_scope(), table, where, [], false)!
774 assert empty_result.fields == ['id']
775 disabled_result := orm.apply_data_scope(scope_disabled(), table, where, [], false)!
776 assert disabled_result.fields == ['id']
777}
778
779fn test_apply_data_scope_multi_field() {
780 scope := orm.DataScope{
781 filters: [
782 orm.QueryFilter{
783 field: 'org_id'
784 value: orm.Primitive(int(1))
785 operator: .eq
786 mode: .dynamic
787 },
788 orm.QueryFilter{
789 field: 'deleted'
790 value: orm.Primitive(false)
791 operator: .eq
792 mode: .dynamic
793 },
794 ]
795 }
796 where := orm.QueryData{}
797 table := orm.Table{
798 name: 'users'
799 }
800 result := orm.apply_data_scope(scope, table, where, [], false)!
801 assert result.fields == ['org_id', 'deleted']
802 assert result.data == [orm.Primitive(int(1)), orm.Primitive(false)]
803 assert result.kinds == [.eq, .eq]
804}
805
806fn test_query_filter_mode_must_be_explicit() {
807 filter := orm.QueryFilter{
808 field: 'tenant_id'
809 value: orm.Primitive(int(5))
810 }
811 // mode defaults to .unset (the zero value); applying it must error
812 assert filter.mode == .unset
813 scope := orm.DataScope{
814 filters: [filter]
815 }
816 orm.apply_data_scope(scope, orm.Table{ name: 't' }, orm.QueryData{}, [], false) or {
817 assert err.msg().contains('must be explicitly set')
818 return
819 }
820 assert false
821}
822
823fn test_apply_data_scope_applies_only_dynamic_filters() {
824 scope := orm.DataScope{
825 filters: [
826 orm.QueryFilter{
827 field: 'tenant_id'
828 value: orm.Primitive(int(5))
829 mode: .static
830 },
831 orm.QueryFilter{
832 field: 'shop_id'
833 value: orm.Primitive(int(10))
834 mode: .dynamic
835 },
836 ]
837 }
838 where := orm.QueryData{}
839 table := orm.Table{
840 name: 'users'
841 fields: ['tenant_id', 'shop_id']
842 }
843 result := orm.apply_data_scope(scope, table, where, [], false)!
844 assert scope.filters[0].mode == .static
845 assert scope.filters[1].mode == .dynamic
846 assert result.fields == ['shop_id']
847 assert result.data == [orm.Primitive(int(10))]
848 assert result.kinds == [.eq]
849}
850
851fn test_apply_data_scope_wraps_parentheses() {
852 scope := scope_single_tenant(42)
853 where := orm.QueryData{
854 fields: ['a', 'b']
855 kinds: [.eq, .eq]
856 is_and: [false]
857 }
858 table := orm.Table{
859 name: 'users'
860 }
861 result := orm.apply_data_scope(scope, table, where, [], false)!
862 assert result.fields == ['a', 'b', 'tenant_id']
863 assert result.is_and == [false, true]
864 assert result.parentheses.len == 1
865 assert result.parentheses[0] == [0, 1]
866}
867
868fn test_apply_data_scope_with_unary_operator() {
869 // Unary operator (is_null) with existing WHERE — verifies is_and marker is appended
870 scope := orm.DataScope{
871 filters: [
872 orm.QueryFilter{
873 field: 'deleted_at'
874 operator: .is_null
875 mode: .dynamic
876 },
877 ]
878 }
879 where := orm.QueryData{
880 fields: ['tenant_id']
881 data: [orm.Primitive(int(5))]
882 kinds: [.eq]
883 }
884 table := orm.Table{
885 name: 'users'
886 fields: ['tenant_id', 'deleted_at']
887 }
888 result := orm.apply_data_scope(scope, table, where, [], false)!
889 assert result.fields == ['tenant_id', 'deleted_at']
890 assert result.kinds == [.eq, .is_null]
891 assert result.is_and == [true]
892 // Unary operators don't add data values
893 assert result.data == [orm.Primitive(int(5))]
894}
895
896fn test_apply_data_scope_rejects_invalid_filter_values() {
897 scope := orm.DataScope{
898 filters: [
899 orm.QueryFilter{
900 field: 'tenant_id'
901 value: orm.Primitive([1, 2])
902 mode: .dynamic
903 },
904 ]
905 }
906 where := orm.QueryData{}
907 table := orm.Table{
908 name: 'users'
909 fields: ['tenant_id']
910 }
911 orm.apply_data_scope(scope, table, where, [], false) or {
912 assert err.msg().contains('requires a scalar value')
913 return
914 }
915 assert false
916}
917
918fn test_apply_data_scope_rejects_scalar_value_for_in_operator() {
919 scope := orm.DataScope{
920 filters: [
921 orm.QueryFilter{
922 field: 'org_id'
923 value: orm.Primitive(1)
924 operator: .in
925 mode: .dynamic
926 },
927 ]
928 }
929 where := orm.QueryData{}
930 table := orm.Table{
931 name: 'users'
932 fields: ['org_id']
933 }
934 orm.apply_data_scope(scope, table, where, [], false) or {
935 assert err.msg().contains('requires a non-empty array value')
936 return
937 }
938 assert false
939}
940
941fn test_apply_data_scope_rejects_empty_array_for_in_operator() {
942 empty_ids := []int{}
943 scope := orm.DataScope{
944 filters: [
945 orm.QueryFilter{
946 field: 'region_id'
947 value: orm.Primitive(empty_ids)
948 operator: .in
949 mode: .dynamic
950 },
951 ]
952 }
953 where := orm.QueryData{}
954 table := orm.Table{
955 name: 'users'
956 fields: ['region_id']
957 }
958 orm.apply_data_scope(scope, table, where, [], false) or {
959 assert err.msg().contains('requires a non-empty array value')
960 return
961 }
962 assert false
963}
964
965fn test_apply_data_scope_accepts_non_empty_array_for_in_operator() {
966 scope := orm.DataScope{
967 filters: [
968 orm.QueryFilter{
969 field: 'shop_id'
970 value: orm.Primitive([3, 4])
971 operator: .in
972 mode: .dynamic
973 },
974 ]
975 }
976 where := orm.QueryData{}
977 table := orm.Table{
978 name: 'users'
979 fields: ['shop_id']
980 }
981 result := orm.apply_data_scope(scope, table, where, [], false)!
982 assert result.fields == ['shop_id']
983 assert result.kinds == [.in]
984 assert result.data == [orm.Primitive([3, 4])]
985}
986
987fn test_apply_data_scope_insert_adds_fields() {
988 scope := scope_single_tenant(99)
989 data := orm.QueryData{
990 fields: ['name']
991 data: [orm.Primitive('alice')]
992 }
993 table := orm.Table{
994 name: 'users'
995 }
996 result := orm.apply_data_scope_insert(scope, table, data, [])!
997 assert result.fields == ['name', 'tenant_id']
998 assert result.data == [orm.Primitive('alice'), orm.Primitive(int(99))]
999}
1000
1001fn test_apply_data_scope_insert_skips_unary_operator() {
1002 scope := orm.DataScope{
1003 filters: [
1004 orm.QueryFilter{
1005 field: 'tenant_id'
1006 value: orm.Primitive(int(99))
1007 mode: .dynamic
1008 },
1009 orm.QueryFilter{
1010 field: 'deleted_at'
1011 operator: .is_null
1012 mode: .dynamic
1013 },
1014 ]
1015 }
1016 data := orm.QueryData{
1017 fields: ['name']
1018 data: [orm.Primitive('alice')]
1019 }
1020 table := orm.Table{
1021 name: 'users'
1022 fields: ['name', 'tenant_id', 'deleted_at']
1023 }
1024 result := orm.apply_data_scope_insert(scope, table, data, [])!
1025 assert result.fields == ['name', 'tenant_id']
1026 assert result.data == [orm.Primitive('alice'), orm.Primitive(int(99))]
1027}
1028
1029fn test_apply_data_scope_insert_rejects_non_equality_operator() {
1030 scope := orm.DataScope{
1031 filters: [
1032 orm.QueryFilter{
1033 field: 'tenant_id'
1034 value: orm.Primitive([orm.Primitive(1), orm.Primitive(2)])
1035 operator: .in
1036 mode: .dynamic
1037 },
1038 orm.QueryFilter{
1039 field: 'shop_id'
1040 value: orm.Primitive(int(9))
1041 mode: .dynamic
1042 },
1043 ]
1044 }
1045 data := orm.QueryData{
1046 fields: ['name']
1047 data: [orm.Primitive('alice')]
1048 }
1049 table := orm.Table{
1050 name: 'users'
1051 fields: ['name', 'tenant_id', 'shop_id']
1052 }
1053 orm.apply_data_scope_insert(scope, table, data, []) or {
1054 assert err.msg().contains('cannot be applied to INSERT')
1055 return
1056 }
1057 assert false
1058}
1059
1060fn test_apply_data_scope_insert_rejects_array_equality_value() {
1061 scope := orm.DataScope{
1062 filters: [
1063 orm.QueryFilter{
1064 field: 'tenant_id'
1065 value: orm.Primitive([orm.Primitive(1), orm.Primitive(2)])
1066 mode: .dynamic
1067 },
1068 orm.QueryFilter{
1069 field: 'shop_id'
1070 value: orm.Primitive(int(9))
1071 mode: .dynamic
1072 },
1073 ]
1074 }
1075 data := orm.QueryData{
1076 fields: ['name']
1077 data: [orm.Primitive('alice')]
1078 }
1079 table := orm.Table{
1080 name: 'users'
1081 fields: ['name', 'tenant_id', 'shop_id']
1082 }
1083 orm.apply_data_scope_insert(scope, table, data, []) or {
1084 assert err.msg().contains('requires a scalar value')
1085 return
1086 }
1087 assert false
1088}
1089
1090fn test_apply_data_scope_insert_overrides_existing_scope_field() {
1091 scope := scope_single_tenant(99)
1092 data := orm.QueryData{
1093 fields: ['tenant_id', 'name']
1094 data: [orm.Primitive(int(7)), orm.Primitive('bob')]
1095 }
1096 table := orm.Table{
1097 name: 'users'
1098 }
1099 result := orm.apply_data_scope_insert(scope, table, data, [])!
1100 assert result.fields == ['tenant_id', 'name']
1101 assert result.data == [orm.Primitive(int(99)), orm.Primitive('bob')]
1102}
1103
1104fn test_apply_data_scope_insert_overrides_existing_scope_field_in_batch() {
1105 scope := scope_single_tenant(99)
1106 data := orm.QueryData{
1107 fields: ['name', 'tenant_id']
1108 data: [orm.Primitive('alice'), orm.Primitive(int(0)), orm.Primitive('bob'),
1109 orm.Primitive(int(7))]
1110 batch_rows: 2
1111 }
1112 table := orm.Table{
1113 name: 'users'
1114 }
1115 result := orm.apply_data_scope_insert(scope, table, data, [])!
1116 assert result.fields == ['name', 'tenant_id']
1117 assert result.data == [orm.Primitive('alice'), orm.Primitive(int(99)), orm.Primitive('bob'),
1118 orm.Primitive(int(99))]
1119}
1120
1121fn test_apply_data_scope_insert_empty_or_disabled() {
1122 data := orm.QueryData{
1123 fields: ['name']
1124 data: [orm.Primitive('alice')]
1125 }
1126 table := orm.Table{
1127 name: 'users'
1128 }
1129 empty_result := orm.apply_data_scope_insert(empty_scope(), table, data, [])!
1130 assert empty_result.fields == ['name']
1131 disabled_result := orm.apply_data_scope_insert(scope_disabled(), table, data, [])!
1132 assert disabled_result.fields == ['name']
1133}
1134
1135// ---- scope_skip_fields unit tests ----------------------------------------
1136
1137fn test_apply_data_scope_skip_single_field() {
1138 scope := scope_single_tenant(5)
1139 where := orm.QueryData{}
1140 table := orm.Table{
1141 name: 'users'
1142 }
1143 // Skip 'tenant_id' - it should not be applied
1144 result := orm.apply_data_scope(scope, table, where, ['tenant_id'], false)!
1145 assert result.fields == []
1146 assert result.data == []
1147}
1148
1149fn test_apply_data_scope_skip_field_still_applies_others() {
1150 scope := orm.DataScope{
1151 filters: [
1152 orm.QueryFilter{
1153 field: 'org_id'
1154 value: orm.Primitive(int(1))
1155 operator: .eq
1156 mode: .dynamic
1157 },
1158 orm.QueryFilter{
1159 field: 'deleted'
1160 value: orm.Primitive(false)
1161 operator: .eq
1162 mode: .dynamic
1163 },
1164 ]
1165 }
1166 where := orm.QueryData{}
1167 table := orm.Table{
1168 name: 'users'
1169 }
1170 // Skip only 'org_id', 'deleted' should still be applied
1171 result := orm.apply_data_scope(scope, table, where, ['org_id'], false)!
1172 assert result.fields == ['deleted']
1173 assert result.data == [orm.Primitive(false)]
1174}
1175
1176fn test_apply_data_scope_skip_non_existent_field() {
1177 scope := scope_single_tenant(5)
1178 where := orm.QueryData{}
1179 table := orm.Table{
1180 name: 'users'
1181 }
1182 // Skip a non-existent field - all filters should still be applied
1183 result := orm.apply_data_scope(scope, table, where, ['nonexistent'], false)!
1184 assert result.fields == ['tenant_id']
1185 assert result.data == [orm.Primitive(int(5))]
1186}
1187
1188fn test_apply_data_scope_insert_skip_single_field() {
1189 scope := scope_single_tenant(99)
1190 data := orm.QueryData{
1191 fields: ['name']
1192 data: [orm.Primitive('alice')]
1193 }
1194 table := orm.Table{
1195 name: 'users'
1196 }
1197 // Skip 'tenant_id' in insert - should not inject it
1198 result := orm.apply_data_scope_insert(scope, table, data, ['tenant_id'])!
1199 assert result.fields == ['name']
1200 assert result.data == [orm.Primitive('alice')]
1201}
1202
1203// ---- Middleware pattern tests: db configured per-request, business code is scope-unaware ----
1204
1205// Simulates a request context with a per-request db.
1206// In a real middleware, the db would be configured once at request entry,
1207// and all subsequent handlers use ctx.db without knowing about scopes.
1208struct RequestCtx {
1209mut:
1210 db orm.DB
1211}
1212
1213fn test_middleware_admin_skips_all_scopes() {
1214 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
1215
1216 sql raw_db {
1217 create table NoScopeUser
1218 }!
1219
1220 alice := NoScopeUser{
1221 name: 'Alice'
1222 tenant_id: 1
1223 }
1224 bob := NoScopeUser{
1225 name: 'Bob'
1226 tenant_id: 2
1227 }
1228
1229 sql raw_db {
1230 insert alice into NoScopeUser
1231 insert bob into NoScopeUser
1232 }!
1233
1234 base_db := orm.new_db(raw_db, orm.DataScope{
1235 filters: [
1236 orm.QueryFilter{
1237 field: 'tenant_id'
1238 value: orm.Primitive(1)
1239 mode: .dynamic
1240 },
1241 ]
1242 })
1243
1244 // --- Middleware: on request entry, configure per-request db by role ---
1245 // Admin role: skip all scopes
1246 mut ctx := RequestCtx{
1247 db: base_db.unscoped()
1248 }
1249 // --- End middleware ---
1250
1251 // --- Business handler: just extract db from ctx, use it like always ---
1252 db := ctx.db
1253 users := sql db {
1254 select from NoScopeUser
1255 }!
1256 // Admin sees all users because middleware configured no scopes
1257 assert users.len == 2
1258
1259 users2 := sql db {
1260 select from NoScopeUser
1261 }!
1262 // Second query also sees all - middleware config persists
1263 assert users2.len == 2
1264}
1265
1266fn test_middleware_manager_skips_specific_scope() {
1267 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
1268
1269 sql raw_db {
1270 create table NoScopeUser
1271 }!
1272
1273 alice := NoScopeUser{
1274 name: 'Alice'
1275 tenant_id: 1
1276 }
1277 bob := NoScopeUser{
1278 name: 'Bob'
1279 tenant_id: 2
1280 }
1281
1282 sql raw_db {
1283 insert alice into NoScopeUser
1284 insert bob into NoScopeUser
1285 }!
1286
1287 base_db := orm.new_db(raw_db, orm.DataScope{
1288 filters: [
1289 orm.QueryFilter{
1290 field: 'tenant_id'
1291 value: orm.Primitive(1)
1292 mode: .dynamic
1293 },
1294 ]
1295 })
1296
1297 // --- Middleware: manager skips tenant_id filter ---
1298 mut ctx := RequestCtx{
1299 db: base_db.unscoped('tenant_id')
1300 }
1301
1302 // --- Business handler: just extract db from ctx ---
1303 db := ctx.db
1304 users := sql db {
1305 select from NoScopeUser
1306 }!
1307 // Manager sees all because tenant_id scope is skipped
1308 assert users.len == 2
1309}
1310
1311fn test_middleware_normal_user_has_full_scopes() {
1312 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
1313
1314 sql raw_db {
1315 create table NoScopeUser
1316 }!
1317
1318 alice := NoScopeUser{
1319 name: 'Alice'
1320 tenant_id: 1
1321 }
1322 bob := NoScopeUser{
1323 name: 'Bob'
1324 tenant_id: 2
1325 }
1326
1327 sql raw_db {
1328 insert alice into NoScopeUser
1329 insert bob into NoScopeUser
1330 }!
1331
1332 base_db := orm.new_db(raw_db, orm.DataScope{
1333 filters: [
1334 orm.QueryFilter{
1335 field: 'tenant_id'
1336 value: orm.Primitive(1)
1337 mode: .dynamic
1338 },
1339 ]
1340 })
1341
1342 // --- Middleware: normal user, no scope skipping ---
1343 mut ctx := RequestCtx{
1344 db: base_db
1345 }
1346
1347 // --- Business handler: just extract db from ctx ---
1348 db := ctx.db
1349 users := sql db {
1350 select from NoScopeUser
1351 }!
1352 // Normal user sees only Alice (tenant_id=1) - scope is fully applied
1353 assert users.len == 1
1354 assert users[0].name == 'Alice'
1355}
1356
1357fn test_middleware_mixed_roles_produce_isolated_results() {
1358 // Simulates multiple concurrent requests with different role configurations.
1359 // Each request has its own ctx.db, so they don't interfere.
1360 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
1361
1362 sql raw_db {
1363 create table NoScopeUser
1364 }!
1365
1366 alice := NoScopeUser{
1367 name: 'Alice'
1368 tenant_id: 1
1369 }
1370 bob := NoScopeUser{
1371 name: 'Bob'
1372 tenant_id: 2
1373 }
1374
1375 sql raw_db {
1376 insert alice into NoScopeUser
1377 insert bob into NoScopeUser
1378 }!
1379
1380 base_db := orm.new_db(raw_db, orm.DataScope{
1381 filters: [
1382 orm.QueryFilter{
1383 field: 'tenant_id'
1384 value: orm.Primitive(1)
1385 mode: .dynamic
1386 },
1387 ]
1388 })
1389
1390 // Request 1: admin - no scopes
1391 mut admin_ctx := RequestCtx{
1392 db: base_db.unscoped()
1393 }
1394 // Request 2: normal user - full scopes
1395 mut normal_ctx := RequestCtx{
1396 db: base_db
1397 }
1398
1399 // Both "handlers" execute with their own ctx.db - results are isolated
1400 db := admin_ctx.db
1401 admin_users := sql db {
1402 select from NoScopeUser
1403 }!
1404 assert admin_users.len == 2 // admin sees all
1405
1406 normal_db := normal_ctx.db
1407 normal_users := sql normal_db {
1408 select from NoScopeUser
1409 }!
1410 assert normal_users.len == 1 // normal user scoped
1411 assert normal_users[0].name == 'Alice'
1412
1413 // Admin still sees all - persistent on per-request db
1414 admin_users2 := sql db {
1415 select from NoScopeUser
1416 }!
1417 assert admin_users2.len == 2
1418}
1419
1420fn test_middleware_ignores_scope_affects_all_crud_operations() {
1421 // Verifies that middleware-configured db.unscoped() works
1422 // for all CRUD operations without business code awareness.
1423 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
1424
1425 sql raw_db {
1426 create table NoScopeUser
1427 }!
1428
1429 alice := NoScopeUser{
1430 name: 'Alice'
1431 tenant_id: 1
1432 }
1433 bob := NoScopeUser{
1434 name: 'Bob'
1435 tenant_id: 2
1436 }
1437
1438 sql raw_db {
1439 insert alice into NoScopeUser
1440 insert bob into NoScopeUser
1441 }!
1442
1443 base_db := orm.new_db(raw_db, orm.DataScope{
1444 filters: [
1445 orm.QueryFilter{
1446 field: 'tenant_id'
1447 value: orm.Primitive(1)
1448 mode: .dynamic
1449 },
1450 ]
1451 })
1452
1453 // Middleware configures admin db at request entry
1454 mut ctx := RequestCtx{
1455 db: base_db.unscoped('tenant_id')
1456 }
1457
1458 // Business handler: just extract db from ctx, use it like always
1459 db := ctx.db
1460
1461 // UPDATE, SELECT, DELETE are all scope-unaware
1462 sql db {
1463 update NoScopeUser set name = 'AdminUpdated' where name == 'Bob'
1464 }!
1465
1466 bob_updated := sql raw_db {
1467 select from NoScopeUser where tenant_id == 2
1468 }!
1469 assert bob_updated.len == 1
1470 assert bob_updated[0].name == 'AdminUpdated'
1471
1472 // SELECT
1473 users := sql db {
1474 select from NoScopeUser
1475 }!
1476 assert users.len == 2
1477
1478 // DELETE: also scope-unaware
1479 sql db {
1480 delete from NoScopeUser where name == 'Alice'
1481 }!
1482
1483 alice_gone := sql raw_db {
1484 select from NoScopeUser where name == 'Alice'
1485 }!
1486 assert alice_gone.len == 0
1487}
1488
1489// ---- Transaction proxy tests ----
1490// Verify orm.DB delegates orm_begin / orm_commit / orm_rollback / orm_savepoint
1491// to the underlying TransactionalConnection, so scoped DBs work in transactions.
1492
1493fn test_db_transaction_commit_through_proxy() {
1494 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
1495
1496 sql raw_db {
1497 create table NoScopeUser
1498 }!
1499
1500 mut db := orm.new_db(raw_db, orm.DataScope{
1501 filters: [
1502 orm.QueryFilter{
1503 field: 'tenant_id'
1504 value: orm.Primitive(1)
1505 mode: .dynamic
1506 },
1507 ]
1508 })
1509
1510 // begin via orm.DB proxy
1511 db.orm_begin()!
1512
1513 alice := NoScopeUser{
1514 name: 'Alice'
1515 tenant_id: 1
1516 }
1517 sql db {
1518 insert alice into NoScopeUser
1519 }!
1520
1521 // commit via proxy
1522 db.orm_commit()!
1523
1524 // verify persisted
1525 users := sql db {
1526 select from NoScopeUser
1527 }!
1528 assert users.len == 1
1529 assert users[0].name == 'Alice'
1530}
1531
1532fn test_db_transaction_rollback_through_proxy() {
1533 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
1534
1535 sql raw_db {
1536 create table NoScopeUser
1537 }!
1538
1539 mut db := orm.new_db(raw_db, orm.DataScope{
1540 filters: [
1541 orm.QueryFilter{
1542 field: 'tenant_id'
1543 value: orm.Primitive(1)
1544 mode: .dynamic
1545 },
1546 ]
1547 })
1548
1549 db.orm_begin()!
1550
1551 alice := NoScopeUser{
1552 name: 'Alice'
1553 tenant_id: 1
1554 }
1555 sql db {
1556 insert alice into NoScopeUser
1557 }!
1558
1559 // rollback via proxy — inserted row should vanish
1560 db.orm_rollback()!
1561
1562 users := sql db {
1563 select from NoScopeUser
1564 }!
1565 assert users.len == 0
1566}
1567
1568fn test_db_transaction_with_data_scope() {
1569 // Scope auto-injects tenant_id=1. Inside a transaction, same scope applies.
1570 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
1571
1572 sql raw_db {
1573 create table NoScopeUser
1574 }!
1575
1576 mut db := orm.new_db(raw_db, orm.DataScope{
1577 filters: [
1578 orm.QueryFilter{
1579 field: 'tenant_id'
1580 value: orm.Primitive(1)
1581 mode: .dynamic
1582 },
1583 ]
1584 })
1585
1586 // Transaction with scope-active DB
1587 db.orm_begin()!
1588
1589 alice := NoScopeUser{
1590 name: 'Alice'
1591 tenant_id: 1 // matches scope
1592 }
1593 bob := NoScopeUser{
1594 name: 'Bob'
1595 tenant_id: 2 // should NOT be visible under scope
1596 }
1597 sql raw_db {
1598 insert alice into NoScopeUser
1599 insert bob into NoScopeUser
1600 }!
1601
1602 db.orm_commit()!
1603
1604 // Scope should filter: only Alice visible
1605 users := sql db {
1606 select from NoScopeUser
1607 }!
1608 assert users.len == 1
1609 assert users[0].name == 'Alice'
1610}
1611
1612fn test_db_transaction_unscoped_in_transaction() {
1613 // unscoped DB in a transaction bypasses scope
1614 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
1615
1616 sql raw_db {
1617 create table NoScopeUser
1618 }!
1619
1620 mut db := orm.new_db(raw_db, orm.DataScope{
1621 filters: [
1622 orm.QueryFilter{
1623 field: 'tenant_id'
1624 value: orm.Primitive(1)
1625 mode: .dynamic
1626 },
1627 ]
1628 })
1629
1630 // unscoped before transaction
1631 mut unscoped_db := db.unscoped('tenant_id')
1632
1633 unscoped_db.orm_begin()!
1634
1635 bob := NoScopeUser{
1636 name: 'Bob'
1637 tenant_id: 2
1638 }
1639 sql unscoped_db {
1640 insert bob into NoScopeUser
1641 }!
1642
1643 unscoped_db.orm_commit()!
1644
1645 // Bob inserted with tenant_id=2
1646 // Original scoped db can't see him (tenant_id=1 scope)
1647 scoped_users := sql db {
1648 select from NoScopeUser
1649 }!
1650 assert scoped_users.len == 0
1651
1652 // But raw DB sees him
1653 raw_users := sql raw_db {
1654 select from NoScopeUser
1655 }!
1656 assert raw_users.len == 1
1657 assert raw_users[0].name == 'Bob'
1658}
1659
1660fn test_db_savepoint_and_rollback_to() {
1661 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
1662
1663 sql raw_db {
1664 create table NoScopeUser
1665 }!
1666
1667 mut db := orm.new_db(raw_db, orm.DataScope{})
1668
1669 db.orm_begin()!
1670
1671 // insert Alice
1672 alice := NoScopeUser{
1673 name: 'Alice'
1674 tenant_id: 1
1675 }
1676 sql db {
1677 insert alice into NoScopeUser
1678 }!
1679
1680 // create savepoint
1681 db.orm_savepoint('sp1')!
1682
1683 // insert Bob
1684 bob := NoScopeUser{
1685 name: 'Bob'
1686 tenant_id: 1
1687 }
1688 sql db {
1689 insert bob into NoScopeUser
1690 }!
1691
1692 // rollback to savepoint — Bob should vanish, Alice remains
1693 db.orm_rollback_to('sp1')!
1694
1695 // insert Carol
1696 carol := NoScopeUser{
1697 name: 'Carol'
1698 tenant_id: 1
1699 }
1700 sql db {
1701 insert carol into NoScopeUser
1702 }!
1703
1704 db.orm_release_savepoint('sp1')!
1705 db.orm_commit()!
1706
1707 users := sql db {
1708 select from NoScopeUser order by id
1709 }!
1710 assert users.len == 2
1711 assert users[0].name == 'Alice'
1712 assert users[1].name == 'Carol'
1713}
1714
1715fn test_db_satisfies_transactional_connection_interface() {
1716 // Compile-time and runtime verification: orm.DB satisfies orm.TransactionalConnection.
1717 // This function accepts a TransactionalConnection and exercises all its operations.
1718 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
1719
1720 sql raw_db {
1721 create table NoScopeUser
1722 }!
1723
1724 mut db := orm.new_db(raw_db, orm.DataScope{})
1725
1726 // Pass orm.DB as TransactionalConnection
1727 transactional_crud(mut db, raw_db) or { panic(err) }
1728
1729 // Verify data committed
1730 users := sql db {
1731 select from NoScopeUser where name == 'tx_test'
1732 }!
1733 assert users.len == 1
1734}
1735
1736// transactional_crud accepts a TransactionalConnection and verifies all
1737// transaction primitives compile and execute correctly.
1738fn transactional_crud(mut db orm.TransactionalConnection, raw_db &sqlite.DB) ! {
1739 db.orm_begin()!
1740
1741 u := NoScopeUser{
1742 name: 'tx_test'
1743 tenant_id: 7
1744 }
1745 // Use raw_db to bypass scope: we're testing the interface, not scope
1746 sql raw_db {
1747 insert u into NoScopeUser
1748 }!
1749
1750 db.orm_commit()!
1751}
1752
1753// ---- Batch insert scope tests ----
1754// Use a struct WITHOUT the scope fields — scope must inject them.
1755// This validates batch_rows > 1 correctly replicates scope values per row.
1756
1757@[table: 'batch_scope_rows']
1758struct BatchScopeRow {
1759 id int @[primary; sql: serial]
1760 name string
1761}
1762
1763// Regression struct: has tenant_id but NOT shop_id, used to trigger the
1764// batch insert overwrite-after-append bug scenario.
1765@[table: 'batch_overwrite_rows']
1766struct BatchOverwriteRow {
1767 id int @[primary; sql: serial]
1768 name string
1769 tenant_id int
1770}
1771
1772fn test_data_scope_batch_insert_replicates_scope_values() {
1773 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
1774
1775 sql raw_db {
1776 create table BatchScopeRow
1777 }!
1778
1779 mut db := orm.new_db(raw_db, orm.DataScope{
1780 filters: [
1781 orm.QueryFilter{
1782 field: 'tenant_id'
1783 value: orm.Primitive(42)
1784 mode: .dynamic
1785 },
1786 ]
1787 })
1788
1789 batch := [
1790 BatchScopeRow{
1791 name: 'Alice'
1792 },
1793 BatchScopeRow{
1794 name: 'Bob'
1795 },
1796 BatchScopeRow{
1797 name: 'Carol'
1798 },
1799 ]
1800
1801 sql db {
1802 insert batch into BatchScopeRow
1803 }!
1804
1805 users := sql raw_db {
1806 select from BatchScopeRow
1807 }!
1808 assert users.len == 3
1809}
1810
1811fn test_data_scope_batch_insert_multi_field_scope() {
1812 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
1813
1814 sql raw_db {
1815 create table BatchScopeRow
1816 }!
1817
1818 mut db := orm.new_db(raw_db, orm.DataScope{
1819 filters: [
1820 orm.QueryFilter{
1821 field: 'tenant_id'
1822 value: orm.Primitive(7)
1823 mode: .dynamic
1824 },
1825 orm.QueryFilter{
1826 field: 'shop_id'
1827 value: orm.Primitive(3)
1828 mode: .dynamic
1829 },
1830 ]
1831 })
1832
1833 batch := [
1834 BatchScopeRow{
1835 name: 'X'
1836 },
1837 BatchScopeRow{
1838 name: 'Y'
1839 },
1840 ]
1841
1842 sql db {
1843 insert batch into BatchScopeRow
1844 }!
1845
1846 users := sql raw_db {
1847 select from BatchScopeRow
1848 }!
1849 assert users.len == 2
1850}
1851
1852fn test_data_scope_batch_insert_overwrite_after_append() {
1853 // Regression test: when a batch insert has multiple dynamic scope filters
1854 // and an earlier filter appends a new column, a later filter that overwrites
1855 // an existing field must use the correct row stride.
1856 // struct: id, name, tenant_id (table.fields may be empty)
1857 // scope[0]: shop_id=3 (appended, not in struct)
1858 // scope[1]: tenant_id=99 (overwrites existing value)
1859 // Without the fix, the overwrite would index past the batch data due to
1860 // using the inflated result.fields.len as row stride.
1861 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
1862
1863 sql raw_db {
1864 create table BatchOverwriteRow
1865 }!
1866
1867 mut db := orm.new_db(raw_db, orm.DataScope{
1868 filters: [
1869 orm.QueryFilter{
1870 field: 'shop_id'
1871 value: orm.Primitive(3)
1872 mode: .dynamic
1873 },
1874 orm.QueryFilter{
1875 field: 'tenant_id'
1876 value: orm.Primitive(99)
1877 mode: .dynamic
1878 },
1879 ]
1880 })
1881
1882 // Batch insert with tenant_id=1,2 — scope should overwrite both to 99
1883 batch := [
1884 BatchOverwriteRow{
1885 name: 'OverA'
1886 tenant_id: 1
1887 },
1888 BatchOverwriteRow{
1889 name: 'OverB'
1890 tenant_id: 2
1891 },
1892 ]
1893
1894 sql db {
1895 insert batch into BatchOverwriteRow
1896 }!
1897
1898 rows := sql raw_db {
1899 select from BatchOverwriteRow order by id
1900 }!
1901 assert rows.len == 2
1902 assert rows[0].name == 'OverA'
1903 assert rows[0].tenant_id == 99
1904 assert rows[1].name == 'OverB'
1905 assert rows[1].tenant_id == 99
1906}
1907
1908// ---- JOIN scope tests ----
1909// Verify that scope filters are table-qualified when JOINs are present,
1910// avoiding ambiguity when joined tables share column names.
1911
1912// ScopeJoin table for testing scoped JOINs — must have distinct column
1913// names from JoinedItem to avoid ambiguity in SELECT *.
1914@[table: 'scope_join_main']
1915struct ScopeJoinMain {
1916 jid int @[primary; sql: serial]
1917 jname string
1918 tenant_id int
1919 jrel_id int
1920}
1921
1922@[table: 'scope_join_rel']
1923struct ScopeJoinRel {
1924 rid int @[primary; sql: serial]
1925 rname string
1926}
1927
1928fn test_data_scope_join_qualifies_table() {
1929 mut raw_db := sqlite.connect(':memory:') or { panic(err) }
1930
1931 sql raw_db {
1932 create table ScopeJoinMain
1933 create table ScopeJoinRel
1934 }!
1935
1936 m1 := ScopeJoinMain{
1937 jname: 'main1'
1938 tenant_id: 1
1939 jrel_id: 1
1940 }
1941 m2 := ScopeJoinMain{
1942 jname: 'main2'
1943 tenant_id: 2
1944 jrel_id: 2
1945 }
1946 r1 := ScopeJoinRel{
1947 rname: 'rel1'
1948 }
1949 r2 := ScopeJoinRel{
1950 rname: 'rel2'
1951 }
1952
1953 sql raw_db {
1954 insert m1 into ScopeJoinMain
1955 insert m2 into ScopeJoinMain
1956 insert r1 into ScopeJoinRel
1957 insert r2 into ScopeJoinRel
1958 }!
1959
1960 mut db := orm.new_db(raw_db, orm.DataScope{
1961 filters: [
1962 orm.QueryFilter{
1963 field: 'tenant_id'
1964 value: orm.Primitive(1)
1965 mode: .dynamic
1966 },
1967 ]
1968 })
1969
1970 // Verify scope filter is applied correctly with JOINs present.
1971 rows := sql db {
1972 select from ScopeJoinMain
1973 join ScopeJoinRel on ScopeJoinMain.jrel_id == ScopeJoinRel.rid
1974 }!
1975 assert rows.len == 1
1976 assert rows[0].jname == 'main1'
1977 assert rows[0].tenant_id == 1
1978}
1979