Skip to content
OBML v1.0
OrionBelt v2.29.0

Query Resolution

Resolution is where a query written in business terms becomes a set of concrete model artefacts the planners can work with.

Query Resolution

orionbelt.compiler.resolution.QueryResolver

Resolves a QueryObject + SemanticModel into a ResolvedQuery.

Source code in src/orionbelt/compiler/resolution.py
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
class QueryResolver:
    """Resolves a QueryObject + SemanticModel into a ResolvedQuery."""

    def resolve(
        self,
        query: QueryObject,
        model: SemanticModel,
        qualify_table: Callable[[DataObject], str] | None = None,
    ) -> ResolvedQuery:
        ctx = _ResolutionContext(
            model=model,
            result=ResolvedQuery(
                limit=query.limit,
                offset=query.offset,
                use_path_names=list(query.use_path_names),
                allow_fan_out=query.allow_fan_out,
                is_raw=query.select.is_raw,
                distinct=query.select.distinct,
                grouping=query.grouping,
            ),
            qualify_table=qualify_table,
        )

        # Build global column lookup: col_name → (object_name, source_column)
        for obj_name, obj in model.data_objects.items():
            for col_name, col_obj in obj.columns.items():
                ctx.global_columns[col_name] = (obj_name, col_obj.code)

        if query.select.is_raw:
            # Raw mode: project physical columns, no aggregation.
            for ref in query.select.fields:
                self._resolve_raw_field(ctx, ref)
        else:
            # Aggregate mode (default).
            # 1. Resolve dimensions (string or coalesce group).
            # Coalesce groups expand into their constituent dimensions, each
            # tagged with the same coalesce_alias so the CFL outer wrapper can
            # emit COALESCE(d1, d2, ...) AS <alias>.
            for dim_entry in query.select.dimensions:
                if isinstance(dim_entry, CoalesceDimension):
                    self._resolve_coalesce_dimension(ctx, dim_entry, ctx.result.coalesce_aliases)
                else:
                    self._append_resolved_dimension(ctx, dim_entry)

            # 2. Resolve measures and track their source objects
            for measure_name in query.select.measures:
                resolved_meas = self._resolve_measure(ctx, measure_name)
                if resolved_meas:
                    ctx.result.measures.append(resolved_meas)
                    source_objs = self._get_measure_source_objects(ctx, measure_name)
                    ctx.result.measure_source_objects.update(source_objs)
                    ctx.result.required_objects.update(source_objs)
                    ctx.result.required_objects.update(
                        self._get_measure_join_objects(ctx, measure_name)
                    )

            # 2.5. Auto-include measures referenced by HAVING but not by SELECT.
            # Without this, codegen emits a HAVING clause that references an
            # alias for a column the SELECT doesn't project — every database
            # rejects the SQL with a "must appear in GROUP BY" binder error.
            # Routing this through the regular measure-resolution path also
            # updates ``measure_source_objects`` so the multi-fact CFL trigger
            # below sees the HAVING-only measure's source.
            existing_measure_names = {m.name for m in ctx.result.measures}
            for ref in self._collect_having_measure_refs(query, model):
                if ref in existing_measure_names:
                    continue
                resolved_meas = self._resolve_measure(ctx, ref)
                if resolved_meas is None:
                    continue
                ctx.result.measures.append(resolved_meas)
                ctx.result.having_only_measures.add(ref)
                existing_measure_names.add(ref)
                source_objs = self._get_measure_source_objects(ctx, ref)
                ctx.result.measure_source_objects.update(source_objs)
                ctx.result.required_objects.update(source_objs)
                ctx.result.required_objects.update(self._get_measure_join_objects(ctx, ref))

        # 3. Determine base object (the one with most joins / most measures).
        # WHERE filters are resolved much later, so the objects they reference
        # are collected up front — the base has to be able to reach them, or
        # the filter is silently dropped as unreachable further down.
        where_filter_objects = self._collect_where_filter_objects(query, model)
        ctx.result.base_object = self._select_base_object(ctx, where_filter_objects)
        if ctx.result.base_object:
            ctx.result.required_objects.add(ctx.result.base_object)

        # An anchored measure pins the base, which bypasses the re-anchoring that
        # normally makes a filter's data object reachable. A predicate on a fact
        # the anchor only reaches by conforming cannot be honoured where it
        # stands: the fact is aggregated to the shared key before the expression
        # is evaluated, so the predicate would compare a per-key total rather
        # than choose rows. Restricting it properly means a WHERE inside the
        # conformed subquery, which is not built.
        #
        # Refused rather than dropped. Filters on an unreachable object are
        # skipped silently elsewhere, which is tolerable when the object is
        # merely absent - here the query names a real fact the plan does read,
        # and skipping returned unfiltered totals with nothing to say so.
        # Static model filters count, and count for more: they are documented
        # as applied to every query, so dropping one silently widens every
        # result the model ever returns. Collected here rather than in
        # ``_collect_where_filter_objects`` so base-object selection keeps the
        # behaviour it has for models with no anchored measure.
        self._reject_filters_on_conformed_objects(
            ctx,
            where_filter_objects | {mf.data_object for mf in model.filters},
        )

        # Detect multi-fact: CFL is needed only when measure source objects
        # span multiple independent fact tables.
        if len(ctx.result.measure_source_objects) > 1:
            graph = JoinGraph(model, use_path_names=query.use_path_names or None)
            reachable = graph.descendants(ctx.result.base_object)
            unreachable = ctx.result.measure_source_objects - reachable - {ctx.result.base_object}
            if unreachable:
                ctx.result.requires_cfl = True

        # Dimension-only queries: when dimensions span independent branches,
        # join through intermediate bridge/fact tables (no CFL needed).
        # Add intermediate tables from the join steps to required_objects
        # so the star schema planner includes them.
        if not ctx.result.measure_source_objects and ctx.result.dimensions:
            dim_objects = {d.object_name for d in ctx.result.dimensions}
            if not dim_objects <= {ctx.result.base_object}:
                graph = JoinGraph(model, use_path_names=query.use_path_names or None)
                steps = graph.find_join_path(
                    {ctx.result.base_object},
                    dim_objects,
                    via_constraints=ctx.result.via_constraints or None,
                )
                for step in steps:
                    ctx.result.required_objects.add(step.from_object)
                    ctx.result.required_objects.add(step.to_object)

        # Raw mode: detect multi-fact (fields span objects unreachable from
        # the base via directed joins). The pipeline rejects this case for
        # now — raw CFL is a planned follow-up.
        if ctx.result.is_raw and ctx.result.base_object:
            field_objects = {f.object_name for f in ctx.result.fields}
            if len(field_objects) > 1:
                graph = JoinGraph(model, use_path_names=query.use_path_names or None)
                reachable = graph.descendants(ctx.result.base_object)
                unreachable = field_objects - reachable - {ctx.result.base_object}
                if unreachable:
                    ctx.result.requires_cfl = True

        # Validate dimensionsExclude constraints
        if query.dimensions_exclude:
            if query.select.measures:
                ctx.errors.append(
                    SemanticError(
                        code="DIMENSIONS_EXCLUDE_WITH_MEASURES",
                        message="dimensionsExclude cannot be combined with measures",
                        path="select",
                    )
                )
            elif len(ctx.result.dimensions) < 2:
                ctx.errors.append(
                    SemanticError(
                        code="DIMENSIONS_EXCLUDE_INSUFFICIENT",
                        message="dimensionsExclude requires at least 2 dimensions",
                        path="select.dimensions",
                    )
                )
            else:
                ctx.result.dimensions_exclude = True

        # Every object this *query* names, including the ones only a predicate
        # does. A WHERE filter is resolved much later, so a guard reading
        # ``required_objects`` alone sees none of them - and a filter is exactly
        # how a nested object reaches a query that projects nothing from it.
        #
        # A static model filter is deliberately not counted. It is a property of
        # the model rather than of the query, and one naming an object this plan
        # cannot reach is documented as skipped rather than fatal
        # (``test_unreachable_filter_silently_ignored``). Counting them made a
        # single nested static filter refuse every multi-fact query in the
        # model, including the ones that never go near it.
        self._reject_unsupported_nested_shapes(
            ctx, ctx.result.required_objects | where_filter_objects
        )

        # 4. Validate usePathNames before building join graph
        self._validate_use_path_names(ctx, query.use_path_names)

        # 5. Resolve join paths
        ctx.graph = JoinGraph(model, use_path_names=query.use_path_names or None)
        if ctx.result.base_object and len(ctx.result.required_objects) > 1:
            ambiguous: dict[str, list[list[str]]] = {}
            ctx.result.join_steps = ctx.graph.find_join_path(
                {ctx.result.base_object},
                ctx.result.required_objects,
                via_constraints=ctx.result.via_constraints or None,
                ambiguous=ambiguous,
            )
            # A dimension the query reaches by two equally close routes is two
            # different roles of one data object, and they select different
            # rows. Refused rather than picked — the same stance the filter
            # path takes, since a projected dimension is no more guessable.
            for object_name, routes in sorted(ambiguous.items()):
                names = (
                    ", ".join(
                        f"'{dim.name}'"
                        for dim in ctx.result.dimensions
                        if dim.object_name == object_name
                    )
                    or f"'{object_name}'"
                )
                ctx.errors.append(
                    SemanticError(
                        code="AMBIGUOUS_JOIN_PATH",
                        message=(
                            f"{names} is on '{object_name}', which this query reaches "
                            f"equally well by more than one route "
                            f"({', '.join(f'via {path[-2]!r}' for path in routes)}). "
                            f"Those are different roles of the same data object and "
                            f"they select different rows."
                        ),
                        path="select.dimensions",
                        hint=(
                            "Say which one is meant: declare a data object per role "
                            "over the same table and select from that, or give the "
                            "dimension a 'via:' waypoint naming the object the join "
                            "must traverse."
                        ),
                    )
                )

        # Build set of all objects present in the query's join graph
        if ctx.result.base_object:
            ctx.joined_objects.add(ctx.result.base_object)
        for step in ctx.result.join_steps:
            ctx.joined_objects.add(step.to_object)

        # Detect required objects that the star-schema planner cannot reach.
        # Many-to-one joins are forward-only (reverse traversal would inflate
        # the base table), so a required object that's only reachable via a
        # reverse m-to-1 hop is unreachable.  Raise a clear error rather than
        # silently producing wrong SQL.  CFL legs are validated separately.
        if ctx.result.base_object and not ctx.result.requires_cfl:
            unreachable = ctx.result.required_objects - ctx.joined_objects
            for unreachable_name in sorted(unreachable):
                ctx.errors.append(
                    SemanticError(
                        code="UNREACHABLE_REQUIRED_OBJECT",
                        message=(
                            f"Data object '{unreachable_name}' is required by the query but "
                            f"cannot be reached from base '{ctx.result.base_object}' via "
                            f"directed joins. Many-to-one joins are forward-only; reverse "
                            f"traversal would inflate row counts. Add an explicit join from "
                            f"'{ctx.result.base_object}' (or an intermediate object) to "
                            f"'{unreachable_name}', or split the query so each fact is "
                            f"queried independently."
                        ),
                        path="select",
                    )
                )

        # 5b. Inject static model filters — always applied as WHERE conditions
        static_exprs: list[Expr] = []
        for mf in model.filters:
            static_filter = self._resolve_static_filter(ctx, mf)
            if static_filter:
                ctx.result.where_filters.append(static_filter)
                static_exprs.append(static_filter.expression)

        # 6. Classify filters — skip query-time duplicates of static filters
        for qfi in query.where:
            resolved_filter = self._resolve_filter_item(ctx, qfi, is_having=False)
            if resolved_filter and resolved_filter.expression not in static_exprs:
                ctx.result.where_filters.append(resolved_filter)

        for qfi in query.having:
            resolved_filter = self._resolve_filter_item(ctx, qfi, is_having=True)
            if resolved_filter:
                ctx.result.having_filters.append(resolved_filter)

        # 7. Resolve order by — must reference a dimension or measure in SELECT
        select_count = len(ctx.result.dimensions) + len(ctx.result.measures)
        for ob in query.order_by:
            expr = self._resolve_order_by_field(ctx, ob.field, select_count)
            if expr:
                ctx.result.order_by_exprs.append((expr, ob.direction == "desc", ob.nulls))

        # 8. ROLLUP / CUBE: backfill NULLS FIRST on any explicit ORDER BY entry
        # that didn't specify a NULLs position. Subtotal and grand-total rows
        # carry NULLs in the rolled-up group-by columns, and BI tools expect
        # those totals at the top of the result — not interleaved with details.
        if ctx.result.grouping is not None and ctx.result.order_by_exprs:
            ctx.result.order_by_exprs = [
                (expr, desc, NullsPosition.FIRST if nulls is None else nulls)
                for expr, desc, nulls in ctx.result.order_by_exprs
            ]

        # 9. Auto-order — when no explicit ORDER BY, append ORDER BY over all
        # SELECT dimensions (or raw fields) under two conditions:
        #   (a) LIMIT is set: cache hashes on compiled SQL; without ORDER BY
        #       ``LIMIT N`` returns any N rows, freezing one arbitrary slice.
        #   (b) ROLLUP / CUBE: subtotal layout is otherwise unpredictable.
        # ROLLUP / CUBE defaults to NULLS FIRST (totals at the top).
        # Aggregate-only queries (no dims, no fields) are already single-row
        # deterministic — skip.
        needs_auto_order = not ctx.result.order_by_exprs and (
            ctx.result.limit is not None or ctx.result.grouping is not None
        )
        if needs_auto_order:
            nulls_default = NullsPosition.FIRST if ctx.result.grouping is not None else None
            if ctx.result.is_raw and ctx.result.fields:
                for f in ctx.result.fields:
                    ctx.result.order_by_exprs.append(
                        (ColumnRef(name=f.alias), False, nulls_default)
                    )
            elif ctx.result.dimensions:
                for dim in ctx.result.dimensions:
                    ctx.result.order_by_exprs.append(
                        (ColumnRef(name=dim.name), False, nulls_default)
                    )

        if ctx.errors:
            raise ResolutionError(ctx.errors)

        return ctx.result

    # -- raw mode fields -----------------------------------------------------

    def _resolve_raw_field(self, ctx: _ResolutionContext, ref: str) -> None:
        """Resolve a ``DataObject.Column`` reference for raw-mode projection.

        Errors are accumulated in the resolution context (raised at the end).
        """
        raw_resolution.resolve_raw_field(self, ctx, ref)

    # -- dimensions ----------------------------------------------------------

    def _append_resolved_dimension(
        self,
        ctx: _ResolutionContext,
        dim_str: str,
        coalesce_alias: str | None = None,
    ) -> ResolvedDimension | None:
        """Resolve a single dimension string and append it to the result."""
        dim_ref = DimensionRef.parse(dim_str)
        resolved_dim = self._resolve_dimension(ctx, dim_ref)
        if resolved_dim is None:
            return None
        dim_def = ctx.model.dimensions.get(dim_ref.name)
        if dim_def and dim_def.via:
            resolved_dim.via = dim_def.via
            ctx.result.required_objects.add(dim_def.via)
            ctx.result.via_constraints[resolved_dim.object_name] = dim_def.via
        if coalesce_alias is not None:
            resolved_dim.coalesce_alias = coalesce_alias
        ctx.result.dimensions.append(resolved_dim)
        ctx.result.required_objects.add(resolved_dim.object_name)
        # A computed column may read a column of another data object, which the
        # plan then has to join — the expression is inlined into the SELECT list
        # and would otherwise name an alias nothing in the FROM chain binds.
        ctx.result.required_objects.update(
            ctx.model.column_reference_objects(resolved_dim.object_name, resolved_dim.column_name)
        )
        return resolved_dim

    def _resolve_coalesce_dimension(
        self,
        ctx: _ResolutionContext,
        coalesce: CoalesceDimension,
        seen_aliases: set[str],
    ) -> None:
        """Expand a coalesce group into its constituent resolved dimensions.

        Validates: at least 2 members, alias is unique within the query and
        does not collide with an existing dimension/measure name, all members
        resolve to the same abstract column type.
        """
        alias = coalesce.alias
        if not alias:
            ctx.errors.append(
                SemanticError(
                    code="COALESCE_MISSING_ALIAS",
                    message="Coalesce dimension requires a non-empty 'as' alias",
                    path="select.dimensions",
                )
            )
            return
        if alias in seen_aliases:
            ctx.errors.append(
                SemanticError(
                    code="DUPLICATE_COALESCE_ALIAS",
                    message=f"Duplicate coalesce alias '{alias}' in this query",
                    path="select.dimensions",
                )
            )
            return
        if alias in ctx.model.dimensions or alias in ctx.model.effective_measures:
            ctx.errors.append(
                SemanticError(
                    code="COALESCE_ALIAS_COLLISION",
                    message=(
                        f"Coalesce alias '{alias}' collides with an existing "
                        f"model dimension or measure name"
                    ),
                    path="select.dimensions",
                )
            )
            return
        if len(coalesce.coalesce) < 2:
            ctx.errors.append(
                SemanticError(
                    code="COALESCE_TOO_FEW_MEMBERS",
                    message=(
                        f"Coalesce '{alias}' requires at least 2 dimensions "
                        f"(got {len(coalesce.coalesce)})"
                    ),
                    path="select.dimensions",
                )
            )
            return
        seen_aliases.add(alias)

        # Resolve each member with the alias tag; verify type compatibility.
        member_types: set[str] = set()
        for member in coalesce.coalesce:
            resolved = self._append_resolved_dimension(ctx, member, coalesce_alias=alias)
            if resolved:
                dim_def = ctx.model.dimensions.get(member)
                if dim_def:
                    member_types.add(dim_def.result_type.value)
        if len(member_types) > 1:
            ctx.errors.append(
                SemanticError(
                    code="COALESCE_TYPE_MISMATCH",
                    message=(
                        f"Coalesce '{alias}' members have incompatible result types: "
                        f"{sorted(member_types)}"
                    ),
                    path="select.dimensions",
                )
            )

    def _resolve_dimension(
        self, ctx: _ResolutionContext, ref: DimensionRef
    ) -> ResolvedDimension | None:
        """Resolve a dimension reference to its physical column."""
        dim = ctx.model.dimensions.get(ref.name)
        if dim is None:
            ctx.errors.append(
                SemanticError(
                    code="UNKNOWN_DIMENSION",
                    message=f"Unknown dimension '{ref.name}'",
                    path="select.dimensions",
                )
            )
            return None

        obj_name = dim.view
        col_name = dim.column
        obj = ctx.model.data_objects.get(obj_name)
        if obj is None:
            ctx.errors.append(
                SemanticError(
                    code="UNKNOWN_DATA_OBJECT",
                    message=f"Dimension '{ref.name}' references unknown data object '{obj_name}'",
                )
            )
            return None

        vf = obj.columns.get(col_name)
        source_col = vf.code if vf else col_name

        grain = ref.grain or dim.time_grain
        if grain is not None and not self._grain_fits_the_column(ctx, ref, dim, vf, grain):
            return None
        if grain is not None and not self._grain_survives_the_cast(ctx, ref, dim, grain):
            return None

        return ResolvedDimension(
            name=ref.name,
            object_name=obj_name,
            column_name=col_name,
            source_column=source_col,
            grain=grain,
        )

    @staticmethod
    def _grain_fits_the_column(
        ctx: _ResolutionContext,
        ref: DimensionRef,
        dim: Dimension,
        column: DataObjectColumn | None,
        grain: TimeGrain,
    ) -> bool:
        """Refuse a grain over a column that carries no date to truncate.

        A grain compiles to ``date_trunc(grain, column)``, which the engine
        refuses over text: measured on DuckDB, ``"Name:hour"`` over a string
        column compiled and then died in the binder with "No function matches
        the given name and argument types 'date_trunc(STRING_LITERAL,
        VARCHAR)'". The model validator refuses a declared ``timeGrain`` over
        such a column for exactly that reason, and a query can name a grain the
        model never declared, so the same rule is read here - an error naming
        the dimension and the column beats one naming generated SQL.

        A column the model does not define is left alone: its type is unknown
        here, and the reference itself is reported elsewhere.
        """
        if column is None or column.abstract_type in DATE_BEARING_TYPES:
            return True
        ctx.errors.append(
            SemanticError(
                code="TIME_GRAIN_ON_NON_TEMPORAL",
                message=(
                    f"Dimension '{ref.name}' is asked for at grain '{grain.value}' "
                    f"but underlying column '{dim.view}.{dim.column}' has "
                    f"abstractType '{column.abstract_type.value}'. A time grain "
                    f"requires the column to be date, timestamp, or timestamp_tz. "
                    f"Ask for the dimension without a grain, fix the column's "
                    f"abstractType, or define a computed column with to_date()."
                ),
                path="select.dimensions",
            )
        )
        return False

    @staticmethod
    def _grain_survives_the_cast(
        ctx: _ResolutionContext, ref: DimensionRef, dim: Dimension, grain: TimeGrain
    ) -> bool:
        """Refuse a grain the dimension's declared type cannot hold.

        ``make_dimension_expr`` casts a grained dimension to its declared
        ``resultType``, in the GROUP BY as well as the projection, so a
        declaration narrower than the grain merges buckets and changes the
        measures rather than relabelling the column. The model validator refuses
        that combination at load, but a query writes its own: ``Occurred:hour``
        names a grain the dimension never declared, so a dimension declaring
        ``date`` -- perfectly valid, with no ``timeGrain`` of its own -- answered
        two rows where three were asked for, the two hours of one day summed
        into one. The model is not at fault there and cannot be checked for it;
        the query is, and this is where it is read.

        ``time_tz`` is refused here too, and it is the one case that merges
        nothing: OBML has no cast target for it, so the value is left alone and
        only the label is wrong. Refused all the same, because a grain always
        carries a date and that declaration cannot describe one.
        """
        declared = dim.result_type
        if result_type_holds_grain(grain, declared):
            return True
        keeps = "timestamp" if grain in SUB_DAY_GRAINS else "date or timestamp"
        asked = "asked for at" if ref.grain is not None else "grouped by"
        if declared in CASTABLE_TEMPORAL_TYPES:
            cost = (
                "The cast is applied in the GROUP BY as well, so buckets would "
                "merge and the measures would change without an error."
            )
        else:
            cost = (
                f"A grain always carries a date, and OBML has no cast target for "
                f"'{declared.value}', so the dimension would answer a date-bearing "
                f"value under a label for a time."
            )
        ctx.errors.append(
            SemanticError(
                code="RESULT_TYPE_LOSES_GRAIN",
                message=(
                    f"Dimension '{ref.name}' is {asked} grain '{grain.value}' but "
                    f"declares resultType '{declared.value}', which cannot hold it. "
                    f"{cost} Declare {keeps}, or ask for the grain the type implies."
                ),
                path="select.dimensions",
            )
        )
        return False

    # -- measures & metrics --------------------------------------------------

    def _resolve_measure(self, ctx: _ResolutionContext, name: str) -> ResolvedMeasure | None:
        """Resolve a measure name to its aggregate expression."""
        measure = ctx.model.effective_measures.get(name)
        if measure is None:
            metric = ctx.model.metrics.get(name)
            if metric:
                return self._resolve_metric(ctx, name, metric)
            ctx.errors.append(
                SemanticError(
                    code="UNKNOWN_MEASURE",
                    message=f"Unknown measure '{name}'",
                    path="select.measures",
                )
            )
            return None

        expr = self._build_measure_expr(ctx, measure)
        grain_override = measure.grain
        effective_grain: list[str] | None = None
        if grain_override is not None:
            query_dim_names = [d.name for d in ctx.result.dimensions]
            effective_grain = _resolve_effective_grain(grain_override, query_dim_names)
            if effective_grain is not None and not set(effective_grain) <= set(query_dim_names):
                bad = sorted(set(effective_grain) - set(query_dim_names))
                ctx.errors.append(
                    SemanticError(
                        code="GRAIN_NOT_SUBSET",
                        message=(
                            f"Measure '{name}' grain {bad} is not a subset of "
                            f"query dimensions {query_dim_names}. "
                            f"This would cause row multiplication."
                        ),
                        path="select.measures",
                    )
                )
        return ResolvedMeasure(
            name=name,
            aggregation=measure.aggregation,
            expression=expr,
            is_expression=measure.expression is not None,
            total=measure.total,
            default_value=measure.default_value,
            grain_override=grain_override,
            effective_grain=effective_grain,
            filter_context=measure.filter_context,
        )

    def _build_measure_expr(self, ctx: _ResolutionContext, measure: Measure) -> Expr:
        """Build the aggregate expression for a measure."""
        # Engine-delegated aggregation (Databricks Metric View). Emit
        # ``MEASURE("<label>")`` literally — there's no source column
        # to read; the engine resolves the aggregation by name. Dialect
        # support is enforced downstream by ``_check_aggregation_supported``.
        if measure.aggregation == AggregationType.MEASURE:
            return FunctionCall(
                name="MEASURE",
                args=[ColumnRef(name=measure.name, table=None)],
            )
        if measure.expression:
            return self._expand_expression(ctx, measure)

        # Build column references for all columns. Routes through
        # ``make_column_expr`` so a measure column that points at a
        # computed (``expression:``) column inlines the template body
        # — without this, ``count_distinct`` over an ``expression:``
        # column would emit ``COUNT(DISTINCT "obj"."")`` (zero-length
        # identifier, DB error).
        # Whether a cast is coming, and whether it is a numeric one. A boolean
        # source only has to become a number when it is about to be read as
        # one; ``None`` here means the measure passes its value through.
        numeric_output = _reads_a_number(measure, ctx.model.settings)

        args: list[Expr] = []
        if measure.columns:
            for ref in measure.columns:
                obj_name = ref.view or ""
                col_name = ref.column or ""
                # A column-less ref (``dataObject`` set, ``column`` empty) anchors the
                # measure on the object without naming a column — used by the
                # synthesized row-count measure to emit ``COUNT(*)`` while still
                # contributing the anchor to source-object resolution.
                if not col_name:
                    continue
                obj = ctx.model.data_objects.get(obj_name)
                if obj and col_name in obj.columns:
                    col_expr = make_column_expr(ctx.model, obj_name, col_name)
                    if numeric_output:
                        col_expr = _flag_as_number(col_expr)
                    args.append(col_expr)
                else:
                    args.append(ColumnRef(name=col_name, table=obj_name))
        if not args:
            args = [Literal.number(1)]

        agg = measure.aggregation.upper()
        distinct = measure.distinct
        if agg == "COUNT_DISTINCT":
            agg = "COUNT"
            distinct = True

        # LISTAGG: attach separator and optional ordering
        separator: str | None = None
        order_by: list[OrderByItem] = []
        if agg == "LISTAGG":
            separator = measure.delimiter if measure.delimiter is not None else ","
            if measure.within_group:
                wg = measure.within_group
                wg_obj_name = wg.column.view or ""
                wg_col_name = wg.column.column or ""
                wg_obj = ctx.model.data_objects.get(wg_obj_name)
                if wg_obj and wg_col_name in wg_obj.columns:
                    wg_expr: Expr = make_column_expr(ctx.model, wg_obj_name, wg_col_name)
                else:
                    wg_expr = ColumnRef(name=wg_col_name, table=wg_obj_name)
                order_by = [
                    OrderByItem(expr=wg_expr, desc=wg.order.upper() == "DESC"),
                ]

        result = FunctionCall(
            name=agg,
            args=args,
            distinct=distinct,
            order_by=order_by,
            separator=separator,
        )
        return self._apply_measure_default(
            measure, self._apply_measure_filters(ctx, measure, result)
        )

    def _expand_expression(self, ctx: _ResolutionContext, measure: Measure) -> Expr:
        """Expand a measure expression with ``{[DataObject].[Column]}`` refs into AST."""
        formula = measure.expression or ""
        agg = measure.aggregation.upper()

        tokens = tokenize_measure_expression(formula, ctx.model)
        # The tokenizer resolves {[Object].[Column]} straight to a physical
        # ref, so the query zone has to be applied here as it is for a column
        # a dimension names: otherwise one column means two instants depending
        # on how the query reached it.
        inner = apply_query_timezone(parse_expression(tokens), ctx.model)
        # The same rule the ``columns:`` form gets: two spellings of one
        # measure, so a boolean reaches a numeric output as a number either
        # way. Scoping it to the other branch left this one failing.
        if _reads_a_number(measure, ctx.model.settings):
            inner = _flag_as_number(inner)

        distinct = measure.distinct
        if agg == "COUNT_DISTINCT":
            agg = "COUNT"
            distinct = True

        result = FunctionCall(
            name=agg,
            args=[inner],
            distinct=distinct,
        )
        return self._apply_measure_default(
            measure, self._apply_measure_filters(ctx, measure, result)
        )

    @staticmethod
    def _apply_measure_default(measure: Measure, expr: Expr) -> Expr:
        """Wrap an aggregate in its declared empty-set value.

        Outside the aggregate rather than inside: ``COALESCE(SUM(x), 0)``
        answers 0 when the aggregate saw nothing, where ``SUM(COALESCE(x, 0))``
        would answer 0 for a row whose value is missing — a different claim.

        Emitted for every dialect, which is the point: an aggregate over an
        empty row set is NULL in standard SQL and 0 on ClickHouse, so a model
        that says what it wants no longer depends on which engine runs it.
        """
        if measure.default_value is None:
            return expr
        return FunctionCall(name="COALESCE", args=[expr, Literal(value=measure.default_value)])

    @staticmethod
    def _apply_measure_filters(
        ctx: _ResolutionContext, measure: Measure, func: FunctionCall
    ) -> FunctionCall:
        """Wrap aggregate args with CASE WHEN if the measure has filters."""
        if not measure.filters:
            return func
        condition = build_measure_filter_condition(measure.filters, ctx.model, ctx.errors)
        if condition is None:
            return func
        wrapped_args: list[Expr] = [CaseExpr(when_clauses=[(condition, arg)]) for arg in func.args]
        return FunctionCall(
            name=func.name,
            args=wrapped_args,
            distinct=func.distinct,
            order_by=func.order_by,
            separator=func.separator,
        )

    def _resolve_metric(
        self, ctx: _ResolutionContext, name: str, metric: Metric
    ) -> ResolvedMeasure | None:
        """Resolve a metric to its combined expression."""
        return metric_resolution.resolve_metric(self, ctx, name, metric)

    def _validate_partition_dimensions(
        self,
        ctx: _ResolutionContext,
        metric_name: str,
        partition_by: list[str],
        path_template: str,
    ) -> bool:
        return metric_resolution.validate_partition_dimensions(
            self, ctx, metric_name, partition_by, path_template
        )

    def _resolve_window_metric(
        self, ctx: _ResolutionContext, name: str, metric: Metric
    ) -> ResolvedMeasure | None:
        """Resolve a window metric (rank/lag/lead/ntile/first_value/last_value)."""
        return metric_resolution.resolve_window_metric(self, ctx, name, metric)

    def _resolve_derived_metric(
        self, ctx: _ResolutionContext, name: str, metric: Metric
    ) -> ResolvedMeasure | None:
        """Resolve a derived metric to its combined expression."""
        return metric_resolution.resolve_derived_metric(self, ctx, name, metric)

    def _resolve_cumulative_metric(
        self, ctx: _ResolutionContext, name: str, metric: Metric
    ) -> ResolvedMeasure | None:
        """Resolve a cumulative metric referencing an existing measure."""
        return metric_resolution.resolve_cumulative_metric(self, ctx, name, metric)

    def _resolve_pop_metric(
        self, ctx: _ResolutionContext, name: str, metric: Metric
    ) -> ResolvedMeasure | None:
        """Resolve a period-over-period metric."""
        return metric_resolution.resolve_pop_metric(self, ctx, name, metric)

    def _collect_having_measure_refs(self, query: QueryObject, model: SemanticModel) -> list[str]:
        """Collect measure/metric names referenced in any HAVING filter.

        Walks ``query.having`` recursively (each entry is a
        ``QueryFilter`` or a ``QueryFilterGroup``) and returns the
        ordered, de-duplicated list of ``field`` values that name a
        known measure or metric in the model. Order is preserved for
        deterministic resolution; duplicates are dropped on first sight.
        """

        seen: set[str] = set()
        out: list[str] = []
        measure_names = model.effective_measures

        def _visit(item: QueryFilterItem) -> None:
            if isinstance(item, QueryFilterGroup):
                for child in item.filters:
                    _visit(child)
                return
            field = item.field
            if field in seen:
                return
            if field in measure_names or field in model.metrics:
                seen.add(field)
                out.append(field)

        for entry in query.having:
            _visit(entry)
        return out

    def _get_measure_join_objects(self, ctx: _ResolutionContext, name: str) -> set[str]:
        """Objects a measure needs *joined* without being sourced from them.

        A ``withinGroup`` column becomes the aggregate's ``ORDER BY``, so it has
        to resolve — but it contributes no value to the measure. Kept out of
        ``measure_source_objects`` for that reason: that set drives CFL
        detection and the explain output's fact-table list, and a LISTAGG's sort
        column is neither a fact nor a source.

        Without this the object is never joined and the compiler emits SQL that
        binds to nothing:

            SELECT LISTAGG("Products"."id", ',' ORDER BY "Sales"."quantity")
            FROM "products" AS "Products"          -- Sales never joined

        which every engine rejects at execution time.

        A column the measure reads may itself be computed from a column of
        another object, which lands here for the same reason and with the same
        care about ``measure_source_objects``: the object supplies part of an
        expression evaluated per fact row, not a second fact to union.
        """
        result: set[str] = set()

        if name in ctx.model.effective_measures:
            return ctx.model.measure_join_objects(name)

        metric = ctx.model.metrics.get(name)
        if metric is not None:
            if metric.measure:
                result.update(self._get_measure_join_objects(ctx, metric.measure))
            if metric.expression:
                for ref_name in re.findall(r"\{\[([^\]]+)\]\}", metric.expression):
                    result.update(self._get_measure_join_objects(ctx, ref_name))
        return result

    def _reject_filters_on_conformed_objects(
        self, ctx: _ResolutionContext, filter_objects: set[str]
    ) -> None:
        """Refuse a WHERE predicate on a fact an anchored measure only conforms.

        Covers both the query's ``where`` and the model's static ``filters:``.
        Either one resolves against an object the plan reads only as an
        aggregate, so neither can choose rows, and both were being skipped.
        """
        if not ctx.result.anchored_measures or not filter_objects:
            return
        conformed: set[str] = set()
        for name in ctx.result.anchored_measures:
            measure = ctx.model.effective_measures.get(name)
            if measure is not None:
                conformed |= anchored_conformed_objects(
                    ctx.model, measure, ctx.result.use_path_names
                )
        constrained = sorted(filter_objects & conformed)
        if not constrained:
            return
        listed = ", ".join(f"'{name}'" for name in constrained)
        ctx.errors.append(
            SemanticError(
                code="FILTER_ON_CONFORMED_OBJECT",
                message=(
                    f"A filter constrains {listed}, which an anchored measure reaches "
                    f"only by aggregating it to a shared key. The filter would compare a "
                    f"per-key total rather than choose rows, so it cannot be applied "
                    f"where it stands. This covers the query's own filters and the "
                    f"model's static ones alike."
                ),
                path="where",
                hint=(
                    "Filter on a data object the anchor reaches directly, or query the "
                    f"anchored measure separately from the restriction on {listed}."
                ),
            )
        )

    def _record_anchor(
        self,
        ctx: _ResolutionContext,
        name: str,
        measure: Measure,
        result: set[str],
    ) -> None:
        """Settle the grain a cross-fact measure is evaluated at, or refuse.

        A declared ``anchor:`` settles it. Otherwise the facts must share
        exactly one directly-joined object: several are several different
        answers, and the measure has to say which it means.
        """
        conformed = measure.source_objects
        anchor = effective_anchor(ctx.model, measure, ctx.result.use_path_names)
        if anchor is None:
            candidates = conform_key_candidates(ctx.model, measure, ctx.result.use_path_names)
            if len(candidates) != 1:
                ctx.errors.append(
                    SemanticError(
                        code="ANCHOR_REQUIRED_AMBIGUOUS_KEY",
                        message=(
                            f"Measure '{name}' reads {', '.join(sorted(conformed))}, which no "
                            f"join path reaches together, so each has to be aggregated to a key "
                            f"they share. "
                            + (
                                f"They share {', '.join(candidates)}, and conforming at each "
                                f"gives a different answer."
                                if candidates
                                else "They share no directly joined data object."
                            )
                        ),
                        path=f"measures.{name}",
                        hint=(
                            "Set anchor: on the measure to the data object whose grain the "
                            "expression should be evaluated at - one of the facts it reads, or "
                            "a data object all of them join to."
                            if candidates
                            else "Join the data objects to a common one, or read them in "
                            "separate measures and combine those with a metric."
                        ),
                    )
                )
                return
            anchor = candidates[0]
            ctx.result.warnings.append(
                warning(
                    code=WarningCode.CONFORMED_GRAIN_ASSUMED,
                    message=(
                        f"Measure '{name}' reads {', '.join(sorted(conformed))}, which no join "
                        f"path reaches together. Each was aggregated to '{anchor}', the only "
                        f"data object they both join to, and the expression evaluated once per "
                        f"'{anchor}' row."
                    ),
                    hint=(
                        "Set anchor: to evaluate per row of one of the facts instead, which "
                        "changes AVG / MIN / MAX (though not SUM)."
                    ),
                    context={"measure": name, "conformedAt": anchor},
                )
            )

        ctx.result.anchored_measures[name] = anchor
        # Only the objects actually conformed leave the join requirements. An
        # object the anchor *can* reach is read directly, by an ordinary join,
        # so dropping it here left the expression naming a table the plan no
        # longer joined - visible only when nothing else in the query happened
        # to require it.
        result -= anchored_conformed_objects(ctx.model, measure, ctx.result.use_path_names)
        result.add(anchor)

    def _get_measure_source_objects(self, ctx: _ResolutionContext, name: str) -> set[str]:
        """Extract all source data objects for a measure or metric."""
        result: set[str] = set()

        measure = ctx.model.effective_measures.get(name)
        if measure:
            for cref in measure.columns:
                if cref.view:
                    result.add(cref.view)
            if measure.expression:
                for obj_name, _col_name in find_qualified_refs(measure.expression):
                    result.add(obj_name)
            for fi in measure.filters:
                collect_measure_filter_objects(fi, result)
            # An anchored measure's independent facts are conformed into
            # subqueries by the planner, not joined. Reporting them here would
            # add them to the join requirements and, worse, make the multi-fact
            # check below flip the query into a CFL plan - whose UNION ALL puts
            # the two facts' columns on different rows, which is the exact
            # arrangement the anchor exists to avoid.
            # Gated on "needs a grain" rather than "has one": a measure that
            # needs one and cannot be given one has to be refused, and treating
            # that as "needs nothing" is what let it through to a CFL leg that
            # projected no such column.
            if needs_conforming(ctx.model, measure, ctx.result.use_path_names):
                self._record_anchor(ctx, name, measure, result)
            return result

        metric = ctx.model.metrics.get(name)
        if metric:
            if metric.type == MetricType.CUMULATIVE and metric.measure:
                # Cumulative metric: source objects come from the referenced measure
                result.update(self._get_measure_source_objects(ctx, metric.measure))
            elif metric.type == MetricType.WINDOW and metric.measure:
                # Window metric: source objects come from the referenced measure
                result.update(self._get_measure_source_objects(ctx, metric.measure))
            elif metric.expression:
                # Derived or PoP metric: parse expression for measure references
                measure_refs = re.findall(r"\{\[([^\]]+)\]\}", metric.expression)
                for ref_name in measure_refs:
                    result.update(self._get_measure_source_objects(ctx, ref_name))

        return result

    # -- base object selection -----------------------------------------------

    @staticmethod
    def _collect_where_filter_objects(query: QueryObject, model: SemanticModel) -> set[str]:
        """Data objects the query's WHERE clause references.

        Walks ``query.where`` recursively; each ``field`` is either a dimension
        name or a qualified ``DataObject.Column``. Measure names are skipped —
        those are HAVING predicates, evaluated after aggregation rather than
        joined into the FROM.

        ``EXISTS`` / ``NONEXISTS`` targets are skipped too: they compile to a
        correlated subquery, not a join, so they place no reachability demand on
        the base object.

        A predicate on a computed column demands whatever objects its expression
        reads as well — the predicate is only as reachable as the columns it
        compares.
        """
        found: set[str] = set()
        measure_names = model.effective_measures

        def visit(item: QueryFilterItem) -> None:
            if isinstance(item, QueryFilterGroup):
                for child in item.filters:
                    visit(child)
                return
            field = item.field
            if not field or field in measure_names or field in model.metrics:
                return
            dimension = model.dimensions.get(field)
            if dimension is not None:
                if dimension.view:
                    found.add(dimension.view)
                    found.update(model.column_reference_objects(dimension.view, dimension.column))
                return
            if "." in field:
                object_name, _, column_name = field.partition(".")
                object_name, column_name = object_name.strip(), column_name.strip()
                if object_name in model.data_objects:
                    found.add(object_name)
                    found.update(model.column_reference_objects(object_name, column_name))

        for entry in query.where:
            visit(entry)
        return found

    @staticmethod
    def _reanchor_if_unreachable(
        ctx: _ResolutionContext, best: str, filter_objects: set[str]
    ) -> str:
        """Re-anchor the base when the chosen measure source cannot reach the query.

        Anchoring on a measure's own source object is right whenever that object
        is the fact table. It is wrong when the measure lives on a *dimension*
        table: joins are declared many-to-one and traversed forward-only, so a
        base of ``Customers`` can reach nothing, and a query grouping
        ``Avg Customer Age`` by ``Category`` fails with
        ``UNREACHABLE_REQUIRED_OBJECT`` even though ``Sales`` joins to both.

        Such a query is not multi-fact — it is single-fact viewed from the wrong
        end. Re-anchoring on the common root that reaches every required object
        makes it plan as an ordinary star; the measure then sits on the replicated
        side of a forward join, where ``compiler.grain_dedup`` aggregates it over
        deduplicated rows.

        Deliberately narrow, so this can only turn an error into a result and
        never re-plan a query that already works:

        * Only with **one** measure source object. Multi-fact queries keep their
          existing base so CFL detection (which runs on it straight after) is
          untouched.
        * Only when that base genuinely cannot reach the rest — the case that
          errors today.
        * Only when a common root actually exists; otherwise the original base
          is returned and the existing error still fires.

        *filter_objects* are the data objects the query's WHERE clause names.
        They are not in ``required_objects`` yet — filters resolve much later —
        but the base still has to reach them: a WHERE on an unreachable object
        is silently dropped downstream, which would answer a different question
        than the one asked. Static model filters are deliberately excluded; those
        are declared "apply where relevant", so they must not drag the base
        towards a table the query never mentioned.
        """
        if len(ctx.result.measure_source_objects) != 1:
            return best

        wanted = ctx.result.required_objects | filter_objects
        remaining = wanted - {best}
        if not remaining:
            return best

        graph = JoinGraph(ctx.model, use_path_names=ctx.result.use_path_names or None)
        if remaining <= graph.descendants(best):
            return best

        root = graph.find_common_root(wanted | {best})
        if not root:
            return best
        # ``find_common_root`` falls back to a Steiner centre when no single
        # ancestor covers everything, so its answer is a best effort rather than
        # a guarantee. Re-anchor only on a root that genuinely reaches the whole
        # query; otherwise keep the original base and let the existing
        # unreachable error or filter skip stand.
        if wanted <= graph.descendants(root) | {root}:
            return root
        return best

    def _reject_unsupported_nested_shapes(
        self, ctx: _ResolutionContext, touched_objects: set[str]
    ) -> None:
        """Refuse the two nested shapes the planner cannot render yet.

        **A nested object in a union leg.** Each CFL leg picks its own root and
        builds its own FROM, and neither knows how to carry an unnest, so the
        leg would select from a table the object does not have. Two nested
        objects in one query are meant to *become* that union; this is what has
        to land first.

        **A nested object whose parent is itself nested.** An unnest names its
        parent's array column, and where the parent is an element rather than a
        row that reference is not a column at all: Snowflake's ``FLATTEN`` row
        exposes the element under ``value``, so the array is ``p.value:"Parts"``
        and ``p."Parts"`` does not compile, and MySQL's ``JSON_TABLE`` projects
        only the scalar columns it was told to extract, so the array is not
        there to read. Reaching it needs a per-dialect parent-element access
        that is designed and measured on its own; until then the shape is
        refused rather than emitted as SQL two engines reject and five have
        never been asked.

        Both are refused rather than compiled, because the alternative is SQL
        naming a table that does not exist or - worse, and this is what the
        filter case actually did - a plausible number from unfiltered rows.
        """
        multi_fact = ctx.result.requires_cfl or ctx.result.dimensions_exclude
        for name in sorted(touched_objects):
            obj = ctx.model.data_objects.get(name)
            source = obj.nested_in if obj is not None else None
            if source is None:
                continue
            parent = ctx.model.data_objects.get(source.data_object)
            if parent is not None and parent.is_nested:
                ctx.errors.append(
                    SemanticError(
                        code="NESTED_WITHIN_NESTED_UNSUPPORTED",
                        message=(
                            f"Data object '{name}' is nested in '{source.data_object}', which "
                            f"is itself nested. An unnest names its parent's array column, and "
                            f"where the parent is an array element rather than a row, that "
                            f"reference is spelled differently on every engine - so this "
                            f"compiles to SQL some of them reject."
                        ),
                        path=f"dataObjects.{name}.nestedIn",
                        hint=(
                            "Nest the object directly in the table's own object, or read the "
                            "inner array through a flattening view by declaring 'code' "
                            "alongside 'nestedIn'."
                        ),
                    )
                )
                continue
            if multi_fact:
                ctx.errors.append(
                    SemanticError(
                        code="NESTED_OBJECT_IN_MULTI_FACT",
                        message=(
                            f"Data object '{name}' takes its rows by unnesting "
                            f"'{source.data_object}.{source.column}', and this "
                            f"query is planned as a union of independent facts, where each "
                            f"leg selects from a table of its own. A nested object has none."
                        ),
                        path="select",
                        hint=(
                            "Query the nested object with measures from its own parent only, "
                            "or read it through a flattening view by declaring 'code' "
                            "alongside 'nestedIn'."
                        ),
                    )
                )

    def _select_base_object(
        self, ctx: _ResolutionContext, filter_objects: set[str] | None = None
    ) -> str:
        """Select the base (fact) object, which is never a nested one.

        A ``nestedIn`` object has no table: its rows are an array column on its
        parent, reached by an unnest that names the parent. Every route into
        this function can nominate one anyway - it is a measure source like any
        other, and the "prefer the object with the most joins" fallback does not
        look at where rows come from - so the answer is mapped up to the nearest
        ancestor a FROM clause can name, which reaches everything the nested
        object does and more.
        """
        return ctx.model.unnest_root(self._choose_base_object(ctx, filter_objects))

    def _choose_base_object(
        self, ctx: _ResolutionContext, filter_objects: set[str] | None = None
    ) -> str:
        """Prefer measure source objects with the most joins."""
        # An anchored measure has already been told which grain to run at, and
        # the planner joins its conformed subqueries against that object. Moving
        # the base elsewhere leaves those joins referencing a table no longer in
        # the FROM. Where the anchor cannot reach the query's other objects the
        # query is genuinely unanswerable at that grain, and the reachability
        # check below says so - which is more use than a binder error.
        anchors = set(ctx.result.anchored_measures.values())
        if len(anchors) == 1:
            return next(iter(anchors))

        if ctx.result.measure_source_objects:
            # Among the measure sources, prefer one that can actually reach the
            # others. "Most joins" alone picks the busiest fact, which is not
            # the same thing: a measure reading Sales and Returns, where the
            # declared join runs Returns -> Sales, has to be based on Returns,
            # because Sales reaches Returns only by traversing that join
            # backwards. Basing it on Sales left the join path empty and the SQL
            # said AVG("Sales"."salesamount" * "Returns"."returnquantity") over
            # a FROM with no Returns in it.
            #
            # Where nothing reaches everything the facts are genuinely
            # independent, and the fallback below leaves CFL and the conformed
            # anchor path to deal with them.
            #
            # Only a measure that *by itself* reads several objects constrains
            # the base, because only it needs them on one row. Constraining on
            # the union of every measure's objects instead re-bases ordinary
            # multi-fact queries: two independent measures would base at
            # whichever fact reaches the other, and the reached fact's rows
            # would then repeat once per row of the base. That is the fanout
            # those queries stay on CFL to avoid.
            spanning: set[str] = set()
            for resolved_measure in ctx.result.measures:
                model_measure = ctx.model.effective_measures.get(resolved_measure.name)
                if model_measure is None:
                    continue
                objects = model_measure.source_objects
                if len(objects) > 1:
                    spanning |= objects

            sources = ctx.result.measure_source_objects
            candidates = sorted(sources)
            if spanning:
                graph = JoinGraph(ctx.model, use_path_names=ctx.result.use_path_names or None)
                candidates = [
                    name for name in candidates if spanning <= (graph.descendants(name) | {name})
                ] or sorted(sources)

            best = ""
            best_joins = -1
            for obj_name in candidates:
                obj = ctx.model.data_objects.get(obj_name)
                n = len(obj.joins) if obj else 0
                if n > best_joins:
                    best = obj_name
                    best_joins = n
            if best:
                return self._reanchor_if_unreachable(ctx, best, filter_objects or set())

        # Dimension-only: use JoinGraph to find the deepest ancestor
        # (possibly an intermediate fact/bridge table) that can reach
        # all required dimension objects via directed join paths.
        # (See ``_reanchor_if_unreachable`` — the same idea, applied when a
        # measure pinned the base to an object that cannot reach the rest.)
        if len(ctx.result.required_objects) > 1:
            graph = JoinGraph(ctx.model, use_path_names=ctx.result.use_path_names or None)
            root = graph.find_common_root(ctx.result.required_objects)
            if root:
                return root

        for obj_name in sorted(ctx.result.required_objects):
            obj = ctx.model.data_objects.get(obj_name)
            if obj and obj.joins:
                return obj_name

        if ctx.result.required_objects:
            return next(iter(sorted(ctx.result.required_objects)))
        if ctx.model.data_objects:
            return next(iter(ctx.model.data_objects))
        return ""

    # -- usePathNames validation ---------------------------------------------

    def _validate_use_path_names(
        self, ctx: _ResolutionContext, use_path_names: list[UsePathName]
    ) -> None:
        """Validate usePathNames references."""
        for upn in use_path_names:
            if upn.source not in ctx.model.data_objects:
                ctx.errors.append(
                    SemanticError(
                        code="UNKNOWN_DATA_OBJECT",
                        message=f"usePathNames references unknown data object '{upn.source}'",
                        path="usePathNames",
                    )
                )
                continue
            if upn.target not in ctx.model.data_objects:
                ctx.errors.append(
                    SemanticError(
                        code="UNKNOWN_DATA_OBJECT",
                        message=f"usePathNames references unknown data object '{upn.target}'",
                        path="usePathNames",
                    )
                )
                continue
            source_obj = ctx.model.data_objects[upn.source]
            found = any(
                j.join_to == upn.target and j.secondary and j.path_name == upn.path_name
                for j in source_obj.joins
            )
            if not found:
                ctx.errors.append(
                    SemanticError(
                        code="UNKNOWN_PATH_NAME",
                        message=(
                            f"No secondary join with pathName '{upn.path_name}' "
                            f"from '{upn.source}' to '{upn.target}'"
                        ),
                        path="usePathNames",
                    )
                )

    # -- static model filters ------------------------------------------------

    def _resolve_static_filter(
        self, ctx: _ResolutionContext, mf: ModelFilter
    ) -> ResolvedFilter | None:
        """Resolve a static model filter to a physical WHERE expression.

        Silently skips filters on data objects that are unreachable from the
        query's join graph — they are simply irrelevant to the current query.
        """
        return filter_resolution.resolve_static_filter(self, ctx, mf)

    # -- filters -------------------------------------------------------------

    def _resolve_filter_object(
        self,
        ctx: _ResolutionContext,
        obj_name: str,
        filter_path: str,
        _field_label: str,
    ) -> bool:
        """Ensure *obj_name* is joined; auto-extend if reachable.

        Silently skips filters on unreachable data objects — they are
        irrelevant to the current query.
        """
        return filter_resolution.resolve_filter_object(
            self, ctx, obj_name, filter_path, _field_label
        )

    def _resolve_filter_item(
        self, ctx: _ResolutionContext, item: QueryFilterItem, *, is_having: bool
    ) -> ResolvedFilter | None:
        """Resolve a filter item (leaf or group) to a physical expression."""
        return filter_resolution.resolve_filter_item(self, ctx, item, is_having=is_having)

    def _resolve_filter_group(
        self, ctx: _ResolutionContext, group: QueryFilterGroup, *, is_having: bool
    ) -> ResolvedFilter | None:
        """Resolve a filter group recursively, combining with AND/OR."""
        return filter_resolution.resolve_filter_group(self, ctx, group, is_having=is_having)

    def _resolve_filter(
        self, ctx: _ResolutionContext, qf: QueryFilter, *, is_having: bool
    ) -> ResolvedFilter | None:
        """Resolve a query filter to a physical expression.

        Filter fields can reference:
        1. A dimension name (e.g. ``"Order Priority"``)
        2. A qualified column ``"DataObject.Column"`` (e.g. ``"Orders.Order Priority"``)
        3. For HAVING filters, a measure name (e.g. ``"Revenue"``)

        If the referenced data object is reachable but not yet joined, the
        join path is auto-extended.
        """
        return filter_resolution.resolve_filter(self, ctx, qf, is_having=is_having)

    # -- order by ------------------------------------------------------------

    def _resolve_order_by_field(
        self, ctx: _ResolutionContext, field_name: str, select_count: int
    ) -> Expr | None:
        """Resolve an order-by field to its expression."""
        return filter_resolution.resolve_order_by_field(self, ctx, field_name, select_count)

resolve(query, model, qualify_table=None)

Source code in src/orionbelt/compiler/resolution.py
def resolve(
    self,
    query: QueryObject,
    model: SemanticModel,
    qualify_table: Callable[[DataObject], str] | None = None,
) -> ResolvedQuery:
    ctx = _ResolutionContext(
        model=model,
        result=ResolvedQuery(
            limit=query.limit,
            offset=query.offset,
            use_path_names=list(query.use_path_names),
            allow_fan_out=query.allow_fan_out,
            is_raw=query.select.is_raw,
            distinct=query.select.distinct,
            grouping=query.grouping,
        ),
        qualify_table=qualify_table,
    )

    # Build global column lookup: col_name → (object_name, source_column)
    for obj_name, obj in model.data_objects.items():
        for col_name, col_obj in obj.columns.items():
            ctx.global_columns[col_name] = (obj_name, col_obj.code)

    if query.select.is_raw:
        # Raw mode: project physical columns, no aggregation.
        for ref in query.select.fields:
            self._resolve_raw_field(ctx, ref)
    else:
        # Aggregate mode (default).
        # 1. Resolve dimensions (string or coalesce group).
        # Coalesce groups expand into their constituent dimensions, each
        # tagged with the same coalesce_alias so the CFL outer wrapper can
        # emit COALESCE(d1, d2, ...) AS <alias>.
        for dim_entry in query.select.dimensions:
            if isinstance(dim_entry, CoalesceDimension):
                self._resolve_coalesce_dimension(ctx, dim_entry, ctx.result.coalesce_aliases)
            else:
                self._append_resolved_dimension(ctx, dim_entry)

        # 2. Resolve measures and track their source objects
        for measure_name in query.select.measures:
            resolved_meas = self._resolve_measure(ctx, measure_name)
            if resolved_meas:
                ctx.result.measures.append(resolved_meas)
                source_objs = self._get_measure_source_objects(ctx, measure_name)
                ctx.result.measure_source_objects.update(source_objs)
                ctx.result.required_objects.update(source_objs)
                ctx.result.required_objects.update(
                    self._get_measure_join_objects(ctx, measure_name)
                )

        # 2.5. Auto-include measures referenced by HAVING but not by SELECT.
        # Without this, codegen emits a HAVING clause that references an
        # alias for a column the SELECT doesn't project — every database
        # rejects the SQL with a "must appear in GROUP BY" binder error.
        # Routing this through the regular measure-resolution path also
        # updates ``measure_source_objects`` so the multi-fact CFL trigger
        # below sees the HAVING-only measure's source.
        existing_measure_names = {m.name for m in ctx.result.measures}
        for ref in self._collect_having_measure_refs(query, model):
            if ref in existing_measure_names:
                continue
            resolved_meas = self._resolve_measure(ctx, ref)
            if resolved_meas is None:
                continue
            ctx.result.measures.append(resolved_meas)
            ctx.result.having_only_measures.add(ref)
            existing_measure_names.add(ref)
            source_objs = self._get_measure_source_objects(ctx, ref)
            ctx.result.measure_source_objects.update(source_objs)
            ctx.result.required_objects.update(source_objs)
            ctx.result.required_objects.update(self._get_measure_join_objects(ctx, ref))

    # 3. Determine base object (the one with most joins / most measures).
    # WHERE filters are resolved much later, so the objects they reference
    # are collected up front — the base has to be able to reach them, or
    # the filter is silently dropped as unreachable further down.
    where_filter_objects = self._collect_where_filter_objects(query, model)
    ctx.result.base_object = self._select_base_object(ctx, where_filter_objects)
    if ctx.result.base_object:
        ctx.result.required_objects.add(ctx.result.base_object)

    # An anchored measure pins the base, which bypasses the re-anchoring that
    # normally makes a filter's data object reachable. A predicate on a fact
    # the anchor only reaches by conforming cannot be honoured where it
    # stands: the fact is aggregated to the shared key before the expression
    # is evaluated, so the predicate would compare a per-key total rather
    # than choose rows. Restricting it properly means a WHERE inside the
    # conformed subquery, which is not built.
    #
    # Refused rather than dropped. Filters on an unreachable object are
    # skipped silently elsewhere, which is tolerable when the object is
    # merely absent - here the query names a real fact the plan does read,
    # and skipping returned unfiltered totals with nothing to say so.
    # Static model filters count, and count for more: they are documented
    # as applied to every query, so dropping one silently widens every
    # result the model ever returns. Collected here rather than in
    # ``_collect_where_filter_objects`` so base-object selection keeps the
    # behaviour it has for models with no anchored measure.
    self._reject_filters_on_conformed_objects(
        ctx,
        where_filter_objects | {mf.data_object for mf in model.filters},
    )

    # Detect multi-fact: CFL is needed only when measure source objects
    # span multiple independent fact tables.
    if len(ctx.result.measure_source_objects) > 1:
        graph = JoinGraph(model, use_path_names=query.use_path_names or None)
        reachable = graph.descendants(ctx.result.base_object)
        unreachable = ctx.result.measure_source_objects - reachable - {ctx.result.base_object}
        if unreachable:
            ctx.result.requires_cfl = True

    # Dimension-only queries: when dimensions span independent branches,
    # join through intermediate bridge/fact tables (no CFL needed).
    # Add intermediate tables from the join steps to required_objects
    # so the star schema planner includes them.
    if not ctx.result.measure_source_objects and ctx.result.dimensions:
        dim_objects = {d.object_name for d in ctx.result.dimensions}
        if not dim_objects <= {ctx.result.base_object}:
            graph = JoinGraph(model, use_path_names=query.use_path_names or None)
            steps = graph.find_join_path(
                {ctx.result.base_object},
                dim_objects,
                via_constraints=ctx.result.via_constraints or None,
            )
            for step in steps:
                ctx.result.required_objects.add(step.from_object)
                ctx.result.required_objects.add(step.to_object)

    # Raw mode: detect multi-fact (fields span objects unreachable from
    # the base via directed joins). The pipeline rejects this case for
    # now — raw CFL is a planned follow-up.
    if ctx.result.is_raw and ctx.result.base_object:
        field_objects = {f.object_name for f in ctx.result.fields}
        if len(field_objects) > 1:
            graph = JoinGraph(model, use_path_names=query.use_path_names or None)
            reachable = graph.descendants(ctx.result.base_object)
            unreachable = field_objects - reachable - {ctx.result.base_object}
            if unreachable:
                ctx.result.requires_cfl = True

    # Validate dimensionsExclude constraints
    if query.dimensions_exclude:
        if query.select.measures:
            ctx.errors.append(
                SemanticError(
                    code="DIMENSIONS_EXCLUDE_WITH_MEASURES",
                    message="dimensionsExclude cannot be combined with measures",
                    path="select",
                )
            )
        elif len(ctx.result.dimensions) < 2:
            ctx.errors.append(
                SemanticError(
                    code="DIMENSIONS_EXCLUDE_INSUFFICIENT",
                    message="dimensionsExclude requires at least 2 dimensions",
                    path="select.dimensions",
                )
            )
        else:
            ctx.result.dimensions_exclude = True

    # Every object this *query* names, including the ones only a predicate
    # does. A WHERE filter is resolved much later, so a guard reading
    # ``required_objects`` alone sees none of them - and a filter is exactly
    # how a nested object reaches a query that projects nothing from it.
    #
    # A static model filter is deliberately not counted. It is a property of
    # the model rather than of the query, and one naming an object this plan
    # cannot reach is documented as skipped rather than fatal
    # (``test_unreachable_filter_silently_ignored``). Counting them made a
    # single nested static filter refuse every multi-fact query in the
    # model, including the ones that never go near it.
    self._reject_unsupported_nested_shapes(
        ctx, ctx.result.required_objects | where_filter_objects
    )

    # 4. Validate usePathNames before building join graph
    self._validate_use_path_names(ctx, query.use_path_names)

    # 5. Resolve join paths
    ctx.graph = JoinGraph(model, use_path_names=query.use_path_names or None)
    if ctx.result.base_object and len(ctx.result.required_objects) > 1:
        ambiguous: dict[str, list[list[str]]] = {}
        ctx.result.join_steps = ctx.graph.find_join_path(
            {ctx.result.base_object},
            ctx.result.required_objects,
            via_constraints=ctx.result.via_constraints or None,
            ambiguous=ambiguous,
        )
        # A dimension the query reaches by two equally close routes is two
        # different roles of one data object, and they select different
        # rows. Refused rather than picked — the same stance the filter
        # path takes, since a projected dimension is no more guessable.
        for object_name, routes in sorted(ambiguous.items()):
            names = (
                ", ".join(
                    f"'{dim.name}'"
                    for dim in ctx.result.dimensions
                    if dim.object_name == object_name
                )
                or f"'{object_name}'"
            )
            ctx.errors.append(
                SemanticError(
                    code="AMBIGUOUS_JOIN_PATH",
                    message=(
                        f"{names} is on '{object_name}', which this query reaches "
                        f"equally well by more than one route "
                        f"({', '.join(f'via {path[-2]!r}' for path in routes)}). "
                        f"Those are different roles of the same data object and "
                        f"they select different rows."
                    ),
                    path="select.dimensions",
                    hint=(
                        "Say which one is meant: declare a data object per role "
                        "over the same table and select from that, or give the "
                        "dimension a 'via:' waypoint naming the object the join "
                        "must traverse."
                    ),
                )
            )

    # Build set of all objects present in the query's join graph
    if ctx.result.base_object:
        ctx.joined_objects.add(ctx.result.base_object)
    for step in ctx.result.join_steps:
        ctx.joined_objects.add(step.to_object)

    # Detect required objects that the star-schema planner cannot reach.
    # Many-to-one joins are forward-only (reverse traversal would inflate
    # the base table), so a required object that's only reachable via a
    # reverse m-to-1 hop is unreachable.  Raise a clear error rather than
    # silently producing wrong SQL.  CFL legs are validated separately.
    if ctx.result.base_object and not ctx.result.requires_cfl:
        unreachable = ctx.result.required_objects - ctx.joined_objects
        for unreachable_name in sorted(unreachable):
            ctx.errors.append(
                SemanticError(
                    code="UNREACHABLE_REQUIRED_OBJECT",
                    message=(
                        f"Data object '{unreachable_name}' is required by the query but "
                        f"cannot be reached from base '{ctx.result.base_object}' via "
                        f"directed joins. Many-to-one joins are forward-only; reverse "
                        f"traversal would inflate row counts. Add an explicit join from "
                        f"'{ctx.result.base_object}' (or an intermediate object) to "
                        f"'{unreachable_name}', or split the query so each fact is "
                        f"queried independently."
                    ),
                    path="select",
                )
            )

    # 5b. Inject static model filters — always applied as WHERE conditions
    static_exprs: list[Expr] = []
    for mf in model.filters:
        static_filter = self._resolve_static_filter(ctx, mf)
        if static_filter:
            ctx.result.where_filters.append(static_filter)
            static_exprs.append(static_filter.expression)

    # 6. Classify filters — skip query-time duplicates of static filters
    for qfi in query.where:
        resolved_filter = self._resolve_filter_item(ctx, qfi, is_having=False)
        if resolved_filter and resolved_filter.expression not in static_exprs:
            ctx.result.where_filters.append(resolved_filter)

    for qfi in query.having:
        resolved_filter = self._resolve_filter_item(ctx, qfi, is_having=True)
        if resolved_filter:
            ctx.result.having_filters.append(resolved_filter)

    # 7. Resolve order by — must reference a dimension or measure in SELECT
    select_count = len(ctx.result.dimensions) + len(ctx.result.measures)
    for ob in query.order_by:
        expr = self._resolve_order_by_field(ctx, ob.field, select_count)
        if expr:
            ctx.result.order_by_exprs.append((expr, ob.direction == "desc", ob.nulls))

    # 8. ROLLUP / CUBE: backfill NULLS FIRST on any explicit ORDER BY entry
    # that didn't specify a NULLs position. Subtotal and grand-total rows
    # carry NULLs in the rolled-up group-by columns, and BI tools expect
    # those totals at the top of the result — not interleaved with details.
    if ctx.result.grouping is not None and ctx.result.order_by_exprs:
        ctx.result.order_by_exprs = [
            (expr, desc, NullsPosition.FIRST if nulls is None else nulls)
            for expr, desc, nulls in ctx.result.order_by_exprs
        ]

    # 9. Auto-order — when no explicit ORDER BY, append ORDER BY over all
    # SELECT dimensions (or raw fields) under two conditions:
    #   (a) LIMIT is set: cache hashes on compiled SQL; without ORDER BY
    #       ``LIMIT N`` returns any N rows, freezing one arbitrary slice.
    #   (b) ROLLUP / CUBE: subtotal layout is otherwise unpredictable.
    # ROLLUP / CUBE defaults to NULLS FIRST (totals at the top).
    # Aggregate-only queries (no dims, no fields) are already single-row
    # deterministic — skip.
    needs_auto_order = not ctx.result.order_by_exprs and (
        ctx.result.limit is not None or ctx.result.grouping is not None
    )
    if needs_auto_order:
        nulls_default = NullsPosition.FIRST if ctx.result.grouping is not None else None
        if ctx.result.is_raw and ctx.result.fields:
            for f in ctx.result.fields:
                ctx.result.order_by_exprs.append(
                    (ColumnRef(name=f.alias), False, nulls_default)
                )
        elif ctx.result.dimensions:
            for dim in ctx.result.dimensions:
                ctx.result.order_by_exprs.append(
                    (ColumnRef(name=dim.name), False, nulls_default)
                )

    if ctx.errors:
        raise ResolutionError(ctx.errors)

    return ctx.result