@@ -111,6 +111,41 @@ defmodule AshPostgres.CalculationTest do
111111 |> Ash . read! ( )
112112 end
113113
114+ test "runtime loading calculation with fragment referencing aggregate works correctly" do
115+ # This test verifies that calculations using fragments that reference aggregates
116+ # work correctly when ash_sql wraps queries for aggregate loading
117+
118+ post =
119+ Post
120+ |> Ash.Changeset . for_create ( :create , % { title: "test post" } )
121+ |> Ash . create! ( )
122+
123+ # Create multiple comments with different likes values to populate the aggregates
124+ Comment
125+ |> Ash.Changeset . for_create ( :create , % { title: "comment1" , likes: 5 } )
126+ |> Ash.Changeset . manage_relationship ( :post , post , type: :append_and_remove )
127+ |> Ash . create! ( )
128+
129+ Comment
130+ |> Ash.Changeset . for_create ( :create , % { title: "comment2" , likes: 15 } )
131+ |> Ash.Changeset . manage_relationship ( :post , post , type: :append_and_remove )
132+ |> Ash . create! ( )
133+
134+ # Loading multiple calculations that each reference multiple aggregates
135+ # Previously this would trigger binding reference errors when ash_sql wrapped
136+ # the query for aggregate loading, but now it should work correctly
137+ result =
138+ Post
139+ |> Ash.Query . load ( [ :comment_metric , :complex_comment_metric , :multi_agg_calc ] )
140+ |> Ash . read! ( )
141+
142+ # Should successfully load the post with calculated values
143+ assert [ post ] = result
144+ assert is_integer ( post . comment_metric )
145+ assert is_integer ( post . complex_comment_metric )
146+ assert is_integer ( post . multi_agg_calc )
147+ end
148+
114149 test "expression calculations don't load when `reuse_values?` is true" do
115150 post =
116151 Post
@@ -1241,4 +1276,242 @@ defmodule AshPostgres.CalculationTest do
12411276
12421277 assert [ ] == Ash . read! ( query )
12431278 end
1279+
1280+ test "expression calculation referencing aggregates loaded via code_interface with load option" do
1281+ post =
1282+ Post
1283+ |> Ash.Changeset . for_create ( :create , % { title: "test post" } )
1284+ |> Ash . create! ( )
1285+
1286+ Comment
1287+ |> Ash.Changeset . for_create ( :create , % { title: "comment1" , likes: 5 } )
1288+ |> Ash.Changeset . manage_relationship ( :post , post , type: :append_and_remove )
1289+ |> Ash . create! ( )
1290+
1291+ Comment
1292+ |> Ash.Changeset . for_create ( :create , % { title: "comment2" , likes: 15 } )
1293+ |> Ash.Changeset . manage_relationship ( :post , post , type: :append_and_remove )
1294+ |> Ash . create! ( )
1295+
1296+ # Call via code_interface with load: option (not Ash.Query.load)
1297+ # This should reproduce the issue where aggregates aren't loaded
1298+ result = Post . get_by_id! ( post . id , load: [ :comment_metric ] )
1299+
1300+ assert result . comment_metric == 200 # count_of_comments (2) * 100
1301+ end
1302+
1303+ test "complex SQL fragment calculation with multiple aggregates" do
1304+ post =
1305+ Post
1306+ |> Ash.Changeset . for_create ( :create , % {
1307+ title: "test post" ,
1308+ base_reading_time: 500 # fallback value
1309+ } )
1310+ |> Ash . create! ( )
1311+
1312+ # Create comments with timing data for aggregates
1313+ Comment
1314+ |> Ash.Changeset . for_create ( :create , % {
1315+ title: "comment1" ,
1316+ edited_duration: 100 ,
1317+ planned_duration: 80 ,
1318+ reading_time: 30 ,
1319+ version: :edited # This will be included in total_edited_time
1320+ } )
1321+ |> Ash.Changeset . manage_relationship ( :post , post , type: :append_and_remove )
1322+ |> Ash . create! ( )
1323+
1324+ Comment
1325+ |> Ash.Changeset . for_create ( :create , % {
1326+ title: "comment2" ,
1327+ edited_duration: 0 ,
1328+ planned_duration: 120 ,
1329+ reading_time: 45 ,
1330+ version: :planned # This will be included in total_planned_time
1331+ } )
1332+ |> Ash.Changeset . manage_relationship ( :post , post , type: :append_and_remove )
1333+ |> Ash . create! ( )
1334+
1335+ # Test complex COALESCE pattern: (edited || planned || base) + (reading || 0)
1336+ # Aggregates: edited=100, planned=120, reading=75
1337+ # Expected: (100 || 120 || 500) + (75 || 0) = 100 + 75 = 175
1338+ result = Post . get_by_id! ( post . id , load: [ :estimated_reading_time ] )
1339+
1340+ assert result . estimated_reading_time == 175
1341+ end
1342+
1343+ test "calculation with missing aggregate dependencies" do
1344+ post =
1345+ Post
1346+ |> Ash.Changeset . for_create ( :create , % {
1347+ title: "test post" ,
1348+ base_reading_time: 500
1349+ } )
1350+ |> Ash . create! ( )
1351+
1352+ # Create comments with DIFFERENT versions to trigger filtered aggregates (like zelo segments)
1353+ Comment
1354+ |> Ash.Changeset . for_create ( :create , % {
1355+ title: "modified comment" ,
1356+ edited_duration: 100 ,
1357+ planned_duration: 0 ,
1358+ reading_time: 30 ,
1359+ version: :edited
1360+ } )
1361+ |> Ash.Changeset . manage_relationship ( :post , post , type: :append_and_remove )
1362+ |> Ash . create! ( )
1363+
1364+ Comment
1365+ |> Ash.Changeset . for_create ( :create , % {
1366+ title: "planned comment" ,
1367+ edited_duration: 0 ,
1368+ planned_duration: 80 ,
1369+ reading_time: 20 ,
1370+ version: :planned
1371+ } )
1372+ |> Ash.Changeset . manage_relationship ( :post , post , type: :append_and_remove )
1373+ |> Ash . create! ( )
1374+
1375+ # Mimic zelo's loading pattern: request calculation but NOT its aggregate dependencies
1376+ # With filtered aggregates, this should reproduce the NotLoaded issue
1377+ result = Post . get_by_id! ( post . id , load: [ :estimated_reading_time ] )
1378+
1379+ # THIS SHOULD FAIL with NotLoaded if filtered aggregates cause the issue
1380+ refute match? ( % Ash.NotLoaded { } , result . estimated_reading_time ) ,
1381+ "Expected calculated value, got: #{ inspect ( result . estimated_reading_time ) } "
1382+ end
1383+
1384+ test "calculation with filtered aggregates and keyset pagination" do
1385+ post =
1386+ Post
1387+ |> Ash.Changeset . for_create ( :create , % {
1388+ title: "test post" ,
1389+ base_reading_time: 500
1390+ } )
1391+ |> Ash . create! ( )
1392+
1393+ # Create completed comments for count aggregate
1394+ Comment
1395+ |> Ash.Changeset . for_create ( :create , % {
1396+ title: "completed comment" ,
1397+ edited_duration: 100 ,
1398+ reading_time: 30 ,
1399+ version: :edited ,
1400+ status: :published
1401+ } )
1402+ |> Ash.Changeset . manage_relationship ( :post , post , type: :append_and_remove )
1403+ |> Ash . create! ( )
1404+
1405+ # Create pending comment (should not be counted)
1406+ Comment
1407+ |> Ash.Changeset . for_create ( :create , % {
1408+ title: "pending comment" ,
1409+ planned_duration: 80 ,
1410+ reading_time: 20 ,
1411+ version: :planned ,
1412+ status: :pending
1413+ } )
1414+ |> Ash.Changeset . manage_relationship ( :post , post , type: :append_and_remove )
1415+ |> Ash . create! ( )
1416+
1417+ # Test complex calculation with filtered aggregates
1418+ # Test individual loading vs combined loading
1419+
1420+ # Test 1: Load only estimated_reading_time (goes to runtime evaluation)
1421+ result_calc_only = Post . get_by_id! ( post . id , load: [ :estimated_reading_time ] )
1422+
1423+ # Debug: Load the individual aggregates to see their values
1424+ debug_result = Post . get_by_id! ( post . id , load: [
1425+ :total_edited_time ,
1426+ :total_planned_time ,
1427+ :total_comment_time ,
1428+ :published_comments ,
1429+ :base_reading_time
1430+ ] )
1431+
1432+ # Test 2: Load only published_comments
1433+ result_count_only = Post . get_by_id! ( post . id , load: [ :published_comments ] )
1434+
1435+ # Test 3: Code interface would be tested here but aggregates/calculations
1436+ # need to be defined at domain level for code interface to work
1437+ # calc_result_2 = AshPostgres.Test.Domain.estimated_reading_time(post)
1438+ # count_result_2 = AshPostgres.Test.Domain.published_comments(post)
1439+
1440+ # Test 4: Traditional load approach for comparison
1441+ result_both = Post . get_by_id! ( post . id , load: [ :published_comments , :estimated_reading_time ] )
1442+
1443+ # The fix ensures traditional loading now works properly
1444+ # Previously this would return NotLoaded or nil
1445+
1446+ # Verify both approaches work
1447+ assert result_both . estimated_reading_time == 150 , "Should calculate correctly with both loaded"
1448+ assert result_both . published_comments == 1 , "Should count correctly with both loaded"
1449+ end
1450+
1451+ test "calculation with keyset pagination works correctly (previously returned NotLoaded)" do
1452+ # Create multiple posts to enable pagination
1453+ _posts = Enum . map ( 1 .. 5 , fn i ->
1454+ post = Post
1455+ |> Ash.Changeset . for_create ( :create , % {
1456+ title: "test post #{ i } " ,
1457+ base_reading_time: 100 * i
1458+ } )
1459+ |> Ash . create! ( )
1460+
1461+ Comment
1462+ |> Ash.Changeset . for_create ( :create , % {
1463+ title: "comment#{ i } " ,
1464+ edited_duration: 50 * i ,
1465+ planned_duration: 40 * i ,
1466+ reading_time: 10 * i ,
1467+ version: :edited , # This ensures aggregate dependency works
1468+ status: :published # This ensures published_comments aggregate works
1469+ } )
1470+ |> Ash.Changeset . manage_relationship ( :post , post , type: :append_and_remove )
1471+ |> Ash . create! ( )
1472+
1473+ post
1474+ end )
1475+
1476+ # Test keyset pagination pattern with complex calculation dependencies
1477+ # First page: get initial results using keyset pagination
1478+ # Load BOTH aggregates AND calculations (this pattern tests complex aggregate dependencies)
1479+ first_page = Post
1480+ |> Ash.Query . load ( [ :published_comments , :estimated_reading_time ] )
1481+ |> Ash . read! ( action: :read_with_related_list_agg_filter , page: [ limit: 2 , count: true ] )
1482+
1483+ # Check if first page calculations are loaded properly
1484+ Enum . each ( first_page . results , fn post ->
1485+ refute match? ( % Ash.NotLoaded { } , post . estimated_reading_time ) ,
1486+ "First page post #{ post . id } should have loaded estimated_reading_time, got: #{ inspect ( post . estimated_reading_time ) } "
1487+ end )
1488+
1489+ # Second page: use keyset pagination (this is where the bug might manifest)
1490+ if first_page . more? do
1491+ second_page = Post
1492+ |> Ash.Query . load ( [ :published_comments , :estimated_reading_time ] )
1493+ |> Ash . read! (
1494+ action: :read_with_related_list_agg_filter ,
1495+ page: [ limit: 2 , after: first_page . results |> List . last ( ) |> Map . get ( :__metadata__ ) |> Map . get ( :keyset ) ]
1496+ )
1497+
1498+ # Check if second page calculations are loaded properly
1499+
1500+ # We can't know exact order due to keyset pagination, but we know:
1501+ # - There should be results (we created 5 posts, got 2 in first page, should have more)
1502+ # - Each result should have properly calculated values, not NotLoaded
1503+ assert length ( second_page . results ) > 0 , "Second page should have results"
1504+
1505+ Enum . each ( second_page . results , fn post ->
1506+
1507+ # The key test: this should NOT be NotLoaded (which was the original bug)
1508+ refute match? ( % Ash.NotLoaded { } , post . estimated_reading_time ) , "estimated_reading_time should be calculated, not NotLoaded"
1509+ refute match? ( % Ash.NotLoaded { } , post . published_comments ) , "published_comments should be calculated, not NotLoaded"
1510+
1511+ # Verify the values are reasonable (each post gets i*50 + i*10 for its calculation)
1512+ assert post . estimated_reading_time > 0 , "estimated_reading_time should be positive"
1513+ assert post . published_comments == 1 , "Each post has exactly 1 completed comment"
1514+ end )
1515+ end
1516+ end
12441517end
0 commit comments