Skip to content
OBML v1.0
OrionBelt v2.26.0

Python API Reference

Auto-generated documentation from source code docstrings.

Service Layer

ModelStore

orionbelt.service.model_store.ModelStore

In-memory model registry. Thread-safe via threading.Lock.

Models are keyed by a 16-char hex id: content-derived for shared models (see service/model_cache.py) or random for private ones. All parsing, validation, and compilation infrastructure is instantiated internally, following the same singleton pattern as api/deps.py.

Source code in src/orionbelt/service/model_store.py
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
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
class ModelStore:
    """In-memory model registry.  Thread-safe via ``threading.Lock``.

    Models are keyed by a 16-char hex id: content-derived for shared models
    (see ``service/model_cache.py``) or random for private ones. All parsing,
    validation, and compilation infrastructure is instantiated internally,
    following the same singleton pattern as ``api/deps.py``.
    """

    def __init__(self, max_models: int = 10, shared_cache: ModelCache | None = None) -> None:
        self._lock = threading.Lock()
        # Process-wide content-addressed cache shared across sessions. When
        # None (CLI, stateless helpers, bare unit tests) every model stays
        # private to this store and ids are random — behaviour identical to
        # before the cache existed.
        self._shared_cache = shared_cache
        # model_ids in this store that are backed by ``_shared_cache`` (so
        # remove_model/close release the shared reference).
        self._shared_ids: set[str] = set()
        self._models: dict[str, SemanticModel] = {}
        # Parallel storage of each loaded model's *merged* raw YAML dict
        # so inheritance can re-merge against the exact same content the
        # parent was built from. Pre-fix (v2.7.5) inheritance round-tripped
        # through ``_model_to_raw`` which dropped most non-essential
        # fields (numClass, primaryKey, expression on computed columns,
        # measure dataType / filters / grain / delimiter / withinGroup,
        # most metric subtype config, …) — child models would inherit
        # a stripped parent and silently compile invalid SQL such as
        # ``SUM("T"."")`` for any parent computed column whose ``code:``
        # the resolver had derived from its ``expression``.
        self._raws: dict[str, dict[str, object]] = {}
        self._graphs: dict[str, GraphArtifact] = {}
        # Per-store summary cache so dedup hits can return the original
        # data_objects/dimensions/measures/metrics counts without re-walking
        # the model.
        self._summaries: dict[str, ModelSummary] = {}
        self._max_models = max_models
        # Dedup index: content_hash → model_id. Populated on every successful
        # load and consulted before parsing on the next load. See
        # design/PLAN_model_load_dedup.md.
        self._content_hash_index: dict[str, str] = {}

        # Internal pipeline singletons (stateless, safe to share).
        self._loader = TrackedLoader()
        self._resolver = ReferenceResolver()
        self._validator = SemanticValidator()
        self._merger = ExtendsMerger()
        self._pipeline = CompilationPipeline()

    # -- helpers -------------------------------------------------------------

    @staticmethod
    def _new_id() -> str:
        return uuid.uuid4().hex[:16]

    @staticmethod
    def _content_id(content_hash: str) -> str:
        """Stable content-derived id for a shared model.

        A 64-bit prefix of the content hash: identical OBML always yields the
        same id (so sessions loading matching bytes collapse to one shared
        compiled model and one result-cache key), while the full 64-hex
        ``content_hash`` remains the sharing key, so an id-label collision can
        never cause a wrong-model share.
        """
        return content_hash[:16]

    def _register_shared(self, entry: CompiledModel) -> None:
        """Register a shared cache entry into this store's local view.

        Caller must hold ``self._lock``. The store's read-through getters
        (get_model/get_raw/get_graph/describe/list_models/...) then serve the
        shared entry with no further changes.
        """
        self._models[entry.model_id] = entry.model
        self._raws[entry.model_id] = entry.raw
        self._graphs[entry.model_id] = entry.graph
        self._summaries[entry.model_id] = entry.summary
        self._content_hash_index[entry.content_hash] = entry.model_id
        self._shared_ids.add(entry.model_id)

    def _adopt_shared(self, entry: CompiledModel) -> None:
        """Take ownership of one reference to ``entry`` for this store.

        ``entry``'s refcount was already incremented by the caller (via
        ``acquire`` / ``insert_or_acquire``). This store holds exactly one
        reference per shared model_id it exposes, so a surplus reference (the
        store already references this content, e.g. concurrent identical loads)
        is released. Raises ``ModelCapacityError`` (releasing the reference
        first) when the store is full.
        """
        assert self._shared_cache is not None  # only called on shared paths
        with self._lock:
            if entry.model_id in self._shared_ids:
                surplus, over_cap = True, False
            else:
                surplus = False
                over_cap = len(self._models) >= self._max_models
            if not surplus and not over_cap:
                self._register_shared(entry)
        if surplus or over_cap:
            # Release outside the store lock — the cache has its own lock.
            self._shared_cache.release(entry.model_id)
        if over_cap:
            raise ModelCapacityError(f"Maximum models per session reached ({self._max_models})")

    @staticmethod
    def _health_for(model: SemanticModel) -> ModelHealthSummary:
        """Compute structural health for a loaded model."""
        h = compute_health(model)
        return ModelHealthSummary(
            status=h.status,
            data_objects=h.data_objects,
            joins=h.joins,
            orphan_data_objects=h.orphan_data_objects,
            fan_trap_risks=[
                FanTrapRiskInfo(
                    tables=r.tables,
                    reason=r.reason,
                    suggested_pattern=r.suggested_pattern,
                )
                for r in h.fan_trap_risks
            ],
            unreachable_dimensions=h.unreachable_dimensions,
            warnings_count=h.warnings_count,
        )

    @staticmethod
    def _content_hash(yaml_str: str) -> str:
        """SHA-256 of the OBML body, with surrounding whitespace stripped.

        Stripping at the boundary makes a trailing newline difference
        invisible to dedup; everything else (key order, comments, internal
        whitespace) still produces a different hash.
        """
        return hashlib.sha256(yaml_str.strip().encode("utf-8")).hexdigest()

    def _parse_and_validate(
        self,
        yaml_str: str | None = None,
        *,
        raw_dict: dict[str, object] | None = None,
        extends_yaml: list[str] | None = None,
        inherits_model_id: str | None = None,
    ) -> tuple[SemanticModel, dict[str, object], list[ErrorInfo], list[ErrorInfo]]:
        """Parse YAML (or accept pre-parsed dict), resolve references, validate.

        Returns ``(model, merged_raw, errors, warnings)``.
        Provide either ``yaml_str`` or ``raw_dict``, not both.

        ``merged_raw`` is the fully-merged raw dict the resolver consumed
        (after extends/inherits processing) — callers store it so future
        inherits-from-this-model loads can re-merge against the exact
        content rather than going through a lossy ``_model_to_raw``
        round-trip.
        """
        errors: list[ErrorInfo] = []
        warnings: list[ErrorInfo] = []

        # 1. Parse YAML or use pre-parsed dict
        if raw_dict is not None:
            raw = raw_dict
            source_map = None
        elif yaml_str is not None:
            try:
                raw, source_map = self._loader.load_string(yaml_str)
            except YAMLSafetyError as exc:
                errors.append(ErrorInfo(code="YAML_SAFETY_ERROR", message=str(exc)))
                return SemanticModel(), {}, errors, warnings
            except Exception as exc:
                errors.append(ErrorInfo(code="YAML_PARSE_ERROR", message=str(exc)))
                return SemanticModel(), {}, errors, warnings
        else:
            errors.append(
                ErrorInfo(
                    code="NO_MODEL_INPUT",
                    message="Provide either model_yaml or model_json",
                )
            )
            return SemanticModel(), {}, errors, warnings

        # 1b. Merge extends/inherits if provided
        try:
            inherits_raw: dict[str, object] | None = None
            if inherits_model_id is not None:
                # Prefer the parent's stored raw dict — captured at load
                # time so every field round-trips intact. Fall back to
                # the lossy ``_model_to_raw`` only when no raw is on
                # record (legacy / programmatically-constructed models).
                with self._lock:
                    inherits_raw = self._raws.get(inherits_model_id)
                if inherits_raw is None:
                    parent_model = self.get_model(inherits_model_id)
                    inherits_raw = self._model_to_raw(parent_model)

            if extends_yaml or inherits_raw is not None:
                raw, merge_warnings = self._merger.merge_from_strings(
                    raw,
                    extend_yamls=extends_yaml,
                    inherits_raw=inherits_raw,
                )
                for mw in merge_warnings:
                    warnings.append(
                        ErrorInfo(
                            code=WarningCode.MERGE_WARNING,
                            message=mw,
                            severity="warning",
                        )
                    )
                source_map = None
        except MergeError as exc:
            errors.append(ErrorInfo(code=exc.code, message=exc.message))
            return SemanticModel(), {}, errors, warnings
        except KeyError:
            errors.append(
                ErrorInfo(
                    code="PARENT_MODEL_NOT_FOUND",
                    message=f"Parent model '{inherits_model_id}' not found in session",
                )
            )
            return SemanticModel(), {}, errors, warnings

        # 2. Resolve references
        model, resolution = self._resolver.resolve(raw, source_map)
        for e in resolution.errors:
            errors.append(
                ErrorInfo(
                    code=e.code,
                    message=e.message,
                    path=e.path,
                    suggestions=list(e.suggestions),
                    severity=e.severity,
                    hint=e.hint,
                    context=e.context,
                )
            )
        for w in resolution.warnings:
            warnings.append(
                ErrorInfo(
                    code=w.code,
                    message=w.message,
                    path=w.path,
                    suggestions=list(w.suggestions),
                    severity=w.severity or "warning",
                    hint=w.hint,
                    context=w.context,
                )
            )

        # 3. Semantic validation
        sem_errors = self._validator.validate(model)
        for e in sem_errors:
            info = ErrorInfo(
                code=e.code,
                message=e.message,
                path=e.path,
                suggestions=list(e.suggestions),
                severity=e.severity,
                hint=e.hint,
                context=e.context,
            )
            if e.severity == "warning":
                warnings.append(info)
            else:
                errors.append(info)

        # 4. Cross-dataObject refresh contract consistency check.
        from orionbelt.cache.contracts import collect_table_contracts

        _, refresh_warnings = collect_table_contracts(model)
        for w in refresh_warnings:
            warnings.append(
                ErrorInfo(
                    code=w.code,
                    message=w.message,
                    path=w.path,
                    suggestions=list(w.suggestions),
                    severity=w.severity or "warning",
                    hint=w.hint,
                    context=w.context,
                )
            )

        return model, raw, errors, warnings

    @staticmethod
    def _model_to_raw(model: SemanticModel) -> dict[str, object]:
        """Convert a SemanticModel back to a raw dict for inherits merging.

        .. deprecated:: v2.7.5
            Lossy fallback only — drops most non-essential fields. New
            code stores and reuses the merged raw dict captured at load
            time (see ``ModelStore._raws``). This method remains for the
            edge case where a parent model was constructed programmatically
            without ever passing through ``load_model``.
        """
        raw: dict[str, object] = {"version": model.version}
        if model.description:
            raw["description"] = model.description
        if not model.expose_counts:
            raw["exposeCounts"] = False
        if model.count_label_pattern != DEFAULT_COUNT_PATTERN:
            raw["countLabelPattern"] = model.count_label_pattern
        if model.data_objects:
            objs: dict[str, object] = {}
            for name, obj in model.data_objects.items():
                obj_raw: dict[str, object] = {
                    "code": obj.code,
                    "database": obj.database,
                    "schema": obj.schema_name,
                }
                if obj.columns:
                    cols: dict[str, object] = {}
                    for cname, col in obj.columns.items():
                        cols[cname] = {
                            "code": col.code,
                            "abstractType": col.abstract_type.value,
                        }
                    obj_raw["columns"] = cols
                if obj.joins:
                    joins: list[dict[str, object]] = []
                    for j in obj.joins:
                        jd: dict[str, object] = {
                            "joinType": j.join_type.value,
                            "joinTo": j.join_to,
                            "columnsFrom": list(j.columns_from),
                            "columnsTo": list(j.columns_to),
                        }
                        if j.secondary:
                            jd["secondary"] = True
                            jd["pathName"] = j.path_name
                        joins.append(jd)
                    obj_raw["joins"] = joins
                # Carry count-synthesis knobs when non-default so inheritance
                # does not silently re-enable a suppressed count.
                if not obj.countable:
                    obj_raw["countable"] = False
                if obj.count_label is not None:
                    obj_raw["countLabel"] = obj.count_label
                if obj.refresh is not None:
                    refresh: dict[str, object] = {"mode": obj.refresh.mode}
                    if obj.refresh.interval:
                        refresh["interval"] = obj.refresh.interval
                    if obj.refresh.anchor:
                        refresh["anchor"] = obj.refresh.anchor
                    if obj.refresh.timezone:
                        refresh["timezone"] = obj.refresh.timezone
                    if obj.refresh.max_staleness:
                        refresh["maxStaleness"] = obj.refresh.max_staleness
                    obj_raw["refresh"] = refresh
                objs[name] = obj_raw
            raw["dataObjects"] = objs
        if model.dimensions:
            dims: dict[str, object] = {}
            for name, dim in model.dimensions.items():
                dd: dict[str, object] = {
                    "dataObject": dim.view,
                    "column": dim.column,
                    "resultType": dim.result_type.value,
                }
                if dim.time_grain:
                    dd["timeGrain"] = dim.time_grain.value
                dims[name] = dd
            raw["dimensions"] = dims
        if model.measures:
            meas: dict[str, object] = {}
            for name, m in model.measures.items():
                md: dict[str, object] = {
                    "aggregation": m.aggregation,
                    "resultType": m.result_type.value,
                }
                if m.expression:
                    md["expression"] = m.expression
                if m.columns:
                    md["columns"] = [
                        {"dataObject": c.view or "", "column": c.column or ""} for c in m.columns
                    ]
                if m.total:
                    md["total"] = True
                meas[name] = md
            raw["measures"] = meas
        if model.metrics:
            mets: dict[str, object] = {}
            for name, met in model.metrics.items():
                mtd: dict[str, object] = {"type": met.type.value}
                if met.expression:
                    mtd["expression"] = met.expression
                if met.measure:
                    mtd["measure"] = met.measure
                if met.time_dimension:
                    mtd["timeDimension"] = met.time_dimension
                mets[name] = mtd
            raw["metrics"] = mets
        if model.filters:
            raw["filters"] = [
                {
                    "dataObject": f.data_object,
                    "column": f.column,
                    "operator": f.operator,
                    **({"value": f.value} if f.value is not None else {}),
                    **({"values": f.values} if f.values else {}),
                }
                for f in model.filters
            ]
        return raw

    # -- public API ----------------------------------------------------------

    def load_model(
        self,
        yaml_str: str | None = None,
        *,
        raw_dict: dict[str, object] | None = None,
        extends_yaml: list[str] | None = None,
        inherits_model_id: str | None = None,
        dedup: bool = True,
    ) -> LoadResult:
        """Parse, validate, and store a model.  Returns id + summary.

        Provide either ``yaml_str`` or ``raw_dict``.
        Raises ``ModelValidationError`` if the model has validation errors.
        Raises ``ModelCapacityError`` if the session's model cap is reached.

        When ``dedup`` is True (default) and the same OBML bytes have already
        been loaded into this store, the existing ``model_id`` is returned
        and ``model_load`` is set to ``"reused"``. Dedup only applies to
        plain ``yaml_str`` loads — when ``raw_dict``, ``extends_yaml``, or
        ``inherits_model_id`` is supplied the load always runs fresh, since
        the effective content depends on inputs not captured by the YAML
        bytes alone.
        """
        # Dedup is meaningful only for a stand-alone YAML body. The other
        # input shapes either skip the YAML stage (raw_dict) or fold in
        # additional state (extends/inherits) that the bytes don't capture.
        dedup_eligible = (
            dedup
            and yaml_str is not None
            and raw_dict is None
            and not extends_yaml
            and inherits_model_id is None
        )
        content_hash: str | None = None
        if dedup_eligible:
            content_hash = self._content_hash(yaml_str or "")
            with self._lock:
                existing_id = self._content_hash_index.get(content_hash)
                if existing_id is not None and existing_id in self._models:
                    summary = self._summaries.get(existing_id)
                    if summary is not None:
                        existing_model = self._models[existing_id]
                        existing_health = self._health_for(existing_model)
                        return LoadResult(
                            model_id=existing_id,
                            data_objects=summary.data_objects,
                            dimensions=summary.dimensions,
                            measures=summary.measures,
                            metrics=summary.metrics,
                            warnings=[],
                            model_load="reused",
                            health=existing_health,
                        )
                # Stale index entry — drop it and fall through to a fresh load.
                if existing_id is not None:
                    self._content_hash_index.pop(content_hash, None)

        # Cross-session hit: another session already compiled these exact
        # bytes. Adopt the shared compiled model with no recompile. From this
        # store's perspective the load is still "fresh" (a new reference for
        # this session); the compile-skip is a transparent optimisation.
        if dedup_eligible and self._shared_cache is not None and content_hash is not None:
            shared = self._shared_cache.acquire(content_hash)
            if shared is not None:
                self._adopt_shared(shared)
                return LoadResult(
                    model_id=shared.model_id,
                    data_objects=shared.summary.data_objects,
                    dimensions=shared.summary.dimensions,
                    measures=shared.summary.measures,
                    metrics=shared.summary.metrics,
                    warnings=[],
                    model_load="fresh",
                    health=self._health_for(shared.model),
                )

        with self._lock:
            if len(self._models) >= self._max_models:
                raise ModelCapacityError(f"Maximum models per session reached ({self._max_models})")

        model, merged_raw, errors, warnings = self._parse_and_validate(
            yaml_str,
            raw_dict=raw_dict,
            extends_yaml=extends_yaml,
            inherits_model_id=inherits_model_id,
        )
        if errors:
            raise ModelValidationError(errors, warnings)

        shared_load = dedup_eligible and self._shared_cache is not None and content_hash is not None
        # Content-derived id for shared models so identical bytes collapse to
        # one id (and one result-cache key) across sessions; random id
        # otherwise, preserving the ``dedup=False`` "distinct model" contract.
        model_id = (
            self._content_id(content_hash)
            if content_hash is not None and shared_load
            else self._new_id()
        )

        # Eagerly export OBSL-Core graph (Option C: at model load time).
        graph = export_obsl(model, model_id)
        turtle = graph.serialize(format="turtle")
        artifact = GraphArtifact(graph=graph, turtle=turtle, generated_at=time.monotonic())

        summary = ModelSummary(
            model_id=model_id,
            data_objects=len(model.data_objects),
            dimensions=len(model.dimensions),
            measures=len(model.measures),
            metrics=len(model.metrics),
        )

        if shared_load:
            assert self._shared_cache is not None and content_hash is not None
            compiled = CompiledModel(
                model_id=model_id,
                content_hash=content_hash,
                model=model,
                raw=merged_raw,
                graph=artifact,
                summary=summary,
            )
            # A concurrent load of the same content may have won the race;
            # insert_or_acquire returns the surviving entry either way.
            entry = self._shared_cache.insert_or_acquire(compiled)
            self._adopt_shared(entry)
            return LoadResult(
                model_id=entry.model_id,
                data_objects=entry.summary.data_objects,
                dimensions=entry.summary.dimensions,
                measures=entry.summary.measures,
                metrics=entry.summary.metrics,
                warnings=warnings,
                model_load="fresh",
                health=self._health_for(entry.model),
            )

        with self._lock:
            # Re-check capacity under lock — the first check (above) ran
            # outside the lock while parsing/exporting, so a concurrent
            # request may have filled the slot in the meantime.
            if len(self._models) >= self._max_models:
                raise ModelCapacityError(f"Maximum models per session reached ({self._max_models})")
            self._models[model_id] = model
            self._raws[model_id] = merged_raw
            self._graphs[model_id] = artifact
            self._summaries[model_id] = summary
            if content_hash is not None:
                # If a concurrent request beat us to it, the last writer wins;
                # the race is benign (both models work, the older one is just
                # not reachable via the index). See PLAN_model_load_dedup.md §6.3.
                self._content_hash_index[content_hash] = model_id

        return LoadResult(
            model_id=model_id,
            data_objects=summary.data_objects,
            dimensions=summary.dimensions,
            measures=summary.measures,
            metrics=summary.metrics,
            warnings=warnings,
            model_load="fresh",
            health=self._health_for(model),
        )

    def get_model(self, model_id: str) -> SemanticModel:
        """Look up a loaded model.  Raises ``KeyError`` if not found."""
        with self._lock:
            try:
                return self._models[model_id]
            except KeyError:
                raise KeyError(f"No model loaded with id '{model_id}'") from None

    def get_raw(self, model_id: str) -> dict[str, object]:
        """Return the raw OBML dict for a loaded model.

        Prefers the merged raw dict captured verbatim at load time (so
        every field round-trips intact). Falls back to the lossy
        ``_model_to_raw`` reconstruction only for models that never passed
        through :meth:`load_model` (e.g. programmatically constructed).

        Raises ``KeyError`` if no model is loaded with the given id.

        Returns a deep copy so callers (and downstream converters) cannot
        mutate the store's internal ``_raws`` entry, which would corrupt
        later exports or ``inherits`` merges.
        """
        with self._lock:
            if model_id not in self._models:
                raise KeyError(f"No model loaded with id '{model_id}'")
            raw = self._raws.get(model_id)
            model = self._models[model_id]
        if raw is None:
            return self._model_to_raw(model)
        return copy.deepcopy(raw)

    def describe(self, model_id: str) -> ModelDescription:
        """Return a structured summary suitable for LLM consumption."""
        model = self.get_model(model_id)

        data_objects = [
            DataObjectInfo(
                label=obj.name,
                code=obj.qualified_code,
                columns=list(obj.columns.keys()),
                join_targets=[j.join_to for j in obj.joins],
                synonyms=obj.synonyms,
                owner=obj.owner,
            )
            for obj in model.data_objects.values()
        ]

        dimensions = [
            DimensionInfo(
                name=dim.name,
                result_type=dim.result_type.value,
                data_object=dim.view,
                column=dim.column,
                time_grain=dim.time_grain.value if dim.time_grain else None,
                synonyms=dim.synonyms,
                owner=dim.owner,
            )
            for dim in model.dimensions.values()
        ]

        measures = [
            MeasureInfo(
                name=m.name,
                result_type=m.result_type.value,
                aggregation=m.aggregation,
                expression=m.expression,
                synonyms=m.synonyms,
                owner=m.owner,
            )
            for m in model.measures.values()
        ]

        metrics = [
            MetricInfo(
                name=met.name,
                expression=met.expression,
                synonyms=met.synonyms,
                type=met.type.value,
                measure=met.measure,
                time_dimension=met.time_dimension,
                owner=met.owner,
            )
            for met in model.metrics.values()
        ]

        return ModelDescription(
            model_id=model_id,
            data_objects=data_objects,
            dimensions=dimensions,
            measures=measures,
            metrics=metrics,
        )

    def list_models(self) -> list[ModelSummary]:
        """Return a short summary for every loaded model."""
        with self._lock:
            return list(self._summaries.values())

    def remove_model(self, model_id: str) -> None:
        """Unload a model and its cached OBSL graph.  Raises ``KeyError`` if not found.

        Also removes the model's entry from the dedup index so the next load
        of the same OBML content runs fresh. PLAN_model_load_dedup.md §6.2.
        """
        with self._lock:
            try:
                del self._models[model_id]
            except KeyError:
                raise KeyError(f"No model loaded with id '{model_id}'") from None
            self._raws.pop(model_id, None)
            self._graphs.pop(model_id, None)
            self._summaries.pop(model_id, None)
            stale_hashes = [h for h, mid in self._content_hash_index.items() if mid == model_id]
            for h in stale_hashes:
                del self._content_hash_index[h]
            was_shared = model_id in self._shared_ids
            self._shared_ids.discard(model_id)
        # Release the shared reference outside the store lock. The shared model
        # stays compiled while any other session still references it.
        if was_shared and self._shared_cache is not None:
            self._shared_cache.release(model_id)

    def close(self) -> None:
        """Drop this store, releasing every shared reference it holds.

        Called by :class:`SessionManager` when a session is purged/expired/
        closed, so shared models are refcount-released and can be evicted once
        no live session references them. Private (non-shared) models are simply
        discarded with the store.
        """
        with self._lock:
            shared_ids = list(self._shared_ids)
            self._shared_ids.clear()
            self._models.clear()
            self._raws.clear()
            self._graphs.clear()
            self._summaries.clear()
            self._content_hash_index.clear()
        if self._shared_cache is not None:
            for mid in shared_ids:
                self._shared_cache.release(mid)

    def compile_query(
        self,
        model_id: str,
        query: QueryObject,
        dialect: str,
    ) -> CompilationResult:
        """Compile a query against a loaded model."""
        model = self.get_model(model_id)
        return self._pipeline.compile(query, model, dialect)

    def refresh_contracts(self, model_id: str) -> dict[str, RefreshContract]:
        """Per-physical-table freshness contracts for the given model.

        Used by the result cache to derive an effective TTL for a query
        based on the dataObjects it touched.
        """
        from orionbelt.cache.contracts import collect_table_contracts

        model = self.get_model(model_id)
        contracts, _ = collect_table_contracts(model)
        return contracts

    def validate(
        self,
        yaml_str: str | None = None,
        *,
        raw_dict: dict[str, object] | None = None,
        extends_yaml: list[str] | None = None,
        inherits_model_id: str | None = None,
    ) -> ValidationSummary:
        """Validate a model without storing it.  Accepts YAML string or raw dict.

        Reports JSON Schema violations alongside semantic errors so that the
        ``/validate`` endpoints match what the schema-guarded load/query
        endpoints enforce — a model that fails the schema is reported invalid
        here rather than being silently coerced.
        """
        _model, raw, errors, warnings = self._parse_and_validate(
            yaml_str,
            raw_dict=raw_dict,
            extends_yaml=extends_yaml,
            inherits_model_id=inherits_model_id,
        )
        # Schema-validate the already-safely-parsed dict (``_parse_and_validate``
        # loads via the safety-checked TrackedLoader). Skip when the YAML never
        # parsed safely — the fatal parse/safety error is already reported and
        # there is no document to validate.
        fatal = {"YAML_SAFETY_ERROR", "YAML_PARSE_ERROR"}
        if not any(e.code in fatal for e in errors):
            errors = self._schema_errors(raw) + errors
        return ValidationSummary(
            valid=len(errors) == 0,
            errors=errors,
            warnings=warnings,
        )

    @staticmethod
    def _schema_errors(raw: dict[str, object]) -> list[ErrorInfo]:
        """JSON Schema errors for a parsed model document, as ``ErrorInfo``."""
        public = {k: v for k, v in raw.items() if not str(k).startswith("_")}
        return [
            ErrorInfo(code=e.code, message=e.message, path=e.path, severity=e.severity)
            for e in validate_obml_document(public)
        ]

    # -- OBSL graph ---------------------------------------------------------

    def get_graph(self, model_id: str) -> GraphArtifact:
        """Return the cached OBSL graph for a model.  Raises ``KeyError`` if not found."""
        with self._lock:
            try:
                return self._graphs[model_id]
            except KeyError:
                raise KeyError(f"No graph for model '{model_id}'") from None

    def query_graph(self, model_id: str, sparql: str) -> SPARQLResult:
        """Execute a read-only SPARQL query against a model's OBSL graph."""
        artifact = self.get_graph(model_id)
        return execute_sparql(artifact.graph, sparql)

load_model(yaml_str=None, *, raw_dict=None, extends_yaml=None, inherits_model_id=None, dedup=True)

Parse, validate, and store a model. Returns id + summary.

Provide either yaml_str or raw_dict. Raises ModelValidationError if the model has validation errors. Raises ModelCapacityError if the session's model cap is reached.

When dedup is True (default) and the same OBML bytes have already been loaded into this store, the existing model_id is returned and model_load is set to "reused". Dedup only applies to plain yaml_str loads — when raw_dict, extends_yaml, or inherits_model_id is supplied the load always runs fresh, since the effective content depends on inputs not captured by the YAML bytes alone.

Source code in src/orionbelt/service/model_store.py
def load_model(
    self,
    yaml_str: str | None = None,
    *,
    raw_dict: dict[str, object] | None = None,
    extends_yaml: list[str] | None = None,
    inherits_model_id: str | None = None,
    dedup: bool = True,
) -> LoadResult:
    """Parse, validate, and store a model.  Returns id + summary.

    Provide either ``yaml_str`` or ``raw_dict``.
    Raises ``ModelValidationError`` if the model has validation errors.
    Raises ``ModelCapacityError`` if the session's model cap is reached.

    When ``dedup`` is True (default) and the same OBML bytes have already
    been loaded into this store, the existing ``model_id`` is returned
    and ``model_load`` is set to ``"reused"``. Dedup only applies to
    plain ``yaml_str`` loads — when ``raw_dict``, ``extends_yaml``, or
    ``inherits_model_id`` is supplied the load always runs fresh, since
    the effective content depends on inputs not captured by the YAML
    bytes alone.
    """
    # Dedup is meaningful only for a stand-alone YAML body. The other
    # input shapes either skip the YAML stage (raw_dict) or fold in
    # additional state (extends/inherits) that the bytes don't capture.
    dedup_eligible = (
        dedup
        and yaml_str is not None
        and raw_dict is None
        and not extends_yaml
        and inherits_model_id is None
    )
    content_hash: str | None = None
    if dedup_eligible:
        content_hash = self._content_hash(yaml_str or "")
        with self._lock:
            existing_id = self._content_hash_index.get(content_hash)
            if existing_id is not None and existing_id in self._models:
                summary = self._summaries.get(existing_id)
                if summary is not None:
                    existing_model = self._models[existing_id]
                    existing_health = self._health_for(existing_model)
                    return LoadResult(
                        model_id=existing_id,
                        data_objects=summary.data_objects,
                        dimensions=summary.dimensions,
                        measures=summary.measures,
                        metrics=summary.metrics,
                        warnings=[],
                        model_load="reused",
                        health=existing_health,
                    )
            # Stale index entry — drop it and fall through to a fresh load.
            if existing_id is not None:
                self._content_hash_index.pop(content_hash, None)

    # Cross-session hit: another session already compiled these exact
    # bytes. Adopt the shared compiled model with no recompile. From this
    # store's perspective the load is still "fresh" (a new reference for
    # this session); the compile-skip is a transparent optimisation.
    if dedup_eligible and self._shared_cache is not None and content_hash is not None:
        shared = self._shared_cache.acquire(content_hash)
        if shared is not None:
            self._adopt_shared(shared)
            return LoadResult(
                model_id=shared.model_id,
                data_objects=shared.summary.data_objects,
                dimensions=shared.summary.dimensions,
                measures=shared.summary.measures,
                metrics=shared.summary.metrics,
                warnings=[],
                model_load="fresh",
                health=self._health_for(shared.model),
            )

    with self._lock:
        if len(self._models) >= self._max_models:
            raise ModelCapacityError(f"Maximum models per session reached ({self._max_models})")

    model, merged_raw, errors, warnings = self._parse_and_validate(
        yaml_str,
        raw_dict=raw_dict,
        extends_yaml=extends_yaml,
        inherits_model_id=inherits_model_id,
    )
    if errors:
        raise ModelValidationError(errors, warnings)

    shared_load = dedup_eligible and self._shared_cache is not None and content_hash is not None
    # Content-derived id for shared models so identical bytes collapse to
    # one id (and one result-cache key) across sessions; random id
    # otherwise, preserving the ``dedup=False`` "distinct model" contract.
    model_id = (
        self._content_id(content_hash)
        if content_hash is not None and shared_load
        else self._new_id()
    )

    # Eagerly export OBSL-Core graph (Option C: at model load time).
    graph = export_obsl(model, model_id)
    turtle = graph.serialize(format="turtle")
    artifact = GraphArtifact(graph=graph, turtle=turtle, generated_at=time.monotonic())

    summary = ModelSummary(
        model_id=model_id,
        data_objects=len(model.data_objects),
        dimensions=len(model.dimensions),
        measures=len(model.measures),
        metrics=len(model.metrics),
    )

    if shared_load:
        assert self._shared_cache is not None and content_hash is not None
        compiled = CompiledModel(
            model_id=model_id,
            content_hash=content_hash,
            model=model,
            raw=merged_raw,
            graph=artifact,
            summary=summary,
        )
        # A concurrent load of the same content may have won the race;
        # insert_or_acquire returns the surviving entry either way.
        entry = self._shared_cache.insert_or_acquire(compiled)
        self._adopt_shared(entry)
        return LoadResult(
            model_id=entry.model_id,
            data_objects=entry.summary.data_objects,
            dimensions=entry.summary.dimensions,
            measures=entry.summary.measures,
            metrics=entry.summary.metrics,
            warnings=warnings,
            model_load="fresh",
            health=self._health_for(entry.model),
        )

    with self._lock:
        # Re-check capacity under lock — the first check (above) ran
        # outside the lock while parsing/exporting, so a concurrent
        # request may have filled the slot in the meantime.
        if len(self._models) >= self._max_models:
            raise ModelCapacityError(f"Maximum models per session reached ({self._max_models})")
        self._models[model_id] = model
        self._raws[model_id] = merged_raw
        self._graphs[model_id] = artifact
        self._summaries[model_id] = summary
        if content_hash is not None:
            # If a concurrent request beat us to it, the last writer wins;
            # the race is benign (both models work, the older one is just
            # not reachable via the index). See PLAN_model_load_dedup.md §6.3.
            self._content_hash_index[content_hash] = model_id

    return LoadResult(
        model_id=model_id,
        data_objects=summary.data_objects,
        dimensions=summary.dimensions,
        measures=summary.measures,
        metrics=summary.metrics,
        warnings=warnings,
        model_load="fresh",
        health=self._health_for(model),
    )

get_model(model_id)

Look up a loaded model. Raises KeyError if not found.

Source code in src/orionbelt/service/model_store.py
def get_model(self, model_id: str) -> SemanticModel:
    """Look up a loaded model.  Raises ``KeyError`` if not found."""
    with self._lock:
        try:
            return self._models[model_id]
        except KeyError:
            raise KeyError(f"No model loaded with id '{model_id}'") from None

describe(model_id)

Return a structured summary suitable for LLM consumption.

Source code in src/orionbelt/service/model_store.py
def describe(self, model_id: str) -> ModelDescription:
    """Return a structured summary suitable for LLM consumption."""
    model = self.get_model(model_id)

    data_objects = [
        DataObjectInfo(
            label=obj.name,
            code=obj.qualified_code,
            columns=list(obj.columns.keys()),
            join_targets=[j.join_to for j in obj.joins],
            synonyms=obj.synonyms,
            owner=obj.owner,
        )
        for obj in model.data_objects.values()
    ]

    dimensions = [
        DimensionInfo(
            name=dim.name,
            result_type=dim.result_type.value,
            data_object=dim.view,
            column=dim.column,
            time_grain=dim.time_grain.value if dim.time_grain else None,
            synonyms=dim.synonyms,
            owner=dim.owner,
        )
        for dim in model.dimensions.values()
    ]

    measures = [
        MeasureInfo(
            name=m.name,
            result_type=m.result_type.value,
            aggregation=m.aggregation,
            expression=m.expression,
            synonyms=m.synonyms,
            owner=m.owner,
        )
        for m in model.measures.values()
    ]

    metrics = [
        MetricInfo(
            name=met.name,
            expression=met.expression,
            synonyms=met.synonyms,
            type=met.type.value,
            measure=met.measure,
            time_dimension=met.time_dimension,
            owner=met.owner,
        )
        for met in model.metrics.values()
    ]

    return ModelDescription(
        model_id=model_id,
        data_objects=data_objects,
        dimensions=dimensions,
        measures=measures,
        metrics=metrics,
    )

list_models()

Return a short summary for every loaded model.

Source code in src/orionbelt/service/model_store.py
def list_models(self) -> list[ModelSummary]:
    """Return a short summary for every loaded model."""
    with self._lock:
        return list(self._summaries.values())

remove_model(model_id)

Unload a model and its cached OBSL graph. Raises KeyError if not found.

Also removes the model's entry from the dedup index so the next load of the same OBML content runs fresh. PLAN_model_load_dedup.md §6.2.

Source code in src/orionbelt/service/model_store.py
def remove_model(self, model_id: str) -> None:
    """Unload a model and its cached OBSL graph.  Raises ``KeyError`` if not found.

    Also removes the model's entry from the dedup index so the next load
    of the same OBML content runs fresh. PLAN_model_load_dedup.md §6.2.
    """
    with self._lock:
        try:
            del self._models[model_id]
        except KeyError:
            raise KeyError(f"No model loaded with id '{model_id}'") from None
        self._raws.pop(model_id, None)
        self._graphs.pop(model_id, None)
        self._summaries.pop(model_id, None)
        stale_hashes = [h for h, mid in self._content_hash_index.items() if mid == model_id]
        for h in stale_hashes:
            del self._content_hash_index[h]
        was_shared = model_id in self._shared_ids
        self._shared_ids.discard(model_id)
    # Release the shared reference outside the store lock. The shared model
    # stays compiled while any other session still references it.
    if was_shared and self._shared_cache is not None:
        self._shared_cache.release(model_id)

compile_query(model_id, query, dialect)

Compile a query against a loaded model.

Source code in src/orionbelt/service/model_store.py
def compile_query(
    self,
    model_id: str,
    query: QueryObject,
    dialect: str,
) -> CompilationResult:
    """Compile a query against a loaded model."""
    model = self.get_model(model_id)
    return self._pipeline.compile(query, model, dialect)

validate(yaml_str=None, *, raw_dict=None, extends_yaml=None, inherits_model_id=None)

Validate a model without storing it. Accepts YAML string or raw dict.

Reports JSON Schema violations alongside semantic errors so that the /validate endpoints match what the schema-guarded load/query endpoints enforce — a model that fails the schema is reported invalid here rather than being silently coerced.

Source code in src/orionbelt/service/model_store.py
def validate(
    self,
    yaml_str: str | None = None,
    *,
    raw_dict: dict[str, object] | None = None,
    extends_yaml: list[str] | None = None,
    inherits_model_id: str | None = None,
) -> ValidationSummary:
    """Validate a model without storing it.  Accepts YAML string or raw dict.

    Reports JSON Schema violations alongside semantic errors so that the
    ``/validate`` endpoints match what the schema-guarded load/query
    endpoints enforce — a model that fails the schema is reported invalid
    here rather than being silently coerced.
    """
    _model, raw, errors, warnings = self._parse_and_validate(
        yaml_str,
        raw_dict=raw_dict,
        extends_yaml=extends_yaml,
        inherits_model_id=inherits_model_id,
    )
    # Schema-validate the already-safely-parsed dict (``_parse_and_validate``
    # loads via the safety-checked TrackedLoader). Skip when the YAML never
    # parsed safely — the fatal parse/safety error is already reported and
    # there is no document to validate.
    fatal = {"YAML_SAFETY_ERROR", "YAML_PARSE_ERROR"}
    if not any(e.code in fatal for e in errors):
        errors = self._schema_errors(raw) + errors
    return ValidationSummary(
        valid=len(errors) == 0,
        errors=errors,
        warnings=warnings,
    )

SessionManager

orionbelt.service.session_manager.SessionManager

Manages TTL-scoped sessions, each holding its own ModelStore.

Thread-safe. Call :meth:start to begin the background cleanup thread and :meth:stop to shut it down.

Parameters

ttl_seconds: Sliding idle timeout — sessions expire after this many seconds of inactivity. max_age_seconds: Absolute maximum session lifetime regardless of activity. max_sessions: Global cap on concurrent sessions. create_session raises :class:SessionCapacityError when at capacity. max_models_per_session: Maximum models a single session may hold. Passed through to each ModelStore instance. cleanup_interval: Seconds between background purge sweeps. is_single_model_mode: Flag retained for backwards compatibility — historically set when a MODEL_FILE preloaded the __default__ session. With MODEL_FILES (admin-curated named sessions), the flag is True and the __default__ session — still created on demand by MCP stdio — is kept alive and excluded from purge. False otherwise, in which case __default__ is treated like any other session and subject to TTL/max-age expiry.

Source code in src/orionbelt/service/session_manager.py
class SessionManager:
    """Manages TTL-scoped sessions, each holding its own ``ModelStore``.

    Thread-safe.  Call :meth:`start` to begin the background cleanup thread
    and :meth:`stop` to shut it down.

    Parameters
    ----------
    ttl_seconds:
        Sliding idle timeout — sessions expire after this many seconds of
        inactivity.
    max_age_seconds:
        Absolute maximum session lifetime regardless of activity.
    max_sessions:
        Global cap on concurrent sessions.  ``create_session`` raises
        :class:`SessionCapacityError` when at capacity.
    max_models_per_session:
        Maximum models a single session may hold.  Passed through to each
        ``ModelStore`` instance.
    cleanup_interval:
        Seconds between background purge sweeps.
    is_single_model_mode:
        Flag retained for backwards compatibility — historically set when
        a ``MODEL_FILE`` preloaded the ``__default__`` session. With
        ``MODEL_FILES`` (admin-curated named sessions), the flag is True
        and the ``__default__`` session — still created on demand by MCP
        stdio — is kept alive and excluded from purge. False otherwise,
        in which case ``__default__`` is treated like any other session
        and subject to TTL/max-age expiry.
    """

    def __init__(
        self,
        ttl_seconds: int = 1800,
        max_age_seconds: int = 86400,
        max_sessions: int = 500,
        max_models_per_session: int = 10,
        cleanup_interval: int = 60,
        is_single_model_mode: bool = False,
    ) -> None:
        self._ttl = ttl_seconds
        self._max_age = max_age_seconds
        self._max_sessions = max_sessions
        self._max_models = max_models_per_session
        self._cleanup_interval = cleanup_interval
        self._is_single_model_mode = is_single_model_mode
        self._lock = threading.Lock()
        # One process-wide content-addressed model cache shared by every
        # per-session store, so identical OBML compiles once across sessions.
        self._model_cache = ModelCache()
        self._sessions: dict[str, _Session] = {}
        self._stop_event = threading.Event()
        self._cleanup_thread: threading.Thread | None = None

    @property
    def ttl(self) -> int:
        """Session TTL in seconds."""
        return self._ttl

    @property
    def max_age(self) -> int:
        """Absolute max session lifetime in seconds."""
        return self._max_age

    @property
    def max_sessions(self) -> int:
        """Global concurrent session cap."""
        return self._max_sessions

    @property
    def max_models_per_session(self) -> int:
        """Maximum models a single session may hold."""
        return self._max_models

    @property
    def is_single_model_mode(self) -> bool:
        """True when admin-curated (``MODEL_FILES``) mode is active.

        In this mode the BI-facing catalog should expose only the curated
        (protected) models, not transient user/scratch sessions.
        """
        return self._is_single_model_mode

    # -- lifecycle -----------------------------------------------------------

    def start(self) -> None:
        """Start the background cleanup daemon thread."""
        if self._cleanup_thread is not None:
            return
        self._stop_event.clear()
        self._cleanup_thread = threading.Thread(
            target=self._cleanup_loop, daemon=True, name="session-cleanup"
        )
        self._cleanup_thread.start()

    def stop(self) -> None:
        """Signal the cleanup thread to stop and wait for it."""
        self._stop_event.set()
        if self._cleanup_thread is not None:
            self._cleanup_thread.join(timeout=5)
            self._cleanup_thread = None

    # -- public API ----------------------------------------------------------

    def create_session(self, metadata: dict[str, str] | None = None) -> SessionInfo:
        """Create a new session and return its info.

        Raises :class:`SessionCapacityError` when the global session cap
        is reached.
        """
        now_mono = time.monotonic()
        now_wall = datetime.now(UTC)
        session_id = secrets.token_hex(16)  # 32-char hex (128-bit)
        session = _Session(
            session_id=session_id,
            store=ModelStore(max_models=self._max_models, shared_cache=self._model_cache),
            created_at=now_wall,
            created_at_mono=now_mono,
            last_accessed=now_mono,
            metadata=metadata or {},
            created_at_wall=now_wall,
            last_accessed_wall=now_wall,
        )
        with self._lock:
            # Count only non-default, non-expired sessions toward the cap.
            active = sum(
                1
                for s in self._sessions.values()
                if s.session_id != _DEFAULT_SESSION_ID and not self._is_expired(s, now_mono)
            )
            if active >= self._max_sessions:
                logger.warning(
                    "Session cap reached (%d/%d), rejecting create",
                    active,
                    self._max_sessions,
                )
                raise SessionCapacityError(
                    f"Maximum number of concurrent sessions reached ({self._max_sessions})"
                )
            self._sessions[session_id] = session
        logger.info("Session created: %s", session_id)
        return self._session_info(session)

    def get_store(self, session_id: str) -> ModelStore:
        """Get the ModelStore for a session, updating its last-accessed time.

        Raises :class:`SessionExpiredError` if the session has expired.
        Raises :class:`SessionNotFoundError` if the session ID is unknown.
        """
        now_mono = time.monotonic()
        with self._lock:
            session = self._sessions.get(session_id)
            if session is None:
                raise SessionNotFoundError(f"Session '{session_id}' not found")
            if self._is_expired(session, now_mono):
                reason = self._expiry_reason(session, now_mono)
                session.store.close()
                del self._sessions[session_id]
                logger.info("Session expired on access: %s (%s)", session_id, reason)
                raise SessionExpiredError(f"Session '{session_id}' has expired ({reason})")
            session.last_accessed = now_mono
            session.last_accessed_wall = datetime.now(UTC)
            return session.store

    def get_session(self, session_id: str) -> SessionInfo:
        """Get session info (also refreshes last-accessed)."""
        now_mono = time.monotonic()
        with self._lock:
            session = self._sessions.get(session_id)
            if session is None:
                raise SessionNotFoundError(f"Session '{session_id}' not found")
            if self._is_expired(session, now_mono):
                reason = self._expiry_reason(session, now_mono)
                session.store.close()
                del self._sessions[session_id]
                logger.info("Session expired on access: %s (%s)", session_id, reason)
                raise SessionExpiredError(f"Session '{session_id}' has expired ({reason})")
            session.last_accessed = now_mono
            session.last_accessed_wall = datetime.now(UTC)
            return self._session_info(session)

    def close_session(self, session_id: str) -> None:
        """Explicitly close a session."""
        with self._lock:
            session = self._sessions.get(session_id)
            if session is None:
                raise SessionNotFoundError(f"Session '{session_id}' not found")
            session.store.close()
            del self._sessions[session_id]
        logger.info("Session closed: %s", session_id)

    def list_sessions(self) -> list[SessionInfo]:
        """Return info for all non-expired user sessions.

        Excludes the default session and any admin-managed (protected)
        sessions created by the multi-model startup loader.
        """
        now_mono = time.monotonic()
        result: list[SessionInfo] = []
        with self._lock:
            for session in self._sessions.values():
                if session.session_id == _DEFAULT_SESSION_ID:
                    continue
                if session.protected:
                    continue
                if not self._is_expired(session, now_mono):
                    result.append(self._session_info(session))
        return result

    @property
    def active_count(self) -> int:
        """Number of active (non-expired) sessions."""
        now_mono = time.monotonic()
        with self._lock:
            return sum(1 for s in self._sessions.values() if not self._is_expired(s, now_mono))

    def get_or_create_default(self) -> ModelStore:
        """Get (or lazily create) the legacy ``__default__`` session.

        Unlike :meth:`get_or_create_named`, the default session is NOT
        marked protected — its lifecycle is controlled by the
        ``is_single_model_mode`` flag, not by the protected mechanism.
        This preserves backward compatibility with the v2.3.x model-
        upload semantics where each new user session inherits the
        preloaded YAML.
        """
        with self._lock:
            session = self._sessions.get(_DEFAULT_SESSION_ID)
            if session is not None:
                session.last_accessed = time.monotonic()
                session.last_accessed_wall = datetime.now(UTC)
                return session.store
            now_mono = time.monotonic()
            now_wall = datetime.now(UTC)
            session = _Session(
                session_id=_DEFAULT_SESSION_ID,
                store=ModelStore(max_models=self._max_models, shared_cache=self._model_cache),
                created_at=now_wall,
                created_at_mono=now_mono,
                last_accessed=now_mono,
                created_at_wall=now_wall,
                last_accessed_wall=now_wall,
            )
            self._sessions[_DEFAULT_SESSION_ID] = session
            return session.store

    def get_or_create_named(self, session_id: str) -> ModelStore:
        """Get (or lazily create) a session with a caller-chosen id.

        Used by the multi-model startup loader to register each pre-loaded
        model as its own internal session whose id is the resolved model
        name. The created session is marked ``protected`` — exempt from
        idle TTL eviction and not listed by :meth:`list_sessions`. Admin-
        managed.
        """
        with self._lock:
            session = self._sessions.get(session_id)
            if session is not None:
                session.last_accessed = time.monotonic()
                session.last_accessed_wall = datetime.now(UTC)
                return session.store
            now_mono = time.monotonic()
            now_wall = datetime.now(UTC)
            session = _Session(
                session_id=session_id,
                store=ModelStore(max_models=self._max_models, shared_cache=self._model_cache),
                created_at=now_wall,
                created_at_mono=now_mono,
                last_accessed=now_mono,
                created_at_wall=now_wall,
                last_accessed_wall=now_wall,
                protected=True,
            )
            self._sessions[session_id] = session
            return session.store

    def list_protected_session_ids(self) -> list[str]:
        """Return the ids of all admin-managed (protected) sessions.

        Used by multi-model discovery (``GET /v1/models``) and by Flight
        routing to enumerate which model names are available.
        """
        with self._lock:
            return [s.session_id for s in self._sessions.values() if s.protected]

    # -- internal ------------------------------------------------------------

    def _is_expired(self, session: _Session, now_mono: float) -> bool:
        """Check if a session has exceeded idle TTL or absolute max-age.

        Protected sessions (admin-loaded models via ``MODEL_FILES``) never
        expire — they're owned by the process lifecycle, not by client
        activity. Without this guard, ``get_store()`` would delete them on
        access past TTL, even though ``_purge_expired`` correctly skips
        them.
        """
        if session.protected:
            return False
        idle = now_mono - session.last_accessed > self._ttl
        aged = now_mono - session.created_at_mono > self._max_age
        return idle or aged

    def _expiry_reason(self, session: _Session, now_mono: float) -> str:
        """Return a human-readable reason why a session expired."""
        idle_elapsed = now_mono - session.last_accessed
        age_elapsed = now_mono - session.created_at_mono
        if age_elapsed > self._max_age:
            return f"max-age {self._max_age}s exceeded after {age_elapsed:.0f}s"
        return f"idle {self._ttl}s exceeded after {idle_elapsed:.0f}s"

    def _session_info(self, session: _Session) -> SessionInfo:
        now_wall = datetime.now(UTC)
        idle_remaining = self._ttl - (time.monotonic() - session.last_accessed)
        age_remaining = self._max_age - (time.monotonic() - session.created_at_mono)

        # expires_at = when the idle TTL would fire (from last access)
        expires_at = now_wall + timedelta(seconds=max(0.0, idle_remaining))
        # max_expires_at = absolute deadline (from creation)
        max_expires_at = now_wall + timedelta(seconds=max(0.0, age_remaining))

        return SessionInfo(
            session_id=session.session_id,
            created_at=session.created_at_wall,
            last_accessed_at=session.last_accessed_wall,
            model_count=len(session.store.list_models()),
            metadata=session.metadata,
            expires_at=expires_at,
            max_expires_at=max_expires_at,
        )

    def _purge_expired(self) -> None:
        """Remove all expired sessions (called by cleanup thread).

        Protected sessions (admin-managed pre-loads) are never purged.
        The legacy ``__default__`` session is kept alive when
        ``is_single_model_mode`` is set.
        """
        now_mono = time.monotonic()
        with self._lock:
            skip_default = self._is_single_model_mode
            expired = [
                sid
                for sid, s in self._sessions.items()
                if not s.protected
                and (not skip_default or sid != _DEFAULT_SESSION_ID)
                and self._is_expired(s, now_mono)
            ]
            for sid in expired:
                reason = self._expiry_reason(self._sessions[sid], now_mono)
                self._sessions[sid].store.close()
                del self._sessions[sid]
                logger.info("Session purged: %s (%s)", sid, reason)
        if expired:
            logger.info(
                "Purge sweep: removed %d session(s), %d remaining",
                len(expired),
                len(self._sessions),
            )

    def _cleanup_loop(self) -> None:
        """Background loop that periodically purges expired sessions."""
        while not self._stop_event.wait(timeout=self._cleanup_interval):
            self._purge_expired()

active_count property

Number of active (non-expired) sessions.

start()

Start the background cleanup daemon thread.

Source code in src/orionbelt/service/session_manager.py
def start(self) -> None:
    """Start the background cleanup daemon thread."""
    if self._cleanup_thread is not None:
        return
    self._stop_event.clear()
    self._cleanup_thread = threading.Thread(
        target=self._cleanup_loop, daemon=True, name="session-cleanup"
    )
    self._cleanup_thread.start()

stop()

Signal the cleanup thread to stop and wait for it.

Source code in src/orionbelt/service/session_manager.py
def stop(self) -> None:
    """Signal the cleanup thread to stop and wait for it."""
    self._stop_event.set()
    if self._cleanup_thread is not None:
        self._cleanup_thread.join(timeout=5)
        self._cleanup_thread = None

create_session(metadata=None)

Create a new session and return its info.

Raises :class:SessionCapacityError when the global session cap is reached.

Source code in src/orionbelt/service/session_manager.py
def create_session(self, metadata: dict[str, str] | None = None) -> SessionInfo:
    """Create a new session and return its info.

    Raises :class:`SessionCapacityError` when the global session cap
    is reached.
    """
    now_mono = time.monotonic()
    now_wall = datetime.now(UTC)
    session_id = secrets.token_hex(16)  # 32-char hex (128-bit)
    session = _Session(
        session_id=session_id,
        store=ModelStore(max_models=self._max_models, shared_cache=self._model_cache),
        created_at=now_wall,
        created_at_mono=now_mono,
        last_accessed=now_mono,
        metadata=metadata or {},
        created_at_wall=now_wall,
        last_accessed_wall=now_wall,
    )
    with self._lock:
        # Count only non-default, non-expired sessions toward the cap.
        active = sum(
            1
            for s in self._sessions.values()
            if s.session_id != _DEFAULT_SESSION_ID and not self._is_expired(s, now_mono)
        )
        if active >= self._max_sessions:
            logger.warning(
                "Session cap reached (%d/%d), rejecting create",
                active,
                self._max_sessions,
            )
            raise SessionCapacityError(
                f"Maximum number of concurrent sessions reached ({self._max_sessions})"
            )
        self._sessions[session_id] = session
    logger.info("Session created: %s", session_id)
    return self._session_info(session)

get_store(session_id)

Get the ModelStore for a session, updating its last-accessed time.

Raises :class:SessionExpiredError if the session has expired. Raises :class:SessionNotFoundError if the session ID is unknown.

Source code in src/orionbelt/service/session_manager.py
def get_store(self, session_id: str) -> ModelStore:
    """Get the ModelStore for a session, updating its last-accessed time.

    Raises :class:`SessionExpiredError` if the session has expired.
    Raises :class:`SessionNotFoundError` if the session ID is unknown.
    """
    now_mono = time.monotonic()
    with self._lock:
        session = self._sessions.get(session_id)
        if session is None:
            raise SessionNotFoundError(f"Session '{session_id}' not found")
        if self._is_expired(session, now_mono):
            reason = self._expiry_reason(session, now_mono)
            session.store.close()
            del self._sessions[session_id]
            logger.info("Session expired on access: %s (%s)", session_id, reason)
            raise SessionExpiredError(f"Session '{session_id}' has expired ({reason})")
        session.last_accessed = now_mono
        session.last_accessed_wall = datetime.now(UTC)
        return session.store

get_session(session_id)

Get session info (also refreshes last-accessed).

Source code in src/orionbelt/service/session_manager.py
def get_session(self, session_id: str) -> SessionInfo:
    """Get session info (also refreshes last-accessed)."""
    now_mono = time.monotonic()
    with self._lock:
        session = self._sessions.get(session_id)
        if session is None:
            raise SessionNotFoundError(f"Session '{session_id}' not found")
        if self._is_expired(session, now_mono):
            reason = self._expiry_reason(session, now_mono)
            session.store.close()
            del self._sessions[session_id]
            logger.info("Session expired on access: %s (%s)", session_id, reason)
            raise SessionExpiredError(f"Session '{session_id}' has expired ({reason})")
        session.last_accessed = now_mono
        session.last_accessed_wall = datetime.now(UTC)
        return self._session_info(session)

close_session(session_id)

Explicitly close a session.

Source code in src/orionbelt/service/session_manager.py
def close_session(self, session_id: str) -> None:
    """Explicitly close a session."""
    with self._lock:
        session = self._sessions.get(session_id)
        if session is None:
            raise SessionNotFoundError(f"Session '{session_id}' not found")
        session.store.close()
        del self._sessions[session_id]
    logger.info("Session closed: %s", session_id)

list_sessions()

Return info for all non-expired user sessions.

Excludes the default session and any admin-managed (protected) sessions created by the multi-model startup loader.

Source code in src/orionbelt/service/session_manager.py
def list_sessions(self) -> list[SessionInfo]:
    """Return info for all non-expired user sessions.

    Excludes the default session and any admin-managed (protected)
    sessions created by the multi-model startup loader.
    """
    now_mono = time.monotonic()
    result: list[SessionInfo] = []
    with self._lock:
        for session in self._sessions.values():
            if session.session_id == _DEFAULT_SESSION_ID:
                continue
            if session.protected:
                continue
            if not self._is_expired(session, now_mono):
                result.append(self._session_info(session))
    return result

get_or_create_default()

Get (or lazily create) the legacy __default__ session.

Unlike :meth:get_or_create_named, the default session is NOT marked protected — its lifecycle is controlled by the is_single_model_mode flag, not by the protected mechanism. This preserves backward compatibility with the v2.3.x model- upload semantics where each new user session inherits the preloaded YAML.

Source code in src/orionbelt/service/session_manager.py
def get_or_create_default(self) -> ModelStore:
    """Get (or lazily create) the legacy ``__default__`` session.

    Unlike :meth:`get_or_create_named`, the default session is NOT
    marked protected — its lifecycle is controlled by the
    ``is_single_model_mode`` flag, not by the protected mechanism.
    This preserves backward compatibility with the v2.3.x model-
    upload semantics where each new user session inherits the
    preloaded YAML.
    """
    with self._lock:
        session = self._sessions.get(_DEFAULT_SESSION_ID)
        if session is not None:
            session.last_accessed = time.monotonic()
            session.last_accessed_wall = datetime.now(UTC)
            return session.store
        now_mono = time.monotonic()
        now_wall = datetime.now(UTC)
        session = _Session(
            session_id=_DEFAULT_SESSION_ID,
            store=ModelStore(max_models=self._max_models, shared_cache=self._model_cache),
            created_at=now_wall,
            created_at_mono=now_mono,
            last_accessed=now_mono,
            created_at_wall=now_wall,
            last_accessed_wall=now_wall,
        )
        self._sessions[_DEFAULT_SESSION_ID] = session
        return session.store

SessionInfo

orionbelt.service.session_manager.SessionInfo dataclass

Public session metadata (returned by list/get).

Source code in src/orionbelt/service/session_manager.py
@dataclass
class SessionInfo:
    """Public session metadata (returned by list/get)."""

    session_id: str
    created_at: datetime
    last_accessed_at: datetime
    model_count: int
    metadata: dict[str, str]
    expires_at: datetime
    max_expires_at: datetime

Compiler Pipeline

orionbelt.compiler.pipeline.CompilationPipeline

Orchestrates: Query → Resolution → Planning → AST → SQL.

Source code in src/orionbelt/compiler/pipeline.py
class CompilationPipeline:
    """Orchestrates: Query → Resolution → Planning → AST → SQL."""

    def __init__(self) -> None:
        self._resolver = QueryResolver()
        self._star_planner = StarSchemaPlanner()
        self._cfl_planner = CFLPlanner()
        self._raw_planner = RawPlanner()

    @staticmethod
    def detect_replication(resolved: ResolvedQuery, model: SemanticModel) -> None:
        """Phase 1.5: fanout detection, and which measures need deduplicating.

        A method rather than inline because a *sub*-query needs the same phase:
        ``filter_wrap`` plans a filterContext measure's scan in its own right,
        and skipping this left a measure on the one side of a replicating join
        summed once per row of the many side - the exact overcount the pass
        exists to prevent, in a query where nothing said so.
        """
        # Skipped for CFL: its join steps span facts a union queries
        # independently, so the check would refuse a query the union answers.
        # The per-leg equivalent lives in the CFL planner, which is where a
        # leg's own joins are known.
        if resolved.requires_cfl:
            return
        detect_fanout(resolved, model)
        # Forward many-to-one joins replicate the *one* side, which
        # `detect_fanout` treats as safe. Flag any measure sourced from a
        # replicated object so the `grain_dedup` pass aggregates it over
        # deduplicated rows instead of the flattened join.
        if not resolved.is_raw:
            dedup_plan = detect_dedup_measures(resolved, model)
            # A filterContext measure is not this plan's to deduplicate: it is
            # computed by a scan of its own, which runs this same phase for
            # itself. Leaving it in built a dedup CTE for a column the final
            # projection takes from elsewhere.
            isolated = {m.name for m in resolved.measures if m.filter_context is not None} | {
                name
                for name, comp in resolved.metric_components.items()
                if comp.filter_context is not None
            }
            resolved.dedup_measures = {
                k: v for k, v in dedup_plan.measures.items() if k not in isolated
            }
            resolved.dedup_components = {
                k: v for k, v in dedup_plan.components.items() if k not in isolated
            }

    def compile(
        self,
        query: QueryObject,
        model: SemanticModel,
        dialect_name: str,
    ) -> CompilationResult:
        """Compile a query to SQL for the specified dialect."""
        # Create dialect first so resolution and planning share one
        # ``qualify_table`` — the EXISTS filter operator needs it during
        # resolution to render the correlated subquery's FROM clause.
        dialect = DialectRegistry.get(dialect_name)
        # The registry hands out a fresh dialect per compile, so the model's
        # calendar can be set on it without leaking into another query.
        if model.settings is not None:
            dialect.week_start = model.settings.week_start

        def qualify_table(obj: DataObject) -> str:
            # Guarded rather than trusting ``code`` to be non-empty: a nested
            # object without the fallback has no table, and an unguarded empty
            # code renders as FROM "" (#342 review).
            obj.require_table_source()
            return dialect.format_table_ref(obj.database, obj.schema_name, obj.code)

        # Phase 1: Resolution
        resolved = self._resolver.resolve(query, model, qualify_table=qualify_table)

        # Phase 1.5: Fanout detection (skip for CFL — each fact queried independently)
        self.detect_replication(resolved, model)

        # Phase 2: Planning (raw / star schema / CFL)
        use_cfl = resolved.requires_cfl or resolved.dimensions_exclude
        if resolved.is_raw:
            plan = self._raw_planner.plan(
                resolved,
                model,
                qualify_table=qualify_table,
                dialect=dialect,
                union_by_name=dialect.capabilities.supports_union_all_by_name,
            )
        elif use_cfl:
            plan = self._cfl_planner.plan(
                resolved,
                model,
                qualify_table=qualify_table,
                union_by_name=dialect.capabilities.supports_union_all_by_name,
                dialect=dialect,
            )
        else:
            plan = self._star_planner.plan(
                resolved, model, qualify_table=qualify_table, dialect=dialect
            )

        # Phase 2.3 – 2.6: Aggregate-mode passes (filter context, PoP,
        # totals, cumulative, window) plus HAVING projection cleanup. Raw
        # mode has no measures, so the passes are no-ops and skipped
        # entirely for clarity. Pass ordering and the feature-compatibility
        # rules live in ``compiler/passes.py``.
        if resolved.is_raw:
            wrapped_ast = plan.ast
        else:
            ctx = CompileContext(
                resolved=resolved,
                model=model,
                dialect=dialect,
                qualify_table=qualify_table,
                query=query,
            )
            wrapped_ast = apply_aggregate_passes(plan.ast, ctx)

        # Phase 3: Dialect-specific SQL rendering
        codegen = CodeGenerator(dialect)
        sql = codegen.generate(wrapped_ast)

        # Phase 4: SQL validation (non-blocking)
        validation_errors = validate_sql(sql, dialect_name)
        sql_valid = len(validation_errors) == 0
        warnings = resolved.warnings
        if not sql_valid:
            warnings = warnings + [
                warning(
                    code=WarningCode.SQL_VALIDATION,
                    message=f"SQL validation: {e}",
                )
                for e in validation_errors
            ]

        # Phase 4b: scope check (non-blocking). Syntax is not the only way a
        # statement can be wrong: an expression left behind in a query that
        # wraps it parses clean and fails at the database, naming a data object
        # from the model as if the model were at fault (#358). Kept separate
        # from ``sql_valid``, which answers whether sqlglot could parse it.
        stray_tables = out_of_scope_tables(wrapped_ast)
        if stray_tables:
            named = ", ".join(sorted(stray_tables))
            warnings = warnings + [
                warning(
                    code=WarningCode.OUT_OF_SCOPE_TABLE,
                    message=(
                        f"Compiled SQL references {named} in the outermost query, "
                        f"which its FROM does not provide"
                    ),
                    hint=(
                        "The statement will be rejected by the database. This is a "
                        "compiler defect rather than a model one - please report the "
                        "query and model."
                    ),
                    context={"tables": sorted(stray_tables)},
                )
            ]

        # Build explain plan
        explain = self._build_explain(resolved, model, use_cfl, plan)

        # Compute deduplicated physical tables touched by the query
        physical_tables = _compute_physical_tables(resolved, query, model)

        return CompilationResult(
            sql=sql,
            dialect=dialect_name,
            physical_tables=physical_tables,
            resolved=ResolvedInfo(
                fact_tables=resolved.fact_tables,
                dimensions=[d.name for d in resolved.dimensions],
                measures=[m.name for m in resolved.measures],
            ),
            warnings=warnings,
            sql_valid=sql_valid,
            explain=explain,
        )

    @staticmethod
    def _q(name: str) -> str:
        """Quote an identifier for explain output."""
        return f'"{name}"'

    def _build_explain(
        self,
        resolved: ResolvedQuery,
        model: SemanticModel,
        use_cfl: bool,
        plan: QueryPlan,
    ) -> ExplainPlan:
        """Build the explain plan from resolution results."""
        q = self._q

        # Planner choice
        if resolved.is_raw:
            planner = "Raw"
            distinct_note = " with DISTINCT" if resolved.distinct else ""
            planner_reason = (
                f"Raw-mode projection of physical columns{distinct_note} — "
                f"no aggregation, no GROUP BY"
            )
        elif use_cfl:
            if resolved.dimensions_exclude:
                planner = "CFL"
                planner_reason = (
                    "dimensionsExclude anti-join — "
                    "CROSS JOIN of distinct values EXCEPT existing combinations"
                )
            else:
                planner = "CFL"
                sources = ", ".join(q(s) for s in sorted(resolved.measure_source_objects))
                planner_reason = (
                    f"Measures reference independent fact tables ({sources}) — "
                    f"Composite Fact Layer merges them via UNION ALL"
                )
        else:
            planner = "Star Schema"
            planner_reason = (
                "All requested objects are reachable from a single base via directed joins"
            )

        # Base object — explain should reflect actual selection logic
        base = resolved.base_object
        if resolved.measure_source_objects:
            if use_cfl and len(resolved.measure_source_objects) > 1:
                base_reason = (
                    "Not applicable — each CFL leg uses its own common root (see cfl_legs)"
                )
            elif len(resolved.measure_source_objects) > 1:
                sources = ", ".join(q(s) for s in sorted(resolved.measure_source_objects))
                base_reason = (
                    f"{q(base)} selected as base — most connected fact table "
                    f"among measure sources ({sources})"
                )
            else:
                base_reason = f"{q(base)} selected as base — sole measure source object"
        elif len(resolved.required_objects) > 1:
            base_reason = (
                f"{q(base)} selected as base — common root that can reach "
                f"all required objects via directed joins"
            )
        else:
            base_reason = f"{q(base)} selected as base for single-object query"

        # Joins — for CFL queries the per-leg joins are more informative,
        # so only include resolution-level joins for star schema queries.
        explain_joins: list[ExplainJoin] = []
        if not use_cfl:
            for step in resolved.join_steps:
                join_cols = [
                    f"{fc} = {tc}"
                    for fc, tc in zip(step.from_columns, step.to_columns, strict=True)
                ]
                if step.reversed:
                    reason = (
                        f"Reversed join from {q(step.from_object)} to {q(step.to_object)} — "
                        f"original join was defined in the opposite direction"
                    )
                else:
                    reason = (
                        f"Join {q(step.from_object)}{q(step.to_object)} to include "
                        f"columns needed by the query"
                    )
                explain_joins.append(
                    ExplainJoin(
                        from_object=step.from_object,
                        to_object=step.to_object,
                        join_columns=join_cols,
                        reason=reason,
                        cardinality=step.cardinality.value,
                    )
                )

        # CFL leg details
        cfl_leg_explains: list[ExplainCflLeg] = []
        for leg in plan.cfl_legs:
            cfl_leg_explains.append(
                ExplainCflLeg(
                    measure_source=leg.measure_source,
                    common_root=leg.common_root,
                    reason=leg.reason,
                    measures=leg.measures,
                    joins=leg.joins,
                )
            )

        return ExplainPlan(
            planner=planner,
            planner_reason=planner_reason,
            base_object=base,
            base_object_reason=base_reason,
            joins=explain_joins,
            where_filter_count=len(resolved.where_filters),
            having_filter_count=len(resolved.having_filters),
            has_totals=resolved.has_totals,
            has_grain_overrides=resolved.has_grain_overrides,
            has_filter_context=resolved.has_filter_context,
            has_cumulative=resolved.has_cumulative,
            has_pop=resolved.has_pop,
            has_window=resolved.has_window,
            cfl_legs=cfl_leg_explains,
        )

compile(query, model, dialect_name)

Compile a query to SQL for the specified dialect.

Source code in src/orionbelt/compiler/pipeline.py
def compile(
    self,
    query: QueryObject,
    model: SemanticModel,
    dialect_name: str,
) -> CompilationResult:
    """Compile a query to SQL for the specified dialect."""
    # Create dialect first so resolution and planning share one
    # ``qualify_table`` — the EXISTS filter operator needs it during
    # resolution to render the correlated subquery's FROM clause.
    dialect = DialectRegistry.get(dialect_name)
    # The registry hands out a fresh dialect per compile, so the model's
    # calendar can be set on it without leaking into another query.
    if model.settings is not None:
        dialect.week_start = model.settings.week_start

    def qualify_table(obj: DataObject) -> str:
        # Guarded rather than trusting ``code`` to be non-empty: a nested
        # object without the fallback has no table, and an unguarded empty
        # code renders as FROM "" (#342 review).
        obj.require_table_source()
        return dialect.format_table_ref(obj.database, obj.schema_name, obj.code)

    # Phase 1: Resolution
    resolved = self._resolver.resolve(query, model, qualify_table=qualify_table)

    # Phase 1.5: Fanout detection (skip for CFL — each fact queried independently)
    self.detect_replication(resolved, model)

    # Phase 2: Planning (raw / star schema / CFL)
    use_cfl = resolved.requires_cfl or resolved.dimensions_exclude
    if resolved.is_raw:
        plan = self._raw_planner.plan(
            resolved,
            model,
            qualify_table=qualify_table,
            dialect=dialect,
            union_by_name=dialect.capabilities.supports_union_all_by_name,
        )
    elif use_cfl:
        plan = self._cfl_planner.plan(
            resolved,
            model,
            qualify_table=qualify_table,
            union_by_name=dialect.capabilities.supports_union_all_by_name,
            dialect=dialect,
        )
    else:
        plan = self._star_planner.plan(
            resolved, model, qualify_table=qualify_table, dialect=dialect
        )

    # Phase 2.3 – 2.6: Aggregate-mode passes (filter context, PoP,
    # totals, cumulative, window) plus HAVING projection cleanup. Raw
    # mode has no measures, so the passes are no-ops and skipped
    # entirely for clarity. Pass ordering and the feature-compatibility
    # rules live in ``compiler/passes.py``.
    if resolved.is_raw:
        wrapped_ast = plan.ast
    else:
        ctx = CompileContext(
            resolved=resolved,
            model=model,
            dialect=dialect,
            qualify_table=qualify_table,
            query=query,
        )
        wrapped_ast = apply_aggregate_passes(plan.ast, ctx)

    # Phase 3: Dialect-specific SQL rendering
    codegen = CodeGenerator(dialect)
    sql = codegen.generate(wrapped_ast)

    # Phase 4: SQL validation (non-blocking)
    validation_errors = validate_sql(sql, dialect_name)
    sql_valid = len(validation_errors) == 0
    warnings = resolved.warnings
    if not sql_valid:
        warnings = warnings + [
            warning(
                code=WarningCode.SQL_VALIDATION,
                message=f"SQL validation: {e}",
            )
            for e in validation_errors
        ]

    # Phase 4b: scope check (non-blocking). Syntax is not the only way a
    # statement can be wrong: an expression left behind in a query that
    # wraps it parses clean and fails at the database, naming a data object
    # from the model as if the model were at fault (#358). Kept separate
    # from ``sql_valid``, which answers whether sqlglot could parse it.
    stray_tables = out_of_scope_tables(wrapped_ast)
    if stray_tables:
        named = ", ".join(sorted(stray_tables))
        warnings = warnings + [
            warning(
                code=WarningCode.OUT_OF_SCOPE_TABLE,
                message=(
                    f"Compiled SQL references {named} in the outermost query, "
                    f"which its FROM does not provide"
                ),
                hint=(
                    "The statement will be rejected by the database. This is a "
                    "compiler defect rather than a model one - please report the "
                    "query and model."
                ),
                context={"tables": sorted(stray_tables)},
            )
        ]

    # Build explain plan
    explain = self._build_explain(resolved, model, use_cfl, plan)

    # Compute deduplicated physical tables touched by the query
    physical_tables = _compute_physical_tables(resolved, query, model)

    return CompilationResult(
        sql=sql,
        dialect=dialect_name,
        physical_tables=physical_tables,
        resolved=ResolvedInfo(
            fact_tables=resolved.fact_tables,
            dimensions=[d.name for d in resolved.dimensions],
            measures=[m.name for m in resolved.measures],
        ),
        warnings=warnings,
        sql_valid=sql_valid,
        explain=explain,
    )

Query Resolution

orionbelt.compiler.resolution.QueryResolver

Resolves a QueryObject + SemanticModel into a ResolvedQuery.

Source code in src/orionbelt/compiler/resolution.py
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 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
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

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

    # -- 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).
        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:
                    args.append(make_column_expr(ctx.model, obj_name, col_name))
                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)

        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

Star Schema Planner

orionbelt.compiler.star.StarSchemaPlanner

Plans star-schema queries: single fact base with dimension joins.

Source code in src/orionbelt/compiler/star.py
class StarSchemaPlanner:
    """Plans star-schema queries: single fact base with dimension joins."""

    def plan(
        self,
        resolved: ResolvedQuery,
        model: SemanticModel,
        qualify_table: Callable[[DataObject], str] | None = None,
        dialect: Dialect | None = None,
    ) -> QueryPlan:
        builder = QueryBuilder()
        graph = JoinGraph(model, use_path_names=resolved.use_path_names or None)

        def qualify(obj: DataObject) -> str:
            return qualify_table(obj) if qualify_table else obj.qualified_code

        base_object = model.data_objects.get(resolved.base_object)
        if not base_object:
            return QueryPlan(ast=builder.build())

        base_alias = resolved.base_object

        # Anchored measures: aggregate each independent fact they read to the
        # key it shares with the anchor, so it joins back many-to-one instead of
        # pairing rows that do not correspond. Planned up front because the
        # measure projection below has to read the conformed columns rather than
        # the foreign fact's own, which this plan is what renames.
        conformed_facts, conformed_exprs = plan_conformed_facts(resolved, model, qualify)
        # Recorded for the wrappers that run after planning: each re-projects a
        # measure's aggregate into its own CTE, and the resolved expression they
        # would otherwise use still names the foreign fact's table.
        resolved.projected_expressions = conformed_exprs

        # SELECT: dimensions (apply time grain truncation if specified)
        grouping_dim_aliases: list[str] = []
        for dim in resolved.dimensions:
            col: Expr = make_dimension_expr(model, dim, dialect)
            builder.select(AliasedExpr(expr=col, alias=dim.name))
            if resolved.grouping is not None:
                grouping_dim_aliases.append(dim.name)

        # SELECT: measures (aggregated) — for metrics, substitute component refs
        settings = model.settings
        measure_exprs: dict[str, Expr] = {}
        # A metric inlines its components' expressions, so a conformed component
        # has to be substituted in its rewritten form - the raw one still names
        # the foreign fact's own table, which the conformed plan does not join.
        metric_components = {
            name: (
                replace(component, expression=conformed_exprs[name])
                if name in conformed_exprs
                else component
            )
            for name, component in resolved.metric_components.items()
        }
        for measure in resolved.measures:
            if measure.component_measures:
                expr: Expr = _substitute_measure_refs(measure.expression, metric_components)
                metric = model.metrics.get(measure.name)
                if metric and dialect:
                    resolved_type = resolve_metric_data_type(metric, settings)
                    if resolved_type:
                        expr = dialect.cast_to_obml_type(expr, resolved_type)
                builder.select(AliasedExpr(expr=expr, alias=measure.name))
            else:
                expr = conformed_exprs.get(measure.name, measure.expression)
                model_measure = model.effective_measures.get(measure.name)
                if model_measure and dialect:
                    expr = cast_measure_to_resolved_type(
                        expr, model_measure, settings, dialect, model
                    )
                builder.select(AliasedExpr(expr=expr, alias=measure.name))
            measure_exprs[measure.name] = expr

        # FROM: base fact table
        builder.from_(qualify(base_object), alias=base_alias)

        # Conformed facts join first: one row per shared key, so many-to-one and
        # no fanout onto the anchor's grain.
        for fact in conformed_facts:
            builder.join(
                table=fact.select,
                on=fact.on,
                join_type=conformed_join_type(),
                alias=fact.alias,
            )

        # JOINs: dimension and intermediate tables
        joined = {base_alias}
        for step in resolved.join_steps:
            # Determine which side of the step needs to be joined
            if step.to_object not in joined:
                new_object = step.to_object
            elif step.from_object not in joined:
                new_object = step.from_object
            else:
                continue  # both already joined
            obj = model.data_objects.get(new_object)
            if not obj:
                continue
            emit_join_step(
                builder=builder,
                step=step,
                new_object=new_object,
                obj=obj,
                graph=graph,
                qualify=qualify,
                dialect=dialect,
                warnings=resolved.warnings,
            )
            joined.add(new_object)

        # WHERE
        for wf in resolved.where_filters:
            builder.where(wf.expression)

        # GROUP BY (all dimension columns, with time grain if applicable).
        # Stash the per-dim group-by expression by alias so GROUPING() below
        # can reuse the SAME expression — Postgres rejects GROUPING(<alias>)
        # with "column does not exist" and requires the group-key expression.
        group_by_exprs: dict[str, Expr] = {}
        for dim in resolved.dimensions:
            gb_col: Expr = make_dimension_expr(model, dim, dialect)
            builder.group_by(gb_col)
            group_by_exprs[dim.name] = gb_col

        # GROUPING() flag columns + grouping modifier (rollup/cube)
        if resolved.grouping is not None and grouping_dim_aliases:
            builder.grouping(resolved.grouping.value)
            for alias in grouping_dim_aliases:
                gb_arg = group_by_exprs.get(alias) or ColumnRef(name=alias)
                flag_col = FunctionCall(name="GROUPING", args=[gb_arg])
                builder.select(AliasedExpr(expr=flag_col, alias=_grouping_flag_alias(alias)))

        # HAVING — expand alias references to actual CAST'd aggregate expressions.
        # A predicate on a measure some later wrapper finishes with a window
        # function is left out entirely: at this level only the pre-window
        # aggregate exists, so evaluating it here filters the wrong value.
        # ``PASS_HAVING_WINDOW`` applies those once, over the windowed rows.
        # Withholding them here rather than stripping them per wrapper is what
        # keeps every wrapper composition correct — each one copies or rebuilds
        # ``ast.having`` differently, and any that kept a stale copy would
        # filter pre-window behind the pass's back.
        from orionbelt.compiler.having_hoist import windowed_aliases

        deferred = windowed_aliases(resolved)
        for hf in resolved.having_filters:
            if hf.referenced_fields & deferred:
                continue
            builder.having(_expand_measure_refs(hf.expression, measure_exprs))

        # ORDER BY (use alias for time-grained dimensions)
        grained_cols: dict[tuple[str, str | None], str] = {
            (d.source_column, d.object_name): d.name for d in resolved.dimensions if d.grain
        }
        for expr, desc, nulls in resolved.order_by_exprs:
            if isinstance(expr, ColumnRef) and (expr.name, expr.table) in grained_cols:
                expr = ColumnRef(name=grained_cols[(expr.name, expr.table)])
            builder.order_by(expr, desc=desc, nulls_last=_nulls_last(nulls))

        # LIMIT / OFFSET
        if resolved.limit is not None:
            builder.limit(resolved.limit)
        if resolved.offset is not None:
            builder.offset(resolved.offset)

        return QueryPlan(ast=builder.build())

plan(resolved, model, qualify_table=None, dialect=None)

Source code in src/orionbelt/compiler/star.py
def plan(
    self,
    resolved: ResolvedQuery,
    model: SemanticModel,
    qualify_table: Callable[[DataObject], str] | None = None,
    dialect: Dialect | None = None,
) -> QueryPlan:
    builder = QueryBuilder()
    graph = JoinGraph(model, use_path_names=resolved.use_path_names or None)

    def qualify(obj: DataObject) -> str:
        return qualify_table(obj) if qualify_table else obj.qualified_code

    base_object = model.data_objects.get(resolved.base_object)
    if not base_object:
        return QueryPlan(ast=builder.build())

    base_alias = resolved.base_object

    # Anchored measures: aggregate each independent fact they read to the
    # key it shares with the anchor, so it joins back many-to-one instead of
    # pairing rows that do not correspond. Planned up front because the
    # measure projection below has to read the conformed columns rather than
    # the foreign fact's own, which this plan is what renames.
    conformed_facts, conformed_exprs = plan_conformed_facts(resolved, model, qualify)
    # Recorded for the wrappers that run after planning: each re-projects a
    # measure's aggregate into its own CTE, and the resolved expression they
    # would otherwise use still names the foreign fact's table.
    resolved.projected_expressions = conformed_exprs

    # SELECT: dimensions (apply time grain truncation if specified)
    grouping_dim_aliases: list[str] = []
    for dim in resolved.dimensions:
        col: Expr = make_dimension_expr(model, dim, dialect)
        builder.select(AliasedExpr(expr=col, alias=dim.name))
        if resolved.grouping is not None:
            grouping_dim_aliases.append(dim.name)

    # SELECT: measures (aggregated) — for metrics, substitute component refs
    settings = model.settings
    measure_exprs: dict[str, Expr] = {}
    # A metric inlines its components' expressions, so a conformed component
    # has to be substituted in its rewritten form - the raw one still names
    # the foreign fact's own table, which the conformed plan does not join.
    metric_components = {
        name: (
            replace(component, expression=conformed_exprs[name])
            if name in conformed_exprs
            else component
        )
        for name, component in resolved.metric_components.items()
    }
    for measure in resolved.measures:
        if measure.component_measures:
            expr: Expr = _substitute_measure_refs(measure.expression, metric_components)
            metric = model.metrics.get(measure.name)
            if metric and dialect:
                resolved_type = resolve_metric_data_type(metric, settings)
                if resolved_type:
                    expr = dialect.cast_to_obml_type(expr, resolved_type)
            builder.select(AliasedExpr(expr=expr, alias=measure.name))
        else:
            expr = conformed_exprs.get(measure.name, measure.expression)
            model_measure = model.effective_measures.get(measure.name)
            if model_measure and dialect:
                expr = cast_measure_to_resolved_type(
                    expr, model_measure, settings, dialect, model
                )
            builder.select(AliasedExpr(expr=expr, alias=measure.name))
        measure_exprs[measure.name] = expr

    # FROM: base fact table
    builder.from_(qualify(base_object), alias=base_alias)

    # Conformed facts join first: one row per shared key, so many-to-one and
    # no fanout onto the anchor's grain.
    for fact in conformed_facts:
        builder.join(
            table=fact.select,
            on=fact.on,
            join_type=conformed_join_type(),
            alias=fact.alias,
        )

    # JOINs: dimension and intermediate tables
    joined = {base_alias}
    for step in resolved.join_steps:
        # Determine which side of the step needs to be joined
        if step.to_object not in joined:
            new_object = step.to_object
        elif step.from_object not in joined:
            new_object = step.from_object
        else:
            continue  # both already joined
        obj = model.data_objects.get(new_object)
        if not obj:
            continue
        emit_join_step(
            builder=builder,
            step=step,
            new_object=new_object,
            obj=obj,
            graph=graph,
            qualify=qualify,
            dialect=dialect,
            warnings=resolved.warnings,
        )
        joined.add(new_object)

    # WHERE
    for wf in resolved.where_filters:
        builder.where(wf.expression)

    # GROUP BY (all dimension columns, with time grain if applicable).
    # Stash the per-dim group-by expression by alias so GROUPING() below
    # can reuse the SAME expression — Postgres rejects GROUPING(<alias>)
    # with "column does not exist" and requires the group-key expression.
    group_by_exprs: dict[str, Expr] = {}
    for dim in resolved.dimensions:
        gb_col: Expr = make_dimension_expr(model, dim, dialect)
        builder.group_by(gb_col)
        group_by_exprs[dim.name] = gb_col

    # GROUPING() flag columns + grouping modifier (rollup/cube)
    if resolved.grouping is not None and grouping_dim_aliases:
        builder.grouping(resolved.grouping.value)
        for alias in grouping_dim_aliases:
            gb_arg = group_by_exprs.get(alias) or ColumnRef(name=alias)
            flag_col = FunctionCall(name="GROUPING", args=[gb_arg])
            builder.select(AliasedExpr(expr=flag_col, alias=_grouping_flag_alias(alias)))

    # HAVING — expand alias references to actual CAST'd aggregate expressions.
    # A predicate on a measure some later wrapper finishes with a window
    # function is left out entirely: at this level only the pre-window
    # aggregate exists, so evaluating it here filters the wrong value.
    # ``PASS_HAVING_WINDOW`` applies those once, over the windowed rows.
    # Withholding them here rather than stripping them per wrapper is what
    # keeps every wrapper composition correct — each one copies or rebuilds
    # ``ast.having`` differently, and any that kept a stale copy would
    # filter pre-window behind the pass's back.
    from orionbelt.compiler.having_hoist import windowed_aliases

    deferred = windowed_aliases(resolved)
    for hf in resolved.having_filters:
        if hf.referenced_fields & deferred:
            continue
        builder.having(_expand_measure_refs(hf.expression, measure_exprs))

    # ORDER BY (use alias for time-grained dimensions)
    grained_cols: dict[tuple[str, str | None], str] = {
        (d.source_column, d.object_name): d.name for d in resolved.dimensions if d.grain
    }
    for expr, desc, nulls in resolved.order_by_exprs:
        if isinstance(expr, ColumnRef) and (expr.name, expr.table) in grained_cols:
            expr = ColumnRef(name=grained_cols[(expr.name, expr.table)])
        builder.order_by(expr, desc=desc, nulls_last=_nulls_last(nulls))

    # LIMIT / OFFSET
    if resolved.limit is not None:
        builder.limit(resolved.limit)
    if resolved.offset is not None:
        builder.offset(resolved.offset)

    return QueryPlan(ast=builder.build())

CFL Planner

orionbelt.compiler.cfl.CFLPlanner

Plans Composite Fact Layer queries: conformed dimensions + fact stitching.

Uses a UNION ALL strategy: 1. Each fact leg SELECTs conformed dimensions + its own measures (NULL for others) 2. UNION ALL combines the legs into a single CTE 3. Outer query aggregates over the union, grouping by conformed dimensions

Source code in src/orionbelt/compiler/cfl.py
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 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
class CFLPlanner:
    """Plans Composite Fact Layer queries: conformed dimensions + fact stitching.

    Uses a UNION ALL strategy:
    1. Each fact leg SELECTs conformed dimensions + its own measures (NULL for others)
    2. UNION ALL combines the legs into a single CTE
    3. Outer query aggregates over the union, grouping by conformed dimensions
    """

    def plan(
        self,
        resolved: ResolvedQuery,
        model: SemanticModel,
        qualify_table: Callable[[DataObject], str] | None = None,
        union_by_name: bool = False,
        dialect: Dialect | None = None,
    ) -> QueryPlan:
        """Plan a CFL query."""
        self._validate_fanout(resolved, model)

        # dimensionsExclude: EXCEPT-based anti-join pattern
        if resolved.dimensions_exclude:
            return self._plan_dimensions_exclude(resolved, model, qualify_table, dialect)

        # Group measures by their source object
        measures_by_object, cross_fact = self._group_measures_by_object(resolved, model)

        # Dimension-only CFL: no measures but dimensions on independent branches.
        # Create leg groupings from connecting fact tables.
        if not measures_by_object and not cross_fact and resolved.requires_cfl:
            measures_by_object = self._group_dimensions_into_legs(resolved, model)

        # A dimension no leg can reach is projected by none of them, and the
        # union then has no such column for the outer SELECT to group on. Under
        # ``UNION ALL BY NAME`` that is not even a NULL pad - the padding fills
        # a column *some* leg supplies, and this one has no supplier at all - so
        # the query compiled to SQL naming a column that does not exist.
        self._reject_unreachable_dimensions(measures_by_object, resolved, model)

        if len(measures_by_object) <= 1 and not cross_fact:
            # Single fact — delegate to star schema. Resolution let this query
            # past the reachability check because it looked multi-fact, and a
            # star has to serve every dimension from one root, so check now
            # that the leg left standing can.
            from orionbelt.compiler.star import StarSchemaPlanner

            return StarSchemaPlanner().plan(
                resolved, model, qualify_table=qualify_table, dialect=dialect
            )

        # Every multi-column aggregate reads its arguments from one row: a
        # two-column statistic (CORR/COVAR_*/REGR_*) correlates a pair, a
        # multi-column COUNT DISTINCT counts observed tuples. A leg that owns
        # all the arguments carries them as separate columns and the outer
        # query rebuilds the aggregate over them, so a multi-fact query costs
        # such a measure nothing.
        #
        # What none of them survives is having their arguments on data objects
        # no single leg reaches — the definition of ``cross_fact``. UNION ALL
        # stacks facts rather than joining them, so each leg supplies one
        # column and NULL-pads the rest, and no row ever carries a complete
        # set. The statistics then return NULL over zero pairs, and the tuple
        # count concatenates a NULL into every row and returns 0 — a wrong
        # answer rather than a failure, which is the reason to refuse here
        # rather than let it compile.
        #
        # Metric components count: a metric is planned by inlining its
        # components' aggregates, so ``{[Cross Corr]}`` reaches the same
        # rebuild — it just used to arrive there without passing the guard.
        cross_fact_names = {m.name for m in cross_fact} if cross_fact else set()
        for measure in (*resolved.measures, *resolved.metric_components.values()):
            if measure.name in cross_fact_names and self._is_multi_field(measure):
                agg = measure.aggregation.lower() if measure.aggregation else ""
                raise UnsupportedAggregationForCFLError(measure.name, agg)

        # Ordered aggregates now carry their sort key through the union as a
        # column of its own, so the outer re-aggregation can order by it. That
        # only works where the leg owning the measure can actually reach the
        # sort column's object — otherwise the leg has nothing to project, and
        # the aggregate would come back in an arbitrary order. Refuse those.
        #
        # A cross-fact measure has no single owning leg, so an ordering on one
        # is refused outright. Metric components are covered because they are
        # planned into legs like any other measure.
        self._validate_ordered_aggregates(resolved, model, measures_by_object, cross_fact)

        # Multi-fact: UNION ALL strategy
        return self._plan_union_all(
            resolved,
            model,
            measures_by_object,
            cross_fact,
            qualify_table=qualify_table,
            union_by_name=union_by_name,
            dialect=dialect,
        )

    def _validate_ordered_aggregates(
        self,
        resolved: ResolvedQuery,
        model: SemanticModel,
        measures_by_object: dict[str, list[ResolvedMeasure]],
        cross_fact: list[ResolvedMeasure] | None,
    ) -> None:
        """Refuse ordered aggregates whose sort key their own leg cannot reach."""
        graph = JoinGraph(model, use_path_names=resolved.use_path_names or None)
        owner: dict[str, str] = {}
        for obj_name, measures in measures_by_object.items():
            for measure in measures:
                owner[measure.name] = obj_name

        candidates = list(measures_by_object.values())
        if cross_fact:
            candidates.append(cross_fact)
        for measures in candidates:
            for measure in measures:
                item = self._within_group_item(measure)
                if item is None:
                    continue
                sort_objects: set[str] = set()
                cfl_projection.collect_table_refs(item.expr, sort_objects)
                if not sort_objects:
                    continue
                leg_object = owner.get(measure.name)
                reachable: set[str] = (
                    graph.descendants_without_unnest(leg_object) | {leg_object}
                    if leg_object is not None
                    else set()
                )
                unreachable = sort_objects - reachable
                if unreachable:
                    raise WithinGroupNotSupportedInCFLError(measure.name, sorted(unreachable)[0])

    def _validate_fanout(self, resolved: ResolvedQuery, model: SemanticModel) -> None:
        """Validate that grain is compatible and no fanout will occur."""
        errors: list[str] = []

        for dim in resolved.dimensions:
            if dim.object_name not in model.data_objects:
                errors.append(
                    f"Dimension '{dim.name}' references unknown data object '{dim.object_name}'"
                )

        if errors:
            raise FanoutError("; ".join(errors))

    def _group_measures_by_object(
        self,
        resolved: ResolvedQuery,
        model: SemanticModel,
    ) -> tuple[dict[str, list[ResolvedMeasure]], list[ResolvedMeasure]]:
        """Group measures by their primary source object."""
        return cfl_projection.group_measures_by_object(self, resolved, model)

    @staticmethod
    def _reject_unreachable_dimensions(
        measures_by_object: dict[str, list[ResolvedMeasure]],
        resolved: ResolvedQuery,
        model: SemanticModel,
    ) -> None:
        """Refuse a dimension **no** leg can produce.

        A query looks multi-fact while its measures span facts, and resolution
        skips the reachability check on that basis - the union answers it, each
        leg projecting the dimensions it reaches and NULL-padding the rest. That
        only works while some leg reaches it. One that none does is projected by
        none of them, so there is no column in the union for the outer SELECT to
        name, and where the legs collapse to one the star this delegates to
        would project a column from a table it does not select from. The same
        refusal resolution would have raised.

        Reachability is measured without crossing a containment edge: a leg is
        built out of tables and has no unnest to reach a nested object with, nor
        anything sitting behind one.
        """
        roots = set(measures_by_object) or {resolved.base_object}
        graph = JoinGraph(model, use_path_names=resolved.use_path_names or None)
        reachable = {
            name for root in roots for name in (graph.descendants_without_unnest(root) | {root})
        }
        root = sorted(roots)[0]
        unreachable = sorted(
            {dim.object_name for dim in resolved.dimensions if dim.object_name not in reachable}
        )
        if not unreachable:
            return
        raise ResolutionError(
            [
                SemanticError(
                    code="UNREACHABLE_REQUIRED_OBJECT",
                    message=(
                        f"Data object '{name}' is required by the query but cannot be "
                        f"reached from base '{root}' via directed joins. Many-to-one joins "
                        f"are forward-only; reverse traversal would inflate row counts. Add "
                        f"an explicit join from '{root}' (or an intermediate object) to "
                        f"'{name}', or split the query so each fact is queried "
                        f"independently."
                    ),
                    path="select",
                )
                for name in unreachable
            ]
        )

    @staticmethod
    def _group_dimensions_into_legs(
        resolved: ResolvedQuery,
        model: SemanticModel,
    ) -> dict[str, list[ResolvedMeasure]]:
        """Group dimensions into CFL legs for dimension-only queries."""
        return cfl_projection.group_dimensions_into_legs(resolved, model)

    @staticmethod
    def _is_multi_field(measure: ResolvedMeasure) -> bool:
        """Check if a measure has multiple field args (e.g. COUNT(a, b))."""
        return cfl_projection.is_multi_field(measure)

    @staticmethod
    def _resolve_union_alignment_type(
        measure: ResolvedMeasure,
        model: SemanticModel,
        dialect: Dialect | None = None,
    ) -> str | None:
        """The type every UNION leg agrees on for *measure*'s column."""
        return cfl_projection.resolve_union_alignment_type(measure, model, dialect)

    def _resolve_owning_leg_cast_type(
        self,
        measure: ResolvedMeasure,
        model: SemanticModel,
        dialect: Dialect | None = None,
    ) -> str | None:
        return cfl_projection.resolve_owning_leg_cast_type(measure, model, dialect)

    @staticmethod
    def _resolve_null_type_for_field(
        measure: ResolvedMeasure,
        field_idx: int,
        model: SemanticModel,
        dialect: Dialect | None = None,
    ) -> str | None:
        """Resolve the SQL type for NULL padding in CFL UNION ALL legs."""
        return cfl_projection.resolve_null_type_for_field(measure, field_idx, model, dialect)

    @staticmethod
    def _unwrap_aggregation(measure: ResolvedMeasure) -> Expr:
        """Extract the inner expression from an aggregated measure."""
        return cfl_projection.unwrap_aggregation(measure)

    def _build_outer_metric_expr(
        self,
        metric: ResolvedMeasure,
        resolved: ResolvedQuery,
        cte_name: str,
    ) -> Expr:
        """Build the outer query expression for a metric."""
        return cfl_projection.build_outer_metric_expr(self, metric, resolved, cte_name)

    def _substitute_outer_refs(self, expr: Expr, resolved: ResolvedQuery, cte_name: str) -> Expr:
        """Recursively substitute measure refs with outer aggregations."""
        return cfl_projection.substitute_outer_refs(self, expr, resolved, cte_name)

    @staticmethod
    def _collect_table_refs(expr: Expr, tables: set[str]) -> None:
        """Recursively collect table names from ColumnRef nodes."""
        cfl_projection.collect_table_refs(expr, tables)

    @staticmethod
    def _leg_projects_argument(
        measure: ResolvedMeasure,
        arg: Expr,
        obj_name: str,
        this_measure_names: set[str],
    ) -> bool:
        """Whether this leg supplies *arg* of a multi-field measure, or NULL-pads it.

        A leg that **owns** the measure projects every argument, full stop.
        Grouping already put the measure here because one root reaches all the
        objects its arguments read (``_single_leg_root``), and this leg is that
        root, so a joined column (``corr(Returns.Qty, Calendar.Month)``), a
        computed column expanding to one, and a computed column that reads no
        column at all (``One: {expression: '1'}``) are each as projectable here
        as a bare own-table reference. Nothing else projects them, so any test
        this applies can only take an argument away from the one leg that could
        have supplied it.

        Two narrower rules were tried and both lost arguments this way. Matching
        a bare ``ColumnRef`` on this exact object dropped joined and computed
        columns; also demanding the argument reference *some* table dropped
        constant expressions, whose reference set is empty. In both cases the
        owning leg NULL-padded its own measure's argument, so the tuple count
        counted a column of NULLs and returned 0, and a two-column statistic -
        NULL unless every argument is present - returned NULL, on the dialects
        that pad explicitly; the ones using ``UNION ALL BY NAME`` failed to bind
        instead. A wrong number is the worse half of that.

        A **cross-fact** measure is the other case: no single leg reaches all its
        arguments, so each leg takes the ones rooted in its own fact and the rest
        are padded. A conformed dimension is reachable from every leg, so the
        stricter own-object rule is what keeps two legs from both claiming it.
        """
        if measure.name in this_measure_names:
            return True
        return isinstance(arg, ColumnRef) and arg.table == obj_name

    @staticmethod
    def _within_group_item(measure: ResolvedMeasure) -> OrderByItem | None:
        """The sort key a leg must carry, or ``None`` if it need not carry one."""
        return cfl_projection.within_group_item(measure)

    @staticmethod
    def _remap_cfl_order_by(expr: Expr, resolved: ResolvedQuery, model: SemanticModel) -> Expr:
        """Remap ORDER BY expressions to use CTE aliases for the outer query."""
        return cfl_projection.remap_cfl_order_by(expr, resolved, model)

    def _plan_union_all(
        self,
        resolved: ResolvedQuery,
        model: SemanticModel,
        measures_by_object: dict[str, list[ResolvedMeasure]],
        cross_fact: list[ResolvedMeasure] | None = None,
        qualify_table: Callable[[DataObject], str] | None = None,
        union_by_name: bool = False,
        dialect: Dialect | None = None,
    ) -> QueryPlan:
        """UNION ALL strategy: stack fact legs with NULL padding, aggregate outside.

        When *union_by_name* is True (DuckDB, Snowflake) each leg only emits
        the columns it actually has — the database fills missing columns with
        NULL automatically via ``UNION ALL BY NAME``.
        """
        graph = JoinGraph(model, use_path_names=resolved.use_path_names or None)

        def qualify(obj: DataObject) -> str:
            return qualify_table(obj) if qualify_table else obj.qualified_code

        # Internal composite columns (multi-field arguments, ordered-aggregate
        # sort keys), allocated once so the legs that project them, the legs
        # that NULL-pad them and the outer re-aggregation all agree — and so
        # none of them shadows a column the composite already carries under a
        # user-facing name.
        aliases = cfl_projection.composite_aliases(resolved)

        # Anchored measures are conformed the same way the star planner does
        # it, but the subqueries are joined inside the leg that owns the
        # measure rather than into one shared FROM.
        conformed_facts, conformed_exprs = plan_conformed_facts(resolved, model, qualify)
        facts_by_measure: dict[str, list[ConformedFact]] = {}
        for fact in conformed_facts:
            facts_by_measure.setdefault(fact.measure_name, []).append(fact)

        # Collect all measures across all objects + cross-fact measures
        all_measures: list[ResolvedMeasure] = []
        for measures in measures_by_object.values():
            all_measures.extend(measures)
        if cross_fact:
            all_measures.extend(cross_fact)

        # Collect data objects referenced by WHERE filters — each leg
        # must join these tables so the filter predicates are valid.
        filter_objects: set[str] = set()
        for wf in resolved.where_filters:
            self._collect_table_refs(wf.expression, filter_objects)
            # An EXISTS body correlates to an outer table, which the walk above
            # cannot see: the body is a Select, not an expression. Each leg
            # emits that body in its own WHERE, so each leg has to join it.
            cfl_projection.collect_correlated_tables(wf.expression, filter_objects)

        # Build one SELECT per fact object group.
        # Each leg computes its own LCA (least common ancestor) as the lead
        # table — the graph-central node that can reach all dimension objects
        # and the measure's source object with minimal hops.
        union_legs: list[Select] = []
        leg_infos: list[CflLegInfo] = []
        dedup_offenders: dict[str, str] = {}
        for obj_name, measures in measures_by_object.items():
            leg_builder = QueryBuilder()
            this_measure_names = {m.name for m in measures}

            # Compute reachability from this leg's fact object upfront.
            # Deliberately not ``descendants``: a leg is a star built out of
            # tables and cannot carry an unnest, so a nested object and anything
            # behind one are out of its reach however ordinary their own joins.
            reachable = graph.descendants_without_unnest(obj_name) | {obj_name}

            # Collect table references from this leg's own-measure
            # expressions. A measure like ``Electronics Sales`` is
            # defined as ``SUM(CASE WHEN Products.productcat = …
            # THEN Sales.salesamount END)`` — the CASE condition
            # references Products, which must be joined into this
            # leg's FROM. Without this, the generated SQL emits
            # ``"Products"."productcat"`` against a FROM clause that
            # only has Sales + Clients, and the database raises
            # "missing FROM-clause entry for table Products".
            measure_expr_objects: set[str] = set()
            for m in measures:
                self._collect_table_refs(m.expression, measure_expr_objects)
            # A conformed fact is reached by a GROUP BY subquery joined below,
            # not by a join from this leg's lead, so it must not become a join
            # requirement: doing so would join the raw fact and fan the leg out.
            for m in measures:
                for fact in facts_by_measure.get(m.name, ()):
                    measure_expr_objects.discard(fact.object_name)
            if cross_fact:
                for m in cross_fact:
                    if m.name in this_measure_names:
                        self._collect_table_refs(m.expression, measure_expr_objects)

            # A leg's FROM is its *lead*, not its key: the common root of the
            # key and what that key reaches. Where the key is a measure's source
            # on the one side of a join - ``Products``, reaching nothing - the
            # lead is the ``Sales`` that reaches both, and dimensions the lead
            # can produce were being NULL-padded on the grounds that the key
            # could not. Every row of the leg then collapsed into one NULL
            # group. Widen the reachability to the lead where a lead covering
            # the query's dimensions exists at all; where none does, the facts
            # really are independent and the padding below is right.
            wanted = (
                {dim.object_name for dim in resolved.dimensions}
                | {obj_name}
                | filter_objects
                | measure_expr_objects
            )
            wide_lead = graph.find_common_root(wanted)
            wide_reach = (
                graph.descendants_without_unnest(wide_lead) | {wide_lead} if wide_lead else set()
            )
            if wide_lead and obj_name in wide_reach:
                reachable = wide_reach

            # SELECT conformed dimensions — only emit real column refs for
            # dimensions reachable from this leg's fact AND whose `via:`
            # waypoint (if any) is also reachable from this leg's fact.
            # Role-playing dimensions tied to a different fact via `via:`
            # are NULL-padded so each leg only projects the values that
            # belong to its own fact.
            for dim in resolved.dimensions:
                via_ok = dim.via is None or dim.via in reachable
                if dim.object_name in reachable and via_ok:
                    col: Expr = make_dimension_expr(model, dim, dialect)
                    leg_builder.select(AliasedExpr(expr=col, alias=dim.name))
                elif not union_by_name:
                    model_dim = model.dimensions.get(dim.name)
                    dim_type = model_dim.result_type.value if model_dim else None
                    col = Cast(Literal.null(), type_name=dim_type) if dim_type else Literal.null()
                    leg_builder.select(AliasedExpr(expr=col, alias=dim.name))

            # SELECT this fact's measures (raw expressions, no aggregation).
            # When union_by_name is True, skip NULL padding for other facts'
            # measures — the database fills them automatically.
            for m in all_measures:
                if self._is_multi_field(m):
                    # The aggregate, not the expression: a declared default
                    # wraps it in a COALESCE whose second argument is the
                    # default itself, which is not one of the aggregate's.
                    assert isinstance(m.aggregate, FunctionCall)
                    for i, arg in enumerate(m.aggregate.args):
                        alias = aliases.multi_field[m.name][i]
                        if self._leg_projects_argument(m, arg, obj_name, this_measure_names):
                            leg_builder.select(AliasedExpr(expr=arg, alias=alias))
                        elif not union_by_name:
                            null_type = self._resolve_null_type_for_field(m, i, model)
                            null_expr: Expr = (
                                Cast(Literal.null(), type_name=null_type)
                                if null_type
                                else Literal.null()
                            )
                            leg_builder.select(AliasedExpr(expr=null_expr, alias=alias))
                elif m.name in this_measure_names:
                    own_expr: Expr = self._unwrap_aggregation(
                        replace(m, expression=conformed_exprs[m.name])
                        if m.name in conformed_exprs
                        else m
                    )
                    # Whether this leg casts the measure it owns belongs to
                    # ``resolve_owning_leg_cast_type``, which states the rule
                    # and the measurements behind it. Deliberately not restated
                    # here: this spot carried a second copy, it went stale when
                    # #313 changed the rule, and the copy still read as
                    # authoritative while ClickHouse could not run a CFL query
                    # at all (#339). One statement, in the function that
                    # decides.
                    own_type_name = self._resolve_owning_leg_cast_type(m, model, dialect)
                    if own_type_name:
                        own_expr = Cast(expr=own_expr, type_name=own_type_name)
                    leg_builder.select(AliasedExpr(expr=own_expr, alias=m.name))
                    # An ordered aggregate's sort key rides along as its own
                    # column so the outer re-aggregation can order by it.
                    wg_item = self._within_group_item(m)
                    if wg_item is not None:
                        leg_builder.select(
                            AliasedExpr(expr=wg_item.expr, alias=aliases.within_group[m.name])
                        )
                elif not union_by_name:
                    model_measure = model.measures.get(m.name)
                    null_type_name = self._resolve_union_alignment_type(m, model, dialect)
                    if null_type_name is None and model_measure:
                        null_type_name = model_measure.result_type.value
                    null_expr = (
                        Cast(Literal.null(), type_name=null_type_name)
                        if null_type_name
                        else Literal.null()
                    )
                    leg_builder.select(AliasedExpr(expr=null_expr, alias=m.name))
                    # Pad the sort-key column too, so every leg agrees on the
                    # union's column list.
                    if self._within_group_item(m) is not None:
                        leg_builder.select(
                            AliasedExpr(expr=Literal.null(), alias=aliases.within_group[m.name])
                        )

            # Determine the common root for this leg:
            # the deepest directed ancestor that can reach all dimension
            # objects, measure's source object, filter-referenced objects,
            # and any objects referenced by this leg's measure expressions.
            # Only include dimensions reachable from this leg's fact object.
            leg_required = {
                dim.object_name for dim in resolved.dimensions if dim.object_name in reachable
            }
            leg_required.add(obj_name)
            # Only filter objects this leg can actually reach. A nested one it
            # never can - the leg has no unnest to reach it with - and a static
            # model filter naming one is documented as skipped rather than
            # fatal, which is what dropping it here delivers: the applicability
            # check below then leaves the predicate out, instead of
            # ``build_join_condition`` raising on a step with no columns.
            leg_required.update(filter_objects & reachable)
            # Include objects referenced by measure expressions, but only
            # those reachable from this leg's fact — cross-fact filter
            # tables would otherwise pull unrelated facts into the leg.
            leg_required.update(measure_expr_objects & reachable)
            lead = graph.find_common_root(leg_required)
            lead_obj = model.data_objects.get(lead)

            # FROM: the lead (LCA) table
            if lead_obj:
                leg_builder.from_(qualify(lead_obj), alias=lead)

            # Conformed facts for the anchored measures this leg owns: one row
            # per shared key, so many-to-one and no fanout onto the leg's grain.
            for m in measures:
                for fact in facts_by_measure.get(m.name, ()):
                    leg_builder.join(
                        table=fact.select,
                        on=fact.on,
                        join_type=conformed_join_type(),
                        alias=fact.alias,
                    )

            # JOINs: all required objects reachable from the lead
            join_targets = leg_required - {lead}
            steps: list[JoinStep] = []
            if join_targets:
                steps = graph.find_join_path(
                    {lead},
                    leg_required,
                    via_constraints=resolved.via_constraints or None,
                )
                # Dedupe by alias so a dim reachable through multiple
                # paths within one leg emits only one JOIN — postgres
                # rejects "table specified more than once" when two
                # role-played dims resolve to the same target object.
                joined_aliases: set[str] = {lead}
                for step in steps:
                    if step.to_object in joined_aliases:
                        continue
                    target_object = model.data_objects.get(step.to_object)
                    if target_object:
                        on_expr = graph.build_join_condition(step)
                        leg_builder.join(
                            table=qualify(target_object),
                            on=on_expr,
                            join_type=step.join_type,
                            alias=step.to_object,
                        )
                        joined_aliases.add(step.to_object)

            # A measure sourced from an object this leg's own joins replicate
            # is summed once per row of the many side. Resolution cannot see
            # that: its join steps are the base object's, and the step that
            # replicates lives inside a leg. The union has no per-leg grain to
            # deduplicate at either - the legs project the values to aggregate
            # rather than aggregating them - so this is refused rather than
            # answered with a plausible number in the right group.
            leg_dedup = detect_dedup_measures(
                replace(resolved, join_steps=steps, base_object=lead, measures=measures),
                model,
            )
            dedup_offenders.update(leg_dedup.measures)
            dedup_offenders.update(leg_dedup.components)

            # Capture leg info for explain
            leg_join_strs = (
                [f"{s.from_object}{s.to_object}" for s in steps] if join_targets else []
            )
            if lead == obj_name:
                leg_reason = (
                    f'"{lead}" is the measure source — '
                    f"all required dimension objects are reachable from it"
                )
            else:
                leg_reason = (
                    f'"{lead}" is the deepest common root that can reach '
                    f'measure source "{obj_name}" and all reachable dimension objects'
                )
            leg_infos.append(
                CflLegInfo(
                    measure_source=obj_name,
                    common_root=lead,
                    reason=leg_reason,
                    measures=[m.name for m in measures],
                    joins=leg_join_strs,
                )
            )

            # Apply WHERE filters to each leg
            for wf in resolved.where_filters:
                leg_builder.where(wf.expression)

            union_legs.append(leg_builder.build())

        if dedup_offenders:
            listed = ", ".join(f"'{name}'" for name in sorted(dedup_offenders))
            raise ResolutionError(
                [
                    SemanticError(
                        code="INCOMPATIBLE_COMBINATION",
                        message=(
                            f"Measure(s) {listed} are sourced from an object whose rows this "
                            f"query's joins replicate, so they must be aggregated over "
                            f"deduplicated rows. This query spans facts that cannot be "
                            f"joined, so it is planned as a UNION ALL whose legs project the "
                            f"values to aggregate rather than aggregating them, leaving no "
                            f"per-leg grain to deduplicate at."
                        ),
                        path="select.measures",
                        hint=(
                            "Query the measure without the measures from the other fact, or "
                            "set allowFanOut: true to aggregate the duplicated rows as-is."
                        ),
                        context={"measures": sorted(dedup_offenders)},
                    )
                ]
            )

        # Create the UNION ALL CTE
        cte_name = "composite_01"
        union_cte = CTE(name=cte_name, query=UnionAll(queries=union_legs))
        # All ColumnRefs that resolve to raw CTE columns inside outer-query
        # aggregate functions are qualified with *cte_name*. ClickHouse otherwise
        # resolves bare identifiers to sibling SELECT aliases first — when those
        # aliases are themselves aggregates (the case for measures and metrics
        # in the outer SELECT), it rejects the resulting nested aggregate as
        # ``ILLEGAL_AGGREGATION``. The qualification is harmless on dialects
        # that resolve column-first.

        # Build outer query: aggregate over the composite CTE
        outer_builder = QueryBuilder()

        # SELECT dimensions.  Coalesce groups emit COALESCE(d1, d2, ...) once
        # under the alias; plain dims keep their original column reference.
        emitted_coalesce_aliases: set[str] = set()
        coalesce_groups: dict[str, list[str]] = {}
        for d in resolved.dimensions:
            if d.coalesce_alias:
                coalesce_groups.setdefault(d.coalesce_alias, []).append(d.name)
        for dim in resolved.dimensions:
            if dim.coalesce_alias:
                if dim.coalesce_alias in emitted_coalesce_aliases:
                    continue
                emitted_coalesce_aliases.add(dim.coalesce_alias)
                outer_builder.select(
                    AliasedExpr(
                        expr=FunctionCall(
                            name="COALESCE",
                            args=[
                                ColumnRef(name=member)
                                for member in coalesce_groups[dim.coalesce_alias]
                            ],
                        ),
                        alias=dim.coalesce_alias,
                    )
                )
            else:
                outer_builder.select(
                    AliasedExpr(
                        expr=ColumnRef(name=dim.name),
                        alias=dim.name,
                    )
                )

        # SELECT aggregated measures and metrics
        # First, aggregate every measure from the UNION ALL legs. This
        # includes component measures pulled in only to feed a metric
        # (e.g. Total Returns / Total Purchases behind Return Rate /
        # Gross Margin). We still compute their aggregate expression and
        # record it in ``outer_measure_exprs`` so HAVING can reference any
        # measure, but we only PROJECT the measures the caller actually
        # requested — otherwise the result carries extra columns the
        # consumer never asked for, which Postgres-federation clients
        # (Dremio) reject as an unexpected dataset shape.
        settings = model.settings
        requested_measure_names = {rm.name for rm in resolved.measures}
        seen_measure_names: set[str] = set()
        outer_measure_exprs: dict[str, Expr] = {}
        for m in all_measures:
            seen_measure_names.add(m.name)
            # Shared with the metric projection so the two cannot drift: this
            # picks the rebuild that matches how the legs projected the measure
            # (concatenated argument columns, or its own single column) and
            # reapplies DISTINCT, the LISTAGG separator and any withinGroup
            # ordering over the sort key the legs carried.
            agg_expr: Expr = cfl_projection.build_outer_measure_expr(m, cte_name, aliases)
            # Apply CAST for resolved data_type (effective_measures so
            # multi-fact synthesized counts get the same integer CAST as
            # declared count measures).
            model_measure = model.effective_measures.get(m.name)
            if model_measure and dialect:
                # Same path as the star planner. Here the argument is the union
                # column rather than the source, which is still the right thing
                # to average: the legs project pre-aggregation rows.
                agg_expr = cast_measure_to_resolved_type(
                    agg_expr, model_measure, settings, dialect, model
                )
            if m.name in requested_measure_names:
                outer_builder.select(AliasedExpr(expr=agg_expr, alias=m.name))
            outer_measure_exprs[m.name] = agg_expr

        # Then, add metric expressions that combine component measures
        for m in resolved.measures:
            if m.component_measures and m.name not in seen_measure_names:
                metric_expr: Expr = self._build_outer_metric_expr(m, resolved, cte_name)
                metric = model.metrics.get(m.name)
                if metric and dialect:
                    resolved_type = resolve_metric_data_type(metric, settings)
                    if resolved_type:
                        metric_expr = dialect.cast_to_obml_type(metric_expr, resolved_type)
                outer_builder.select(AliasedExpr(expr=metric_expr, alias=m.name))
                outer_measure_exprs[m.name] = metric_expr

        # Recorded for the wrappers that run after planning. Each of them
        # re-projects some measure's aggregate into a CTE of its own, and every
        # such CTE selects from the composite below - where the fact tables the
        # resolved expressions name are not in scope, so rebuilding from those
        # produces SQL that does not bind.
        resolved.projected_expressions = dict(outer_measure_exprs)
        resolved.composite_cte = cte_name

        outer_builder.from_(cte_name, alias=cte_name)

        # GROUP BY dimensions.  Coalesce groups group by the COALESCE expression
        # itself (most dialects accept either the alias or the expression; the
        # expression is portable across all eight supported dialects).
        grouped_coalesce_aliases: set[str] = set()
        for dim in resolved.dimensions:
            if dim.coalesce_alias:
                if dim.coalesce_alias in grouped_coalesce_aliases:
                    continue
                grouped_coalesce_aliases.add(dim.coalesce_alias)
                outer_builder.group_by(
                    FunctionCall(
                        name="COALESCE",
                        args=[
                            ColumnRef(name=member) for member in coalesce_groups[dim.coalesce_alias]
                        ],
                    )
                )
            else:
                outer_builder.group_by(ColumnRef(name=dim.name))

        # GROUPING() flag columns + grouping modifier (rollup/cube) — outer query only
        # so subtotal rows compose correctly over the unioned facts (the
        # individual UNION ALL legs stay at detail grain).
        if resolved.grouping is not None and resolved.dimensions:
            outer_builder.grouping(resolved.grouping.value)
            flag_aliases: list[str] = []
            for dim in resolved.dimensions:
                alias_name = dim.coalesce_alias or dim.name
                if alias_name in flag_aliases:
                    continue
                flag_aliases.append(alias_name)
            for alias in flag_aliases:
                flag_col = FunctionCall(name="GROUPING", args=[ColumnRef(name=alias)])
                outer_builder.select(AliasedExpr(expr=flag_col, alias=_grouping_flag_alias(alias)))

        # HAVING — expand alias references to actual CAST'd aggregate expressions.
        # A predicate on a measure a later wrapper finishes with a window
        # function is withheld, exactly as ``star.py`` withholds it: only the
        # pre-window aggregate exists here, so evaluating it would filter the
        # wrong value, and ``PASS_HAVING_WINDOW`` applies it over the windowed
        # rows instead. CFL is picked by the planner before any pass runs, so
        # the window pass lands on ``composite_01`` just as it lands on a
        # wrapper's CTE - this is the multi-fact half of the same rule.
        from orionbelt.compiler.having_hoist import windowed_aliases

        deferred = windowed_aliases(resolved)
        for hf in resolved.having_filters:
            if hf.referenced_fields & deferred:
                continue
            outer_builder.having(_expand_cfl_measure_refs(hf.expression, outer_measure_exprs))

        # ORDER BY and LIMIT — remap to CTE aliases
        for expr, desc, nulls in resolved.order_by_exprs:
            outer_builder.order_by(
                self._remap_cfl_order_by(expr, resolved, model),
                desc=desc,
                nulls_last=_nulls_last(nulls),
            )
        if resolved.limit is not None:
            outer_builder.limit(resolved.limit)
        if resolved.offset is not None:
            outer_builder.offset(resolved.offset)

        outer_select = outer_builder.build()

        # Attach CTE
        final = Select(
            columns=outer_select.columns,
            from_=outer_select.from_,
            joins=outer_select.joins,
            where=outer_select.where,
            group_by=outer_select.group_by,
            having=outer_select.having,
            order_by=outer_select.order_by,
            limit=outer_select.limit,
            offset=outer_select.offset,
            ctes=[union_cte],
            grouping=outer_select.grouping,
        )

        return QueryPlan(ast=final, cfl_legs=leg_infos)

    # -- dimensionsExclude: EXCEPT-based anti-join ----------------------------

    def _plan_dimensions_exclude(
        self,
        resolved: ResolvedQuery,
        model: SemanticModel,
        qualify_table: Callable[[DataObject], str] | None = None,
        dialect: Dialect | None = None,
    ) -> QueryPlan:
        """Plan a dimensionsExclude query using EXCEPT pattern."""
        return cfl_exclude.plan_dimensions_exclude(self, resolved, model, qualify_table, dialect)

    @staticmethod
    def _partition_dimensions(
        resolved: ResolvedQuery,
        graph: JoinGraph,
    ) -> list[list[ResolvedDimension]]:
        """Partition dimensions into groups on independent branches."""
        return cfl_exclude.partition_dimensions(resolved, graph)

    @staticmethod
    def _build_group_distinct_select(
        dims: list[ResolvedDimension],
        model: SemanticModel,
        graph: JoinGraph,
        qualify: Callable[[DataObject], str],
        via_constraints: dict[str, str] | None = None,
        dialect: Dialect | None = None,
    ) -> Select:
        """Build SELECT DISTINCT (via GROUP BY) for a group of dimensions."""
        return cfl_exclude.build_group_distinct_select(
            dims, model, graph, qualify, via_constraints=via_constraints, dialect=dialect
        )

    def _build_existing_pairs_select(
        self,
        resolved: ResolvedQuery,
        model: SemanticModel,
        graph: JoinGraph,
        qualify: Callable[[DataObject], str],
        dialect: Dialect | None = None,
    ) -> Select:
        """Build SELECT for existing dimension combinations via fact-table joins."""
        return cfl_exclude.build_existing_pairs_select(
            self, resolved, model, graph, qualify, dialect
        )

plan(resolved, model, qualify_table=None, union_by_name=False, dialect=None)

Plan a CFL query.

Source code in src/orionbelt/compiler/cfl.py
def plan(
    self,
    resolved: ResolvedQuery,
    model: SemanticModel,
    qualify_table: Callable[[DataObject], str] | None = None,
    union_by_name: bool = False,
    dialect: Dialect | None = None,
) -> QueryPlan:
    """Plan a CFL query."""
    self._validate_fanout(resolved, model)

    # dimensionsExclude: EXCEPT-based anti-join pattern
    if resolved.dimensions_exclude:
        return self._plan_dimensions_exclude(resolved, model, qualify_table, dialect)

    # Group measures by their source object
    measures_by_object, cross_fact = self._group_measures_by_object(resolved, model)

    # Dimension-only CFL: no measures but dimensions on independent branches.
    # Create leg groupings from connecting fact tables.
    if not measures_by_object and not cross_fact and resolved.requires_cfl:
        measures_by_object = self._group_dimensions_into_legs(resolved, model)

    # A dimension no leg can reach is projected by none of them, and the
    # union then has no such column for the outer SELECT to group on. Under
    # ``UNION ALL BY NAME`` that is not even a NULL pad - the padding fills
    # a column *some* leg supplies, and this one has no supplier at all - so
    # the query compiled to SQL naming a column that does not exist.
    self._reject_unreachable_dimensions(measures_by_object, resolved, model)

    if len(measures_by_object) <= 1 and not cross_fact:
        # Single fact — delegate to star schema. Resolution let this query
        # past the reachability check because it looked multi-fact, and a
        # star has to serve every dimension from one root, so check now
        # that the leg left standing can.
        from orionbelt.compiler.star import StarSchemaPlanner

        return StarSchemaPlanner().plan(
            resolved, model, qualify_table=qualify_table, dialect=dialect
        )

    # Every multi-column aggregate reads its arguments from one row: a
    # two-column statistic (CORR/COVAR_*/REGR_*) correlates a pair, a
    # multi-column COUNT DISTINCT counts observed tuples. A leg that owns
    # all the arguments carries them as separate columns and the outer
    # query rebuilds the aggregate over them, so a multi-fact query costs
    # such a measure nothing.
    #
    # What none of them survives is having their arguments on data objects
    # no single leg reaches — the definition of ``cross_fact``. UNION ALL
    # stacks facts rather than joining them, so each leg supplies one
    # column and NULL-pads the rest, and no row ever carries a complete
    # set. The statistics then return NULL over zero pairs, and the tuple
    # count concatenates a NULL into every row and returns 0 — a wrong
    # answer rather than a failure, which is the reason to refuse here
    # rather than let it compile.
    #
    # Metric components count: a metric is planned by inlining its
    # components' aggregates, so ``{[Cross Corr]}`` reaches the same
    # rebuild — it just used to arrive there without passing the guard.
    cross_fact_names = {m.name for m in cross_fact} if cross_fact else set()
    for measure in (*resolved.measures, *resolved.metric_components.values()):
        if measure.name in cross_fact_names and self._is_multi_field(measure):
            agg = measure.aggregation.lower() if measure.aggregation else ""
            raise UnsupportedAggregationForCFLError(measure.name, agg)

    # Ordered aggregates now carry their sort key through the union as a
    # column of its own, so the outer re-aggregation can order by it. That
    # only works where the leg owning the measure can actually reach the
    # sort column's object — otherwise the leg has nothing to project, and
    # the aggregate would come back in an arbitrary order. Refuse those.
    #
    # A cross-fact measure has no single owning leg, so an ordering on one
    # is refused outright. Metric components are covered because they are
    # planned into legs like any other measure.
    self._validate_ordered_aggregates(resolved, model, measures_by_object, cross_fact)

    # Multi-fact: UNION ALL strategy
    return self._plan_union_all(
        resolved,
        model,
        measures_by_object,
        cross_fact,
        qualify_table=qualify_table,
        union_by_name=union_by_name,
        dialect=dialect,
    )

Join Graph

orionbelt.compiler.graph.JoinGraph

Graph of data objects (nodes) and relationships (edges) for join path resolution.

Source code in src/orionbelt/compiler/graph.py
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
class JoinGraph:
    """Graph of data objects (nodes) and relationships (edges) for join path resolution."""

    def __init__(
        self,
        model: SemanticModel,
        use_path_names: list[UsePathName] | None = None,
    ) -> None:
        self._graph: nx.Graph[str] = nx.Graph()
        self._directed: nx.DiGraph[str] = nx.DiGraph()
        # Path-finding graph: many-to-one is forward-only (would cause fanout
        # in reverse); one-to-one and many-to-many are bidirectional.
        self._traversable: nx.DiGraph[str] = nx.DiGraph()
        self._model = model
        self._build(model, use_path_names)

    def _build(
        self,
        model: SemanticModel,
        use_path_names: list[UsePathName] | None = None,
    ) -> None:
        """Build the graph from the semantic model.

        Secondary joins are only included when their pathName is requested
        via *use_path_names*.  When a secondary override is active for a
        ``(source, target)`` pair, the primary join for that pair is excluded.
        """
        for name in model.data_objects:
            self._graph.add_node(name)
            self._directed.add_node(name)
            self._traversable.add_node(name)

        active_overrides = path_overrides(use_path_names)

        for obj_name, obj in model.data_objects.items():
            for join in obj.joins:
                if join.join_to not in model.data_objects:
                    continue
                # A nested object's declared join to its own parent is the
                # ``code`` fallback's join, not a second route: containment
                # already connects the two, and adding both would put a cycle
                # between them. Its columns are folded onto the nested edge
                # below, which is where the fallback reads them from.
                if obj.nested_in is not None and join.join_to == obj.nested_in.data_object:
                    continue
                pair = (obj_name, join.join_to)

                if join.secondary:
                    # Only include if this secondary join's pathName is active
                    if pair in active_overrides and active_overrides[pair] == join.path_name:
                        self._add_edge(obj_name, join)
                else:
                    # Primary join: skip if an active override exists for this pair
                    if pair not in active_overrides:
                        self._add_edge(obj_name, join)

        for obj_name, obj in model.data_objects.items():
            if obj.nested_in is not None and obj.nested_in.data_object in model.data_objects:
                self._add_nested_edge(obj_name, obj)

    def _add_edge(self, obj_name: str, join: object) -> None:
        """Add an edge to the undirected, directed, and traversable graphs.

        The traversable graph is used by :meth:`find_join_path` to enforce
        the rule "many-to-one is never bidirectional": walking such a join
        backwards would multiply rows of the source table, so only forward
        traversal is allowed.  One-to-one and many-to-many joins remain
        bidirectional in the traversable graph.
        """
        from orionbelt.models.semantic import DataObjectJoin

        assert isinstance(join, DataObjectJoin)
        self._graph.add_edge(
            obj_name,
            join.join_to,
            columns_from=join.columns_from,
            columns_to=join.columns_to,
            cardinality=join.join_type,
            source_object=obj_name,
            required=join.required,
        )
        self._directed.add_edge(
            obj_name,
            join.join_to,
            columns_from=join.columns_from,
            columns_to=join.columns_to,
            cardinality=join.join_type,
        )
        self._traversable.add_edge(obj_name, join.join_to)
        if join.join_type != Cardinality.MANY_TO_ONE:
            # Safe to walk backwards: row count is preserved.
            self._traversable.add_edge(join.join_to, obj_name)

    def _add_nested_edge(self, obj_name: str, obj: DataObject) -> None:
        """Add the edge a ``nestedIn`` object declares by containment.

        Oriented **parent to child**, which is the opposite of how the object
        reads: the child is the many side, but only the parent can put it in
        scope, so the parent is what the walk starts from and what
        :meth:`find_common_root` has to answer. Traversal is one-way for the
        same reason - there is nothing to reach by leaving a nested object that
        its parent does not already reach.

        The edge is many-to-one *declared child to parent*, so walking it in the
        direction stored here multiplies the parent's rows. That is exactly what
        an unnest does, and ``nested`` is what tells fanout detection and the
        planner so, since neither the cardinality nor ``reversed`` can say it:
        the multiplication comes out of the FROM clause rather than a predicate.

        ``columns_from`` / ``columns_to`` are the *fallback* join's, oriented
        parent-first to match the edge. They are empty unless the object also
        declares ``code`` and a join to its parent, and are read only where the
        dialect cannot unnest.
        """
        source = obj.nested_in
        if source is None:
            return
        parent = source.data_object
        fallback = next((j for j in obj.joins if j.join_to == parent), None)
        columns_from = list(fallback.columns_to) if fallback else []
        columns_to = list(fallback.columns_from) if fallback else []
        self._graph.add_edge(
            parent,
            obj_name,
            columns_from=columns_from,
            columns_to=columns_to,
            cardinality=Cardinality.MANY_TO_ONE,
            source_object=parent,
            # An empty array keeps its parent row, which is a LEFT join: a
            # charge carrying no labels still contributes its cost to an
            # unfiltered total, and 61% of the rows in a real billing export
            # carry none.
            required=False,
            nested=True,
        )
        self._directed.add_edge(
            parent,
            obj_name,
            columns_from=columns_from,
            columns_to=columns_to,
            cardinality=Cardinality.MANY_TO_ONE,
            nested=True,
        )
        self._traversable.add_edge(parent, obj_name)

    def _unnest_root(self, name: str) -> str:
        """The nearest ancestor of *name* a FROM clause can name.

        Delegates to the model, which is where the same question is asked from
        query resolution - a nested object must not be picked as a base object
        either, and the two answers have to be the same one.
        """
        return self._model.unnest_root(name)

    def descendants(self, node: str) -> set[str]:
        """Return all nodes reachable from *node* via directed join paths."""
        if node not in self._directed:
            return set()
        return nx.descendants(self._directed, node)

    def descendants_without_unnest(self, node: str) -> set[str]:
        """Reachable from *node* without crossing a containment edge.

        What a plan that can only emit **joins** can reach. A CFL leg is one:
        it builds a single-fact star out of tables, and an unnest is not a table
        it can put in a FROM clause. So a nested object is out of reach there,
        and so is anything reachable only *through* one - a dimension a nested
        fact joins onward to is behind an unnest however ordinary its own join
        is, and routing a leg through it produced a join with no columns.
        """
        if node not in self._directed:
            return set()
        reached: set[str] = set()
        frontier = [node]
        while frontier:
            current = frontier.pop()
            for successor in self._directed.successors(current):
                if successor in reached:
                    continue
                if self._directed.edges[current, successor].get("nested"):
                    continue
                reached.add(successor)
                frontier.append(successor)
        return reached

    def find_common_root(self, required_objects: set[str]) -> str:
        """Find the common root for a set of required objects.

        The join graph is a DAG (joins define direction: source → joinTo).
        The common root is the **deepest** node that can reach ALL
        *required_objects* via directed join paths.  "Deepest" = smallest
        descendant set (most specific ancestor, closest to the required nodes).

        In ``returns → sales → customer``, with required ``{customer, item}``,
        the common root is ``sales`` (it can reach both).  With required
        ``{customer, item, returns}``, the common root is ``returns`` (the
        only node that can reach all three).
        """
        required = required_objects & set(self._directed.nodes)
        if len(required) <= 1:
            return self._unnest_root(next(iter(sorted(required)))) if required else ""

        # Find all nodes that can reach ALL required nodes via directed paths
        candidates: list[tuple[str, int]] = []
        for node in self._directed.nodes:
            reachable = nx.descendants(self._directed, node) | {node}
            if required <= reachable:
                candidates.append((node, len(reachable)))

        if not candidates:
            # Fallback: no single directed ancestor covers all —
            # use undirected shortest-path center
            return self._find_center_undirected(required)

        # Pick the deepest ancestor: smallest reachable set that still covers all
        candidates.sort(key=lambda x: (x[1], x[0]))
        return self._unnest_root(candidates[0][0])

    def _find_center_undirected(self, required: set[str]) -> str:
        """Fallback: center of the Steiner tree in the undirected graph."""
        nodes = sorted(required)
        if len(nodes) <= 1:
            return nodes[0] if nodes else ""

        steiner: set[str] = set()
        for i in range(len(nodes)):
            for j in range(i + 1, len(nodes)):
                try:
                    path: list[str] = nx.shortest_path(self._graph, nodes[i], nodes[j])
                    steiner.update(path)
                except nx.NetworkXNoPath:
                    pass

        if not steiner:
            return self._unnest_root(nodes[0])

        # ``nodes`` can span disconnected components — the pairwise loop above
        # simply skips those pairs, so a Steiner node need not reach every
        # required node. Score an unreachable target as worse than any real
        # distance instead of letting ``shortest_path_length`` raise.
        unreachable = len(self._graph.nodes) + 1

        def _eccentricity(node: str) -> int:
            worst: int = 0
            for target in nodes:
                try:
                    # Unweighted graph, so the hop count is always integral;
                    # the stub types it as float to cover weighted callers.
                    worst = max(worst, int(nx.shortest_path_length(self._graph, node, target)))
                except nx.NetworkXNoPath:
                    worst = max(worst, unreachable)
            return worst

        best: str = nodes[0]
        best_max: int = unreachable + 1
        for node in sorted(steiner):
            max_dist = _eccentricity(node)
            if max_dist < best_max:
                best_max = max_dist
                best = node
        return self._unnest_root(best)

    def _hops_from(self, origin: str | None, node: str) -> int:
        """Hops from *origin* to *node* in the traversable graph.

        ``0`` when they are the same or no origin was given, and a value larger
        than any real path when *node* is out of reach — so an unreachable
        source sorts last rather than raising.
        """
        if origin is None or origin == node:
            return 0
        try:
            return int(nx.shortest_path_length(self._traversable, origin, node))
        except (nx.NetworkXNoPath, nx.NodeNotFound):
            return len(self._traversable) + 1

    def role_candidates(
        self,
        from_objects: set[str],
        to_object: str,
        prefer_from: str | None = None,
    ) -> list[list[str]]:
        """The equally-good ways to reach *to_object*, one path each.

        A data object joined by more than one of the objects already in the
        query is reachable by more than one *role* — ``date_dim`` as the sold
        date and as the returned date, ``warehouse`` as the sale's and as the
        inventory's. Which role a plain reference means is a question the model
        cannot answer, so the caller has to know when there is a real choice.

        Candidates are ranked by path length first, then by how far the source
        sits from *prefer_from* (the query's base object). That makes the
        answer both deterministic and principled: the base is what the query is
        anchored on, so the role reached from it directly beats one reached
        through another fact. Ranking by nothing, as this did before, left the
        choice to set iteration order — the same query then compiled to a
        different role from run to run, since string hashing is randomised per
        process.

        Returns every path tied at the best rank that enters *to_object* by a
        *different* join, deduplicated on that last edge: two routes arriving
        on the same key are the same role, however they got there. One element
        means the choice is clear; more than one means it is genuinely
        ambiguous and only the query can resolve it.
        """
        ranked: list[tuple[tuple[int, int, str], list[str]]] = []
        for source in sorted(from_objects):
            if source not in self._traversable or to_object not in self._traversable:
                continue
            lengths = nx.single_source_shortest_path_length(self._traversable, source)
            distance = lengths.get(to_object)
            if distance is None:
                continue
            # What separates one role from another is the *last* edge — which
            # join actually lands on the target. Every predecessor sitting one
            # hop closer to the source is the last edge of some shortest path,
            # so one traversal yields the complete set of roles. Enumerating
            # the paths instead would need a cap, and a cap applied before
            # roles are distinguished can hide one behind many routes that
            # share an entry.
            for entry in sorted(self._traversable.predecessors(to_object)):
                if lengths.get(entry) != distance - 1:
                    continue
                prefix: list[str] = nx.shortest_path(self._traversable, source, entry)
                candidate = [*prefix, to_object]
                rank = (len(candidate), self._hops_from(prefer_from, source), source)
                ranked.append((rank, candidate))
        if not ranked:
            return []

        best = min(rank for rank, _ in ranked)[:2]
        by_entry: dict[tuple[str, str], list[str]] = {}
        for rank, candidate in sorted(ranked):
            if rank[:2] != best:
                continue
            last_edge = (
                (candidate[-2], candidate[-1])
                if len(candidate) > 1
                else (candidate[0], candidate[0])
            )
            by_entry.setdefault(last_edge, candidate)
        return list(by_entry.values())

    def find_join_path(
        self,
        from_objects: set[str],
        to_objects: set[str],
        via_constraints: dict[str, str] | None = None,
        prefer_from: str | None = None,
        ambiguous: dict[str, list[list[str]]] | None = None,
    ) -> list[JoinStep]:
        """Find a minimal join path connecting all required data objects.

        Uses shortest path for each target object from the set of source
        objects, ranked by :meth:`role_candidates` so the choice among several
        reachable roles is deterministic rather than set-order dependent.

        *via_constraints* maps ``target → via``: for constrained targets, only
        the ``via`` object is used as the source so the path is forced through it.
        *prefer_from* is the query's base object, which breaks ties in favour of
        the role it reaches directly.

        A path is still produced when several roles tie, because most callers
        only need *a* join. Pass *ambiguous* to learn about it: each target that
        tied is recorded there with the routes it tied between, so a caller that
        must not guess — query resolution — can refuse instead.
        """
        steps: list[JoinStep] = []
        visited_edges: set[tuple[str, str]] = set()
        via = via_constraints or {}

        # Process via waypoints first so they are in source_list when their
        # constrained targets are processed.
        all_targets = to_objects - from_objects
        via_targets = {t for t in all_targets if t in via}
        non_via_targets = all_targets - via_targets
        via_waypoints = {via[t] for t in via_targets} - from_objects - via_targets
        ordered_targets = sorted(via_waypoints) + sorted(non_via_targets) + sorted(via_targets)

        source_list = list(from_objects)
        # A single starting object *is* the anchor — that is how the planner
        # calls this, from the query's base. Without it the tie-break falls
        # back to the source name, which is deterministic but arbitrary: it
        # bound a date filter to whichever fact sorted first.
        anchor = prefer_from or (next(iter(from_objects)) if len(from_objects) == 1 else None)

        for target in ordered_targets:
            sources = [via[target]] if target in via and via[target] in source_list else source_list
            candidates = self.role_candidates(set(sources), target, prefer_from=anchor)
            if len(candidates) > 1 and ambiguous is not None:
                ambiguous[target] = candidates
            best_path = candidates[0] if candidates else None

            if best_path is None:
                continue

            for i in range(len(best_path) - 1):
                edge = (best_path[i], best_path[i + 1])
                rev_edge = (best_path[i + 1], best_path[i])
                if edge in visited_edges or rev_edge in visited_edges:
                    continue
                visited_edges.add(edge)

                edge_data = self._graph.edges[edge]
                source_object = edge_data.get("source_object", edge[0])

                if source_object == edge[0]:
                    step = JoinStep(
                        from_object=edge[0],
                        to_object=edge[1],
                        from_columns=edge_data["columns_from"],
                        to_columns=edge_data["columns_to"],
                        join_type=_join_type_for(edge_data),
                        cardinality=edge_data["cardinality"],
                        nested=bool(edge_data.get("nested")),
                    )
                else:
                    # Path traverses the edge against its declared direction.
                    # ``JoinStep`` keeps from/to in *declared* order — ``edge[1]``
                    # is the object that declares the join, so it keeps
                    # ``columns_from`` — and records the real traversal
                    # direction in ``reversed``.
                    step = JoinStep(
                        from_object=edge[1],
                        to_object=edge[0],
                        from_columns=edge_data["columns_from"],
                        to_columns=edge_data["columns_to"],
                        join_type=_join_type_for(edge_data),
                        cardinality=edge_data["cardinality"],
                        reversed=True,
                        nested=bool(edge_data.get("nested")),
                    )
                steps.append(step)

            # Add target to sources for subsequent lookups
            if target not in source_list:
                source_list.append(target)

        return steps

    def find_join_path_undirected(
        self,
        from_object: str,
        to_object: str,
    ) -> list[JoinStep]:
        """Find a join path ignoring cardinality direction.

        Unlike :meth:`find_join_path` (which forbids walking many-to-one
        joins backwards to prevent fanout in the outer query), this walker
        considers the join graph as undirected.  It's intended for
        correlated subqueries — EXISTS / NOT EXISTS — where row counts on
        the outer side are unaffected by how many rows the subquery scans.

        Each emitted :class:`JoinStep` is oriented so ``from_object`` is the
        step's predecessor on the path and ``to_object`` is its successor;
        ``from_columns`` / ``to_columns`` are swapped when the underlying
        join edge is traversed against its declared direction.
        """
        if from_object == to_object:
            return []
        if from_object not in self._graph or to_object not in self._graph:
            return []
        try:
            path: list[str] = nx.shortest_path(self._graph, from_object, to_object)
        except nx.NetworkXNoPath:
            return []

        steps: list[JoinStep] = []
        for i in range(len(path) - 1):
            pred, succ = path[i], path[i + 1]
            edge_data = self._graph.edges[(pred, succ)]
            source_object = edge_data.get("source_object", pred)
            if source_object == pred:
                from_cols = edge_data["columns_from"]
                to_cols = edge_data["columns_to"]
                reversed_ = False
            else:
                from_cols = edge_data["columns_to"]
                to_cols = edge_data["columns_from"]
                reversed_ = True
            steps.append(
                JoinStep(
                    from_object=pred,
                    to_object=succ,
                    from_columns=from_cols,
                    to_columns=to_cols,
                    join_type=_join_type_for(edge_data),
                    cardinality=edge_data["cardinality"],
                    reversed=reversed_,
                    nested=bool(edge_data.get("nested")),
                )
            )
        return steps

    def build_join_condition(self, step: JoinStep) -> Expr:
        """Build the ON clause expression for a join step.

        Routes both sides through ``make_column_expr`` so a computed
        join key (``expression:`` instead of ``code:`` on the column)
        inlines its template body. Without this, a join on a computed
        key would render ``"obj"."" = "other"."key"`` and the database
        would error on the zero-length identifier.

        The model's query time zone is deliberately *not* applied here. A join
        asks whether two rows belong together, which no calendar changes: both
        sides would convert identically and the answer would be the same, at
        the cost of wrapping a join key in a function, which is how an index or
        a partition stops being used. Conversion exists so that bucketing and
        display happen in the model's frame, and an ON clause is neither.
        """
        from orionbelt.compiler.resolution import make_column_expr

        conditions: list[Expr] = []
        for from_c, to_c in zip(step.from_columns, step.to_columns, strict=True):
            from_obj = self._model.data_objects.get(step.from_object)
            to_obj = self._model.data_objects.get(step.to_object)
            if from_obj and from_c in from_obj.columns:
                left_expr: Expr = make_column_expr(
                    self._model, step.from_object, from_c, in_query_timezone=False
                )
            else:
                left_expr = ColumnRef(name=from_c, table=step.from_object)
            if to_obj and to_c in to_obj.columns:
                right_expr: Expr = make_column_expr(
                    self._model, step.to_object, to_c, in_query_timezone=False
                )
            else:
                right_expr = ColumnRef(name=to_c, table=step.to_object)
            conditions.append(BinaryOp(left=left_expr, op="=", right=right_expr))

        if not conditions:
            msg = f"Join from '{step.from_object}' to '{step.to_object}' has no join columns"
            raise ValueError(msg)
        result: Expr = conditions[0]
        for cond in conditions[1:]:
            result = BinaryOp(left=result, op="AND", right=cond)
        return result

    def detect_cycles(self) -> list[list[str]]:
        """Detect cyclic join paths."""
        try:
            cycles = list(nx.simple_cycles(self._directed))
            return cycles
        except nx.NetworkXError:
            return []

    def validate_deterministic(self) -> list[SemanticError]:
        """Ensure join paths are deterministic (no ambiguity)."""
        errors: list[SemanticError] = []
        # Check for multiple edges between the same pair of nodes
        for u, v in self._graph.edges():
            if self._graph.number_of_edges(u, v) > 1:
                errors.append(
                    SemanticError(
                        code="AMBIGUOUS_JOIN",
                        message=f"Multiple join paths between '{u}' and '{v}'",
                        path=f"dataObjects.{u}.joins",
                    )
                )
        return errors

find_join_path(from_objects, to_objects, via_constraints=None, prefer_from=None, ambiguous=None)

Find a minimal join path connecting all required data objects.

Uses shortest path for each target object from the set of source objects, ranked by :meth:role_candidates so the choice among several reachable roles is deterministic rather than set-order dependent.

via_constraints maps target → via: for constrained targets, only the via object is used as the source so the path is forced through it. prefer_from is the query's base object, which breaks ties in favour of the role it reaches directly.

A path is still produced when several roles tie, because most callers only need a join. Pass ambiguous to learn about it: each target that tied is recorded there with the routes it tied between, so a caller that must not guess — query resolution — can refuse instead.

Source code in src/orionbelt/compiler/graph.py
def find_join_path(
    self,
    from_objects: set[str],
    to_objects: set[str],
    via_constraints: dict[str, str] | None = None,
    prefer_from: str | None = None,
    ambiguous: dict[str, list[list[str]]] | None = None,
) -> list[JoinStep]:
    """Find a minimal join path connecting all required data objects.

    Uses shortest path for each target object from the set of source
    objects, ranked by :meth:`role_candidates` so the choice among several
    reachable roles is deterministic rather than set-order dependent.

    *via_constraints* maps ``target → via``: for constrained targets, only
    the ``via`` object is used as the source so the path is forced through it.
    *prefer_from* is the query's base object, which breaks ties in favour of
    the role it reaches directly.

    A path is still produced when several roles tie, because most callers
    only need *a* join. Pass *ambiguous* to learn about it: each target that
    tied is recorded there with the routes it tied between, so a caller that
    must not guess — query resolution — can refuse instead.
    """
    steps: list[JoinStep] = []
    visited_edges: set[tuple[str, str]] = set()
    via = via_constraints or {}

    # Process via waypoints first so they are in source_list when their
    # constrained targets are processed.
    all_targets = to_objects - from_objects
    via_targets = {t for t in all_targets if t in via}
    non_via_targets = all_targets - via_targets
    via_waypoints = {via[t] for t in via_targets} - from_objects - via_targets
    ordered_targets = sorted(via_waypoints) + sorted(non_via_targets) + sorted(via_targets)

    source_list = list(from_objects)
    # A single starting object *is* the anchor — that is how the planner
    # calls this, from the query's base. Without it the tie-break falls
    # back to the source name, which is deterministic but arbitrary: it
    # bound a date filter to whichever fact sorted first.
    anchor = prefer_from or (next(iter(from_objects)) if len(from_objects) == 1 else None)

    for target in ordered_targets:
        sources = [via[target]] if target in via and via[target] in source_list else source_list
        candidates = self.role_candidates(set(sources), target, prefer_from=anchor)
        if len(candidates) > 1 and ambiguous is not None:
            ambiguous[target] = candidates
        best_path = candidates[0] if candidates else None

        if best_path is None:
            continue

        for i in range(len(best_path) - 1):
            edge = (best_path[i], best_path[i + 1])
            rev_edge = (best_path[i + 1], best_path[i])
            if edge in visited_edges or rev_edge in visited_edges:
                continue
            visited_edges.add(edge)

            edge_data = self._graph.edges[edge]
            source_object = edge_data.get("source_object", edge[0])

            if source_object == edge[0]:
                step = JoinStep(
                    from_object=edge[0],
                    to_object=edge[1],
                    from_columns=edge_data["columns_from"],
                    to_columns=edge_data["columns_to"],
                    join_type=_join_type_for(edge_data),
                    cardinality=edge_data["cardinality"],
                    nested=bool(edge_data.get("nested")),
                )
            else:
                # Path traverses the edge against its declared direction.
                # ``JoinStep`` keeps from/to in *declared* order — ``edge[1]``
                # is the object that declares the join, so it keeps
                # ``columns_from`` — and records the real traversal
                # direction in ``reversed``.
                step = JoinStep(
                    from_object=edge[1],
                    to_object=edge[0],
                    from_columns=edge_data["columns_from"],
                    to_columns=edge_data["columns_to"],
                    join_type=_join_type_for(edge_data),
                    cardinality=edge_data["cardinality"],
                    reversed=True,
                    nested=bool(edge_data.get("nested")),
                )
            steps.append(step)

        # Add target to sources for subsequent lookups
        if target not in source_list:
            source_list.append(target)

    return steps

build_join_condition(step)

Build the ON clause expression for a join step.

Routes both sides through make_column_expr so a computed join key (expression: instead of code: on the column) inlines its template body. Without this, a join on a computed key would render "obj"."" = "other"."key" and the database would error on the zero-length identifier.

The model's query time zone is deliberately not applied here. A join asks whether two rows belong together, which no calendar changes: both sides would convert identically and the answer would be the same, at the cost of wrapping a join key in a function, which is how an index or a partition stops being used. Conversion exists so that bucketing and display happen in the model's frame, and an ON clause is neither.

Source code in src/orionbelt/compiler/graph.py
def build_join_condition(self, step: JoinStep) -> Expr:
    """Build the ON clause expression for a join step.

    Routes both sides through ``make_column_expr`` so a computed
    join key (``expression:`` instead of ``code:`` on the column)
    inlines its template body. Without this, a join on a computed
    key would render ``"obj"."" = "other"."key"`` and the database
    would error on the zero-length identifier.

    The model's query time zone is deliberately *not* applied here. A join
    asks whether two rows belong together, which no calendar changes: both
    sides would convert identically and the answer would be the same, at
    the cost of wrapping a join key in a function, which is how an index or
    a partition stops being used. Conversion exists so that bucketing and
    display happen in the model's frame, and an ON clause is neither.
    """
    from orionbelt.compiler.resolution import make_column_expr

    conditions: list[Expr] = []
    for from_c, to_c in zip(step.from_columns, step.to_columns, strict=True):
        from_obj = self._model.data_objects.get(step.from_object)
        to_obj = self._model.data_objects.get(step.to_object)
        if from_obj and from_c in from_obj.columns:
            left_expr: Expr = make_column_expr(
                self._model, step.from_object, from_c, in_query_timezone=False
            )
        else:
            left_expr = ColumnRef(name=from_c, table=step.from_object)
        if to_obj and to_c in to_obj.columns:
            right_expr: Expr = make_column_expr(
                self._model, step.to_object, to_c, in_query_timezone=False
            )
        else:
            right_expr = ColumnRef(name=to_c, table=step.to_object)
        conditions.append(BinaryOp(left=left_expr, op="=", right=right_expr))

    if not conditions:
        msg = f"Join from '{step.from_object}' to '{step.to_object}' has no join columns"
        raise ValueError(msg)
    result: Expr = conditions[0]
    for cond in conditions[1:]:
        result = BinaryOp(left=result, op="AND", right=cond)
    return result

detect_cycles()

Detect cyclic join paths.

Source code in src/orionbelt/compiler/graph.py
def detect_cycles(self) -> list[list[str]]:
    """Detect cyclic join paths."""
    try:
        cycles = list(nx.simple_cycles(self._directed))
        return cycles
    except nx.NetworkXError:
        return []

Code Generator

orionbelt.compiler.codegen.CodeGenerator

Generates SQL from AST using a dialect.

Source code in src/orionbelt/compiler/codegen.py
class CodeGenerator:
    """Generates SQL from AST using a dialect."""

    def __init__(self, dialect: Dialect) -> None:
        self._dialect = dialect

    @property
    def dialect(self) -> Dialect:
        return self._dialect

    def generate(self, ast: Select) -> str:
        """Generate SQL string from AST using the configured dialect."""
        return self._dialect.compile(ast)

generate(ast)

Generate SQL string from AST using the configured dialect.

Source code in src/orionbelt/compiler/codegen.py
def generate(self, ast: Select) -> str:
    """Generate SQL string from AST using the configured dialect."""
    return self._dialect.compile(ast)

Dialect Base

orionbelt.dialect.base.Dialect

Bases: ABC

Abstract base for all SQL dialects.

Provides default SQL compilation; dialects override specific methods.

Source code in src/orionbelt/dialect/base.py
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 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
class Dialect(ABC):
    """Abstract base for all SQL dialects.

    Provides default SQL compilation; dialects override specific methods.
    """

    _ABSTRACT_TYPE_MAP: dict[str, str] = {
        "string": "VARCHAR",
        "json": "VARCHAR",
        "int": "INTEGER",
        "float": "FLOAT",
        "date": "DATE",
        "time": "TIME",
        "time_tz": "TIME",
        "timestamp": "TIMESTAMP",
        "timestamp_tz": "TIMESTAMP",
        "boolean": "BOOLEAN",
    }

    _MAX_DECIMAL_PRECISION: int = 38

    _OBML_SIMPLE_TYPE_MAP: dict[str, str] = {
        "bigint": "BIGINT",
        "integer": "INTEGER",
        "double": "DOUBLE",
        "date": "DATE",
        "timestamp": "TIMESTAMP",
        "time": "TIME",
        "string": "VARCHAR",
        "boolean": "BOOLEAN",
    }

    @property
    def max_decimal_precision(self) -> int:
        """The widest decimal precision this engine accepts.

        Public because the CFL union alignment has to reason about it from
        outside the dialect layer (#339).
        """
        return self._MAX_DECIMAL_PRECISION

    def render_obml_type(self, obml_type: OBMLType) -> str:
        """Render an OBMLType to a dialect-specific SQL type string.

        Handles precision clamping for decimal types.
        """
        if isinstance(obml_type, DecimalType):
            p = min(obml_type.precision, self._MAX_DECIMAL_PRECISION)
            s = min(obml_type.scale, p)
            return f"DECIMAL({p}, {s})"
        return self._OBML_SIMPLE_TYPE_MAP.get(obml_type.name, obml_type.name.upper())

    def cast_to_obml_type(self, expr: Expr, obml_type: OBMLType) -> Expr:
        """Build an Expr that coerces ``expr`` to the given OBML type.

        Default form is a plain ``CAST(expr AS <type>)``. Dialects whose
        ``CAST`` doesn't accept a parameterized decimal (notably BigQuery
        — "Parameterized types are not allowed in CAST expressions") can
        override to wrap the cast with a ROUND to honour the user-specified
        scale.
        """
        return Cast(expr=expr, type_name=self.render_obml_type(obml_type))

    # Widest decimal every supported engine accepts, and the integer digits kept
    # free for a running total. A sum is as many digits as its rows make it, so
    # it needs room the average it divides down to does not.
    _MAX_DECIMAL_PRECISION = 38
    _SUM_HEADROOM_DIGITS = 24

    def _exact_avg_by_sum_over_count(self, arg: Expr, obml_type: OBMLType) -> Expr | None:
        """``SUM(x) / COUNT(x)`` in decimal, for engines that divide exactly.

        The textbook rewrite, shared by Dremio and Databricks. It is *not*
        available on DuckDB, where every division returns DOUBLE whatever the
        operands - that engine assembles its average from integer arithmetic
        instead, in its own override (#316).

        **The cast goes inside the SUM.** ``SUM`` over a 64-bit column
        accumulates in 64 bits, and casting afterwards only widens a number
        that has already wrapped: two rows of 9000000000000000000 summed to
        -446744073709551616 on Dremio, silently, and raise ARITHMETIC_OVERFLOW
        on Databricks. Both are fixed by widening the argument first.

        The running total then needs integer room the average will not, so the
        scale is capped to leave ``_SUM_HEADROOM_DIGITS`` for the integer part:
        38 digits cannot hold both a large total and a long fraction. A result
        asking for more scale gets the extra places as zeros, which is the
        honest trade against overflowing on a total the source holds legally.

        An empty group divides to NULL on both engines, measured, so no
        zero-count guard is needed here - unlike ClickHouse, whose
        ``divideDecimal`` raises.
        """
        if not isinstance(obml_type, DecimalType):
            return None
        scale = min(obml_type.scale, self._MAX_DECIMAL_PRECISION - self._SUM_HEADROOM_DIGITS)
        accumulated = FunctionCall(
            name="SUM",
            args=[
                Cast(
                    expr=arg,
                    type_name=self.render_obml_type(
                        DecimalType(precision=self._MAX_DECIMAL_PRECISION, scale=0)
                    ),
                )
            ],
        )
        return BinaryOp(
            left=Cast(
                expr=accumulated,
                type_name=self.render_obml_type(
                    DecimalType(precision=self._MAX_DECIMAL_PRECISION, scale=scale)
                ),
            ),
            op="/",
            right=FunctionCall(name="COUNT", args=[arg]),
        )

    #: Whether a UNION column whose legs carry different numeric types
    #: resolves to a common type here.
    #:
    #: True everywhere but ClickHouse, and measured rather than assumed: a
    #: ``numeric(38, 20)`` NULL pad beside an uncast ``numeric`` column
    #: resolves to plain ``numeric`` on Postgres and carries a 21-integer-digit
    #: value through intact, and DuckDB widens to accommodate the leg the same
    #: way. ClickHouse instead builds ``Variant(Decimal(38, 20), Float64)`` and
    #: refuses to ``SUM`` it with ILLEGAL_TYPE_OF_ARGUMENT.
    #:
    #: That difference decides whether a CFL leg has to cast the measure it
    #: owns. Where the engine unifies, casting can only lose - it rounded
    #: pre-aggregation rows (#305) and then overflowed a value the source held
    #: legally (#311) - so the leg is left alone. Where it does not, one type
    #: per union column has to be spelled out (#339).
    unions_resolve_leg_types: bool = True

    #: Whether ``AVG`` over a 64-bit integer column is exact natively.
    #:
    #: True on Postgres (``numeric``), MySQL (``decimal``) and Snowflake, which
    #: need no rewrite - but still need the **result type** widened, because an
    #: exact average the declared type cannot hold is no better than an
    #: inexact one. Measured on MySQL, ``CAST(AVG(qty) AS DECIMAL(18, 2))``
    #: returns 9999999999999999.99 for a true 1000000000000000003, saturating
    #: silently with no warning at all; Postgres raises instead.
    #:
    #: False where the aggregate itself drifts. Every one of those now has an
    #: exact rewrite (:meth:`exact_integer_avg`), DuckDB included, which is why
    #: this flag says only whether the *engine* is exact on its own and not
    #: whether the answer ends up exact - :meth:`integer_avg_is_exact` answers
    #: that (#316).
    avg_over_integers_is_exact: bool = False

    def integer_avg_is_exact(self) -> bool:
        """Whether an integer ``AVG`` ends up exact here, natively or rewritten.

        Deliberately independent of any expression. The **type** a measure is
        cast to has to be decided the same way wherever the cast happens, and
        by the time a wrapper composes - a window over a period-over-period,
        say - the expression it holds is a CTE alias rather than the aggregate.
        Asking "is this a bare AVG I can rewrite?" answers no there, and the
        cast fell back to the narrow default even though the value inside the
        CTE had already been computed exactly.

        Detected by introspection rather than a second flag, so a dialect that
        overrides :meth:`exact_integer_avg` cannot forget to declare it.
        """
        return self.avg_over_integers_is_exact or (
            type(self).exact_integer_avg is not Dialect.exact_integer_avg
        )

    def exact_integer_avg(self, arg: Expr, obml_type: OBMLType) -> Expr | None:
        """An exact ``AVG(arg)`` over an integer column, or ``None`` for none.

        ``AVG`` is a floating-point aggregate on several engines whatever the
        input type, so it drifts once the average passes a ``double`` mantissa,
        around fifteen significant digits. That is not a defect any of them is
        likely to change - duckdb/duckdb#6829 was closed as not planned - and
        no output cast repairs it, because the loss is already inside the
        aggregate.

        Dialects that offer exact arithmetic override this to say how. The
        four that do are all different: BigQuery only needs its **input** cast
        to NUMERIC, Dremio divides decimals exactly so ``SUM``/``COUNT``
        works, ClickHouse needs its own ``divideDecimal``, and DuckDB, which
        has no exact division at all, assembles the average from integer
        arithmetic (#316). Returning ``None`` - the default - keeps the plain
        ``AVG``, which is right for the engines that are already exact:
        Postgres, MySQL and Snowflake.

        ``obml_type`` is the type the result will be cast to, already widened
        to hold a 64-bit integer part, and carries the scale an engine needs
        when it wants one explicitly.
        """
        return None

    def _sum_over_widened_argument(self, arg: Expr) -> Expr:
        """``SUM(CAST(arg AS DECIMAL(38, 0)))``.

        The plain form of the same move :meth:`_exact_avg_by_sum_over_count`
        makes, for the engines whose only problem is the accumulator. Dremio
        uses it; ClickHouse spells the widening ``toDecimal128`` and overrides.
        """
        widened = Cast(
            expr=arg,
            type_name=self.render_obml_type(
                DecimalType(precision=PORTABLE_DECIMAL_PRECISION, scale=0)
            ),
        )
        return FunctionCall(name="SUM", args=[widened])

    def integer_sum_is_widened(self) -> bool:
        """Whether an integer ``SUM`` is rewritten to a wider accumulator here.

        Deliberately independent of any expression, for the same reason
        :meth:`integer_avg_is_exact` is. The **type** such a measure is cast to
        has to be decided the same way wherever the cast happens, and by the
        time a wrapper composes - a cumulative over a period-over-period, say -
        what it holds is a CTE alias rather than the aggregate. Asking "is this
        a bare SUM I can rewrite?" answers no there, and the cast fell back to
        the inferred ``bigint``, narrowing an exact 128-bit total straight back
        into the 64 bits the rewrite existed to escape.

        Detected by introspection rather than a second flag, so a dialect that
        overrides :meth:`exact_integer_sum` cannot forget to declare it.
        """
        return type(self).exact_integer_sum is not Dialect.exact_integer_sum

    def exact_integer_sum(self, arg: Expr) -> Expr | None:
        """An exact ``SUM(arg)`` over an integer column, and its type.

        ``None`` - the default - keeps the plain ``SUM``, which is right for
        every engine that either computes the total exactly or refuses it. Most
        do one or the other: measured on two rows of 9000000000000000000,
        DuckDB, Postgres, BigQuery and Databricks raise, and Snowflake returns
        18000000000000000000 intact.

        A dialect overrides this where its accumulator **wraps** instead. That
        is the one outcome no output type can repair, for the same reason
        :meth:`exact_integer_avg` exists: the loss is inside the aggregate, and
        a cast only widens a number that has already gone wrong. Measured on
        ClickHouse, ``SUM`` over Int64 returns -446744073709551616 for that
        pair, and casting the result to ``Decimal(38, 0)`` returns it
        unchanged, while casting the **argument** returns the true total.

        Returns the expression only. An integer ``SUM`` infers ``bigint``
        (#315), which would cast the exact 128-bit total straight back into the
        64 bits the rewrite escaped, so the result type has to move too - but
        it moves through :meth:`integer_sum_is_widened`, which answers without
        looking at an expression and so still answers inside a wrapper.

        Takes no ``obml_type``, unlike its ``AVG`` counterpart: an average
        needs a scale to divide to, and a sum of integers has no fractional
        part to declare one for.
        """
        return None

    #: Whether a backslash escapes the next character inside a string literal.
    #:
    #: False is the SQL standard: a backslash is an ordinary character and a
    #: quote is escaped by doubling it. True on MySQL, ClickHouse, BigQuery,
    #: Snowflake and Databricks, where a backslash starts an escape sequence and
    #: has to be doubled itself.
    #:
    #: Measured on all seven reachable engines, and each convention is *wrong*
    #: on the other side rather than merely unnecessary: doubling a quote breaks
    #: on BigQuery, which reads ``'it''s'`` as two concatenated literals and
    #: raises, and on Databricks, which silently returns ``its``. Backslash
    #: escaping breaks on Postgres and DuckDB, which take the backslash
    #: literally and would double it.
    backslash_escapes_strings: bool = False

    def quote_string_literal(self, value: str) -> str:
        """*value* as a quoted string literal for this engine.

        The single place a string becomes SQL text, so a filter value, a
        LISTAGG separator and a time-zone name cannot disagree about escaping.
        They did: every one of them doubled the quote and left the backslash
        alone, which is right on two engines out of seven.

        Measured, with the old rendering: ``a\\b`` came back as ``a\x08`` - a
        backspace - on MySQL, ClickHouse, BigQuery, Snowflake and Databricks,
        and ``C:\\temp\\x`` raised on three of them. A Windows path, a regex or
        an escaped delimiter in a filter was silently wrong on five engines.
        """
        if self.backslash_escapes_strings:
            escaped = (
                value.replace("\\", "\\\\")
                .replace("'", "\\'")
                # A quoted string cannot span lines on BigQuery: a real newline
                # or carriage return closes it, and the query fails with
                # "Unclosed string literal". Measured, it is the only engine of
                # the seven that minds - the other six take a raw newline, tab,
                # form feed or control byte and hand it back unchanged. Written
                # as escapes for all five backslash dialects rather than only
                # BigQuery, because in this convention that is simply how a
                # control character is spelled, and all five read it back.
                .replace("\n", "\\n")
                .replace("\r", "\\r")
            )
        else:
            # Standard SQL has no escape sequences here, so a control character
            # rides through literally. Measured working on Postgres, DuckDB and
            # Dremio, including a newline: a quoted string may span lines.
            escaped = value.replace("'", "''")
        return f"'{escaped}'"

    def _resolve_type_name(self, type_name: str) -> str:
        """Map an abstract type name to a dialect-specific SQL type.

        Looks up ``_ABSTRACT_TYPE_MAP`` first; if *type_name* is not found
        (e.g. already a concrete SQL type like ``VARCHAR``), returns it as-is.
        """
        return self._ABSTRACT_TYPE_MAP.get(type_name, type_name)

    def format_table_ref(self, database: str, schema: str, code: str) -> str:
        """Format a fully-qualified table reference.

        Default: three-part ``database.schema.code`` (Snowflake/Databricks/Dremio).
        Postgres and ClickHouse override to two-part naming.
        All components are quoted to prevent SQL injection.

        An omitted component is dropped rather than emitted as an empty
        identifier. ``database`` is optional in OBML, and quoting it anyway
        produced ``""."schema"."table"``, which Snowflake rejects with
        ``Database '""' does not exist``. Leaving it out lets the reference
        resolve against the connection's current database, which is how a
        single model serves several deployments of the same schema.
        """
        if database and not schema:
            raise AmbiguousTableReferenceError(self.name, database, code)
        parts = [database, schema, code]
        return ".".join(self.quote_identifier(p) for p in parts if p)

    @property
    @abstractmethod
    def name(self) -> str: ...

    @property
    @abstractmethod
    def capabilities(self) -> DialectCapabilities: ...

    @abstractmethod
    def quote_identifier(self, name: str) -> str:
        """Quote an identifier per dialect rules."""

    def render_time_grain(self, column: Expr, grain: TimeGrain) -> Expr:
        """Wrap a column expression for the given time grain.

        A week is routed through the model's calendar rather than the dialect's
        own weekly truncation, so a ``timeGrain: week`` dimension, a weekly
        period-over-period and an explicit ``date_trunc('week', …)`` all bucket
        the same rows the same way. Left to the dialects, they did not: BigQuery
        hard-coded ISOWEEK, ClickHouse ``toMonday``, MySQL a ``%Y-%u`` label,
        and Snowflake a ``DATE_TRUNC('week')`` that follows its WEEK_START
        session parameter.
        """
        if grain is TimeGrain.WEEK:
            # RawSQL: re-wraps SQL this dialect just rendered, so the weekly
            # floor has one implementation rather than one per entry point.
            return RawSQL(sql=self._render_week_floor(column))
        return self._render_time_grain(column, grain)

    @abstractmethod
    def _render_time_grain(self, column: Expr, grain: TimeGrain) -> Expr:
        """Wrap a column expression for a grain other than a week."""

    def render_unnest(self, node: Unnest) -> str:
        """A FROM-clause fragment that unnests a parent's array column.

        The default is the comma-lateral every engine but four accepts::

            , UNNEST(`c`.`labels`) AS `l`

        with the outer form spelled as a ``LEFT JOIN ... ON TRUE``, which keeps
        a parent row whose array is empty. Measured on BigQuery, DuckDB and
        Postgres; ClickHouse, Databricks, MySQL and Snowflake override.

        Dremio has no FROM-clause form at all - ``FLATTEN`` is a projection
        function, so the unnest goes in the SELECT list of a derived table -
        and refuses here rather than emitting something that will not parse.
        """
        source = f"UNNEST({self.unnest_path(node)})"
        alias = self.quote_identifier(node.alias)
        if node.outer:
            return f"LEFT JOIN {source} AS {alias} ON TRUE"
        return f", {source} AS {alias}"

    def nested_field(self, alias: str, field: str, sql_type: str | None = None) -> Expr:
        """How a column of an unnested element is addressed.

        Ordinary column access almost everywhere: measured, ``L."Key"`` reads
        the field on BigQuery, DuckDB, Postgres, MySQL, ClickHouse and
        Databricks, because the alias *is* the element. Snowflake overrides,
        because there the alias is a row whose ``value`` holds the element as a
        VARIANT.

        ``sql_type`` is what the field should be read as. Only the VARIANT
        dialect needs it; the rest carry their own types.
        """
        return ColumnRef(name=field, table=alias)

    def render_nested_field(self, node: NestedField) -> Expr:
        """How a nested object's column is addressed in a plan this dialect built.

        Two different things, depending on which source the planner chose. Where
        the FROM clause carries an unnest, this is a field of the element -
        :meth:`nested_field`. Where it cannot, the planner read the object's
        ``code`` fallback instead and put that table in FROM under the same
        alias, so the column is an ordinary one and reading it as an element
        field would name something that does not exist.
        """
        if not self.capabilities.supports_from_unnest:
            return ColumnRef(name=node.field, table=node.alias)
        return self.nested_field(
            node.alias, node.field, self.nested_column_type(node.abstract_type)
        )

    def nested_column_type(self, abstract_type: str | None) -> str:
        """The SQL type a field of an unnested element is read as.

        Two dialects need one and the other five ignore it: MySQL's
        ``JSON_TABLE`` declares the shape it extracts rather than inferring it,
        and Snowflake's VARIANT path has to be cast or a string field comes back
        with its JSON quotes still on. Both are served by the abstract type map
        every other cast already goes through, so a nested column is typed the
        same way an ordinary one is.
        """
        return self._resolve_type_name(abstract_type or "string")

    def unnest_path(self, node: Unnest) -> str:
        """The parent's array column, quoted segment by segment.

        A dotted ``column`` addresses an array inside a struct, and each segment
        is an identifier in its own right: ``x_Project.Ancestors`` becomes two
        quoted identifiers joined by a dot, rather than one quoted string
        containing a dot, which would name a column that does not exist.

        The dotted chain is the majority form, measured on DuckDB, BigQuery,
        Databricks and ClickHouse. Three engines cannot read it and override:
        Postgres needs the composite parenthesised, Snowflake needs a VARIANT
        ``:`` path, and MySQL has to move the member into the JSON path
        entirely - see :meth:`MySQLDialect.render_unnest`.
        """
        parts = [node.parent_alias, *node.column.split(".")]
        return ".".join(self.quote_identifier(p) for p in parts)

    @abstractmethod
    def render_cast(self, expr: Expr, target_type: str) -> Expr:
        """Render a CAST expression."""

    @abstractmethod
    def current_date_sql(self) -> str:
        """Return SQL for the current date."""

    @abstractmethod
    def date_add_sql(self, date_sql: str, unit: str, count: int) -> str:
        """Return SQL that adds count units to date_sql."""

    def render_date_trunc_sql(self, column_sql: str, grain: str) -> str:
        """Truncate a date/timestamp to the given grain, as a SQL string.

        String-level helper (not AST) for use in raw SQL CTEs like date_range
        and the period-over-period spine. A week goes through the model's
        calendar for the same reason it does in ``render_time_grain``: a weekly
        PoP and a weekly dimension have to agree on where a week starts.
        """
        if grain == TimeGrain.WEEK.value:
            # RawSQL: the caller already has SQL text, and the floor is defined
            # over expressions.
            return self._render_week_floor(RawSQL(sql=column_sql))
        return self._render_date_trunc_sql(column_sql, grain)

    @abstractmethod
    def _render_date_trunc_sql(self, column_sql: str, grain: str) -> str:
        """Truncate to a grain other than a week, as a SQL string."""

    @abstractmethod
    def render_date_spine_cte_sql(
        self,
        min_date: str,
        max_date: str,
        grain: str,
        offset: int,
        offset_grain: str,
    ) -> str:
        """Return the SQL body for a date spine CTE.

        Must produce two columns: ``spine_date`` and ``spine_date_prev``.
        ``spine_date_prev`` is NULL when the offset date falls before min_date.

        Parameters
        ----------
        min_date : str
            SQL expression referencing the minimum date (e.g. ``date_range.min_date``).
        max_date : str
            SQL expression referencing the maximum date.
        grain : str
            Time grain string: ``day``, ``week``, ``month``, ``quarter``, ``year``.
        offset : int
            Signed period offset (e.g. ``-1`` for previous period).
        offset_grain : str
            Grain of the offset (e.g. ``year`` for YoY).
        """

    def render_string_contains(self, column: Expr, pattern: Expr) -> Expr:
        """Default: column LIKE '%' || pattern || '%'."""
        return BinaryOp(
            left=column,
            op="LIKE",
            right=BinaryOp(
                left=BinaryOp(left=Literal.string("%"), op="||", right=pattern),
                op="||",
                right=Literal.string("%"),
            ),
        )

    def _map_function_name(self, name: str) -> str:
        """Map a function name to the dialect-specific equivalent.

        Override in subclasses to remap names (e.g. ANY_VALUE → any in ClickHouse).
        """
        return name

    # Canonical catalog name → this dialect's spelling, for entries whose only
    # difference from the ANSI default is the name (ClickHouse ``lengthUTF8``,
    # Snowflake ``STARTSWITH``). An entry whose *shape* differs — argument
    # order, an operator instead of a call — overrides the matching
    # ``_render_<name>`` method instead.
    _SCALAR_FUNCTION_NAMES: dict[str, str] = {}

    def _check_function_supported(self, name: str) -> None:
        """Raise ``UnsupportedFunctionError`` when this dialect has no
        equivalent for the catalog function *name* (lowercase canonical).
        """
        if name in {f.lower() for f in self.capabilities.unsupported_functions}:
            raise UnsupportedFunctionError(self.name, name)

    def _coerce_text_argument(self, expr: Expr) -> Expr:
        """Make a text argument safe for this engine's string functions.

        The identity everywhere but ClickHouse, which is the one engine with a
        fixed-width string type whose padding leaks into the answer.
        """
        return expr

    def _coerce_text_arguments(self, spec: FunctionSpec, args: list[Expr]) -> list[Expr]:
        """Apply :meth:`_coerce_text_argument` to the positions *spec* marks.

        A literal is never the problem - it is already the engine's ordinary
        string type - so it is left alone and the SQL stays readable.
        """
        if not spec.text_arguments:
            return args
        positions = (
            range(len(args))
            if spec.text_arguments == (TEXT_ALL,)
            else [i for i in spec.text_arguments if i < len(args)]
        )
        coerced = list(args)
        for i in positions:
            if not isinstance(coerced[i], Literal):
                coerced[i] = self._coerce_text_argument(coerced[i])
        return coerced

    def _render_function(self, name: str, args: list[Expr]) -> str:
        """Render a call to a portable-catalog scalar function.

        *name* is the canonical lowercase catalog name (``models/functions.py``)
        and *args* are already in canonical order with an arity the entry
        accepts — ``compile_expr`` only routes a call here once
        :meth:`FunctionSpec.accepts` holds, so each renderer can index its
        arguments directly.

        The signature takes arguments rather than just a name because a
        portable catalog needs more than a rename table: ``position(needle,
        haystack)`` is ``POSITION(needle IN haystack)`` here and
        ``STRPOS(haystack, needle)`` on BigQuery, and ``concat`` has to become
        an operator chain on the engines whose ``CONCAT`` skips NULLs. Renaming
        is the trivial case, handled by ``_SCALAR_FUNCTION_NAMES``.
        """
        self._check_function_supported(name)
        match name:
            case "concat":
                return self._render_concat(args)
            case "length":
                return self._render_length(args)
            case "position":
                return self._render_position(args)
            case "split_part":
                return self._render_split_part(args)
            case "starts_with":
                return self._render_starts_with(args)
            case "ends_with":
                return self._render_ends_with(args)
            case "round":
                return self._render_round(args)
            case "trunc":
                return self._render_trunc(args)
            case "div":
                return self._render_div(self._with_guarded_divisor(args))
            case "log":
                return self._guard_log_domain(args)
            case "greatest" | "least":
                return self._render_extremum(name, args)
            case "date_trunc":
                unit = _unit_of(args[0])
                if unit == "week":
                    return self._render_week_floor(args[1])
                return self._render_date_trunc(unit, args[1])
            case "date_add":
                return self._render_date_add(_unit_of(args[0]), args[1], args[2])
            case "date_diff":
                unit = _unit_of(args[0])
                if unit == "week":
                    return self._render_week_diff(args[1], args[2])
                return self._render_date_diff(unit, args[1], args[2])
            case "extract":
                return self._render_extract(_unit_of(args[0]), args[1])
            case "last_day":
                return self._render_last_day(args[0])
            case "current_date":
                return self._render_current_date()
            case "cast":
                return self._render_cast_call(args)
            case "to_number":
                return self._render_to_number(args)
            case "json_value":
                return self._render_json_value(args)
            case _:
                return self._render_named_function(name, args)

    #: A number, as a POSIX regular expression: an optional sign, digits with an
    #: optional fractional part or a bare fraction, and an optional exponent.
    #: ``to_number`` tests against it on **every** dialect, and *before* the
    #: conversion rather than around it. Before, because MySQL's failure is a
    #: silent 0 that nothing downstream can tell from a genuine zero. Every
    #: dialect, because it is the definition of "names a number" the entry
    #: promises: a safe cast reads ``NaN`` and ``Infinity`` as numbers where
    #: this pattern does not, so testing only the engines without one would
    #: split the answer five against three.
    _NUMERIC_TEXT_RE = r"^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)([eE][+-]?[0-9]+)?$"

    #: The safe cast ``to_number`` converts with, for the engines whose safe
    #: cast is ``TRY_CAST``-shaped: DuckDB, Snowflake and Databricks by this
    #: name, BigQuery as ``SAFE_CAST``. ClickHouse, PostgreSQL, MySQL and Dremio
    #: do not use it at all - they override ``_render_safe_number_cast``, having
    #: a per-type ``OrNull`` conversion or no safe cast whatsoever.
    _TRY_CAST_FN = "TRY_CAST"

    def _render_to_number(self, args: list[Expr]) -> str:
        """``CASE WHEN <trimmed text> names a number THEN <safe cast> END``.

        One shape on all eight, and the pattern is what makes the entry's claim
        true rather than nearly true. The engines with a safe cast also accept
        the special float tokens - ``TRY_CAST('NaN' AS DOUBLE)`` is nan on
        DuckDB and ClickHouse - where the three without one answer NULL,
        because a pattern for a decimal numeral does not match ``NaN`` or
        ``Infinity``. Testing everywhere settles it at NULL, which is the
        answer the entry promises for text that does not name a number, and
        those tokens do not name one in decimal notation.

        The safe cast stays *inside* the test on the engines that have one: a
        pattern says whether the text is a numeral, not whether the numeral
        fits, and a 400-digit one still has to not raise.

        The argument goes through this dialect's string type first. It is not
        always text - ``to_number(4.6)`` is an accepted call - and DuckDB has no
        ``trim(DECIMAL)``, so trimming the argument as it arrives fails to
        compile. The round trip is exact: measured on DuckDB, PostgreSQL and
        ClickHouse, a double through the engine's own text form and back is the
        same double, including 1e308 and a 17-digit mantissa.
        """
        as_text = self._as_text_expr(args[0])
        trimmed = self._render_named_function("trim", [as_text])
        return self._render_numeric_text_guard(as_text, self._render_safe_number_cast(trimmed))

    def _as_text_expr(self, value: Expr) -> Expr:
        """*value* as this dialect's string type, so text functions can read it."""
        return Cast(expr=value, type_name=self.render_obml_type(SimpleType(name="string")))

    def _render_safe_number_cast(self, trimmed: str) -> str:
        """The conversion inside the test: a safe cast where the engine has one.

        ``TRY_CAST`` on DuckDB, Snowflake and Databricks; ``SAFE_CAST`` on
        BigQuery, spelled through ``_TRY_CAST_FN``. ClickHouse has a per-type
        ``OrNull`` conversion instead, and PostgreSQL, MySQL and Dremio have
        none at all - all four override this.
        """
        return (
            f"{self._TRY_CAST_FN}({trimmed} AS {self.render_obml_type(SimpleType(name='double'))})"
        )

    def _render_numeric_text_guard(self, value: Expr, convert: str) -> str:
        """``CASE WHEN <trimmed> matches a number THEN <convert> END``.

        The test goes through :meth:`compile_regex_match`, which already knows
        each engine's spelling - ``~`` on PostgreSQL, ``REGEXP`` on MySQL,
        ``REGEXP_LIKE`` by default, which is Dremio's.
        """
        trimmed = FunctionCall(name="trim", args=[value])
        test = self.compile_regex_match(trimmed, self._NUMERIC_TEXT_RE, negated=False)
        return f"CASE WHEN {test} THEN {convert} END"

    def _render_cast_call(self, args: list[Expr]) -> str:
        """``cast(x, 'decimal(18, 2)')`` through this dialect's own cast.

        No dialect overrides this one. The target is an OBML type, so the whole
        of the per-engine difference is already inside ``cast_to_obml_type``
        and its type map: BigQuery's ROUND wrap for a parameterized decimal,
        MySQL's widening to 38 digits, ClickHouse's ``Nullable`` and its
        rounding to the target scale. An override here would be a second place
        for the same knowledge to live.
        """
        target = _cast_target_of(args[1])
        # ``compile_expr`` only routes here once the target is a literal this
        # catalog takes, so this holds; the assert says so rather than the
        # reader having to go and check.
        assert target is not None
        return self.compile_expr(self.cast_to_obml_type(args[0], target))

    def _render_json_value(self, args: list[Expr]) -> str:
        """Default: ANSI ``JSON_VALUE(x, path)``, taking the path verbatim.

        Correct as measured on BigQuery. ClickHouse accepts the same spelling
        but returns the empty string rather than NULL for an absent path, and
        DuckDB's ``JSON_VALUE`` leaves the result quoted, so both override, as
        do Postgres, Snowflake, Databricks and MySQL, which have no
        ``JSON_VALUE`` at all.
        """
        doc = self.compile_expr(args[0])
        path = _json_path_of(args[1])
        return f"JSON_VALUE({doc}, {self._quote_text(path)})"

    def _quote_text(self, text: str) -> str:
        """A string literal carrying *text*, escaped for this dialect."""
        return self.compile_expr(Literal.string(text))

    def _render_named_function(self, name: str, args: list[Expr]) -> str:
        """Render ``NAME(arg, ...)`` using this dialect's spelling of *name*."""
        sql_name = self._SCALAR_FUNCTION_NAMES.get(name, name.upper())
        rendered = ", ".join(self.compile_expr(a) for a in args)
        return f"{sql_name}({rendered})"

    def _render_concat(self, args: list[Expr]) -> str:
        """Default: native ``CONCAT``, which propagates NULL on ClickHouse,
        MySQL, Snowflake, BigQuery and Databricks — the catalog's rule.
        DuckDB, Postgres and Dremio skip NULL arguments and override.
        """
        return self._render_named_function("concat", args)

    def _render_infix(self, sql: str) -> str:
        """Parenthesise a rewrite that emits an infix operator.

        ``compile_expr`` hands a ``FunctionCall``'s rendering straight to the
        surrounding expression and treats it as an atom, so a renderer that
        expands a call into ``a * b`` or ``a / b`` has to bracket itself or the
        surrounding operators bind into it: ``10 / trunc(2.5)`` on Databricks
        would compile to ``10 / SIGN(2.5) * FLOOR(ABS(2.5))``, which is 20
        rather than 5, and ``10 / log(2, 8)`` on Dremio to
        ``10 / LOG10(8) / LOG10(2)``.

        A call that stays a call needs nothing; this is only for the rewrites
        that do not.
        """
        return f"({sql})"

    def _render_concat_operator_chain(self, args: list[Expr]) -> str:
        """``(a || b || ...)`` — the NULL-propagating form on engines whose
        ``CONCAT`` skips NULLs but whose ``||`` does not.

        Operands are rendered one level above ``||``'s own precedence so a
        child that binds equally loosely keeps its parens: Postgres reads
        ``'x' || a - b`` as ``('x' || a) - b``. The chain itself is wrapped
        because ``compile_expr`` treats a function call as an atom and gives
        the result no parens of its own.
        """
        chain = " || ".join(self.compile_expr(a, _parent_prec=self._PREC_ADD + 1) for a in args)
        return f"({chain})"

    def _render_null_guard(self, inner_sql: str, args: list[Expr]) -> str:
        """``CASE WHEN a IS NULL OR ... THEN NULL ELSE <inner_sql> END``.

        The portable way to make a NULL-skipping function propagate NULL: used
        by ``concat`` on Dremio and by ``greatest`` / ``least`` on the four
        engines that skip. Verbose, but it does not depend on the engine having
        a NULL-aware alternative, and it is type-agnostic where a sentinel
        value (a negative infinity for a numeric ``greatest``) would not be.
        """
        guards = " OR ".join(
            f"{self.compile_expr(a, _parent_prec=self._PREC_CMP)} IS NULL" for a in args
        )
        return f"CASE WHEN {guards} THEN NULL ELSE {inner_sql} END"

    def _render_concat_null_guard(self, args: list[Expr]) -> str:
        """``concat`` for an engine whose ``CONCAT`` skips NULL arguments and
        whose ``||`` cannot be shown to behave differently.
        """
        return self._render_null_guard(self._render_named_function("concat", args), args)

    def _render_length(self, args: list[Expr]) -> str:
        """Default: ``LENGTH``, which counts characters everywhere except
        ClickHouse and MySQL — both of which override.
        """
        return self._render_named_function("length", args)

    def _render_position(self, args: list[Expr]) -> str:
        """Default: ANSI ``POSITION(needle IN haystack)``.

        Accepted by DuckDB, Postgres, ClickHouse, MySQL, Snowflake, Databricks
        and Dremio; BigQuery has no ``POSITION`` at all and overrides.
        """
        needle = self.compile_expr(args[0])
        haystack = self.compile_expr(args[1])
        return f"POSITION({needle} IN {haystack})"

    def _render_split_part(self, args: list[Expr]) -> str:
        """Default: native ``SPLIT_PART(x, delim, n)``."""
        return self._render_named_function("split_part", args)

    def _render_starts_with(self, args: list[Expr]) -> str:
        """Default: native ``STARTS_WITH(x, prefix)``."""
        return self._render_named_function("starts_with", args)

    def _render_ends_with(self, args: list[Expr]) -> str:
        """Default: native ``ENDS_WITH(x, suffix)``."""
        return self._render_named_function("ends_with", args)

    #: The widest scale this engine's decimal type carries. Rounding to that
    #: many places or more leaves every value it can hold unchanged, so the
    #: call is the identity there and needs no arithmetic at all.
    _MAX_ROUND_DIGITS: int = 0

    #: The largest power of ten a factor can be and still be a finite double.
    #: Not a property of the decimal type: the factor has to out-scale whatever
    #: arrives, and a float column reaches far past any DECIMAL. Past this, no
    #: representable factor is coarse enough and the answer is zero instead.
    _MAX_ROUND_MAGNITUDE: int = 308

    #: Set by an engine that rounds its float type to even and needs the
    #: add-half-and-truncate rewrite. ``None`` means the native ROUND is right.
    _ROUND_TRUNCATE_FN: str | None = None

    def _round_digits(self, args: list[Expr]) -> int | None:
        """The digit count as an integer, or ``None`` when it is computed.

        Read only from an integer literal, which is what a model formula
        writes. Returned unbounded: the two ends are not symmetric and each
        dialect handles them where the meaning is clear, in
        :meth:`_render_round`.
        """
        if len(args) < 2:
            return 0
        digits = args[1]
        # bool is a subclass of int, and `round(x, true)` is not a digit count.
        if (
            isinstance(digits, Literal)
            and isinstance(digits.value, int)
            and not isinstance(digits.value, bool)
        ):
            return digits.value
        return None

    @staticmethod
    def _decimal_half(digits: int) -> str:
        """Half of the last place *digits* keeps, written out exactly.

        ``0.5`` at 0 places, ``0.005`` at 2, ``50`` at -2. Spelled rather than
        computed, so no float is involved in producing it.
        """
        if digits >= 0:
            return "0." + "0" * digits + "5"
        return "5" + "0" * (abs(digits) - 1)

    def _round_half_sql(self, half: str) -> str:
        """Spell *half* so this engine reads it as an exact decimal."""
        return half

    def _round_half_computed(self, digits_sql: str) -> str:
        """The same half when the digit count is only known at run time."""
        return f"0.5 * POW(10, -({digits_sql}))"

    def _round_decimal_cast(self, value_sql: str) -> str | None:
        """An exact-decimal cast to put under the native ``ROUND``.

        ``None`` unless the engine both rounds its float type to even *and* has
        a decimal type wide enough to take any value unharmed, which of the
        eight is true only of PostgreSQL and its unbounded ``numeric``.
        """
        return None

    def _render_round(self, args: list[Expr]) -> str:
        """Ties away from zero, which three engines do not do for floats.

        Measured, not assumed. ClickHouse, PostgreSQL and MySQL all use
        banker's rounding for their *float* type and away from zero for their
        *decimal* type - ``round(2.5)`` is 2 on a double and 3 on a numeric, on
        the same engine, and all three document it. ClickHouse is not the odd
        one out it was once described as. The other five need nothing.

        Two shapes cover the three, and which one an engine gets is decided by
        whether it has a decimal type that can hold anything:

        **PostgreSQL** does. Its ``numeric`` is unbounded, so casting to it
        names no width, loses nothing, and its own ROUND then rounds the way
        the catalog wants.

        **MySQL and ClickHouse** do not, so nothing is cast. Adding half of the
        last kept place and truncating is the same operation and needs no
        conversion: the arithmetic runs in whatever type arrived, and only the
        *half* is written as an exact decimal, which is what keeps a decimal
        operand exact while leaving a float a float.

        Casting either of those two was tried and measured worse. MySQL's
        DECIMAL is 65 digits split between the sides, so ``CAST(1e50 AS
        DECIMAL(65, 18))`` saturates silently to 999...9. ClickHouse's
        conversion from Float64 scales by a power of ten in floating point, so
        ``round(toDecimal256(1e19, 18))`` is 9999999999999999539 where DuckDB
        says 1e19, and an infinity cannot be converted at all.
        """
        cast_sql = self._round_decimal_cast(self.compile_expr(args[0]))
        if cast_sql is not None:
            # RawSQL: re-wraps SQL this dialect just rendered, so the argument
            # goes back through _render_named_function and picks up the
            # engine's own spelling of ROUND.
            return self._render_named_function("round", [RawSQL(sql=cast_sql), *args[1:]])
        if self._ROUND_TRUNCATE_FN is None:
            return self._render_named_function("round", args)

        value = self.compile_expr(args[0], _parent_prec=self._PREC_MUL)
        fn = self._ROUND_TRUNCATE_FN
        digits = self._round_digits(args)

        if digits is None:
            # A computed count cannot be spelled as a literal, and the fallback
            # is a float, so a decimal operand degrades. 2.25.0 did this too.
            n_sql = self.compile_expr(args[1])
            half = self._round_half_computed(n_sql)
            return f"{fn}({value} + SIGN({value}) * {half}, {n_sql})"

        if digits >= self._MAX_ROUND_DIGITS:
            # Rounding to at least as many places as the decimal type carries
            # leaves every value unchanged, so there is nothing to do. Saying
            # so is also the only correct answer at the ceiling: the half would
            # need one place *more* than the count, which is a scale the engine
            # cannot express, and rounding a value to its own scale must not
            # change it.
            return value

        if digits < 0:
            # Rounding to tens or hundreds. Not the same shape: the half would
            # have to be 5, 50, 500..., and truncating at a negative count
            # stops working once it passes the value's own magnitude - measured,
            # ClickHouse leaves 1e40 alone at -41 where the answer is 0. Divide
            # first, round at zero places, and put the scale back, which is
            # exact because the factor is an integer both ways.
            #
            # Bounding the *count* is what a clamp would do, and it is wrong
            # here: measured, round(9e64, -5000) is 0 while a factor of 10**65
            # leaves 9e64 sitting at 1e65, and 9e64 is an ordinary DECIMAL(65).
            # The factor has to out-scale the value, not the type.
            #
            # Past the largest finite double no factor can, so there is nothing
            # to divide by - but there is also nothing left to decide: a
            # granularity coarser than every representable number rounds all of
            # them to zero. SIGN carries a NULL through and reads an infinity as
            # 1, which is what DuckDB answers there too.
            if -digits > self._MAX_ROUND_MAGNITUDE:
                return self._render_infix(f"SIGN({value}) * 0")
            factor = 10**-digits
            half = self._round_half_sql("0.5")
            return self._render_infix(
                f"{fn}({value} / {factor} + SIGN({value}) * {half}, 0) * {factor}"
            )

        half = self._round_half_sql(self._decimal_half(digits))
        return f"{fn}({value} + SIGN({value}) * {half}, {digits})"

    def _render_trunc(self, args: list[Expr]) -> str:
        """Default: native ``TRUNC(x[, n])``, truncating toward zero."""
        return self._render_named_function("trunc", args)

    def _render_trunc_by_floor(self, args: list[Expr]) -> str:
        """``(sign(x) * floor(abs(x) * 10^n) / 10^n)`` — truncation for an
        engine with no numeric truncation of its own.

        Via the absolute value so the result goes toward zero rather than down:
        ``floor(-1.9)`` is -2 where the catalog documents -1. ``sign(0)`` is 0,
        which keeps zero at zero.

        Wrapped, like every rewrite that emits an infix operator: see
        :meth:`_render_infix`.
        """
        value = self.compile_expr(args[0], _parent_prec=self._PREC_MUL)
        if len(args) == 1:
            return self._render_infix(f"SIGN({value}) * FLOOR(ABS({value}))")
        scale = f"POWER(10, {self.compile_expr(args[1])})"
        return self._render_infix(f"SIGN({value}) * FLOOR(ABS({value}) * {scale}) / {scale}")

    def _with_guarded_divisor(self, args: list[Expr]) -> list[Expr]:
        """``div(a, b)`` with its divisor wrapped so a zero yields NULL.

        ``div`` is a named function, so it never reaches the guard the ``/``
        operator gets (#319), and the engines disagree just as widely: measured,
        ``div(7, 0)`` returns NULL on DuckDB and MySQL and raises on PostgreSQL,
        BigQuery, Snowflake and ClickHouse.

        Guarded by rewriting the **argument** rather than the rendering, because
        every dialect spells this function differently - ``a // b``, ``DIV(a,
        b)``, ``intDiv``, ``a DIV b``, ``TRUNC(a / b)`` - and a guard applied to
        the argument is carried by all of them. ``nullif`` is itself a catalog
        entry, so it renders per dialect too.

        Applied at the dispatch site rather than inside ``_render_div``, so a
        dialect that overrides the rendering cannot drop the guard by doing so.
        The internal caller in ``_render_days_to_weeks`` divides by a literal 7
        and goes straight to ``_render_div``, unguarded, which is right.
        """
        if len(args) != 2:
            return args
        return [args[0], FunctionCall(name="nullif", args=[args[1], Literal.number(0)])]

    def _guard_log_domain(self, args: list[Expr]) -> str:
        """``log(base, x)`` outside its domain yields NULL rather than nonsense.

        The catalog exists to pin one meaning per function, and this one had
        four. Measured, for every undefined input - base of 1, base of 0, x of
        0, negative x - PostgreSQL, DuckDB, BigQuery and Snowflake raise, MySQL
        answers NULL, and **ClickHouse returns a number**: ``inf``, ``-0.0``,
        ``-inf`` and ``nan`` respectively. A silent ``inf`` flowing into an
        aggregate is the worst of the three, and the same reason #319 chose NULL
        for a zero divisor.

        Guarding only the base of 1 - the case that is literally a zero divisor,
        since ClickHouse and Dremio rewrite this as ``log10(x) / log10(base)`` -
        would leave its three neighbours silently wrong on the same engine, so
        the whole undefined domain is guarded together.

        A ``CASE`` is used rather than NULLIF-ing the arguments because the
        domain is not just "not zero": a negative ``x`` has no logarithm either.
        Verified that the guard holds for literal arguments too, on PostgreSQL,
        DuckDB and ClickHouse - constant folding does not evaluate the ``ELSE``
        branch and raise before the ``WHEN`` is considered.
        """
        rendered = self._render_log(args)
        if len(args) != 2:
            return rendered
        base = self.compile_expr(args[0])
        value = self.compile_expr(args[1])
        return self._render_infix(
            f"CASE WHEN {base} <= 0 OR {base} = 1 OR {value} <= 0 THEN NULL ELSE {rendered} END"
        )

    def _render_div(self, args: list[Expr]) -> str:
        """Default: native ``DIV(a, b)`` (BigQuery, Postgres)."""
        return self._render_named_function("div", args)

    def _render_div_by_truncation(self, args: list[Expr]) -> str:
        """``TRUNC(a / b)`` — integer division for an engine with no operator
        or function of its own, and whose ``/`` is float division.
        """
        left = self.compile_expr(args[0], _parent_prec=self._PREC_MUL)
        right = self.compile_expr(args[1], _parent_prec=self._PREC_MUL + 1)
        return f"TRUNC({left} / {right})"

    def _render_div_operator(self, args: list[Expr], operator: str) -> str:
        """``(a <op> b)`` for the engines whose integer division is an operator.

        Wrapped because ``compile_expr`` treats a function call as an atom and
        gives the result no parens of its own.
        """
        left = self.compile_expr(args[0], _parent_prec=self._PREC_MUL)
        right = self.compile_expr(args[1], _parent_prec=self._PREC_MUL + 1)
        return f"({left} {operator} {right})"

    def _render_log(self, args: list[Expr]) -> str:
        """Default: native ``LOG(base, x)``."""
        return self._render_named_function("log", args)

    # ---- date/time ---------------------------------------------------------
    #
    # These take the unit already extracted and lower-cased, because every one
    # of them has to switch on it: the unit is a keyword on BigQuery and
    # ClickHouse, a quoted string on Snowflake, an interval qualifier on MySQL,
    # and a different expression per unit on Postgres. A call whose unit is not
    # a literal from the vocabulary never reaches here — ``compile_expr``
    # leaves it to the pass-through path, and the validator reports it.

    _SQL_UNITS: dict[str, str] = {unit: unit.upper() for unit in TIME_UNITS}
    """Canonical unit → the keyword this dialect spells it with."""

    week_start: WeekStart = WeekStart.MONDAY
    """Which day ``date_trunc('week', …)`` rounds down to.

    Set per compile from ``settings.weekStart`` by the pipeline, which builds a
    fresh dialect for each query, so one model's calendar cannot leak into
    another's.
    """

    def _render_in_timezone(self, value: Expr, zone: str, from_zone: str | None) -> str:
        """Default: ANSI ``AT TIME ZONE``, which DuckDB and Postgres share.

        A naive value is first declared to be in *from_zone*, then read in
        *zone*; an aware one already knows its instant and is only read.
        """
        rendered = self.compile_expr(value, _parent_prec=self._PREC_CMP + 1)
        if from_zone is not None:
            rendered = f"{rendered} AT TIME ZONE {self._quote_zone(from_zone)}"
        return self._render_infix(f"{rendered} AT TIME ZONE {self._quote_zone(zone)}")

    def _quote_zone(self, zone: str) -> str:
        """A time zone name as a SQL string literal."""
        return self.quote_string_literal(zone)

    def _render_date_trunc(self, unit: str, value: Expr) -> str:
        """Default: ``DATE_TRUNC('unit', x)``, unit first and quoted.

        Only ever called for the model's own week start; a Sunday week is
        routed to :meth:`_render_week_start_sunday` by the dispatcher, so a
        dialect overriding this one does not have to remember the calendar.
        """
        return f"DATE_TRUNC('{unit}', {self.compile_expr(value)})"

    def _render_week_start_sunday(self, value: Expr) -> str:
        """Default: step back to the preceding Sunday by the ANSI day-of-week.

        ``EXTRACT(DOW …)`` numbers Sunday as 0 on DuckDB and Postgres, so the
        offset is the number itself. Engines that number differently, or that
        have a week-start argument of their own, override.
        """
        rendered = self.compile_expr(value)
        return self._render_infix(
            f"DATE_TRUNC('day', {rendered}) - EXTRACT(DOW FROM {rendered}) * INTERVAL '1 day'"
        )

    def _render_date_add(self, unit: str, count: Expr, value: Expr) -> str:
        """Default: ``x + n * INTERVAL '1 unit'``.

        Multiplication rather than ``INTERVAL n unit`` because *n* is an
        expression in a real model, and Postgres and DuckDB only accept a
        constant inside an interval literal.
        """
        n = self.compile_expr(count, _parent_prec=self._PREC_MUL)
        return self._render_infix(
            f"{self.compile_expr(value, _parent_prec=self._PREC_ADD)} + {n} * INTERVAL '1 {unit}'"
        )

    def _render_date_diff(self, unit: str, start: Expr, end: Expr) -> str:
        """Default: ``DATE_DIFF('unit', start, end)``, counting boundaries."""
        return f"DATE_DIFF('{unit}', {self.compile_expr(start)}, {self.compile_expr(end)})"

    def _render_week_floor(self, value: Expr) -> str:
        """The start of *value*'s week, per the model's calendar."""
        if self.week_start is WeekStart.SUNDAY:
            return self._render_week_start_sunday(value)
        return self._render_date_trunc("week", value)

    def _render_week_diff(self, start: Expr, end: Expr) -> str:
        """Week boundaries crossed, for every dialect and both calendars.

        Not the engine's own week difference, for two reasons. It counts the
        engine's week boundaries, Monday's on all but BigQuery, so it answers
        the wrong number as soon as the model says Sunday. And the engines do
        not even agree on the question: from Sunday 2026-08-09 to Saturday
        2026-08-15, one Monday apart, ClickHouse, Snowflake and BigQuery count
        the boundary and answer 1, while DuckDB and MySQL count whole seven-day
        spans and answer 0, and Postgres has no week difference at all.

        Truncating both ends to the model's week start and dividing the day
        difference by seven gives the boundary count the catalog documents,
        through this dialect's own truncation, day difference and integer
        division rather than an eighth dialect-specific rewrite.
        """
        # RawSQL: re-wraps SQL this dialect just rendered so the composition
        # runs through its own truncation, day difference and integer division
        # rather than an eighth copy of per-engine week arithmetic. Nothing
        # user-authored enters here.
        left = RawSQL(sql=self._render_week_floor(start))
        right = RawSQL(sql=self._render_week_floor(end))
        days = RawSQL(sql=self._render_date_diff("day", left, right))
        return self._render_div([days, Literal.number(7)])

    def _render_extract(self, unit: str, value: Expr) -> str:
        """Default: ANSI ``EXTRACT(UNIT FROM x)``."""
        return f"EXTRACT({self._SQL_UNITS[unit]} FROM {self.compile_expr(value)})"

    def _render_last_day(self, value: Expr) -> str:
        """Default: native ``LAST_DAY(x)``."""
        return f"LAST_DAY({self.compile_expr(value)})"

    def _render_current_date(self) -> str:
        """Default: ``CURRENT_DATE()``. Postgres rejects the parentheses."""
        return "CURRENT_DATE()"

    def _render_extremum(self, name: str, args: list[Expr]) -> str:
        """Default: native ``GREATEST`` / ``LEAST``, which propagate NULL on
        MySQL, Snowflake and BigQuery — the catalog's rule. DuckDB, Postgres,
        ClickHouse and Databricks skip NULL arguments and override.
        """
        return self._render_named_function(name, args)

    def _check_aggregation_supported(self, name: str) -> None:
        """Raise ``UnsupportedAggregationError`` when the dialect doesn't support
        the given aggregation. Matches case-insensitively against
        ``capabilities.unsupported_aggregations`` (lowercase OBML names).

        Existing per-function compile overrides (``_compile_mode``,
        ``_compile_median``) still raise directly — this generic gate is a
        catch-all for purely-standard aggregations like ``REGR_SLOPE`` where
        no special compile path exists.
        """
        if name.lower() in {a.lower() for a in self.capabilities.unsupported_aggregations}:
            raise UnsupportedAggregationError(self.name, name.lower())

    def _compile_median(self, args: list[Expr]) -> str:
        """Compile MEDIAN — default uses MEDIAN(col).

        Works for Snowflake, ClickHouse, Databricks, and Dremio. Postgres overrides.
        """
        col_sql = self.compile_expr(args[0]) if args else "NULL"
        return f"MEDIAN({col_sql})"

    def _compile_mode(self, args: list[Expr]) -> str:
        """Compile MODE — default uses MODE(col).

        Works for Snowflake and Databricks. Postgres, ClickHouse, and Dremio override.
        """
        col_sql = self.compile_expr(args[0]) if args else "NULL"
        return f"MODE({col_sql})"

    def _compile_listagg(
        self,
        args: list[Expr],
        distinct: bool,
        order_by: list[OrderByItem],
        separator: str | None,
    ) -> str:
        """Compile LISTAGG — default uses LISTAGG(col, sep) WITHIN GROUP (ORDER BY ...).

        Works for Snowflake and Dremio. Postgres, ClickHouse, and Databricks override.
        """
        sep = separator if separator is not None else ","
        col_sql = self.compile_expr(args[0]) if args else "''"
        distinct_sql = "DISTINCT " if distinct else ""
        escaped_sep = self.quote_string_literal(sep)[1:-1]
        result = f"LISTAGG({distinct_sql}{col_sql}, '{escaped_sep}')"
        if order_by:
            ob = ", ".join(self.compile_order_by(o) for o in order_by)
            result += f" WITHIN GROUP (ORDER BY {ob})"
        return result

    def _compile_cast(self, inner: Expr, type_name: str) -> str:
        """Render ``CAST(expr AS type)``. Dialects override to handle nullability."""
        resolved_type = self._resolve_type_name(type_name)
        return f"CAST({self.compile_expr(inner)} AS {resolved_type})"

    # SQL operator precedence (higher = binds tighter). Used by the
    # precedence-aware emitter in ``compile_expr`` to skip wrapping a
    # child whose precedence is higher than its parent's required level.
    # Pre-v2.7.4 the emitter wrapped *every* operator unconditionally,
    # producing deeply-nested unreadable SQL (issue #79).
    _CLAUSE_ROOT_PREC = 0  # no surrounding context → no wrap
    _PREC_OR = 1
    _PREC_AND = 2
    _PREC_NOT = 3
    _PREC_CMP = 4  # =, <>, <, <=, >, >=, IS NULL, IN, BETWEEN, LIKE
    _PREC_ADD = 5  # +, -, ||
    _PREC_MUL = 6  # *, /, %
    _PREC_UNARY = 7  # unary -, +
    _PREC_ATOM = 100  # literals, column refs, function calls, CAST(...), CASE...END

    @staticmethod
    def _wrap_if_lower(sql: str, self_prec: int, parent_prec: int) -> str:
        """Wrap ``sql`` in ``(...)`` only when it would bind weaker than
        its parent — i.e. its precedence is strictly less than the
        parent's required level. ``parent_prec = 0`` (clause root) is
        always satisfied so the outermost expression never gets a
        redundant outer wrap.
        """
        if self_prec < parent_prec:
            return f"({sql})"
        return sql

    @classmethod
    def _binary_op_precedence(cls, op: str) -> int:
        """Return the precedence of a ``BinaryOp.op`` value."""
        up = op.upper().strip()
        if up == "OR":
            return cls._PREC_OR
        if up == "AND":
            return cls._PREC_AND
        if up in ("=", "<>", "!=", "<", "<=", ">", ">=", "LIKE", "NOT LIKE"):
            return cls._PREC_CMP
        if up in ("+", "-", "||"):
            return cls._PREC_ADD
        if up in ("*", "/", "%", "//"):
            return cls._PREC_MUL
        # Unknown operator — wrap defensively. ``_PREC_OR`` and not
        # ``_CLAUSE_ROOT_PREC``: the root level means "no surrounding context",
        # so a child claiming it is never wrapped at all, which is the opposite
        # of defensive. Binding weaker than every operator that *is* known is
        # what makes the wrap happen.
        return cls._PREC_OR

    # Non-associative operators — children at the same precedence must
    # be wrapped on BOTH sides. SQL forbids chained comparisons
    # (``a >= b = c`` is a syntax error in every dialect we support),
    # subtraction and division are left-associative but ``a - (b - c)``
    # differs from ``a - b - c``, so the right operand is wrapped at
    # equal precedence — see the left-associative branch below.
    _NON_ASSOCIATIVE_OPS: frozenset[str] = frozenset(
        {"=", "<>", "!=", "<", "<=", ">", ">=", "LIKE", "NOT LIKE"}
    )

    def _compile_binary_op(self, left: Expr, op: str, right: Expr) -> str:
        """Render an infix binary expression *without* an outer wrap.

        The dispatcher in ``compile_expr`` decides whether to add an outer
        ``(...)`` wrap based on the parent's precedence. Dialects override
        to widen operand precision (e.g. ClickHouse decimal division) or
        special-case operators that don't translate one-to-one (e.g. MySQL
        string concat).
        """
        self_prec = self._binary_op_precedence(op)
        # Comparison + LIKE forbid chaining — wrap any equal-precedence
        # child on either side. Other ops are left-associative: left at
        # self_prec, right at self_prec + 1 so ``a - (b - c)`` keeps its
        # required parens.
        op_upper = op.upper().strip()
        if op_upper in self._NON_ASSOCIATIVE_OPS:
            left_sql = self.compile_expr(left, _parent_prec=self_prec + 1)
            right_sql = self.compile_expr(right, _parent_prec=self_prec + 1)
        else:
            left_sql = self.compile_expr(left, _parent_prec=self_prec)
            right_sql = self.compile_expr(right, _parent_prec=self_prec + 1)
        if op.strip() == "/":
            # NULLIF is an atom, so the divisor no longer needs its own parens.
            right_sql = self.guard_zero_divisor(right, self.compile_expr(right))
        return f"{left_sql} {op} {right_sql}"

    def guard_zero_divisor(self, right: Expr | None, right_sql: str) -> str:
        """Wrap a divisor so that dividing by zero yields NULL, not chaos.

        Left alone, the same ratio means five different things across the
        supported engines: measured, ``SUM(a) / SUM(b)`` with a zero divisor
        returns ``inf`` on DuckDB, NULL on MySQL, and raises on PostgreSQL,
        BigQuery and ClickHouse. A semantic layer cannot promise that a measure
        means one thing everywhere and then hand back a number, a null and an
        error depending on the warehouse behind it.

        NULL is the answer chosen (#319). It reads naturally as "no value" in a
        BI tool, it is what MySQL already does, and it removes DuckDB's
        ``inf`` - the only one of the three outcomes that can silently corrupt
        a downstream figure rather than stopping.

        Applied where divisions are *compiled* rather than where they are
        built, so it covers a modeller's expression, the divisions OBSL
        generates itself, and all eight dialects without being remembered at
        each site. ``NULLIF`` renders identically everywhere, verified.

        A literal divisor that is plainly not zero is left unwrapped - there is
        nothing to guard, and the noise would show up in every snapshot.
        """
        if right is not None and isinstance(right, Literal):
            value = right.value
            if isinstance(value, (int, float)) and not isinstance(value, bool) and value != 0:
                return right_sql
        return f"NULLIF({right_sql}, 0)"

    def render_decimal_division_sql(self, left_sql: str, right_sql: str) -> str:
        """Render ``left / right`` for decimal-typed operands, given raw SQL.

        Used by code paths that build division as string SQL (e.g. PoP
        comparison CTEs) rather than as ``BinaryOp`` AST nodes.

        **Do not override this** - override :meth:`_render_decimal_division`
        instead. This method exists to apply the zero-divisor guard (#319) in
        one place that a dialect cannot forget. It was overridden directly by
        ClickHouse and MySQL for operand widening, and when the guard moved
        here from ``pop_wrap`` those two overrides silently dropped it: a
        period-over-period ratio against a zero previous value went from NULL
        back to ILLEGAL_DIVISION on ClickHouse. Splitting the two concerns
        makes the guard structural rather than remembered.
        """
        return self._render_decimal_division(left_sql, self.guard_zero_divisor(None, right_sql))

    def _render_decimal_division(self, left_sql: str, right_sql: str) -> str:
        """The division itself, for dialects that need the operands widened.

        Default is plain SQL division; ClickHouse and MySQL override to widen
        both sides first so ratio precision survives. The divisor arrives
        already guarded.
        """
        return f"{left_sql} / {right_sql}"

    def render_pop_previous_value_sql(self, prev_sql: str, current_sql: str) -> str:
        """Render a ``previousValue`` PoP projection (the prior period's measure).

        Default is the prior value verbatim. Dremio overrides this because its
        executor miscompiles a self-joined CTE column projected on its own (see
        ``DremioDialect``); ``current_sql`` (``pop_base``'s measure) is supplied
        so a dialect can reference it in a value-preserving way if needed.
        """
        return prev_sql

    def _compile_multi_field_count(self, args: list[Expr], distinct: bool) -> str:
        """Compile COUNT with multiple fields by concatenating with ``||``.

        Default (non-Snowflake) strategy: cast each field to VARCHAR and
        join with ``'|'`` separator so the database sees a single expression.
        Snowflake overrides this to emit native ``COUNT(col1, col2)``.
        """
        parts = [f"CAST({self.compile_expr(a)} AS VARCHAR)" for a in args]
        concat = " || '|' || ".join(parts)
        if distinct:
            return f"COUNT(DISTINCT {concat})"
        return f"COUNT({concat})"

    def compile(self, ast: Select) -> str:
        """Render a complete SQL AST to a dialect-specific string."""
        return self.compile_select(ast)

    def compile_select(self, node: Select) -> str:
        """Compile a SELECT statement."""
        parts: list[str] = []

        # CTEs
        if node.ctes:
            cte_parts = []
            for cte in node.ctes:
                if isinstance(cte.query, RawSQL):
                    cte_sql = cte.query.sql
                elif isinstance(cte.query, UnionAll):
                    cte_sql = self.compile_union_all(cte.query)
                elif isinstance(cte.query, Except):
                    cte_sql = self.compile_except(cte.query)
                else:
                    cte_sql = self.compile_select(cte.query)
                cte_parts.append(f"{self.quote_identifier(cte.name)} AS (\n{cte_sql}\n)")
            parts.append("WITH " + ",\n".join(cte_parts))

        # SELECT
        keyword = "SELECT DISTINCT" if node.distinct else "SELECT"
        if node.columns:
            cols = ", ".join(self.compile_expr(c) for c in node.columns)
            parts.append(f"{keyword} {cols}")
        else:
            parts.append(f"{keyword} *")

        # FROM
        if node.from_:
            parts.append(f"FROM {self.compile_from(node.from_)}")

        # JOINs, and the unnests that ride between them
        for join in node.joins:
            if isinstance(join, Unnest):
                parts.append(self.render_unnest(join))
            else:
                parts.append(self.compile_join(join))

        # WHERE
        if node.where:
            parts.append(f"WHERE {self.compile_expr(node.where)}")

        # GROUP BY
        if node.group_by:
            parts.append(self.compile_group_by(node.group_by, node.grouping))

        # HAVING
        if node.having:
            parts.append(f"HAVING {self.compile_expr(node.having)}")

        # ORDER BY
        if node.order_by:
            orders = ", ".join(self.compile_order_by(o) for o in node.order_by)
            parts.append(f"ORDER BY {orders}")

        # LIMIT
        if node.limit is not None:
            parts.append(f"LIMIT {node.limit}")

        # OFFSET
        if node.offset is not None:
            parts.append(f"OFFSET {node.offset}")

        return "\n".join(parts)

    def compile_group_by(self, group_by: list[Expr], grouping: str | None) -> str:
        """Render the GROUP BY clause.

        Default ANSI form (Postgres, Snowflake, DuckDB, BigQuery, Databricks,
        Dremio, MySQL): ``GROUP BY ROLLUP(a, b)`` / ``GROUP BY CUBE(a, b)``.
        ClickHouse overrides to the trailing-modifier form
        (``GROUP BY a, b WITH ROLLUP``).

        When ``capabilities.supports_group_by_all`` is set and no grouping
        modifier is requested, emits ``GROUP BY ALL`` — the engine
        auto-derives the grouping list from the SELECT. Equivalent SQL
        with a much shorter and more idiomatic form on modern OLAP
        engines, especially for queries with computed dimensions.
        """
        if grouping == "rollup":
            groups = ", ".join(self.compile_expr(g) for g in group_by)
            return f"GROUP BY ROLLUP({groups})"
        if grouping == "cube":
            groups = ", ".join(self.compile_expr(g) for g in group_by)
            return f"GROUP BY CUBE({groups})"
        if self.capabilities.supports_group_by_all:
            return "GROUP BY ALL"
        groups = ", ".join(self.compile_expr(g) for g in group_by)
        return f"GROUP BY {groups}"

    def compile_from(self, node: From) -> str:
        if isinstance(node.source, Select):
            sub = self.compile_select(node.source)
            result = f"(\n{sub}\n)"
        else:
            result = self._render_source_string(node.source)
        if node.alias:
            result += f" AS {self.quote_identifier(node.alias)}"
        return result

    def compile_join(self, node: Join) -> str:
        if isinstance(node.source, Select):
            source = f"(\n{self.compile_select(node.source)}\n)"
        else:
            source = self._render_source_string(node.source)
        if node.alias:
            source += f" AS {self.quote_identifier(node.alias)}"

        parts = [f"{node.join_type.value} JOIN {source}"]
        if node.on:
            parts.append(f"ON {self.compile_expr(node.on)}")
        return " ".join(parts)

    def _render_source_string(self, source: str) -> str:
        """Render a ``From``/``Join`` string source.

        Wrap modules emit bare CTE names (e.g. ``base``); the star/CFL
        planners emit pre-quoted qualified table strings (e.g.
        ``"DB"."SCHEMA"."TABLE"``). Quote the former so case-sensitive
        dialects like Snowflake match the CTE declaration; pass the latter
        through unchanged.
        """
        if source.isidentifier():
            return self.quote_identifier(source)
        return source

    def compile_order_by(self, node: OrderByItem) -> str:
        result = self.compile_expr(node.expr)
        if node.desc:
            result += " DESC"
        else:
            result += " ASC"
        if node.nulls_last is True:
            result += " NULLS LAST"
        elif node.nulls_last is False:
            result += " NULLS FIRST"
        return result

    def compile_union_all(self, node: UnionAll) -> str:
        """Compile a UNION ALL of multiple SELECT statements."""
        return "\nUNION ALL\n".join(self.compile_select(q) for q in node.queries)

    def compile_except(self, node: Except) -> str:
        """Compile an EXCEPT of two SELECT statements."""
        return self.compile_select(node.left) + "\nEXCEPT\n" + self.compile_select(node.right)

    def compile_expr(self, expr: Expr, _parent_prec: int = 0) -> str:
        """Compile an expression node to SQL string.

        ``_parent_prec`` is the precedence of the surrounding operator
        (or ``_CLAUSE_ROOT_PREC = 0`` when called at the root of a SELECT
        projection, ON / WHERE / HAVING clause, GROUP BY / ORDER BY item,
        or function argument). Each operator branch wraps its own SQL in
        ``(...)`` only when its precedence is strictly less than the
        parent's required level; atoms (literals, column refs, function
        calls, CAST, CASE) are at ``_PREC_ATOM`` and never wrap.

        Pre-v2.7.4 every ``BinaryOp`` / ``IsNull`` / ``InList`` /
        ``Between`` / ``UnaryOp`` wrapped itself unconditionally,
        producing deeply-nested unreadable SQL — issue #79.
        """
        match expr:
            case Literal(value=None):
                return "NULL"
            case Literal(value=True):
                return "TRUE"
            case Literal(value=False):
                return "FALSE"
            case Literal(value=v) if isinstance(v, str):
                return self.quote_string_literal(v)
            case Literal(value=v):
                return str(v)
            case Star(table=None):
                return "*"
            case Star(table=t) if t is not None:
                return f"{self.quote_identifier(t)}.*"
            case ColumnRef(name=name, table=None):
                return self.quote_identifier(name)
            case ColumnRef(name=name, table=table) if table is not None:
                return f"{self.quote_identifier(table)}.{self.quote_identifier(name)}"
            case NestedField():
                # Routed through the dialect rather than rendered here: the
                # element is a column on six engines and a VARIANT path on
                # Snowflake, and the planner cannot know which without one.
                return self.compile_expr(self.render_nested_field(expr))
            case AliasedExpr(expr=inner, alias=alias):
                return f"{self.compile_expr(inner)} AS {self.quote_identifier(alias)}"
            case FunctionCall(
                name=fname,
                args=args,
                distinct=distinct,
                order_by=order_by,
                separator=separator,
            ):
                # Reject aggregations explicitly listed as unsupported by the dialect.
                # Per-function overrides (_compile_mode etc.) still apply for cases
                # that have a special compile path; this catches plain aggregates
                # like REGR_SLOPE that have no override.
                self._check_aggregation_supported(fname)
                # LISTAGG: dialect-specific rendering
                if fname.upper() == "LISTAGG":
                    return self._compile_listagg(args, distinct, order_by, separator)
                # MODE: dialect-specific rendering
                if fname.upper() == "MODE":
                    return self._compile_mode(args)
                # MEDIAN: dialect-specific rendering
                if fname.upper() == "MEDIAN":
                    return self._compile_median(args)
                # Multi-field COUNT: concatenate fields for portability
                # (Snowflake overrides to use native multi-arg syntax)
                if fname.upper() == "COUNT" and len(args) > 1:
                    return self._compile_multi_field_count(args, distinct)
                # Portable scalar catalog (``models/functions.py``): a call the
                # catalog defines is rendered per its pinned semantics rather
                # than passed through. A wrong arity falls through to the
                # verbatim path below — the model validator reports it, and
                # emitting the author's own call keeps the database error
                # recognisable instead of raising from codegen.
                spec = lookup_function(fname)
                if (
                    spec is not None
                    and not distinct
                    and spec.accepts(len(args))
                    and (spec.unit_argument is None or _is_unit_literal(args[spec.unit_argument]))
                    and (
                        spec.path_argument is None
                        or _is_json_path_literal(args[spec.path_argument])
                    )
                    and (
                        spec.type_argument is None
                        or _cast_target_of(args[spec.type_argument]) is not None
                    )
                ):
                    return self._render_function(spec.name, self._coerce_text_arguments(spec, args))
                # Everything else stays pass-through: removing the escape
                # hatch would break every model built before the catalog.
                fname = self._map_function_name(fname)
                args_sql = ", ".join(self.compile_expr(a) for a in args)
                if distinct:
                    return f"{fname}(DISTINCT {args_sql})"
                return f"{fname}({args_sql})"
            case BinaryOp(left=left, op=op, right=right):
                self_prec = self._binary_op_precedence(op)
                sql = self._compile_binary_op(left, op, right)
                return self._wrap_if_lower(sql, self_prec, _parent_prec)
            case UnaryOp(op=op, operand=operand):
                self_prec = self._PREC_NOT if op.upper() == "NOT" else self._PREC_UNARY
                sql = f"{op} {self.compile_expr(operand, _parent_prec=self_prec)}"
                return self._wrap_if_lower(sql, self_prec, _parent_prec)
            case IsNull(expr=inner, negated=False):
                sql = f"{self.compile_expr(inner, _parent_prec=self._PREC_CMP)} IS NULL"
                return self._wrap_if_lower(sql, self._PREC_CMP, _parent_prec)
            case IsNull(expr=inner, negated=True):
                sql = f"{self.compile_expr(inner, _parent_prec=self._PREC_CMP)} IS NOT NULL"
                return self._wrap_if_lower(sql, self._PREC_CMP, _parent_prec)
            case InList(expr=inner, values=values, negated=negated):
                vals = ", ".join(self.compile_expr(v) for v in values)
                op = "NOT IN" if negated else "IN"
                sql = f"{self.compile_expr(inner, _parent_prec=self._PREC_CMP)} {op} ({vals})"
                return self._wrap_if_lower(sql, self._PREC_CMP, _parent_prec)
            case CaseExpr(when_clauses=whens, else_clause=else_):
                parts = ["CASE"]
                for when_cond, then_val in whens:
                    parts.append(
                        f"WHEN {self.compile_expr(when_cond)} THEN {self.compile_expr(then_val)}"
                    )
                if else_ is not None:
                    parts.append(f"ELSE {self.compile_expr(else_)}")
                parts.append("END")
                return " ".join(parts)
            case Cast(expr=inner, type_name=type_name):
                return self._compile_cast(inner, type_name)
            case SubqueryExpr(query=query):
                return f"(\n{self.compile_select(query)}\n)"
            case Exists(subquery=subq, negated=False):
                return f"EXISTS (\n{self.compile_select(subq)}\n)"
            case Exists(subquery=subq, negated=True):
                return f"NOT EXISTS (\n{self.compile_select(subq)}\n)"
            case RawSQL(sql=sql):
                return sql
            case Between(expr=inner, low=low, high=high, negated=negated):
                op = "NOT BETWEEN" if negated else "BETWEEN"
                inner_sql = self.compile_expr(inner, _parent_prec=self._PREC_CMP)
                low_sql = self.compile_expr(low, _parent_prec=self._PREC_CMP)
                high_sql = self.compile_expr(high, _parent_prec=self._PREC_CMP)
                sql = f"{inner_sql} {op} {low_sql} AND {high_sql}"
                return self._wrap_if_lower(sql, self._PREC_CMP, _parent_prec)
            case InTimeZone(expr=inner, zone=zone, from_zone=from_zone):
                return self._render_in_timezone(inner, zone, from_zone)
            case RegexMatch(column=column, pattern=pattern, negated=negated):
                return self.compile_regex_match(column, pattern, negated=negated)
            case RelativeDateRange(
                column=column,
                unit=unit,
                count=count,
                direction=direction,
                include_current=include_current,
            ):
                return self.compile_relative_date_range(
                    column=column,
                    unit=unit,
                    count=count,
                    direction=direction,
                    include_current=include_current,
                )
            case WindowFunction(
                func_name=fname,
                args=args,
                partition_by=partition_by,
                order_by=order_by,
                frame=frame,
                distinct=distinct,
            ):
                args_sql = ", ".join(self.compile_expr(a) for a in args)
                func_sql = f"{fname}(DISTINCT {args_sql})" if distinct else f"{fname}({args_sql})"
                over_parts: list[str] = []
                if partition_by:
                    pb = ", ".join(self.compile_expr(p) for p in partition_by)
                    over_parts.append(f"PARTITION BY {pb}")
                if order_by:
                    ob = ", ".join(self.compile_order_by(o) for o in order_by)
                    over_parts.append(f"ORDER BY {ob}")
                if frame is not None:
                    over_parts.append(f"{frame.mode} BETWEEN {frame.start} AND {frame.end}")
                over_clause = " ".join(over_parts)
                return f"{func_sql} OVER ({over_clause})"
            case _:
                raise ValueError(f"Unknown AST node type: {type(expr).__name__}")

    def compile_regex_match(self, column: Expr, pattern: str, *, negated: bool) -> str:
        """Compile a regex predicate. Default uses ``REGEXP_LIKE`` — overridden
        per dialect that needs a different syntax (Postgres ``~``, MySQL
        ``REGEXP``, ClickHouse ``match`` etc.).

        The pattern is rendered as a SQL string literal; callers pass it
        as ``RegexMatch.pattern`` (already a Python ``str``).
        """
        col_sql = self.compile_expr(column)
        pat_sql = self.compile_expr(Literal.string(pattern))
        op_sql = f"REGEXP_LIKE({col_sql}, {pat_sql})"
        return f"NOT {op_sql}" if negated else op_sql

    def compile_relative_date_range(
        self,
        column: Expr,
        unit: str,
        count: int,
        direction: str,
        include_current: bool,
    ) -> str:
        """Compile a relative date range predicate to SQL."""
        col_sql = self.compile_expr(column)
        base = self.current_date_sql()

        if direction == "future":
            start = base if include_current else self.date_add_sql(base, "day", 1)
            end = self.date_add_sql(start, unit, count)
        else:
            end = self.date_add_sql(base, "day", 1) if include_current else base
            start = self.date_add_sql(end, unit, -count)

        return f"({col_sql} >= {start} AND {col_sql} < {end})"

max_decimal_precision property

The widest decimal precision this engine accepts.

Public because the CFL union alignment has to reason about it from outside the dialect layer (#339).

week_start = WeekStart.MONDAY class-attribute instance-attribute

Which day date_trunc('week', …) rounds down to.

Set per compile from settings.weekStart by the pipeline, which builds a fresh dialect for each query, so one model's calendar cannot leak into another's.

render_obml_type(obml_type)

Render an OBMLType to a dialect-specific SQL type string.

Handles precision clamping for decimal types.

Source code in src/orionbelt/dialect/base.py
def render_obml_type(self, obml_type: OBMLType) -> str:
    """Render an OBMLType to a dialect-specific SQL type string.

    Handles precision clamping for decimal types.
    """
    if isinstance(obml_type, DecimalType):
        p = min(obml_type.precision, self._MAX_DECIMAL_PRECISION)
        s = min(obml_type.scale, p)
        return f"DECIMAL({p}, {s})"
    return self._OBML_SIMPLE_TYPE_MAP.get(obml_type.name, obml_type.name.upper())

cast_to_obml_type(expr, obml_type)

Build an Expr that coerces expr to the given OBML type.

Default form is a plain CAST(expr AS <type>). Dialects whose CAST doesn't accept a parameterized decimal (notably BigQuery — "Parameterized types are not allowed in CAST expressions") can override to wrap the cast with a ROUND to honour the user-specified scale.

Source code in src/orionbelt/dialect/base.py
def cast_to_obml_type(self, expr: Expr, obml_type: OBMLType) -> Expr:
    """Build an Expr that coerces ``expr`` to the given OBML type.

    Default form is a plain ``CAST(expr AS <type>)``. Dialects whose
    ``CAST`` doesn't accept a parameterized decimal (notably BigQuery
    — "Parameterized types are not allowed in CAST expressions") can
    override to wrap the cast with a ROUND to honour the user-specified
    scale.
    """
    return Cast(expr=expr, type_name=self.render_obml_type(obml_type))

integer_avg_is_exact()

Whether an integer AVG ends up exact here, natively or rewritten.

Deliberately independent of any expression. The type a measure is cast to has to be decided the same way wherever the cast happens, and by the time a wrapper composes - a window over a period-over-period, say - the expression it holds is a CTE alias rather than the aggregate. Asking "is this a bare AVG I can rewrite?" answers no there, and the cast fell back to the narrow default even though the value inside the CTE had already been computed exactly.

Detected by introspection rather than a second flag, so a dialect that overrides :meth:exact_integer_avg cannot forget to declare it.

Source code in src/orionbelt/dialect/base.py
def integer_avg_is_exact(self) -> bool:
    """Whether an integer ``AVG`` ends up exact here, natively or rewritten.

    Deliberately independent of any expression. The **type** a measure is
    cast to has to be decided the same way wherever the cast happens, and
    by the time a wrapper composes - a window over a period-over-period,
    say - the expression it holds is a CTE alias rather than the aggregate.
    Asking "is this a bare AVG I can rewrite?" answers no there, and the
    cast fell back to the narrow default even though the value inside the
    CTE had already been computed exactly.

    Detected by introspection rather than a second flag, so a dialect that
    overrides :meth:`exact_integer_avg` cannot forget to declare it.
    """
    return self.avg_over_integers_is_exact or (
        type(self).exact_integer_avg is not Dialect.exact_integer_avg
    )

exact_integer_avg(arg, obml_type)

An exact AVG(arg) over an integer column, or None for none.

AVG is a floating-point aggregate on several engines whatever the input type, so it drifts once the average passes a double mantissa, around fifteen significant digits. That is not a defect any of them is likely to change - duckdb/duckdb#6829 was closed as not planned - and no output cast repairs it, because the loss is already inside the aggregate.

Dialects that offer exact arithmetic override this to say how. The four that do are all different: BigQuery only needs its input cast to NUMERIC, Dremio divides decimals exactly so SUM/COUNT works, ClickHouse needs its own divideDecimal, and DuckDB, which has no exact division at all, assembles the average from integer arithmetic (#316). Returning None - the default - keeps the plain AVG, which is right for the engines that are already exact: Postgres, MySQL and Snowflake.

obml_type is the type the result will be cast to, already widened to hold a 64-bit integer part, and carries the scale an engine needs when it wants one explicitly.

Source code in src/orionbelt/dialect/base.py
def exact_integer_avg(self, arg: Expr, obml_type: OBMLType) -> Expr | None:
    """An exact ``AVG(arg)`` over an integer column, or ``None`` for none.

    ``AVG`` is a floating-point aggregate on several engines whatever the
    input type, so it drifts once the average passes a ``double`` mantissa,
    around fifteen significant digits. That is not a defect any of them is
    likely to change - duckdb/duckdb#6829 was closed as not planned - and
    no output cast repairs it, because the loss is already inside the
    aggregate.

    Dialects that offer exact arithmetic override this to say how. The
    four that do are all different: BigQuery only needs its **input** cast
    to NUMERIC, Dremio divides decimals exactly so ``SUM``/``COUNT``
    works, ClickHouse needs its own ``divideDecimal``, and DuckDB, which
    has no exact division at all, assembles the average from integer
    arithmetic (#316). Returning ``None`` - the default - keeps the plain
    ``AVG``, which is right for the engines that are already exact:
    Postgres, MySQL and Snowflake.

    ``obml_type`` is the type the result will be cast to, already widened
    to hold a 64-bit integer part, and carries the scale an engine needs
    when it wants one explicitly.
    """
    return None

integer_sum_is_widened()

Whether an integer SUM is rewritten to a wider accumulator here.

Deliberately independent of any expression, for the same reason :meth:integer_avg_is_exact is. The type such a measure is cast to has to be decided the same way wherever the cast happens, and by the time a wrapper composes - a cumulative over a period-over-period, say - what it holds is a CTE alias rather than the aggregate. Asking "is this a bare SUM I can rewrite?" answers no there, and the cast fell back to the inferred bigint, narrowing an exact 128-bit total straight back into the 64 bits the rewrite existed to escape.

Detected by introspection rather than a second flag, so a dialect that overrides :meth:exact_integer_sum cannot forget to declare it.

Source code in src/orionbelt/dialect/base.py
def integer_sum_is_widened(self) -> bool:
    """Whether an integer ``SUM`` is rewritten to a wider accumulator here.

    Deliberately independent of any expression, for the same reason
    :meth:`integer_avg_is_exact` is. The **type** such a measure is cast to
    has to be decided the same way wherever the cast happens, and by the
    time a wrapper composes - a cumulative over a period-over-period, say -
    what it holds is a CTE alias rather than the aggregate. Asking "is this
    a bare SUM I can rewrite?" answers no there, and the cast fell back to
    the inferred ``bigint``, narrowing an exact 128-bit total straight back
    into the 64 bits the rewrite existed to escape.

    Detected by introspection rather than a second flag, so a dialect that
    overrides :meth:`exact_integer_sum` cannot forget to declare it.
    """
    return type(self).exact_integer_sum is not Dialect.exact_integer_sum

exact_integer_sum(arg)

An exact SUM(arg) over an integer column, and its type.

None - the default - keeps the plain SUM, which is right for every engine that either computes the total exactly or refuses it. Most do one or the other: measured on two rows of 9000000000000000000, DuckDB, Postgres, BigQuery and Databricks raise, and Snowflake returns 18000000000000000000 intact.

A dialect overrides this where its accumulator wraps instead. That is the one outcome no output type can repair, for the same reason :meth:exact_integer_avg exists: the loss is inside the aggregate, and a cast only widens a number that has already gone wrong. Measured on ClickHouse, SUM over Int64 returns -446744073709551616 for that pair, and casting the result to Decimal(38, 0) returns it unchanged, while casting the argument returns the true total.

Returns the expression only. An integer SUM infers bigint (#315), which would cast the exact 128-bit total straight back into the 64 bits the rewrite escaped, so the result type has to move too - but it moves through :meth:integer_sum_is_widened, which answers without looking at an expression and so still answers inside a wrapper.

Takes no obml_type, unlike its AVG counterpart: an average needs a scale to divide to, and a sum of integers has no fractional part to declare one for.

Source code in src/orionbelt/dialect/base.py
def exact_integer_sum(self, arg: Expr) -> Expr | None:
    """An exact ``SUM(arg)`` over an integer column, and its type.

    ``None`` - the default - keeps the plain ``SUM``, which is right for
    every engine that either computes the total exactly or refuses it. Most
    do one or the other: measured on two rows of 9000000000000000000,
    DuckDB, Postgres, BigQuery and Databricks raise, and Snowflake returns
    18000000000000000000 intact.

    A dialect overrides this where its accumulator **wraps** instead. That
    is the one outcome no output type can repair, for the same reason
    :meth:`exact_integer_avg` exists: the loss is inside the aggregate, and
    a cast only widens a number that has already gone wrong. Measured on
    ClickHouse, ``SUM`` over Int64 returns -446744073709551616 for that
    pair, and casting the result to ``Decimal(38, 0)`` returns it
    unchanged, while casting the **argument** returns the true total.

    Returns the expression only. An integer ``SUM`` infers ``bigint``
    (#315), which would cast the exact 128-bit total straight back into the
    64 bits the rewrite escaped, so the result type has to move too - but
    it moves through :meth:`integer_sum_is_widened`, which answers without
    looking at an expression and so still answers inside a wrapper.

    Takes no ``obml_type``, unlike its ``AVG`` counterpart: an average
    needs a scale to divide to, and a sum of integers has no fractional
    part to declare one for.
    """
    return None

quote_string_literal(value)

value as a quoted string literal for this engine.

The single place a string becomes SQL text, so a filter value, a LISTAGG separator and a time-zone name cannot disagree about escaping. They did: every one of them doubled the quote and left the backslash alone, which is right on two engines out of seven.

Measured, with the old rendering: a\b came back as a - a backspace - on MySQL, ClickHouse, BigQuery, Snowflake and Databricks, and C:\temp\x raised on three of them. A Windows path, a regex or an escaped delimiter in a filter was silently wrong on five engines.

Source code in src/orionbelt/dialect/base.py
def quote_string_literal(self, value: str) -> str:
    """*value* as a quoted string literal for this engine.

    The single place a string becomes SQL text, so a filter value, a
    LISTAGG separator and a time-zone name cannot disagree about escaping.
    They did: every one of them doubled the quote and left the backslash
    alone, which is right on two engines out of seven.

    Measured, with the old rendering: ``a\\b`` came back as ``a\x08`` - a
    backspace - on MySQL, ClickHouse, BigQuery, Snowflake and Databricks,
    and ``C:\\temp\\x`` raised on three of them. A Windows path, a regex or
    an escaped delimiter in a filter was silently wrong on five engines.
    """
    if self.backslash_escapes_strings:
        escaped = (
            value.replace("\\", "\\\\")
            .replace("'", "\\'")
            # A quoted string cannot span lines on BigQuery: a real newline
            # or carriage return closes it, and the query fails with
            # "Unclosed string literal". Measured, it is the only engine of
            # the seven that minds - the other six take a raw newline, tab,
            # form feed or control byte and hand it back unchanged. Written
            # as escapes for all five backslash dialects rather than only
            # BigQuery, because in this convention that is simply how a
            # control character is spelled, and all five read it back.
            .replace("\n", "\\n")
            .replace("\r", "\\r")
        )
    else:
        # Standard SQL has no escape sequences here, so a control character
        # rides through literally. Measured working on Postgres, DuckDB and
        # Dremio, including a newline: a quoted string may span lines.
        escaped = value.replace("'", "''")
    return f"'{escaped}'"

format_table_ref(database, schema, code)

Format a fully-qualified table reference.

Default: three-part database.schema.code (Snowflake/Databricks/Dremio). Postgres and ClickHouse override to two-part naming. All components are quoted to prevent SQL injection.

An omitted component is dropped rather than emitted as an empty identifier. database is optional in OBML, and quoting it anyway produced ""."schema"."table", which Snowflake rejects with Database '""' does not exist. Leaving it out lets the reference resolve against the connection's current database, which is how a single model serves several deployments of the same schema.

Source code in src/orionbelt/dialect/base.py
def format_table_ref(self, database: str, schema: str, code: str) -> str:
    """Format a fully-qualified table reference.

    Default: three-part ``database.schema.code`` (Snowflake/Databricks/Dremio).
    Postgres and ClickHouse override to two-part naming.
    All components are quoted to prevent SQL injection.

    An omitted component is dropped rather than emitted as an empty
    identifier. ``database`` is optional in OBML, and quoting it anyway
    produced ``""."schema"."table"``, which Snowflake rejects with
    ``Database '""' does not exist``. Leaving it out lets the reference
    resolve against the connection's current database, which is how a
    single model serves several deployments of the same schema.
    """
    if database and not schema:
        raise AmbiguousTableReferenceError(self.name, database, code)
    parts = [database, schema, code]
    return ".".join(self.quote_identifier(p) for p in parts if p)

quote_identifier(name) abstractmethod

Quote an identifier per dialect rules.

Source code in src/orionbelt/dialect/base.py
@abstractmethod
def quote_identifier(self, name: str) -> str:
    """Quote an identifier per dialect rules."""

render_time_grain(column, grain)

Wrap a column expression for the given time grain.

A week is routed through the model's calendar rather than the dialect's own weekly truncation, so a timeGrain: week dimension, a weekly period-over-period and an explicit date_trunc('week', …) all bucket the same rows the same way. Left to the dialects, they did not: BigQuery hard-coded ISOWEEK, ClickHouse toMonday, MySQL a %Y-%u label, and Snowflake a DATE_TRUNC('week') that follows its WEEK_START session parameter.

Source code in src/orionbelt/dialect/base.py
def render_time_grain(self, column: Expr, grain: TimeGrain) -> Expr:
    """Wrap a column expression for the given time grain.

    A week is routed through the model's calendar rather than the dialect's
    own weekly truncation, so a ``timeGrain: week`` dimension, a weekly
    period-over-period and an explicit ``date_trunc('week', …)`` all bucket
    the same rows the same way. Left to the dialects, they did not: BigQuery
    hard-coded ISOWEEK, ClickHouse ``toMonday``, MySQL a ``%Y-%u`` label,
    and Snowflake a ``DATE_TRUNC('week')`` that follows its WEEK_START
    session parameter.
    """
    if grain is TimeGrain.WEEK:
        # RawSQL: re-wraps SQL this dialect just rendered, so the weekly
        # floor has one implementation rather than one per entry point.
        return RawSQL(sql=self._render_week_floor(column))
    return self._render_time_grain(column, grain)

render_unnest(node)

A FROM-clause fragment that unnests a parent's array column.

The default is the comma-lateral every engine but four accepts::

, UNNEST(`c`.`labels`) AS `l`

with the outer form spelled as a LEFT JOIN ... ON TRUE, which keeps a parent row whose array is empty. Measured on BigQuery, DuckDB and Postgres; ClickHouse, Databricks, MySQL and Snowflake override.

Dremio has no FROM-clause form at all - FLATTEN is a projection function, so the unnest goes in the SELECT list of a derived table - and refuses here rather than emitting something that will not parse.

Source code in src/orionbelt/dialect/base.py
def render_unnest(self, node: Unnest) -> str:
    """A FROM-clause fragment that unnests a parent's array column.

    The default is the comma-lateral every engine but four accepts::

        , UNNEST(`c`.`labels`) AS `l`

    with the outer form spelled as a ``LEFT JOIN ... ON TRUE``, which keeps
    a parent row whose array is empty. Measured on BigQuery, DuckDB and
    Postgres; ClickHouse, Databricks, MySQL and Snowflake override.

    Dremio has no FROM-clause form at all - ``FLATTEN`` is a projection
    function, so the unnest goes in the SELECT list of a derived table -
    and refuses here rather than emitting something that will not parse.
    """
    source = f"UNNEST({self.unnest_path(node)})"
    alias = self.quote_identifier(node.alias)
    if node.outer:
        return f"LEFT JOIN {source} AS {alias} ON TRUE"
    return f", {source} AS {alias}"

nested_field(alias, field, sql_type=None)

How a column of an unnested element is addressed.

Ordinary column access almost everywhere: measured, L."Key" reads the field on BigQuery, DuckDB, Postgres, MySQL, ClickHouse and Databricks, because the alias is the element. Snowflake overrides, because there the alias is a row whose value holds the element as a VARIANT.

sql_type is what the field should be read as. Only the VARIANT dialect needs it; the rest carry their own types.

Source code in src/orionbelt/dialect/base.py
def nested_field(self, alias: str, field: str, sql_type: str | None = None) -> Expr:
    """How a column of an unnested element is addressed.

    Ordinary column access almost everywhere: measured, ``L."Key"`` reads
    the field on BigQuery, DuckDB, Postgres, MySQL, ClickHouse and
    Databricks, because the alias *is* the element. Snowflake overrides,
    because there the alias is a row whose ``value`` holds the element as a
    VARIANT.

    ``sql_type`` is what the field should be read as. Only the VARIANT
    dialect needs it; the rest carry their own types.
    """
    return ColumnRef(name=field, table=alias)

render_nested_field(node)

How a nested object's column is addressed in a plan this dialect built.

Two different things, depending on which source the planner chose. Where the FROM clause carries an unnest, this is a field of the element - :meth:nested_field. Where it cannot, the planner read the object's code fallback instead and put that table in FROM under the same alias, so the column is an ordinary one and reading it as an element field would name something that does not exist.

Source code in src/orionbelt/dialect/base.py
def render_nested_field(self, node: NestedField) -> Expr:
    """How a nested object's column is addressed in a plan this dialect built.

    Two different things, depending on which source the planner chose. Where
    the FROM clause carries an unnest, this is a field of the element -
    :meth:`nested_field`. Where it cannot, the planner read the object's
    ``code`` fallback instead and put that table in FROM under the same
    alias, so the column is an ordinary one and reading it as an element
    field would name something that does not exist.
    """
    if not self.capabilities.supports_from_unnest:
        return ColumnRef(name=node.field, table=node.alias)
    return self.nested_field(
        node.alias, node.field, self.nested_column_type(node.abstract_type)
    )

nested_column_type(abstract_type)

The SQL type a field of an unnested element is read as.

Two dialects need one and the other five ignore it: MySQL's JSON_TABLE declares the shape it extracts rather than inferring it, and Snowflake's VARIANT path has to be cast or a string field comes back with its JSON quotes still on. Both are served by the abstract type map every other cast already goes through, so a nested column is typed the same way an ordinary one is.

Source code in src/orionbelt/dialect/base.py
def nested_column_type(self, abstract_type: str | None) -> str:
    """The SQL type a field of an unnested element is read as.

    Two dialects need one and the other five ignore it: MySQL's
    ``JSON_TABLE`` declares the shape it extracts rather than inferring it,
    and Snowflake's VARIANT path has to be cast or a string field comes back
    with its JSON quotes still on. Both are served by the abstract type map
    every other cast already goes through, so a nested column is typed the
    same way an ordinary one is.
    """
    return self._resolve_type_name(abstract_type or "string")

unnest_path(node)

The parent's array column, quoted segment by segment.

A dotted column addresses an array inside a struct, and each segment is an identifier in its own right: x_Project.Ancestors becomes two quoted identifiers joined by a dot, rather than one quoted string containing a dot, which would name a column that does not exist.

The dotted chain is the majority form, measured on DuckDB, BigQuery, Databricks and ClickHouse. Three engines cannot read it and override: Postgres needs the composite parenthesised, Snowflake needs a VARIANT : path, and MySQL has to move the member into the JSON path entirely - see :meth:MySQLDialect.render_unnest.

Source code in src/orionbelt/dialect/base.py
def unnest_path(self, node: Unnest) -> str:
    """The parent's array column, quoted segment by segment.

    A dotted ``column`` addresses an array inside a struct, and each segment
    is an identifier in its own right: ``x_Project.Ancestors`` becomes two
    quoted identifiers joined by a dot, rather than one quoted string
    containing a dot, which would name a column that does not exist.

    The dotted chain is the majority form, measured on DuckDB, BigQuery,
    Databricks and ClickHouse. Three engines cannot read it and override:
    Postgres needs the composite parenthesised, Snowflake needs a VARIANT
    ``:`` path, and MySQL has to move the member into the JSON path
    entirely - see :meth:`MySQLDialect.render_unnest`.
    """
    parts = [node.parent_alias, *node.column.split(".")]
    return ".".join(self.quote_identifier(p) for p in parts)

render_cast(expr, target_type) abstractmethod

Render a CAST expression.

Source code in src/orionbelt/dialect/base.py
@abstractmethod
def render_cast(self, expr: Expr, target_type: str) -> Expr:
    """Render a CAST expression."""

current_date_sql() abstractmethod

Return SQL for the current date.

Source code in src/orionbelt/dialect/base.py
@abstractmethod
def current_date_sql(self) -> str:
    """Return SQL for the current date."""

date_add_sql(date_sql, unit, count) abstractmethod

Return SQL that adds count units to date_sql.

Source code in src/orionbelt/dialect/base.py
@abstractmethod
def date_add_sql(self, date_sql: str, unit: str, count: int) -> str:
    """Return SQL that adds count units to date_sql."""

render_date_trunc_sql(column_sql, grain)

Truncate a date/timestamp to the given grain, as a SQL string.

String-level helper (not AST) for use in raw SQL CTEs like date_range and the period-over-period spine. A week goes through the model's calendar for the same reason it does in render_time_grain: a weekly PoP and a weekly dimension have to agree on where a week starts.

Source code in src/orionbelt/dialect/base.py
def render_date_trunc_sql(self, column_sql: str, grain: str) -> str:
    """Truncate a date/timestamp to the given grain, as a SQL string.

    String-level helper (not AST) for use in raw SQL CTEs like date_range
    and the period-over-period spine. A week goes through the model's
    calendar for the same reason it does in ``render_time_grain``: a weekly
    PoP and a weekly dimension have to agree on where a week starts.
    """
    if grain == TimeGrain.WEEK.value:
        # RawSQL: the caller already has SQL text, and the floor is defined
        # over expressions.
        return self._render_week_floor(RawSQL(sql=column_sql))
    return self._render_date_trunc_sql(column_sql, grain)

render_date_spine_cte_sql(min_date, max_date, grain, offset, offset_grain) abstractmethod

Return the SQL body for a date spine CTE.

Must produce two columns: spine_date and spine_date_prev. spine_date_prev is NULL when the offset date falls before min_date.

Parameters

min_date : str SQL expression referencing the minimum date (e.g. date_range.min_date). max_date : str SQL expression referencing the maximum date. grain : str Time grain string: day, week, month, quarter, year. offset : int Signed period offset (e.g. -1 for previous period). offset_grain : str Grain of the offset (e.g. year for YoY).

Source code in src/orionbelt/dialect/base.py
@abstractmethod
def render_date_spine_cte_sql(
    self,
    min_date: str,
    max_date: str,
    grain: str,
    offset: int,
    offset_grain: str,
) -> str:
    """Return the SQL body for a date spine CTE.

    Must produce two columns: ``spine_date`` and ``spine_date_prev``.
    ``spine_date_prev`` is NULL when the offset date falls before min_date.

    Parameters
    ----------
    min_date : str
        SQL expression referencing the minimum date (e.g. ``date_range.min_date``).
    max_date : str
        SQL expression referencing the maximum date.
    grain : str
        Time grain string: ``day``, ``week``, ``month``, ``quarter``, ``year``.
    offset : int
        Signed period offset (e.g. ``-1`` for previous period).
    offset_grain : str
        Grain of the offset (e.g. ``year`` for YoY).
    """

render_string_contains(column, pattern)

Default: column LIKE '%' || pattern || '%'.

Source code in src/orionbelt/dialect/base.py
def render_string_contains(self, column: Expr, pattern: Expr) -> Expr:
    """Default: column LIKE '%' || pattern || '%'."""
    return BinaryOp(
        left=column,
        op="LIKE",
        right=BinaryOp(
            left=BinaryOp(left=Literal.string("%"), op="||", right=pattern),
            op="||",
            right=Literal.string("%"),
        ),
    )

guard_zero_divisor(right, right_sql)

Wrap a divisor so that dividing by zero yields NULL, not chaos.

Left alone, the same ratio means five different things across the supported engines: measured, SUM(a) / SUM(b) with a zero divisor returns inf on DuckDB, NULL on MySQL, and raises on PostgreSQL, BigQuery and ClickHouse. A semantic layer cannot promise that a measure means one thing everywhere and then hand back a number, a null and an error depending on the warehouse behind it.

NULL is the answer chosen (#319). It reads naturally as "no value" in a BI tool, it is what MySQL already does, and it removes DuckDB's inf - the only one of the three outcomes that can silently corrupt a downstream figure rather than stopping.

Applied where divisions are compiled rather than where they are built, so it covers a modeller's expression, the divisions OBSL generates itself, and all eight dialects without being remembered at each site. NULLIF renders identically everywhere, verified.

A literal divisor that is plainly not zero is left unwrapped - there is nothing to guard, and the noise would show up in every snapshot.

Source code in src/orionbelt/dialect/base.py
def guard_zero_divisor(self, right: Expr | None, right_sql: str) -> str:
    """Wrap a divisor so that dividing by zero yields NULL, not chaos.

    Left alone, the same ratio means five different things across the
    supported engines: measured, ``SUM(a) / SUM(b)`` with a zero divisor
    returns ``inf`` on DuckDB, NULL on MySQL, and raises on PostgreSQL,
    BigQuery and ClickHouse. A semantic layer cannot promise that a measure
    means one thing everywhere and then hand back a number, a null and an
    error depending on the warehouse behind it.

    NULL is the answer chosen (#319). It reads naturally as "no value" in a
    BI tool, it is what MySQL already does, and it removes DuckDB's
    ``inf`` - the only one of the three outcomes that can silently corrupt
    a downstream figure rather than stopping.

    Applied where divisions are *compiled* rather than where they are
    built, so it covers a modeller's expression, the divisions OBSL
    generates itself, and all eight dialects without being remembered at
    each site. ``NULLIF`` renders identically everywhere, verified.

    A literal divisor that is plainly not zero is left unwrapped - there is
    nothing to guard, and the noise would show up in every snapshot.
    """
    if right is not None and isinstance(right, Literal):
        value = right.value
        if isinstance(value, (int, float)) and not isinstance(value, bool) and value != 0:
            return right_sql
    return f"NULLIF({right_sql}, 0)"

render_decimal_division_sql(left_sql, right_sql)

Render left / right for decimal-typed operands, given raw SQL.

Used by code paths that build division as string SQL (e.g. PoP comparison CTEs) rather than as BinaryOp AST nodes.

Do not override this - override :meth:_render_decimal_division instead. This method exists to apply the zero-divisor guard (#319) in one place that a dialect cannot forget. It was overridden directly by ClickHouse and MySQL for operand widening, and when the guard moved here from pop_wrap those two overrides silently dropped it: a period-over-period ratio against a zero previous value went from NULL back to ILLEGAL_DIVISION on ClickHouse. Splitting the two concerns makes the guard structural rather than remembered.

Source code in src/orionbelt/dialect/base.py
def render_decimal_division_sql(self, left_sql: str, right_sql: str) -> str:
    """Render ``left / right`` for decimal-typed operands, given raw SQL.

    Used by code paths that build division as string SQL (e.g. PoP
    comparison CTEs) rather than as ``BinaryOp`` AST nodes.

    **Do not override this** - override :meth:`_render_decimal_division`
    instead. This method exists to apply the zero-divisor guard (#319) in
    one place that a dialect cannot forget. It was overridden directly by
    ClickHouse and MySQL for operand widening, and when the guard moved
    here from ``pop_wrap`` those two overrides silently dropped it: a
    period-over-period ratio against a zero previous value went from NULL
    back to ILLEGAL_DIVISION on ClickHouse. Splitting the two concerns
    makes the guard structural rather than remembered.
    """
    return self._render_decimal_division(left_sql, self.guard_zero_divisor(None, right_sql))

render_pop_previous_value_sql(prev_sql, current_sql)

Render a previousValue PoP projection (the prior period's measure).

Default is the prior value verbatim. Dremio overrides this because its executor miscompiles a self-joined CTE column projected on its own (see DremioDialect); current_sql (pop_base's measure) is supplied so a dialect can reference it in a value-preserving way if needed.

Source code in src/orionbelt/dialect/base.py
def render_pop_previous_value_sql(self, prev_sql: str, current_sql: str) -> str:
    """Render a ``previousValue`` PoP projection (the prior period's measure).

    Default is the prior value verbatim. Dremio overrides this because its
    executor miscompiles a self-joined CTE column projected on its own (see
    ``DremioDialect``); ``current_sql`` (``pop_base``'s measure) is supplied
    so a dialect can reference it in a value-preserving way if needed.
    """
    return prev_sql

compile(ast)

Render a complete SQL AST to a dialect-specific string.

Source code in src/orionbelt/dialect/base.py
def compile(self, ast: Select) -> str:
    """Render a complete SQL AST to a dialect-specific string."""
    return self.compile_select(ast)

compile_select(node)

Compile a SELECT statement.

Source code in src/orionbelt/dialect/base.py
def compile_select(self, node: Select) -> str:
    """Compile a SELECT statement."""
    parts: list[str] = []

    # CTEs
    if node.ctes:
        cte_parts = []
        for cte in node.ctes:
            if isinstance(cte.query, RawSQL):
                cte_sql = cte.query.sql
            elif isinstance(cte.query, UnionAll):
                cte_sql = self.compile_union_all(cte.query)
            elif isinstance(cte.query, Except):
                cte_sql = self.compile_except(cte.query)
            else:
                cte_sql = self.compile_select(cte.query)
            cte_parts.append(f"{self.quote_identifier(cte.name)} AS (\n{cte_sql}\n)")
        parts.append("WITH " + ",\n".join(cte_parts))

    # SELECT
    keyword = "SELECT DISTINCT" if node.distinct else "SELECT"
    if node.columns:
        cols = ", ".join(self.compile_expr(c) for c in node.columns)
        parts.append(f"{keyword} {cols}")
    else:
        parts.append(f"{keyword} *")

    # FROM
    if node.from_:
        parts.append(f"FROM {self.compile_from(node.from_)}")

    # JOINs, and the unnests that ride between them
    for join in node.joins:
        if isinstance(join, Unnest):
            parts.append(self.render_unnest(join))
        else:
            parts.append(self.compile_join(join))

    # WHERE
    if node.where:
        parts.append(f"WHERE {self.compile_expr(node.where)}")

    # GROUP BY
    if node.group_by:
        parts.append(self.compile_group_by(node.group_by, node.grouping))

    # HAVING
    if node.having:
        parts.append(f"HAVING {self.compile_expr(node.having)}")

    # ORDER BY
    if node.order_by:
        orders = ", ".join(self.compile_order_by(o) for o in node.order_by)
        parts.append(f"ORDER BY {orders}")

    # LIMIT
    if node.limit is not None:
        parts.append(f"LIMIT {node.limit}")

    # OFFSET
    if node.offset is not None:
        parts.append(f"OFFSET {node.offset}")

    return "\n".join(parts)

compile_group_by(group_by, grouping)

Render the GROUP BY clause.

Default ANSI form (Postgres, Snowflake, DuckDB, BigQuery, Databricks, Dremio, MySQL): GROUP BY ROLLUP(a, b) / GROUP BY CUBE(a, b). ClickHouse overrides to the trailing-modifier form (GROUP BY a, b WITH ROLLUP).

When capabilities.supports_group_by_all is set and no grouping modifier is requested, emits GROUP BY ALL — the engine auto-derives the grouping list from the SELECT. Equivalent SQL with a much shorter and more idiomatic form on modern OLAP engines, especially for queries with computed dimensions.

Source code in src/orionbelt/dialect/base.py
def compile_group_by(self, group_by: list[Expr], grouping: str | None) -> str:
    """Render the GROUP BY clause.

    Default ANSI form (Postgres, Snowflake, DuckDB, BigQuery, Databricks,
    Dremio, MySQL): ``GROUP BY ROLLUP(a, b)`` / ``GROUP BY CUBE(a, b)``.
    ClickHouse overrides to the trailing-modifier form
    (``GROUP BY a, b WITH ROLLUP``).

    When ``capabilities.supports_group_by_all`` is set and no grouping
    modifier is requested, emits ``GROUP BY ALL`` — the engine
    auto-derives the grouping list from the SELECT. Equivalent SQL
    with a much shorter and more idiomatic form on modern OLAP
    engines, especially for queries with computed dimensions.
    """
    if grouping == "rollup":
        groups = ", ".join(self.compile_expr(g) for g in group_by)
        return f"GROUP BY ROLLUP({groups})"
    if grouping == "cube":
        groups = ", ".join(self.compile_expr(g) for g in group_by)
        return f"GROUP BY CUBE({groups})"
    if self.capabilities.supports_group_by_all:
        return "GROUP BY ALL"
    groups = ", ".join(self.compile_expr(g) for g in group_by)
    return f"GROUP BY {groups}"

compile_union_all(node)

Compile a UNION ALL of multiple SELECT statements.

Source code in src/orionbelt/dialect/base.py
def compile_union_all(self, node: UnionAll) -> str:
    """Compile a UNION ALL of multiple SELECT statements."""
    return "\nUNION ALL\n".join(self.compile_select(q) for q in node.queries)

compile_except(node)

Compile an EXCEPT of two SELECT statements.

Source code in src/orionbelt/dialect/base.py
def compile_except(self, node: Except) -> str:
    """Compile an EXCEPT of two SELECT statements."""
    return self.compile_select(node.left) + "\nEXCEPT\n" + self.compile_select(node.right)

compile_expr(expr, _parent_prec=0)

Compile an expression node to SQL string.

_parent_prec is the precedence of the surrounding operator (or _CLAUSE_ROOT_PREC = 0 when called at the root of a SELECT projection, ON / WHERE / HAVING clause, GROUP BY / ORDER BY item, or function argument). Each operator branch wraps its own SQL in (...) only when its precedence is strictly less than the parent's required level; atoms (literals, column refs, function calls, CAST, CASE) are at _PREC_ATOM and never wrap.

Pre-v2.7.4 every BinaryOp / IsNull / InList / Between / UnaryOp wrapped itself unconditionally, producing deeply-nested unreadable SQL — issue #79.

Source code in src/orionbelt/dialect/base.py
def compile_expr(self, expr: Expr, _parent_prec: int = 0) -> str:
    """Compile an expression node to SQL string.

    ``_parent_prec`` is the precedence of the surrounding operator
    (or ``_CLAUSE_ROOT_PREC = 0`` when called at the root of a SELECT
    projection, ON / WHERE / HAVING clause, GROUP BY / ORDER BY item,
    or function argument). Each operator branch wraps its own SQL in
    ``(...)`` only when its precedence is strictly less than the
    parent's required level; atoms (literals, column refs, function
    calls, CAST, CASE) are at ``_PREC_ATOM`` and never wrap.

    Pre-v2.7.4 every ``BinaryOp`` / ``IsNull`` / ``InList`` /
    ``Between`` / ``UnaryOp`` wrapped itself unconditionally,
    producing deeply-nested unreadable SQL — issue #79.
    """
    match expr:
        case Literal(value=None):
            return "NULL"
        case Literal(value=True):
            return "TRUE"
        case Literal(value=False):
            return "FALSE"
        case Literal(value=v) if isinstance(v, str):
            return self.quote_string_literal(v)
        case Literal(value=v):
            return str(v)
        case Star(table=None):
            return "*"
        case Star(table=t) if t is not None:
            return f"{self.quote_identifier(t)}.*"
        case ColumnRef(name=name, table=None):
            return self.quote_identifier(name)
        case ColumnRef(name=name, table=table) if table is not None:
            return f"{self.quote_identifier(table)}.{self.quote_identifier(name)}"
        case NestedField():
            # Routed through the dialect rather than rendered here: the
            # element is a column on six engines and a VARIANT path on
            # Snowflake, and the planner cannot know which without one.
            return self.compile_expr(self.render_nested_field(expr))
        case AliasedExpr(expr=inner, alias=alias):
            return f"{self.compile_expr(inner)} AS {self.quote_identifier(alias)}"
        case FunctionCall(
            name=fname,
            args=args,
            distinct=distinct,
            order_by=order_by,
            separator=separator,
        ):
            # Reject aggregations explicitly listed as unsupported by the dialect.
            # Per-function overrides (_compile_mode etc.) still apply for cases
            # that have a special compile path; this catches plain aggregates
            # like REGR_SLOPE that have no override.
            self._check_aggregation_supported(fname)
            # LISTAGG: dialect-specific rendering
            if fname.upper() == "LISTAGG":
                return self._compile_listagg(args, distinct, order_by, separator)
            # MODE: dialect-specific rendering
            if fname.upper() == "MODE":
                return self._compile_mode(args)
            # MEDIAN: dialect-specific rendering
            if fname.upper() == "MEDIAN":
                return self._compile_median(args)
            # Multi-field COUNT: concatenate fields for portability
            # (Snowflake overrides to use native multi-arg syntax)
            if fname.upper() == "COUNT" and len(args) > 1:
                return self._compile_multi_field_count(args, distinct)
            # Portable scalar catalog (``models/functions.py``): a call the
            # catalog defines is rendered per its pinned semantics rather
            # than passed through. A wrong arity falls through to the
            # verbatim path below — the model validator reports it, and
            # emitting the author's own call keeps the database error
            # recognisable instead of raising from codegen.
            spec = lookup_function(fname)
            if (
                spec is not None
                and not distinct
                and spec.accepts(len(args))
                and (spec.unit_argument is None or _is_unit_literal(args[spec.unit_argument]))
                and (
                    spec.path_argument is None
                    or _is_json_path_literal(args[spec.path_argument])
                )
                and (
                    spec.type_argument is None
                    or _cast_target_of(args[spec.type_argument]) is not None
                )
            ):
                return self._render_function(spec.name, self._coerce_text_arguments(spec, args))
            # Everything else stays pass-through: removing the escape
            # hatch would break every model built before the catalog.
            fname = self._map_function_name(fname)
            args_sql = ", ".join(self.compile_expr(a) for a in args)
            if distinct:
                return f"{fname}(DISTINCT {args_sql})"
            return f"{fname}({args_sql})"
        case BinaryOp(left=left, op=op, right=right):
            self_prec = self._binary_op_precedence(op)
            sql = self._compile_binary_op(left, op, right)
            return self._wrap_if_lower(sql, self_prec, _parent_prec)
        case UnaryOp(op=op, operand=operand):
            self_prec = self._PREC_NOT if op.upper() == "NOT" else self._PREC_UNARY
            sql = f"{op} {self.compile_expr(operand, _parent_prec=self_prec)}"
            return self._wrap_if_lower(sql, self_prec, _parent_prec)
        case IsNull(expr=inner, negated=False):
            sql = f"{self.compile_expr(inner, _parent_prec=self._PREC_CMP)} IS NULL"
            return self._wrap_if_lower(sql, self._PREC_CMP, _parent_prec)
        case IsNull(expr=inner, negated=True):
            sql = f"{self.compile_expr(inner, _parent_prec=self._PREC_CMP)} IS NOT NULL"
            return self._wrap_if_lower(sql, self._PREC_CMP, _parent_prec)
        case InList(expr=inner, values=values, negated=negated):
            vals = ", ".join(self.compile_expr(v) for v in values)
            op = "NOT IN" if negated else "IN"
            sql = f"{self.compile_expr(inner, _parent_prec=self._PREC_CMP)} {op} ({vals})"
            return self._wrap_if_lower(sql, self._PREC_CMP, _parent_prec)
        case CaseExpr(when_clauses=whens, else_clause=else_):
            parts = ["CASE"]
            for when_cond, then_val in whens:
                parts.append(
                    f"WHEN {self.compile_expr(when_cond)} THEN {self.compile_expr(then_val)}"
                )
            if else_ is not None:
                parts.append(f"ELSE {self.compile_expr(else_)}")
            parts.append("END")
            return " ".join(parts)
        case Cast(expr=inner, type_name=type_name):
            return self._compile_cast(inner, type_name)
        case SubqueryExpr(query=query):
            return f"(\n{self.compile_select(query)}\n)"
        case Exists(subquery=subq, negated=False):
            return f"EXISTS (\n{self.compile_select(subq)}\n)"
        case Exists(subquery=subq, negated=True):
            return f"NOT EXISTS (\n{self.compile_select(subq)}\n)"
        case RawSQL(sql=sql):
            return sql
        case Between(expr=inner, low=low, high=high, negated=negated):
            op = "NOT BETWEEN" if negated else "BETWEEN"
            inner_sql = self.compile_expr(inner, _parent_prec=self._PREC_CMP)
            low_sql = self.compile_expr(low, _parent_prec=self._PREC_CMP)
            high_sql = self.compile_expr(high, _parent_prec=self._PREC_CMP)
            sql = f"{inner_sql} {op} {low_sql} AND {high_sql}"
            return self._wrap_if_lower(sql, self._PREC_CMP, _parent_prec)
        case InTimeZone(expr=inner, zone=zone, from_zone=from_zone):
            return self._render_in_timezone(inner, zone, from_zone)
        case RegexMatch(column=column, pattern=pattern, negated=negated):
            return self.compile_regex_match(column, pattern, negated=negated)
        case RelativeDateRange(
            column=column,
            unit=unit,
            count=count,
            direction=direction,
            include_current=include_current,
        ):
            return self.compile_relative_date_range(
                column=column,
                unit=unit,
                count=count,
                direction=direction,
                include_current=include_current,
            )
        case WindowFunction(
            func_name=fname,
            args=args,
            partition_by=partition_by,
            order_by=order_by,
            frame=frame,
            distinct=distinct,
        ):
            args_sql = ", ".join(self.compile_expr(a) for a in args)
            func_sql = f"{fname}(DISTINCT {args_sql})" if distinct else f"{fname}({args_sql})"
            over_parts: list[str] = []
            if partition_by:
                pb = ", ".join(self.compile_expr(p) for p in partition_by)
                over_parts.append(f"PARTITION BY {pb}")
            if order_by:
                ob = ", ".join(self.compile_order_by(o) for o in order_by)
                over_parts.append(f"ORDER BY {ob}")
            if frame is not None:
                over_parts.append(f"{frame.mode} BETWEEN {frame.start} AND {frame.end}")
            over_clause = " ".join(over_parts)
            return f"{func_sql} OVER ({over_clause})"
        case _:
            raise ValueError(f"Unknown AST node type: {type(expr).__name__}")

compile_regex_match(column, pattern, *, negated)

Compile a regex predicate. Default uses REGEXP_LIKE — overridden per dialect that needs a different syntax (Postgres ~, MySQL REGEXP, ClickHouse match etc.).

The pattern is rendered as a SQL string literal; callers pass it as RegexMatch.pattern (already a Python str).

Source code in src/orionbelt/dialect/base.py
def compile_regex_match(self, column: Expr, pattern: str, *, negated: bool) -> str:
    """Compile a regex predicate. Default uses ``REGEXP_LIKE`` — overridden
    per dialect that needs a different syntax (Postgres ``~``, MySQL
    ``REGEXP``, ClickHouse ``match`` etc.).

    The pattern is rendered as a SQL string literal; callers pass it
    as ``RegexMatch.pattern`` (already a Python ``str``).
    """
    col_sql = self.compile_expr(column)
    pat_sql = self.compile_expr(Literal.string(pattern))
    op_sql = f"REGEXP_LIKE({col_sql}, {pat_sql})"
    return f"NOT {op_sql}" if negated else op_sql

compile_relative_date_range(column, unit, count, direction, include_current)

Compile a relative date range predicate to SQL.

Source code in src/orionbelt/dialect/base.py
def compile_relative_date_range(
    self,
    column: Expr,
    unit: str,
    count: int,
    direction: str,
    include_current: bool,
) -> str:
    """Compile a relative date range predicate to SQL."""
    col_sql = self.compile_expr(column)
    base = self.current_date_sql()

    if direction == "future":
        start = base if include_current else self.date_add_sql(base, "day", 1)
        end = self.date_add_sql(start, unit, count)
    else:
        end = self.date_add_sql(base, "day", 1) if include_current else base
        start = self.date_add_sql(end, unit, -count)

    return f"({col_sql} >= {start} AND {col_sql} < {end})"

orionbelt.dialect.base.DialectCapabilities dataclass

Flags indicating what SQL features a dialect supports.

Source code in src/orionbelt/dialect/base.py
@dataclass
class DialectCapabilities:
    """Flags indicating what SQL features a dialect supports."""

    supports_cte: bool = True
    supports_qualify: bool = False
    supports_arrays: bool = False
    supports_window_filters: bool = False
    supports_ilike: bool = False
    supports_time_travel: bool = False
    supports_semi_structured: bool = False
    supports_union_all_by_name: bool = False
    # ``GROUP BY ALL`` (Snowflake 2022+, Databricks/Spark 3.4+, DuckDB 0.7+,
    # BigQuery, ClickHouse 22.6+) auto-derives the grouping list from the
    # SELECT clause. Functionally equivalent to the explicit list but much
    # shorter on queries with computed dimensions, where the explicit form
    # repeats the full expression. Postgres, MySQL, Dremio do not support it.
    supports_group_by_all: bool = False
    # A FROM-clause unnest of an array column. True everywhere but Dremio,
    # whose ``FLATTEN`` is a projection function and needs a derived table
    # rather than an extension of the FROM clause. The planner reads this to
    # decide between the unnest and a nested object's ``code`` fallback,
    # because that choice has to be made while the plan is built rather than
    # when it is rendered.
    supports_from_unnest: bool = True
    unsupported_aggregations: list[str] = field(default_factory=list)
    # Canonical names from the portable function catalog
    # (``models/functions.py``) this engine has no equivalent for. Empty for
    # every dialect today — the string group renders on all eight — but the
    # catalog admits a function on the strength of the majority, so a later
    # group can leave one engine behind without dropping the entry.
    unsupported_functions: list[str] = field(default_factory=list)

Dialect Registry

orionbelt.dialect.registry.DialectRegistry

Registry for SQL dialect plugins.

Source code in src/orionbelt/dialect/registry.py
class DialectRegistry:
    """Registry for SQL dialect plugins."""

    _dialects: dict[str, type[Dialect]] = {}

    @classmethod
    def register(cls, dialect_class: type[Dialect]) -> type[Dialect]:
        """Register a dialect class. Can be used as a decorator."""
        # Instantiate to read the name property
        instance = dialect_class()
        cls._dialects[instance.name] = dialect_class
        return dialect_class

    @classmethod
    def get(cls, name: str) -> Dialect:
        """Get an instance of the named dialect."""
        if name not in cls._dialects:
            raise UnsupportedDialectError(name, available=cls.available())
        return cls._dialects[name]()

    @classmethod
    def available(cls) -> list[str]:
        """List registered dialect names."""
        return sorted(cls._dialects.keys())

    @classmethod
    def reset(cls) -> None:
        """Clear all registered dialects (for testing)."""
        cls._dialects.clear()

get(name) classmethod

Get an instance of the named dialect.

Source code in src/orionbelt/dialect/registry.py
@classmethod
def get(cls, name: str) -> Dialect:
    """Get an instance of the named dialect."""
    if name not in cls._dialects:
        raise UnsupportedDialectError(name, available=cls.available())
    return cls._dialects[name]()

available() classmethod

List registered dialect names.

Source code in src/orionbelt/dialect/registry.py
@classmethod
def available(cls) -> list[str]:
    """List registered dialect names."""
    return sorted(cls._dialects.keys())

register(dialect_class) classmethod

Register a dialect class. Can be used as a decorator.

Source code in src/orionbelt/dialect/registry.py
@classmethod
def register(cls, dialect_class: type[Dialect]) -> type[Dialect]:
    """Register a dialect class. Can be used as a decorator."""
    # Instantiate to read the name property
    instance = dialect_class()
    cls._dialects[instance.name] = dialect_class
    return dialect_class

YAML Parser

orionbelt.parser.loader.TrackedLoader

YAML loader that tracks source positions for error reporting.

Uses ruamel.yaml which preserves line/column info on every parsed node.

Source code in src/orionbelt/parser/loader.py
class TrackedLoader:
    """YAML loader that tracks source positions for error reporting.

    Uses ruamel.yaml which preserves line/column info on every parsed node.
    """

    def __init__(self) -> None:
        self._yaml = YAML()
        self._yaml.preserve_quotes = True
        # Reject duplicate YAML keys (e.g. two columns with the same name).
        # Without this, ruamel.yaml silently keeps only the last value.
        self._yaml.allow_duplicate_keys = False
        # Reject deeply nested structures (mitigates stack-based DoS).
        # ruamel.yaml raises an error when nesting exceeds this limit.
        self._yaml.max_depth = _MAX_DEPTH

    # -- safety checks -------------------------------------------------------

    @staticmethod
    def _check_yaml_safety(content: str) -> None:
        """Pre-parse safety checks on raw YAML text.

        Raises ``YAMLSafetyError`` if the content contains anchors/aliases
        (not used in OBML) or exceeds the maximum document size.
        """
        if len(content) > _MAX_DOCUMENT_SIZE:
            raise YAMLSafetyError(
                f"YAML document exceeds maximum size "
                f"({len(content):,} chars > {_MAX_DOCUMENT_SIZE:,} limit)"
            )
        # Strip full-line comments before scanning so that &name inside
        # comments (e.g. "# see R&D notes") does not cause a false positive.
        stripped = _COMMENT_LINE_RE.sub("", content)
        if _ANCHOR_RE.search(stripped):
            raise YAMLSafetyError("YAML anchors/aliases are not supported in OBML")

    @staticmethod
    def _check_node_count(data: Any, limit: int = _MAX_NODE_COUNT) -> None:
        """Post-parse defense-in-depth: reject documents with too many nodes."""
        count = 0
        stack: list[Any] = [data]
        while stack:
            node = stack.pop()
            count += 1
            if count > limit:
                raise YAMLSafetyError(f"YAML document exceeds maximum node count ({limit:,})")
            if isinstance(node, dict):
                stack.extend(node.values())
            elif isinstance(node, list):
                stack.extend(node)

    # -- public loading API --------------------------------------------------

    def load(self, path: Path) -> tuple[dict[str, Any], SourceMap]:
        """Load a YAML file and return parsed dict + source position map."""
        with path.open("r", encoding="utf-8") as handle:
            content = handle.read()
        self._check_yaml_safety(content)
        data = self._yaml.load(content)
        if data is None:
            return {}, SourceMap()
        self._check_node_count(data)
        source_map = SourceMap()
        self._extract_positions(data, str(path), "", source_map)
        return self._to_plain_dict(data), source_map

    def load_string(
        self, content: str, filename: str = "<string>"
    ) -> tuple[dict[str, Any], SourceMap]:
        """Load YAML from a string."""
        self._check_yaml_safety(content)
        data = self._yaml.load(content)
        if data is None:
            return {}, SourceMap()
        self._check_node_count(data)
        source_map = SourceMap()
        self._extract_positions(data, filename, "", source_map)
        return self._to_plain_dict(data), source_map

    def _extract_positions(
        self,
        data: Any,
        filename: str,
        prefix: str,
        source_map: SourceMap,
    ) -> None:
        """Recursively extract source positions from ruamel.yaml nodes."""
        if isinstance(data, CommentedMap):
            for key in data:
                key_path = f"{prefix}.{key}" if prefix else str(key)
                # Try to get position for this key from ruamel.yaml's lc object
                try:
                    lc = data.lc
                    # lc.key() returns a callable in newer ruamel.yaml
                    key_positions = lc.key(key)
                    if key_positions:
                        line, col = key_positions
                        source_map.add(
                            key_path,
                            SourceSpan(file=filename, line=line + 1, column=col + 1),
                        )
                except (AttributeError, KeyError, TypeError):
                    # Fallback: use the map's own position
                    try:
                        lc = data.lc
                        source_map.add(
                            key_path,
                            SourceSpan(file=filename, line=lc.line + 1, column=lc.col + 1),
                        )
                    except (AttributeError, TypeError):
                        pass
                self._extract_positions(data[key], filename, key_path, source_map)
        elif isinstance(data, CommentedSeq):
            for i, item in enumerate(data):
                item_path = f"{prefix}[{i}]"
                try:
                    lc = data.lc
                    item_pos = lc.item(i)
                    if item_pos:
                        line, col = item_pos
                        source_map.add(
                            item_path,
                            SourceSpan(file=filename, line=line + 1, column=col + 1),
                        )
                except (AttributeError, KeyError, TypeError):
                    pass
                self._extract_positions(item, filename, item_path, source_map)

    def _to_plain_dict(self, data: Any) -> dict[str, Any]:
        """Convert ruamel.yaml CommentedMap/Seq to plain Python dict/list."""
        if isinstance(data, CommentedMap):
            return {str(k): self._to_plain_value(v) for k, v in data.items()}
        if isinstance(data, dict):
            return {str(k): self._to_plain_value(v) for k, v in data.items()}
        return {}

    def _to_plain_value(self, data: Any) -> Any:
        if isinstance(data, CommentedMap):
            return {str(k): self._to_plain_value(v) for k, v in data.items()}
        if isinstance(data, CommentedSeq):
            return [self._to_plain_value(item) for item in data]
        if isinstance(data, dict):
            return {str(k): self._to_plain_value(v) for k, v in data.items()}
        if isinstance(data, list):
            return [self._to_plain_value(item) for item in data]
        return data

load(path)

Load a YAML file and return parsed dict + source position map.

Source code in src/orionbelt/parser/loader.py
def load(self, path: Path) -> tuple[dict[str, Any], SourceMap]:
    """Load a YAML file and return parsed dict + source position map."""
    with path.open("r", encoding="utf-8") as handle:
        content = handle.read()
    self._check_yaml_safety(content)
    data = self._yaml.load(content)
    if data is None:
        return {}, SourceMap()
    self._check_node_count(data)
    source_map = SourceMap()
    self._extract_positions(data, str(path), "", source_map)
    return self._to_plain_dict(data), source_map

load_string(content, filename='<string>')

Load YAML from a string.

Source code in src/orionbelt/parser/loader.py
def load_string(
    self, content: str, filename: str = "<string>"
) -> tuple[dict[str, Any], SourceMap]:
    """Load YAML from a string."""
    self._check_yaml_safety(content)
    data = self._yaml.load(content)
    if data is None:
        return {}, SourceMap()
    self._check_node_count(data)
    source_map = SourceMap()
    self._extract_positions(data, filename, "", source_map)
    return self._to_plain_dict(data), source_map

Reference Resolver

orionbelt.parser.resolver.ReferenceResolver

Resolves all references in a raw YAML model to a fully-typed SemanticModel.

Source code in src/orionbelt/parser/resolver.py
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 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
class ReferenceResolver:
    """Resolves all references in a raw YAML model to a fully-typed SemanticModel."""

    def resolve(
        self,
        raw: dict[str, Any],
        source_map: SourceMap | None = None,
    ) -> tuple[SemanticModel, ValidationResult]:
        """Resolve raw YAML dict into a validated SemanticModel.

        Returns (model, validation_result). If there are errors,
        the model may be partially populated.
        """
        errors: list[SemanticError] = []
        warnings: list[SemanticError] = []

        # Strict OBML: reject unknown top-level keys (catches typos like
        # ``dataObjekt:`` that would silently be dropped by ``raw.get(...)``).
        _check_unknown_keys(raw, _TOP_LEVEL_KEYS, "", errors, source_map)

        # Parse data objects
        data_objects: dict[str, DataObject] = {}
        raw_objects = raw.get("dataObjects", {})
        if not isinstance(raw_objects, dict):
            errors.append(
                SemanticError(
                    code="DATA_OBJECT_PARSE_ERROR",
                    message="'dataObjects' must be a YAML mapping, not a list or scalar",
                    path="dataObjects",
                )
            )
            raw_objects = {}
        for name, raw_obj in raw_objects.items():
            try:
                _check_unknown_keys(
                    raw_obj, _DATA_OBJECT_KEYS, f"dataObjects.{name}", errors, source_map
                )
                obj_columns: dict[str, DataObjectColumn] = {}
                for fname, fdata in raw_obj.get("columns", {}).items():
                    _check_unknown_keys(
                        fdata,
                        _DATA_OBJECT_COLUMN_KEYS,
                        f"dataObjects.{name}.columns.{fname}",
                        errors,
                        source_map,
                    )
                    obj_columns[fname] = DataObjectColumn(
                        name=fname,
                        code=fdata.get("code", fname if not fdata.get("expression") else ""),
                        abstract_type=fdata.get("abstractType", "string"),
                        sql_type=fdata.get("sqlType"),
                        sql_precision=fdata.get("sqlPrecision"),
                        sql_scale=fdata.get("sqlScale"),
                        num_class=fdata.get("numClass"),
                        primary_key=bool(fdata.get("primaryKey", False)),
                        description=fdata.get("description"),
                        comment=fdata.get("comment"),
                        owner=fdata.get("owner"),
                        expression=fdata.get("expression"),
                        synonyms=fdata.get("synonyms", []),
                        custom_extensions=_parse_extensions(fdata),
                    )

                obj_joins: list[DataObjectJoin] = []
                for ji, jdata in enumerate(raw_obj.get("joins", [])):
                    _check_unknown_keys(
                        jdata,
                        _DATA_OBJECT_JOIN_KEYS,
                        f"dataObjects.{name}.joins[{ji}]",
                        errors,
                        source_map,
                    )
                    obj_joins.append(
                        DataObjectJoin(
                            join_type=jdata["joinType"],
                            join_to=jdata["joinTo"],
                            columns_from=jdata["columnsFrom"],
                            columns_to=jdata["columnsTo"],
                            secondary=jdata.get("secondary", False),
                            path_name=jdata.get("pathName"),
                            required=jdata.get("required", False),
                        )
                    )

                data_objects[name] = DataObject(
                    name=name,
                    code=raw_obj.get("code", ""),
                    database=raw_obj.get("database", ""),
                    schema_name=raw_obj.get("schema", ""),
                    columns=obj_columns,
                    joins=obj_joins,
                    description=raw_obj.get("description"),
                    comment=raw_obj.get("comment"),
                    owner=raw_obj.get("owner"),
                    countable=raw_obj.get("countable", True),
                    count_label=raw_obj.get("countLabel"),
                    synonyms=raw_obj.get("synonyms", []),
                    custom_extensions=_parse_extensions(raw_obj),
                    refresh=_parse_refresh(raw_obj.get("refresh"), name, errors),
                    nested_in=_parse_nested_in(raw_obj.get("nestedIn")),
                )
            except Exception as e:
                span = source_map.get(f"dataObjects.{name}") if source_map else None
                errors.append(
                    SemanticError(
                        code="DATA_OBJECT_PARSE_ERROR",
                        message=f"Failed to parse data object '{name}': {e}",
                        path=f"dataObjects.{name}",
                        span=span,
                    )
                )

        # Parse dimensions
        dimensions: dict[str, Dimension] = {}
        raw_dims = raw.get("dimensions", {})
        if not isinstance(raw_dims, dict):
            errors.append(
                SemanticError(
                    code="DIMENSION_PARSE_ERROR",
                    message="'dimensions' must be a YAML mapping, not a list or scalar",
                    path="dimensions",
                )
            )
            raw_dims = {}
        for name, raw_dim in raw_dims.items():
            try:
                _check_unknown_keys(
                    raw_dim, _DIMENSION_KEYS, f"dimensions.{name}", errors, source_map
                )
                data_object = raw_dim.get("dataObject")
                column = raw_dim.get("column")

                # Validate the data object exists
                if data_object and data_object not in data_objects:
                    span = source_map.get(f"dimensions.{name}") if source_map else None
                    errors.append(
                        SemanticError(
                            code="UNKNOWN_DATA_OBJECT",
                            message=(
                                f"Dimension '{name}' references unknown data object '{data_object}'"
                            ),
                            path=f"dimensions.{name}",
                            span=span,
                            suggestions=_suggest_similar(data_object, list(data_objects.keys())),
                        )
                    )

                # Validate the column exists in the data object
                if (
                    data_object
                    and column
                    and data_object in data_objects
                    and column not in data_objects[data_object].columns
                ):
                    span = source_map.get(f"dimensions.{name}") if source_map else None
                    errors.append(
                        SemanticError(
                            code="UNKNOWN_COLUMN",
                            message=(
                                f"Dimension '{name}' references unknown column "
                                f"'{column}' in data object '{data_object}'"
                            ),
                            path=f"dimensions.{name}",
                            span=span,
                            suggestions=_suggest_similar(
                                column, list(data_objects[data_object].columns.keys())
                            ),
                        )
                    )

                via = raw_dim.get("via")
                if via and via not in data_objects:
                    span = source_map.get(f"dimensions.{name}") if source_map else None
                    errors.append(
                        SemanticError(
                            code="UNKNOWN_DATA_OBJECT",
                            message=(
                                f"Dimension '{name}' via references unknown data object '{via}'"
                            ),
                            path=f"dimensions.{name}",
                            span=span,
                            suggestions=_suggest_similar(via, list(data_objects.keys())),
                        )
                    )

                dimensions[name] = Dimension(
                    name=name,
                    view=data_object or "",
                    column=column or "",
                    result_type=raw_dim.get("resultType", "string"),
                    time_grain=raw_dim.get("timeGrain"),
                    via=via,
                    description=raw_dim.get("description"),
                    format=raw_dim.get("format"),
                    owner=raw_dim.get("owner"),
                    synonyms=raw_dim.get("synonyms", []),
                    custom_extensions=_parse_extensions(raw_dim),
                )
            except Exception as e:
                span = source_map.get(f"dimensions.{name}") if source_map else None
                errors.append(
                    SemanticError(
                        code="DIMENSION_PARSE_ERROR",
                        message=f"Failed to parse dimension '{name}': {e}",
                        path=f"dimensions.{name}",
                        span=span,
                    )
                )

        # Parse measures
        measures: dict[str, Measure] = {}
        raw_measures = raw.get("measures", {})
        if not isinstance(raw_measures, dict):
            errors.append(
                SemanticError(
                    code="MEASURE_PARSE_ERROR",
                    message="'measures' must be a YAML mapping, not a list or scalar",
                    path="measures",
                )
            )
            raw_measures = {}
        for name, raw_meas in raw_measures.items():
            try:
                _check_unknown_keys(raw_meas, _MEASURE_KEYS, f"measures.{name}", errors, source_map)
                measure_columns: list[DataColumnRef] = []
                for ci, fdata in enumerate(raw_meas.get("columns", [])):
                    _check_unknown_keys(
                        fdata,
                        _DATA_COLUMN_REF_KEYS,
                        f"measures.{name}.columns[{ci}]",
                        errors,
                        source_map,
                    )
                    measure_columns.append(
                        DataColumnRef(
                            view=fdata.get("dataObject"),
                            column=fdata.get("column"),
                        )
                    )

                # Resolve expression field references
                expression = raw_meas.get("expression")
                if expression:
                    self._validate_expression_refs(
                        name, expression, data_objects, errors, source_map
                    )

                # Parse measure filters (new `filters:` list or legacy `filter:` single)
                measure_filters: list[MeasureFilterItem] = []
                raw_filters = raw_meas.get("filters")
                if raw_filters and isinstance(raw_filters, list):
                    for fi, rf in enumerate(raw_filters):
                        measure_filters.append(
                            _parse_measure_filter_item(
                                rf,
                                f"measures.{name}.filters[{fi}]",
                                errors,
                                source_map,
                            )
                        )
                else:
                    # Backward compat: single `filter:` key → [filter]
                    raw_filter = raw_meas.get("filter")
                    if raw_filter:
                        measure_filters.append(
                            _parse_measure_filter_item(
                                raw_filter, f"measures.{name}.filter", errors, source_map
                            )
                        )

                # Parse grain override
                grain_override: GrainOverride | None = None
                raw_grain = raw_meas.get("grain")
                if raw_grain and isinstance(raw_grain, dict):
                    _check_unknown_keys(
                        raw_grain,
                        _GRAIN_OVERRIDE_KEYS,
                        f"measures.{name}.grain",
                        errors,
                        source_map,
                    )
                    grain_override = GrainOverride(
                        mode=raw_grain.get("mode", "RELATIVE"),
                        exclude=raw_grain.get("exclude", []),
                        include=raw_grain.get("include", []),
                        keep_only=raw_grain.get("keepOnly", []),
                    )
                    # Validate dimension references in grain
                    for dim_name in (
                        grain_override.include + grain_override.exclude + grain_override.keep_only
                    ):
                        if dim_name not in dimensions:
                            span = source_map.get(f"measures.{name}.grain") if source_map else None
                            errors.append(
                                SemanticError(
                                    code="UNKNOWN_GRAIN_DIMENSION",
                                    message=(
                                        f"Measure '{name}' grain references "
                                        f"unknown dimension '{dim_name}'"
                                    ),
                                    path=f"measures.{name}.grain",
                                    span=span,
                                    suggestions=_suggest_similar(dim_name, list(dimensions.keys())),
                                )
                            )

                # Parse filter context
                filter_ctx: FilterContext | None = None
                raw_fc = raw_meas.get("filterContext")
                if raw_fc and isinstance(raw_fc, dict):
                    _check_unknown_keys(
                        raw_fc,
                        _FILTER_CONTEXT_KEYS,
                        f"measures.{name}.filterContext",
                        errors,
                        source_map,
                    )
                    include_filters: list[FilterContextFilter] = []
                    for inc_i, raw_incl in enumerate(raw_fc.get("include", [])):
                        if isinstance(raw_incl, dict):
                            _check_unknown_keys(
                                raw_incl,
                                _FILTER_CONTEXT_FILTER_KEYS,
                                f"measures.{name}.filterContext.include[{inc_i}]",
                                errors,
                                source_map,
                            )
                            include_filters.append(
                                FilterContextFilter(
                                    field=raw_incl.get("field", ""),
                                    op=raw_incl.get("op", "equals"),
                                    value=raw_incl.get("value"),
                                )
                            )
                    filter_ctx = FilterContext(
                        mode=raw_fc.get("mode", "RELATIVE"),
                        exclude=raw_fc.get("exclude", []),
                        include=include_filters,
                        keep_only=raw_fc.get("keepOnly", []),
                    )
                    # Validate field references in exclude/keepOnly
                    all_dim_names = set(dimensions.keys())
                    all_col_refs: set[str] = set()
                    for obj_name, obj_def in data_objects.items():
                        for col_name in obj_def.columns:
                            all_col_refs.add(f"{obj_name}.{col_name}")
                    for field_name in filter_ctx.exclude + filter_ctx.keep_only:
                        if field_name not in all_dim_names and field_name not in all_col_refs:
                            span = (
                                source_map.get(f"measures.{name}.filterContext")
                                if source_map
                                else None
                            )
                            errors.append(
                                SemanticError(
                                    code="UNKNOWN_FILTER_CONTEXT_FIELD",
                                    message=(
                                        f"Measure '{name}' filterContext references "
                                        f"unknown field '{field_name}'"
                                    ),
                                    path=f"measures.{name}.filterContext",
                                    span=span,
                                    suggestions=_suggest_similar(field_name, list(all_dim_names)),
                                )
                            )
                    for incl in filter_ctx.include:
                        if incl.field not in all_dim_names and incl.field not in all_col_refs:
                            span = (
                                source_map.get(f"measures.{name}.filterContext")
                                if source_map
                                else None
                            )
                            errors.append(
                                SemanticError(
                                    code="UNKNOWN_FILTER_CONTEXT_FIELD",
                                    message=(
                                        f"Measure '{name}' filterContext.include "
                                        f"references unknown field '{incl.field}'"
                                    ),
                                    path=f"measures.{name}.filterContext.include",
                                    span=span,
                                    suggestions=_suggest_similar(incl.field, list(all_dim_names)),
                                )
                            )

                measures[name] = Measure(
                    name=name,
                    columns=measure_columns,
                    result_type=raw_meas.get("resultType", "float"),
                    aggregation=raw_meas.get("aggregation", "sum"),
                    expression=expression,
                    distinct=raw_meas.get("distinct", False),
                    total=raw_meas.get("total", False),
                    default_value=raw_meas.get("defaultValue"),
                    anchor=raw_meas.get("anchor"),
                    grain=grain_override,
                    filter_context=filter_ctx,
                    filters=measure_filters,
                    data_type=raw_meas.get("dataType"),
                    description=raw_meas.get("description"),
                    format=raw_meas.get("format"),
                    allow_fan_out=raw_meas.get("allowFanOut", False),
                    delimiter=raw_meas.get("delimiter"),
                    within_group=raw_meas.get("withinGroup"),
                    owner=raw_meas.get("owner"),
                    synonyms=raw_meas.get("synonyms", []),
                    custom_extensions=_parse_extensions(raw_meas),
                )
            except Exception as e:
                span = source_map.get(f"measures.{name}") if source_map else None
                errors.append(
                    SemanticError(
                        code="MEASURE_PARSE_ERROR",
                        message=f"Failed to parse measure '{name}': {e}",
                        path=f"measures.{name}",
                        span=span,
                    )
                )

        # Validate the count-synthesis knobs here so a bad value becomes a
        # structured SemanticError rather than a raw AttributeError (list
        # pattern) or an uncaught Pydantic ValidationError (invalid token) at
        # model construction below. Fall back to safe values so resolution can
        # continue collecting errors.
        _count_pattern = raw.get("countLabelPattern", DEFAULT_COUNT_PATTERN)
        _pattern_err = count_pattern_error(_count_pattern)
        if _pattern_err is not None:
            span = source_map.get("countLabelPattern") if source_map else None
            errors.append(
                SemanticError(
                    code="INVALID_COUNT_LABEL_PATTERN",
                    message=_pattern_err,
                    path="countLabelPattern",
                    span=span,
                )
            )
            _count_pattern = DEFAULT_COUNT_PATTERN
        _expose_counts = raw.get("exposeCounts", True)
        if not isinstance(_expose_counts, bool):
            span = source_map.get("exposeCounts") if source_map else None
            errors.append(
                SemanticError(
                    code="INVALID_EXPOSE_COUNTS",
                    message="exposeCounts must be a boolean (true/false)",
                    path="exposeCounts",
                    span=span,
                )
            )
            _expose_counts = True

        # Names of synthesized count measures (name == resolved count label,
        # e.g. "Sales Count"). These are valid measure references (metrics may
        # target them) even though they are not declared — they are materialized
        # on read via ``effective_measures`` (see models/synthesis.py). Declared
        # measures already sit in ``measures``; a declared count of the same
        # name overrides synthesis, so unioning is safe either way.
        synthesized_measure_names: set[str] = (
            {
                count_label(key, obj, _count_pattern)
                for key, obj in data_objects.items()
                if obj.countable
            }
            if _expose_counts
            else set()
        )

        # Parse metrics
        metrics: dict[str, Metric] = {}
        raw_metrics = raw.get("metrics", {})
        if not isinstance(raw_metrics, dict):
            errors.append(
                SemanticError(
                    code="METRIC_PARSE_ERROR",
                    message="'metrics' must be a YAML mapping, not a list or scalar",
                    path="metrics",
                )
            )
            raw_metrics = {}
        for name, raw_metric in raw_metrics.items():
            try:
                _check_unknown_keys(raw_metric, _METRIC_KEYS, f"metrics.{name}", errors, source_map)
                raw_pop_block = raw_metric.get("periodOverPeriod")
                if isinstance(raw_pop_block, dict):
                    _check_unknown_keys(
                        raw_pop_block,
                        _PERIOD_OVER_PERIOD_KEYS,
                        f"metrics.{name}.periodOverPeriod",
                        errors,
                        source_map,
                    )
                metric_type = raw_metric.get("type", "derived")

                if metric_type == MetricType.CUMULATIVE:
                    # Cumulative metric: validate measure reference exists
                    ref_measure = raw_metric.get("measure", "")
                    if (
                        ref_measure
                        and ref_measure not in measures
                        and ref_measure not in synthesized_measure_names
                    ):
                        span = source_map.get(f"metrics.{name}.measure") if source_map else None
                        errors.append(
                            SemanticError(
                                code="UNKNOWN_MEASURE",
                                message=(
                                    f"Cumulative metric '{name}' references "
                                    f"unknown measure '{ref_measure}'"
                                ),
                                path=f"metrics.{name}.measure",
                                span=span,
                            )
                        )

                    # Validate timeDimension references a known dimension
                    cum_time_dim = raw_metric.get("timeDimension", "")
                    if cum_time_dim and cum_time_dim not in dimensions:
                        span = (
                            source_map.get(f"metrics.{name}.timeDimension") if source_map else None
                        )
                        errors.append(
                            SemanticError(
                                code="CUMULATIVE_UNKNOWN_TIME_DIMENSION",
                                message=(
                                    f"Cumulative metric '{name}' references "
                                    f"unknown time dimension '{cum_time_dim}'"
                                ),
                                path=f"metrics.{name}.timeDimension",
                                span=span,
                                suggestions=_suggest_similar(cum_time_dim, list(dimensions.keys())),
                            )
                        )

                    metrics[name] = Metric(
                        name=name,
                        type=MetricType.CUMULATIVE,
                        measure=raw_metric.get("measure"),
                        time_dimension=raw_metric.get("timeDimension"),
                        cumulative_type=raw_metric.get("cumulativeType", "sum"),
                        window=raw_metric.get("window"),
                        grain_to_date=raw_metric.get("grainToDate"),
                        partition_by=list(raw_metric.get("partitionBy", []) or []),
                        data_type=raw_metric.get("dataType"),
                        description=raw_metric.get("description"),
                        format=raw_metric.get("format"),
                        owner=raw_metric.get("owner"),
                        synonyms=raw_metric.get("synonyms", []),
                        custom_extensions=_parse_extensions(raw_metric),
                    )
                elif metric_type == MetricType.PERIOD_OVER_PERIOD:
                    # Period-over-period metric: validate expression + PoP config.
                    # Its base has to be re-aggregated per period from the fact
                    # tables, so the reference must be a measure.
                    expression = raw_metric.get("expression", "")
                    self._validate_metric_expression_refs(
                        name,
                        expression,
                        measures,
                        errors,
                        source_map,
                        metrics,
                        synthesized_measure_names,
                    )

                    raw_pop = raw_metric.get("periodOverPeriod")
                    if not raw_pop:
                        span = source_map.get(f"metrics.{name}") if source_map else None
                        errors.append(
                            SemanticError(
                                code="METRIC_PARSE_ERROR",
                                message=(
                                    f"Period-over-period metric '{name}' "
                                    f"requires 'periodOverPeriod' configuration"
                                ),
                                path=f"metrics.{name}",
                                span=span,
                            )
                        )
                        raw_pop = {}

                    # Validate time dimension reference
                    pop_time_dim = raw_pop.get("timeDimension", "")
                    if pop_time_dim and pop_time_dim not in dimensions:
                        span = (
                            source_map.get(f"metrics.{name}.periodOverPeriod")
                            if source_map
                            else None
                        )
                        errors.append(
                            SemanticError(
                                code="POP_UNKNOWN_TIME_DIMENSION",
                                message=(
                                    f"Period-over-period metric '{name}' references "
                                    f"unknown time dimension '{pop_time_dim}'"
                                ),
                                path=f"metrics.{name}.periodOverPeriod.timeDimension",
                                span=span,
                                suggestions=_suggest_similar(pop_time_dim, list(dimensions.keys())),
                            )
                        )

                    pop_config = PeriodOverPeriod(
                        time_dimension=raw_pop.get("timeDimension", ""),
                        grain=raw_pop.get("grain", "month"),
                        offset=raw_pop.get("offset", -1),
                        offset_grain=raw_pop.get("offsetGrain", "year"),
                        comparison=raw_pop.get("comparison", "percentChange"),
                    )

                    metrics[name] = Metric(
                        name=name,
                        type=MetricType.PERIOD_OVER_PERIOD,
                        expression=expression,
                        period_over_period=pop_config,
                        data_type=raw_metric.get("dataType"),
                        description=raw_metric.get("description"),
                        format=raw_metric.get("format"),
                        owner=raw_metric.get("owner"),
                        synonyms=raw_metric.get("synonyms", []),
                        custom_extensions=_parse_extensions(raw_metric),
                    )
                elif metric_type == MetricType.WINDOW:
                    # Window metric (rank/lag/lead/ntile/first_value/last_value)
                    ref_measure = raw_metric.get("measure")
                    if (
                        ref_measure
                        and ref_measure not in measures
                        and ref_measure not in synthesized_measure_names
                    ):
                        span = source_map.get(f"metrics.{name}.measure") if source_map else None
                        errors.append(
                            SemanticError(
                                code="UNKNOWN_MEASURE",
                                message=(
                                    f"Window metric '{name}' references "
                                    f"unknown measure '{ref_measure}'"
                                ),
                                path=f"metrics.{name}.measure",
                                span=span,
                            )
                        )

                    win_time_dim = raw_metric.get("timeDimension", "")
                    if win_time_dim and win_time_dim not in dimensions:
                        span = (
                            source_map.get(f"metrics.{name}.timeDimension") if source_map else None
                        )
                        errors.append(
                            SemanticError(
                                code="WINDOW_UNKNOWN_TIME_DIMENSION",
                                message=(
                                    f"Window metric '{name}' references "
                                    f"unknown time dimension '{win_time_dim}'"
                                ),
                                path=f"metrics.{name}.timeDimension",
                                span=span,
                                suggestions=_suggest_similar(win_time_dim, list(dimensions.keys())),
                            )
                        )

                    metrics[name] = Metric(
                        name=name,
                        type=MetricType.WINDOW,
                        measure=ref_measure,
                        time_dimension=raw_metric.get("timeDimension"),
                        window_function=raw_metric.get("windowFunction"),
                        offset=raw_metric.get("offset"),
                        buckets=raw_metric.get("buckets"),
                        order_direction=raw_metric.get("orderDirection", "desc"),
                        default_value=raw_metric.get("defaultValue"),
                        partition_by=list(raw_metric.get("partitionBy", []) or []),
                        data_type=raw_metric.get("dataType"),
                        description=raw_metric.get("description"),
                        format=raw_metric.get("format"),
                        owner=raw_metric.get("owner"),
                        synonyms=raw_metric.get("synonyms", []),
                        custom_extensions=_parse_extensions(raw_metric),
                    )
                else:
                    # Derived metric (default). It may reference another derived
                    # metric, expanded in place down to real aggregates, or a
                    # window metric, which the window wrapper projects as a
                    # column of its base CTE — that is what makes
                    # ``{[Revenue]} - {[Revenue Prior Month]}`` work.
                    expression = raw_metric.get("expression", "")
                    self._validate_metric_expression_refs(
                        name,
                        expression,
                        measures,
                        errors,
                        source_map,
                        metrics,
                        synthesized_measure_names,
                        composable_metric_types=(MetricType.DERIVED, MetricType.WINDOW),
                    )

                    metrics[name] = Metric(
                        name=name,
                        expression=expression,
                        data_type=raw_metric.get("dataType"),
                        description=raw_metric.get("description"),
                        format=raw_metric.get("format"),
                        owner=raw_metric.get("owner"),
                        synonyms=raw_metric.get("synonyms", []),
                        custom_extensions=_parse_extensions(raw_metric),
                    )
            except Exception as e:
                span = source_map.get(f"metrics.{name}") if source_map else None
                errors.append(
                    SemanticError(
                        code="METRIC_PARSE_ERROR",
                        message=f"Failed to parse metric '{name}': {e}",
                        path=f"metrics.{name}",
                        span=span,
                    )
                )

        # Parse static model filters
        model_filters: list[ModelFilter] = []
        raw_filters = raw.get("filters", [])
        if not isinstance(raw_filters, list):
            errors.append(
                SemanticError(
                    code="FILTER_PARSE_ERROR",
                    message="'filters' must be a YAML list, not a mapping or scalar",
                    path="filters",
                )
            )
            raw_filters = []
        for i, rf in enumerate(raw_filters):
            try:
                _check_unknown_keys(rf, _MODEL_FILTER_KEYS, f"filters[{i}]", errors, source_map)
                obj_name = rf.get("dataObject", "")
                col_name = rf.get("column", "")
                if obj_name and obj_name not in data_objects:
                    span = source_map.get(f"filters[{i}]") if source_map else None
                    errors.append(
                        SemanticError(
                            code="UNKNOWN_FILTER_DATA_OBJECT",
                            message=(
                                f"Static filter[{i}] references unknown data object '{obj_name}'"
                            ),
                            path=f"filters[{i}]",
                            span=span,
                        )
                    )
                elif obj_name and col_name and col_name not in data_objects[obj_name].columns:
                    span = source_map.get(f"filters[{i}]") if source_map else None
                    errors.append(
                        SemanticError(
                            code="UNKNOWN_FILTER_COLUMN",
                            message=(
                                f"Static filter[{i}] references unknown column "
                                f"'{col_name}' in data object '{obj_name}'"
                            ),
                            path=f"filters[{i}]",
                            span=span,
                        )
                    )
                raw_val = rf.get("value")
                raw_vals = rf.get("values", [])
                model_filters.append(
                    ModelFilter(
                        data_object=obj_name,
                        column=col_name,
                        operator=rf.get("operator", "equals"),
                        value=_coerce_filter_value(raw_val),
                        values=[_coerce_filter_value(v) for v in raw_vals],
                    )
                )
            except Exception as e:
                span = source_map.get(f"filters[{i}]") if source_map else None
                errors.append(
                    SemanticError(
                        code="FILTER_PARSE_ERROR",
                        message=f"Failed to parse static filter[{i}]: {e}",
                        path=f"filters[{i}]",
                        span=span,
                    )
                )

        settings = _parse_settings(raw.get("settings"), errors, source_map)

        # Parse examples block (PLAN_agent_api_improvements §5)
        examples = self._parse_examples(raw.get("examples"), errors)

        model = SemanticModel(
            version=raw.get("version", 1.0),
            name=raw.get("name"),
            description=raw.get("description"),
            data_objects=data_objects,
            dimensions=dimensions,
            measures=measures,
            metrics=metrics,
            filters=model_filters,
            examples=examples,
            extends_sources=raw.get("_extends_sources", []),
            inherits_source=raw.get("_inherits_source"),
            owner=raw.get("owner"),
            # Sanitized above so an invalid value is a structured error, not a
            # ValidationError raised here.
            expose_counts=_expose_counts,
            count_label_pattern=_count_pattern,
            custom_extensions=_parse_extensions(raw, "", errors, source_map),
            settings=settings,
        )

        result = ValidationResult(
            valid=len(errors) == 0,
            errors=errors,
            warnings=warnings,
        )

        return model, result

    def _parse_examples(self, raw: object, errors: list[SemanticError]) -> list[ModelExample]:
        """Parse the model-level ``examples:`` block.

        Accepts a list of mapping entries. Each entry must have ``name``,
        ``description``, and ``query``. ``intent_tags`` (alias ``intentTags``)
        is optional. Names must be unique within the block.
        """
        if raw is None:
            return []
        if not isinstance(raw, list):
            errors.append(
                SemanticError(
                    code="EXAMPLES_PARSE_ERROR",
                    message="'examples' must be a YAML list of example entries",
                    path="examples",
                )
            )
            return []

        out: list[ModelExample] = []
        seen: set[str] = set()
        for i, entry in enumerate(raw):
            if not isinstance(entry, dict):
                errors.append(
                    SemanticError(
                        code="EXAMPLES_PARSE_ERROR",
                        message=f"examples[{i}] must be a mapping",
                        path=f"examples[{i}]",
                    )
                )
                continue
            _check_unknown_keys(entry, _MODEL_EXAMPLE_KEYS, f"examples[{i}]", errors)
            name = entry.get("name")
            description = entry.get("description")
            query = entry.get("query")
            intent_tags = entry.get("intent_tags") or entry.get("intentTags") or []
            if not isinstance(name, str) or not name:
                errors.append(
                    SemanticError(
                        code="EXAMPLES_PARSE_ERROR",
                        message=f"examples[{i}].name is required and must be a string",
                        path=f"examples[{i}].name",
                    )
                )
                continue
            if name in seen:
                errors.append(
                    SemanticError(
                        code="DUPLICATE_EXAMPLE_NAME",
                        message=f"Duplicate example name '{name}'",
                        path=f"examples[{i}].name",
                    )
                )
                continue
            if not isinstance(description, str):
                errors.append(
                    SemanticError(
                        code="EXAMPLES_PARSE_ERROR",
                        message=f"examples[{i}].description is required",
                        path=f"examples[{i}].description",
                    )
                )
                continue
            if not isinstance(query, dict):
                errors.append(
                    SemanticError(
                        code="EXAMPLES_PARSE_ERROR",
                        message=f"examples[{i}].query must be a mapping (QueryObject payload)",
                        path=f"examples[{i}].query",
                    )
                )
                continue
            if not isinstance(intent_tags, list):
                errors.append(
                    SemanticError(
                        code="EXAMPLES_PARSE_ERROR",
                        message=f"examples[{i}].intent_tags must be a list",
                        path=f"examples[{i}].intent_tags",
                    )
                )
                continue
            seen.add(name)
            out.append(
                ModelExample(
                    name=name,
                    description=description,
                    intent_tags=[str(t) for t in intent_tags],
                    query=dict(query),
                )
            )
        return out

    def _validate_expression_refs(
        self,
        measure_name: str,
        expression: str,
        data_objects: dict[str, DataObject],
        errors: list[SemanticError],
        source_map: SourceMap | None,
    ) -> None:
        """Validate {[DataObject].[Column]} references in a measure expression."""
        span = source_map.get(f"measures.{measure_name}.expression") if source_map else None
        # The same scanner the tokenizer and the dependency walk use, so a
        # padded reference is read here exactly as it will be compiled.
        named_refs = find_qualified_refs(expression)
        for obj_name, col_name in named_refs:
            if obj_name not in data_objects:
                errors.append(
                    SemanticError(
                        code="UNKNOWN_DATA_OBJECT_IN_EXPRESSION",
                        message=(
                            f"Measure '{measure_name}' expression references unknown "
                            f"data object '{obj_name}'"
                        ),
                        path=f"measures.{measure_name}.expression",
                        span=span,
                    )
                )
            elif col_name not in data_objects[obj_name].columns:
                errors.append(
                    SemanticError(
                        code="UNKNOWN_COLUMN_IN_EXPRESSION",
                        message=(
                            f"Measure '{measure_name}' expression references unknown column "
                            f"'{col_name}' in data object '{obj_name}'"
                        ),
                        path=f"measures.{measure_name}.expression",
                        span=span,
                    )
                )

        # Strip valid refs, scan remainder for malformed attempts.
        remainder = re.sub(r"\{\[[^\]{}\[]+\]\.\[[^\]{}\[]+\]\}", "", expression)
        path = f"measures.{measure_name}.expression"

        def _merr(msg: str) -> None:
            errors.append(
                SemanticError(code="MALFORMED_EXPRESSION_REF", message=msg, path=path, span=span)
            )

        # {[Obj][Col]} — missing dot separator
        for o, c in re.findall(r"\{\[([^\]{}\[]+)\]\[([^\]{}\[]+)\]\}", remainder):
            _merr(
                f"Measure '{measure_name}' has malformed reference"
                f" '{{[{o}][{c}]}}' — missing '.' separator"
            )

        # {[Obj.Col]} — dot inside single bracket pair
        for bad in re.findall(r"\{\[([^\]{}\[]+\.[^\]{}\[]+)\]\}", remainder):
            _merr(
                f"Measure '{measure_name}' has malformed reference"
                f" '{{[{bad}]}}' — use '{{[Obj].[Col]}}' syntax"
            )

        # {Obj.Col} — missing all inner brackets
        for bad in re.findall(r"\{([A-Za-z][^\[{}\]]*\.[A-Za-z][^\[{}\]]*)\}", remainder):
            _merr(
                f"Measure '{measure_name}' has malformed reference"
                f" '{{{bad}}}' — missing '[' and ']', use '{{[Obj].[Col]}}' syntax"
            )

        # {[Obj].[Col] — missing closing }
        for o, c in re.findall(r"\{\[([^\]{}\[]+)\]\.\[([^\]{}\[]+)\](?!\})", remainder):
            _merr(
                f"Measure '{measure_name}' has malformed reference"
                f" '{{[{o}].[{c}]' — missing closing '}}'"
            )

        # [Obj].[Col]} — missing opening {
        for o, c in re.findall(r"(?<!\{)\[([^\]{}\[]+)\]\.\[([^\]{}\[]+)\]\}", remainder):
            _merr(
                f"Measure '{measure_name}' has malformed reference"
                f" '[{o}].[{c}]}}' — missing opening '{{'"
            )

        # {[Obj].[Col} — missing ] on column
        for o, c in re.findall(r"\{\[([^\]{}\[]+)\]\.\[([^\]{}\[]*)\}(?!\])", remainder):
            _merr(
                f"Measure '{measure_name}' has malformed reference"
                f" '{{[{o}].[{c}}}' — missing closing ']' on column"
            )

        # {[Obj.[Col]} — missing ] on data object
        for o, c in re.findall(r"\{\[([^\]{}\[]*)\.?\[([^\]{}\[]+)\]\}", remainder):
            _merr(
                f"Measure '{measure_name}' has malformed reference"
                f" '{{[{o}.[{c}]}}' — missing closing ']' on data object"
            )

        # {Obj].[Col]} — missing [ on data object
        for o, c in re.findall(r"\{([^\[{}\]]+)\]\.\[([^\]{}\[]+)\]\}", remainder):
            _merr(
                f"Measure '{measure_name}' has malformed reference"
                f" '{{{o}].[{c}]}}' — missing opening '[' on data object"
            )

        # {[Obj].Col]} — missing [ on column
        for o, c in re.findall(r"\{\[([^\]{}\[]+)\]\.([^\[{}\]]+)\]\}", remainder):
            _merr(
                f"Measure '{measure_name}' has malformed reference"
                f" '{{[{o}].{c}]}}' — missing opening '[' on column"
            )

    def _validate_metric_expression_refs(
        self,
        metric_name: str,
        expression: str,
        measures: dict[str, Measure],
        errors: list[SemanticError],
        source_map: SourceMap | None,
        metrics: dict[str, Metric] | None = None,
        synthesized_measures: set[str] | None = None,
        composable_metric_types: tuple[MetricType, ...] = (),
    ) -> None:
        """Validate {[Measure Name]} references in a metric expression.

        References resolve to measures. ``synthesized_measures`` names the
        auto-generated ``<object> Count`` measures, which are valid references
        even though they are not in ``measures``.

        A reference to another *metric* is only valid for the compositions the
        compiler actually expands, named by *composable_metric_types*: a derived
        metric over a **window** metric, which the window wrapper substitutes as
        a column of its base CTE. Every other metric-over-metric reference is
        refused here rather than compiled: the planner substitutes a metric's
        components one level only, so the inner metric's own placeholders
        survive into the SQL as bare column names that no engine can bind
        (``Referenced column "Revenue" not found in FROM clause``).

        ``metrics`` defaults to ``None`` so existing callers continue to work;
        the caller passes the in-progress metrics dict so a reference can be
        classified.
        """
        span = source_map.get(f"metrics.{metric_name}.expression") if source_map else None

        valid_refs = re.findall(r"\{\[([^\]{}\[]+)\]\}", expression)

        # Strip valid {[Name]} refs, then scan remainder for malformed attempts.
        remainder = re.sub(r"\{\[[^\]{}\[]+\]\}", "", expression)

        # {[Name} — missing closing ]
        for bad in re.findall(r"\{\[([^\]{}]*)\}", remainder):
            errors.append(
                SemanticError(
                    code="MALFORMED_EXPRESSION_REF",
                    message=(
                        f"Metric '{metric_name}' has malformed reference"
                        f" '{{[{bad}}}' — missing closing ']'"
                    ),
                    path=f"metrics.{metric_name}.expression",
                    span=span,
                )
            )

        # {[Name] — missing closing }
        for bad in re.findall(r"\{\[([^\]{}]+)\](?!\})", remainder):
            errors.append(
                SemanticError(
                    code="MALFORMED_EXPRESSION_REF",
                    message=(
                        f"Metric '{metric_name}' has malformed reference"
                        f" '{{[{bad}]' — missing closing '}}'"
                    ),
                    path=f"metrics.{metric_name}.expression",
                    span=span,
                )
            )

        # {Name]} — missing opening [
        for bad in re.findall(r"\{([^\[{}\]]+)\]\}", remainder):
            errors.append(
                SemanticError(
                    code="MALFORMED_EXPRESSION_REF",
                    message=(
                        f"Metric '{metric_name}' has malformed reference"
                        f" '{{{bad}]}}' — missing opening '['"
                    ),
                    path=f"metrics.{metric_name}.expression",
                    span=span,
                )
            )

        # {Name} — missing both [ and ]
        for bad in re.findall(r"\{([^\[{\]}\s]+)\}", remainder):
            errors.append(
                SemanticError(
                    code="MALFORMED_EXPRESSION_REF",
                    message=(
                        f"Metric '{metric_name}' has malformed reference"
                        f" '{{{bad}}}' — missing '[' and ']'"
                    ),
                    path=f"metrics.{metric_name}.expression",
                    span=span,
                )
            )

        # [Name]} — missing opening {
        for bad in re.findall(r"(?<!\{)\[([^\]{}\[]+)\]\}", remainder):
            errors.append(
                SemanticError(
                    code="MALFORMED_EXPRESSION_REF",
                    message=(
                        f"Metric '{metric_name}' has malformed reference"
                        f" '[{bad}]}}' — missing opening '{{'"
                    ),
                    path=f"metrics.{metric_name}.expression",
                    span=span,
                )
            )

        known_metrics = metrics or {}
        known_counts = synthesized_measures or set()
        for ref_name in valid_refs:
            if ref_name in measures or ref_name in known_counts:
                continue

            referenced = known_metrics.get(ref_name)
            if referenced is None:
                errors.append(
                    SemanticError(
                        code="UNKNOWN_MEASURE_REF",
                        message=(f"Metric '{metric_name}' references unknown measure '{ref_name}'"),
                        path=f"metrics.{metric_name}.expression",
                        span=span,
                        suggestions=_suggest_similar(
                            ref_name,
                            list(measures.keys())
                            + list(known_metrics.keys())
                            + sorted(known_counts),
                        ),
                    )
                )
                continue

            if referenced.type not in composable_metric_types:
                allowed = ", ".join(sorted(t.value for t in composable_metric_types))
                permitted = f" or a {allowed} metric" if allowed else ""
                errors.append(
                    SemanticError(
                        code="UNSUPPORTED_METRIC_REF",
                        message=(
                            f"Metric '{metric_name}' references metric '{ref_name}', which is a "
                            f"{referenced.type.value} metric. A metric expression can reference "
                            f"measures{permitted} — nesting one metric inside another is not "
                            f"supported, and the query would compile to a column reference no "
                            f"engine can resolve."
                        ),
                        path=f"metrics.{metric_name}.expression",
                        span=span,
                        hint=(
                            f"Reference the measures '{ref_name}' is built from, or inline its "
                            f"expression into '{metric_name}'."
                        ),
                        context={"metric": metric_name, "references": ref_name},
                    )
                )

resolve(raw, source_map=None)

Resolve raw YAML dict into a validated SemanticModel.

Returns (model, validation_result). If there are errors, the model may be partially populated.

Source code in src/orionbelt/parser/resolver.py
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 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
def resolve(
    self,
    raw: dict[str, Any],
    source_map: SourceMap | None = None,
) -> tuple[SemanticModel, ValidationResult]:
    """Resolve raw YAML dict into a validated SemanticModel.

    Returns (model, validation_result). If there are errors,
    the model may be partially populated.
    """
    errors: list[SemanticError] = []
    warnings: list[SemanticError] = []

    # Strict OBML: reject unknown top-level keys (catches typos like
    # ``dataObjekt:`` that would silently be dropped by ``raw.get(...)``).
    _check_unknown_keys(raw, _TOP_LEVEL_KEYS, "", errors, source_map)

    # Parse data objects
    data_objects: dict[str, DataObject] = {}
    raw_objects = raw.get("dataObjects", {})
    if not isinstance(raw_objects, dict):
        errors.append(
            SemanticError(
                code="DATA_OBJECT_PARSE_ERROR",
                message="'dataObjects' must be a YAML mapping, not a list or scalar",
                path="dataObjects",
            )
        )
        raw_objects = {}
    for name, raw_obj in raw_objects.items():
        try:
            _check_unknown_keys(
                raw_obj, _DATA_OBJECT_KEYS, f"dataObjects.{name}", errors, source_map
            )
            obj_columns: dict[str, DataObjectColumn] = {}
            for fname, fdata in raw_obj.get("columns", {}).items():
                _check_unknown_keys(
                    fdata,
                    _DATA_OBJECT_COLUMN_KEYS,
                    f"dataObjects.{name}.columns.{fname}",
                    errors,
                    source_map,
                )
                obj_columns[fname] = DataObjectColumn(
                    name=fname,
                    code=fdata.get("code", fname if not fdata.get("expression") else ""),
                    abstract_type=fdata.get("abstractType", "string"),
                    sql_type=fdata.get("sqlType"),
                    sql_precision=fdata.get("sqlPrecision"),
                    sql_scale=fdata.get("sqlScale"),
                    num_class=fdata.get("numClass"),
                    primary_key=bool(fdata.get("primaryKey", False)),
                    description=fdata.get("description"),
                    comment=fdata.get("comment"),
                    owner=fdata.get("owner"),
                    expression=fdata.get("expression"),
                    synonyms=fdata.get("synonyms", []),
                    custom_extensions=_parse_extensions(fdata),
                )

            obj_joins: list[DataObjectJoin] = []
            for ji, jdata in enumerate(raw_obj.get("joins", [])):
                _check_unknown_keys(
                    jdata,
                    _DATA_OBJECT_JOIN_KEYS,
                    f"dataObjects.{name}.joins[{ji}]",
                    errors,
                    source_map,
                )
                obj_joins.append(
                    DataObjectJoin(
                        join_type=jdata["joinType"],
                        join_to=jdata["joinTo"],
                        columns_from=jdata["columnsFrom"],
                        columns_to=jdata["columnsTo"],
                        secondary=jdata.get("secondary", False),
                        path_name=jdata.get("pathName"),
                        required=jdata.get("required", False),
                    )
                )

            data_objects[name] = DataObject(
                name=name,
                code=raw_obj.get("code", ""),
                database=raw_obj.get("database", ""),
                schema_name=raw_obj.get("schema", ""),
                columns=obj_columns,
                joins=obj_joins,
                description=raw_obj.get("description"),
                comment=raw_obj.get("comment"),
                owner=raw_obj.get("owner"),
                countable=raw_obj.get("countable", True),
                count_label=raw_obj.get("countLabel"),
                synonyms=raw_obj.get("synonyms", []),
                custom_extensions=_parse_extensions(raw_obj),
                refresh=_parse_refresh(raw_obj.get("refresh"), name, errors),
                nested_in=_parse_nested_in(raw_obj.get("nestedIn")),
            )
        except Exception as e:
            span = source_map.get(f"dataObjects.{name}") if source_map else None
            errors.append(
                SemanticError(
                    code="DATA_OBJECT_PARSE_ERROR",
                    message=f"Failed to parse data object '{name}': {e}",
                    path=f"dataObjects.{name}",
                    span=span,
                )
            )

    # Parse dimensions
    dimensions: dict[str, Dimension] = {}
    raw_dims = raw.get("dimensions", {})
    if not isinstance(raw_dims, dict):
        errors.append(
            SemanticError(
                code="DIMENSION_PARSE_ERROR",
                message="'dimensions' must be a YAML mapping, not a list or scalar",
                path="dimensions",
            )
        )
        raw_dims = {}
    for name, raw_dim in raw_dims.items():
        try:
            _check_unknown_keys(
                raw_dim, _DIMENSION_KEYS, f"dimensions.{name}", errors, source_map
            )
            data_object = raw_dim.get("dataObject")
            column = raw_dim.get("column")

            # Validate the data object exists
            if data_object and data_object not in data_objects:
                span = source_map.get(f"dimensions.{name}") if source_map else None
                errors.append(
                    SemanticError(
                        code="UNKNOWN_DATA_OBJECT",
                        message=(
                            f"Dimension '{name}' references unknown data object '{data_object}'"
                        ),
                        path=f"dimensions.{name}",
                        span=span,
                        suggestions=_suggest_similar(data_object, list(data_objects.keys())),
                    )
                )

            # Validate the column exists in the data object
            if (
                data_object
                and column
                and data_object in data_objects
                and column not in data_objects[data_object].columns
            ):
                span = source_map.get(f"dimensions.{name}") if source_map else None
                errors.append(
                    SemanticError(
                        code="UNKNOWN_COLUMN",
                        message=(
                            f"Dimension '{name}' references unknown column "
                            f"'{column}' in data object '{data_object}'"
                        ),
                        path=f"dimensions.{name}",
                        span=span,
                        suggestions=_suggest_similar(
                            column, list(data_objects[data_object].columns.keys())
                        ),
                    )
                )

            via = raw_dim.get("via")
            if via and via not in data_objects:
                span = source_map.get(f"dimensions.{name}") if source_map else None
                errors.append(
                    SemanticError(
                        code="UNKNOWN_DATA_OBJECT",
                        message=(
                            f"Dimension '{name}' via references unknown data object '{via}'"
                        ),
                        path=f"dimensions.{name}",
                        span=span,
                        suggestions=_suggest_similar(via, list(data_objects.keys())),
                    )
                )

            dimensions[name] = Dimension(
                name=name,
                view=data_object or "",
                column=column or "",
                result_type=raw_dim.get("resultType", "string"),
                time_grain=raw_dim.get("timeGrain"),
                via=via,
                description=raw_dim.get("description"),
                format=raw_dim.get("format"),
                owner=raw_dim.get("owner"),
                synonyms=raw_dim.get("synonyms", []),
                custom_extensions=_parse_extensions(raw_dim),
            )
        except Exception as e:
            span = source_map.get(f"dimensions.{name}") if source_map else None
            errors.append(
                SemanticError(
                    code="DIMENSION_PARSE_ERROR",
                    message=f"Failed to parse dimension '{name}': {e}",
                    path=f"dimensions.{name}",
                    span=span,
                )
            )

    # Parse measures
    measures: dict[str, Measure] = {}
    raw_measures = raw.get("measures", {})
    if not isinstance(raw_measures, dict):
        errors.append(
            SemanticError(
                code="MEASURE_PARSE_ERROR",
                message="'measures' must be a YAML mapping, not a list or scalar",
                path="measures",
            )
        )
        raw_measures = {}
    for name, raw_meas in raw_measures.items():
        try:
            _check_unknown_keys(raw_meas, _MEASURE_KEYS, f"measures.{name}", errors, source_map)
            measure_columns: list[DataColumnRef] = []
            for ci, fdata in enumerate(raw_meas.get("columns", [])):
                _check_unknown_keys(
                    fdata,
                    _DATA_COLUMN_REF_KEYS,
                    f"measures.{name}.columns[{ci}]",
                    errors,
                    source_map,
                )
                measure_columns.append(
                    DataColumnRef(
                        view=fdata.get("dataObject"),
                        column=fdata.get("column"),
                    )
                )

            # Resolve expression field references
            expression = raw_meas.get("expression")
            if expression:
                self._validate_expression_refs(
                    name, expression, data_objects, errors, source_map
                )

            # Parse measure filters (new `filters:` list or legacy `filter:` single)
            measure_filters: list[MeasureFilterItem] = []
            raw_filters = raw_meas.get("filters")
            if raw_filters and isinstance(raw_filters, list):
                for fi, rf in enumerate(raw_filters):
                    measure_filters.append(
                        _parse_measure_filter_item(
                            rf,
                            f"measures.{name}.filters[{fi}]",
                            errors,
                            source_map,
                        )
                    )
            else:
                # Backward compat: single `filter:` key → [filter]
                raw_filter = raw_meas.get("filter")
                if raw_filter:
                    measure_filters.append(
                        _parse_measure_filter_item(
                            raw_filter, f"measures.{name}.filter", errors, source_map
                        )
                    )

            # Parse grain override
            grain_override: GrainOverride | None = None
            raw_grain = raw_meas.get("grain")
            if raw_grain and isinstance(raw_grain, dict):
                _check_unknown_keys(
                    raw_grain,
                    _GRAIN_OVERRIDE_KEYS,
                    f"measures.{name}.grain",
                    errors,
                    source_map,
                )
                grain_override = GrainOverride(
                    mode=raw_grain.get("mode", "RELATIVE"),
                    exclude=raw_grain.get("exclude", []),
                    include=raw_grain.get("include", []),
                    keep_only=raw_grain.get("keepOnly", []),
                )
                # Validate dimension references in grain
                for dim_name in (
                    grain_override.include + grain_override.exclude + grain_override.keep_only
                ):
                    if dim_name not in dimensions:
                        span = source_map.get(f"measures.{name}.grain") if source_map else None
                        errors.append(
                            SemanticError(
                                code="UNKNOWN_GRAIN_DIMENSION",
                                message=(
                                    f"Measure '{name}' grain references "
                                    f"unknown dimension '{dim_name}'"
                                ),
                                path=f"measures.{name}.grain",
                                span=span,
                                suggestions=_suggest_similar(dim_name, list(dimensions.keys())),
                            )
                        )

            # Parse filter context
            filter_ctx: FilterContext | None = None
            raw_fc = raw_meas.get("filterContext")
            if raw_fc and isinstance(raw_fc, dict):
                _check_unknown_keys(
                    raw_fc,
                    _FILTER_CONTEXT_KEYS,
                    f"measures.{name}.filterContext",
                    errors,
                    source_map,
                )
                include_filters: list[FilterContextFilter] = []
                for inc_i, raw_incl in enumerate(raw_fc.get("include", [])):
                    if isinstance(raw_incl, dict):
                        _check_unknown_keys(
                            raw_incl,
                            _FILTER_CONTEXT_FILTER_KEYS,
                            f"measures.{name}.filterContext.include[{inc_i}]",
                            errors,
                            source_map,
                        )
                        include_filters.append(
                            FilterContextFilter(
                                field=raw_incl.get("field", ""),
                                op=raw_incl.get("op", "equals"),
                                value=raw_incl.get("value"),
                            )
                        )
                filter_ctx = FilterContext(
                    mode=raw_fc.get("mode", "RELATIVE"),
                    exclude=raw_fc.get("exclude", []),
                    include=include_filters,
                    keep_only=raw_fc.get("keepOnly", []),
                )
                # Validate field references in exclude/keepOnly
                all_dim_names = set(dimensions.keys())
                all_col_refs: set[str] = set()
                for obj_name, obj_def in data_objects.items():
                    for col_name in obj_def.columns:
                        all_col_refs.add(f"{obj_name}.{col_name}")
                for field_name in filter_ctx.exclude + filter_ctx.keep_only:
                    if field_name not in all_dim_names and field_name not in all_col_refs:
                        span = (
                            source_map.get(f"measures.{name}.filterContext")
                            if source_map
                            else None
                        )
                        errors.append(
                            SemanticError(
                                code="UNKNOWN_FILTER_CONTEXT_FIELD",
                                message=(
                                    f"Measure '{name}' filterContext references "
                                    f"unknown field '{field_name}'"
                                ),
                                path=f"measures.{name}.filterContext",
                                span=span,
                                suggestions=_suggest_similar(field_name, list(all_dim_names)),
                            )
                        )
                for incl in filter_ctx.include:
                    if incl.field not in all_dim_names and incl.field not in all_col_refs:
                        span = (
                            source_map.get(f"measures.{name}.filterContext")
                            if source_map
                            else None
                        )
                        errors.append(
                            SemanticError(
                                code="UNKNOWN_FILTER_CONTEXT_FIELD",
                                message=(
                                    f"Measure '{name}' filterContext.include "
                                    f"references unknown field '{incl.field}'"
                                ),
                                path=f"measures.{name}.filterContext.include",
                                span=span,
                                suggestions=_suggest_similar(incl.field, list(all_dim_names)),
                            )
                        )

            measures[name] = Measure(
                name=name,
                columns=measure_columns,
                result_type=raw_meas.get("resultType", "float"),
                aggregation=raw_meas.get("aggregation", "sum"),
                expression=expression,
                distinct=raw_meas.get("distinct", False),
                total=raw_meas.get("total", False),
                default_value=raw_meas.get("defaultValue"),
                anchor=raw_meas.get("anchor"),
                grain=grain_override,
                filter_context=filter_ctx,
                filters=measure_filters,
                data_type=raw_meas.get("dataType"),
                description=raw_meas.get("description"),
                format=raw_meas.get("format"),
                allow_fan_out=raw_meas.get("allowFanOut", False),
                delimiter=raw_meas.get("delimiter"),
                within_group=raw_meas.get("withinGroup"),
                owner=raw_meas.get("owner"),
                synonyms=raw_meas.get("synonyms", []),
                custom_extensions=_parse_extensions(raw_meas),
            )
        except Exception as e:
            span = source_map.get(f"measures.{name}") if source_map else None
            errors.append(
                SemanticError(
                    code="MEASURE_PARSE_ERROR",
                    message=f"Failed to parse measure '{name}': {e}",
                    path=f"measures.{name}",
                    span=span,
                )
            )

    # Validate the count-synthesis knobs here so a bad value becomes a
    # structured SemanticError rather than a raw AttributeError (list
    # pattern) or an uncaught Pydantic ValidationError (invalid token) at
    # model construction below. Fall back to safe values so resolution can
    # continue collecting errors.
    _count_pattern = raw.get("countLabelPattern", DEFAULT_COUNT_PATTERN)
    _pattern_err = count_pattern_error(_count_pattern)
    if _pattern_err is not None:
        span = source_map.get("countLabelPattern") if source_map else None
        errors.append(
            SemanticError(
                code="INVALID_COUNT_LABEL_PATTERN",
                message=_pattern_err,
                path="countLabelPattern",
                span=span,
            )
        )
        _count_pattern = DEFAULT_COUNT_PATTERN
    _expose_counts = raw.get("exposeCounts", True)
    if not isinstance(_expose_counts, bool):
        span = source_map.get("exposeCounts") if source_map else None
        errors.append(
            SemanticError(
                code="INVALID_EXPOSE_COUNTS",
                message="exposeCounts must be a boolean (true/false)",
                path="exposeCounts",
                span=span,
            )
        )
        _expose_counts = True

    # Names of synthesized count measures (name == resolved count label,
    # e.g. "Sales Count"). These are valid measure references (metrics may
    # target them) even though they are not declared — they are materialized
    # on read via ``effective_measures`` (see models/synthesis.py). Declared
    # measures already sit in ``measures``; a declared count of the same
    # name overrides synthesis, so unioning is safe either way.
    synthesized_measure_names: set[str] = (
        {
            count_label(key, obj, _count_pattern)
            for key, obj in data_objects.items()
            if obj.countable
        }
        if _expose_counts
        else set()
    )

    # Parse metrics
    metrics: dict[str, Metric] = {}
    raw_metrics = raw.get("metrics", {})
    if not isinstance(raw_metrics, dict):
        errors.append(
            SemanticError(
                code="METRIC_PARSE_ERROR",
                message="'metrics' must be a YAML mapping, not a list or scalar",
                path="metrics",
            )
        )
        raw_metrics = {}
    for name, raw_metric in raw_metrics.items():
        try:
            _check_unknown_keys(raw_metric, _METRIC_KEYS, f"metrics.{name}", errors, source_map)
            raw_pop_block = raw_metric.get("periodOverPeriod")
            if isinstance(raw_pop_block, dict):
                _check_unknown_keys(
                    raw_pop_block,
                    _PERIOD_OVER_PERIOD_KEYS,
                    f"metrics.{name}.periodOverPeriod",
                    errors,
                    source_map,
                )
            metric_type = raw_metric.get("type", "derived")

            if metric_type == MetricType.CUMULATIVE:
                # Cumulative metric: validate measure reference exists
                ref_measure = raw_metric.get("measure", "")
                if (
                    ref_measure
                    and ref_measure not in measures
                    and ref_measure not in synthesized_measure_names
                ):
                    span = source_map.get(f"metrics.{name}.measure") if source_map else None
                    errors.append(
                        SemanticError(
                            code="UNKNOWN_MEASURE",
                            message=(
                                f"Cumulative metric '{name}' references "
                                f"unknown measure '{ref_measure}'"
                            ),
                            path=f"metrics.{name}.measure",
                            span=span,
                        )
                    )

                # Validate timeDimension references a known dimension
                cum_time_dim = raw_metric.get("timeDimension", "")
                if cum_time_dim and cum_time_dim not in dimensions:
                    span = (
                        source_map.get(f"metrics.{name}.timeDimension") if source_map else None
                    )
                    errors.append(
                        SemanticError(
                            code="CUMULATIVE_UNKNOWN_TIME_DIMENSION",
                            message=(
                                f"Cumulative metric '{name}' references "
                                f"unknown time dimension '{cum_time_dim}'"
                            ),
                            path=f"metrics.{name}.timeDimension",
                            span=span,
                            suggestions=_suggest_similar(cum_time_dim, list(dimensions.keys())),
                        )
                    )

                metrics[name] = Metric(
                    name=name,
                    type=MetricType.CUMULATIVE,
                    measure=raw_metric.get("measure"),
                    time_dimension=raw_metric.get("timeDimension"),
                    cumulative_type=raw_metric.get("cumulativeType", "sum"),
                    window=raw_metric.get("window"),
                    grain_to_date=raw_metric.get("grainToDate"),
                    partition_by=list(raw_metric.get("partitionBy", []) or []),
                    data_type=raw_metric.get("dataType"),
                    description=raw_metric.get("description"),
                    format=raw_metric.get("format"),
                    owner=raw_metric.get("owner"),
                    synonyms=raw_metric.get("synonyms", []),
                    custom_extensions=_parse_extensions(raw_metric),
                )
            elif metric_type == MetricType.PERIOD_OVER_PERIOD:
                # Period-over-period metric: validate expression + PoP config.
                # Its base has to be re-aggregated per period from the fact
                # tables, so the reference must be a measure.
                expression = raw_metric.get("expression", "")
                self._validate_metric_expression_refs(
                    name,
                    expression,
                    measures,
                    errors,
                    source_map,
                    metrics,
                    synthesized_measure_names,
                )

                raw_pop = raw_metric.get("periodOverPeriod")
                if not raw_pop:
                    span = source_map.get(f"metrics.{name}") if source_map else None
                    errors.append(
                        SemanticError(
                            code="METRIC_PARSE_ERROR",
                            message=(
                                f"Period-over-period metric '{name}' "
                                f"requires 'periodOverPeriod' configuration"
                            ),
                            path=f"metrics.{name}",
                            span=span,
                        )
                    )
                    raw_pop = {}

                # Validate time dimension reference
                pop_time_dim = raw_pop.get("timeDimension", "")
                if pop_time_dim and pop_time_dim not in dimensions:
                    span = (
                        source_map.get(f"metrics.{name}.periodOverPeriod")
                        if source_map
                        else None
                    )
                    errors.append(
                        SemanticError(
                            code="POP_UNKNOWN_TIME_DIMENSION",
                            message=(
                                f"Period-over-period metric '{name}' references "
                                f"unknown time dimension '{pop_time_dim}'"
                            ),
                            path=f"metrics.{name}.periodOverPeriod.timeDimension",
                            span=span,
                            suggestions=_suggest_similar(pop_time_dim, list(dimensions.keys())),
                        )
                    )

                pop_config = PeriodOverPeriod(
                    time_dimension=raw_pop.get("timeDimension", ""),
                    grain=raw_pop.get("grain", "month"),
                    offset=raw_pop.get("offset", -1),
                    offset_grain=raw_pop.get("offsetGrain", "year"),
                    comparison=raw_pop.get("comparison", "percentChange"),
                )

                metrics[name] = Metric(
                    name=name,
                    type=MetricType.PERIOD_OVER_PERIOD,
                    expression=expression,
                    period_over_period=pop_config,
                    data_type=raw_metric.get("dataType"),
                    description=raw_metric.get("description"),
                    format=raw_metric.get("format"),
                    owner=raw_metric.get("owner"),
                    synonyms=raw_metric.get("synonyms", []),
                    custom_extensions=_parse_extensions(raw_metric),
                )
            elif metric_type == MetricType.WINDOW:
                # Window metric (rank/lag/lead/ntile/first_value/last_value)
                ref_measure = raw_metric.get("measure")
                if (
                    ref_measure
                    and ref_measure not in measures
                    and ref_measure not in synthesized_measure_names
                ):
                    span = source_map.get(f"metrics.{name}.measure") if source_map else None
                    errors.append(
                        SemanticError(
                            code="UNKNOWN_MEASURE",
                            message=(
                                f"Window metric '{name}' references "
                                f"unknown measure '{ref_measure}'"
                            ),
                            path=f"metrics.{name}.measure",
                            span=span,
                        )
                    )

                win_time_dim = raw_metric.get("timeDimension", "")
                if win_time_dim and win_time_dim not in dimensions:
                    span = (
                        source_map.get(f"metrics.{name}.timeDimension") if source_map else None
                    )
                    errors.append(
                        SemanticError(
                            code="WINDOW_UNKNOWN_TIME_DIMENSION",
                            message=(
                                f"Window metric '{name}' references "
                                f"unknown time dimension '{win_time_dim}'"
                            ),
                            path=f"metrics.{name}.timeDimension",
                            span=span,
                            suggestions=_suggest_similar(win_time_dim, list(dimensions.keys())),
                        )
                    )

                metrics[name] = Metric(
                    name=name,
                    type=MetricType.WINDOW,
                    measure=ref_measure,
                    time_dimension=raw_metric.get("timeDimension"),
                    window_function=raw_metric.get("windowFunction"),
                    offset=raw_metric.get("offset"),
                    buckets=raw_metric.get("buckets"),
                    order_direction=raw_metric.get("orderDirection", "desc"),
                    default_value=raw_metric.get("defaultValue"),
                    partition_by=list(raw_metric.get("partitionBy", []) or []),
                    data_type=raw_metric.get("dataType"),
                    description=raw_metric.get("description"),
                    format=raw_metric.get("format"),
                    owner=raw_metric.get("owner"),
                    synonyms=raw_metric.get("synonyms", []),
                    custom_extensions=_parse_extensions(raw_metric),
                )
            else:
                # Derived metric (default). It may reference another derived
                # metric, expanded in place down to real aggregates, or a
                # window metric, which the window wrapper projects as a
                # column of its base CTE — that is what makes
                # ``{[Revenue]} - {[Revenue Prior Month]}`` work.
                expression = raw_metric.get("expression", "")
                self._validate_metric_expression_refs(
                    name,
                    expression,
                    measures,
                    errors,
                    source_map,
                    metrics,
                    synthesized_measure_names,
                    composable_metric_types=(MetricType.DERIVED, MetricType.WINDOW),
                )

                metrics[name] = Metric(
                    name=name,
                    expression=expression,
                    data_type=raw_metric.get("dataType"),
                    description=raw_metric.get("description"),
                    format=raw_metric.get("format"),
                    owner=raw_metric.get("owner"),
                    synonyms=raw_metric.get("synonyms", []),
                    custom_extensions=_parse_extensions(raw_metric),
                )
        except Exception as e:
            span = source_map.get(f"metrics.{name}") if source_map else None
            errors.append(
                SemanticError(
                    code="METRIC_PARSE_ERROR",
                    message=f"Failed to parse metric '{name}': {e}",
                    path=f"metrics.{name}",
                    span=span,
                )
            )

    # Parse static model filters
    model_filters: list[ModelFilter] = []
    raw_filters = raw.get("filters", [])
    if not isinstance(raw_filters, list):
        errors.append(
            SemanticError(
                code="FILTER_PARSE_ERROR",
                message="'filters' must be a YAML list, not a mapping or scalar",
                path="filters",
            )
        )
        raw_filters = []
    for i, rf in enumerate(raw_filters):
        try:
            _check_unknown_keys(rf, _MODEL_FILTER_KEYS, f"filters[{i}]", errors, source_map)
            obj_name = rf.get("dataObject", "")
            col_name = rf.get("column", "")
            if obj_name and obj_name not in data_objects:
                span = source_map.get(f"filters[{i}]") if source_map else None
                errors.append(
                    SemanticError(
                        code="UNKNOWN_FILTER_DATA_OBJECT",
                        message=(
                            f"Static filter[{i}] references unknown data object '{obj_name}'"
                        ),
                        path=f"filters[{i}]",
                        span=span,
                    )
                )
            elif obj_name and col_name and col_name not in data_objects[obj_name].columns:
                span = source_map.get(f"filters[{i}]") if source_map else None
                errors.append(
                    SemanticError(
                        code="UNKNOWN_FILTER_COLUMN",
                        message=(
                            f"Static filter[{i}] references unknown column "
                            f"'{col_name}' in data object '{obj_name}'"
                        ),
                        path=f"filters[{i}]",
                        span=span,
                    )
                )
            raw_val = rf.get("value")
            raw_vals = rf.get("values", [])
            model_filters.append(
                ModelFilter(
                    data_object=obj_name,
                    column=col_name,
                    operator=rf.get("operator", "equals"),
                    value=_coerce_filter_value(raw_val),
                    values=[_coerce_filter_value(v) for v in raw_vals],
                )
            )
        except Exception as e:
            span = source_map.get(f"filters[{i}]") if source_map else None
            errors.append(
                SemanticError(
                    code="FILTER_PARSE_ERROR",
                    message=f"Failed to parse static filter[{i}]: {e}",
                    path=f"filters[{i}]",
                    span=span,
                )
            )

    settings = _parse_settings(raw.get("settings"), errors, source_map)

    # Parse examples block (PLAN_agent_api_improvements §5)
    examples = self._parse_examples(raw.get("examples"), errors)

    model = SemanticModel(
        version=raw.get("version", 1.0),
        name=raw.get("name"),
        description=raw.get("description"),
        data_objects=data_objects,
        dimensions=dimensions,
        measures=measures,
        metrics=metrics,
        filters=model_filters,
        examples=examples,
        extends_sources=raw.get("_extends_sources", []),
        inherits_source=raw.get("_inherits_source"),
        owner=raw.get("owner"),
        # Sanitized above so an invalid value is a structured error, not a
        # ValidationError raised here.
        expose_counts=_expose_counts,
        count_label_pattern=_count_pattern,
        custom_extensions=_parse_extensions(raw, "", errors, source_map),
        settings=settings,
    )

    result = ValidationResult(
        valid=len(errors) == 0,
        errors=errors,
        warnings=warnings,
    )

    return model, result

Semantic Validator

orionbelt.parser.validator.SemanticValidator

Validates semantic rules from spec §3.8.

Source code in src/orionbelt/parser/validator.py
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 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
class SemanticValidator:
    """Validates semantic rules from spec §3.8."""

    def validate(self, model: SemanticModel) -> list[SemanticError]:
        errors: list[SemanticError] = []
        errors.extend(self._check_unique_identifiers(model))
        errors.extend(self._check_unique_column_names(model))
        errors.extend(self._check_secondary_joins(model))
        errors.extend(self._check_no_cyclic_joins(model))
        errors.extend(self._check_no_multipath_joins(model))
        errors.extend(self._check_measures_resolve(model))
        errors.extend(self._check_join_targets_exist(model))
        errors.extend(self._check_references_resolve(model))
        errors.extend(self._check_num_class_on_numeric_columns(model))
        errors.extend(self._check_time_grain_on_temporal_columns(model))
        errors.extend(self._check_measure_filter_refs(model))
        errors.extend(self._check_within_group_refs(model))
        # An expression whose references do not resolve is reported once, by the
        # check that names the reference: it does not parse either, and
        # "unknown column 'X'" is the useful half of that pair.
        reference_errors = self._check_computed_column_refs(model)
        errors.extend(reference_errors)
        errors.extend(
            self._check_computed_column_expressions(
                model, {e.path for e in reference_errors if e.path}
            )
        )
        errors.extend(self._check_expression_functions(model))
        errors.extend(self._check_query_timezone_coverage(model))
        errors.extend(self._check_reference_name_collisions(model))
        errors.extend(self._check_no_cyclic_computed_columns(model))
        errors.extend(self._check_join_key_expressions(model))
        errors.extend(self._check_distinct_within_group(model))
        errors.extend(self._check_via_reachability(model))
        errors.extend(self._check_missing_via(model))
        errors.extend(self._check_measure_anchors(model))
        errors.extend(self._check_nested_objects(model))
        errors.extend(self._check_narrowing_data_types(model))
        return errors

    def _check_nested_objects(self, model: SemanticModel) -> list[SemanticError]:
        """Rules a ``nestedIn`` object has to satisfy.

        A nested object's rows exist only inside its parent's, which constrains
        it in ways an ordinary object is not:

        * its parent has to exist, and cannot be itself;
        * the chain of parents has to terminate, or a leg would never reach a
          table to select from;
        * nothing may join **to** it. There is no key to join on - the parent
          correlation is containment rather than an equality - and its rows
          cannot be addressed from outside the parent. Emitting SQL for such a
          join is not possible, so it is refused here rather than later.

        Joining *from* a nested object to a third one is fine and deliberately
        not checked: the nested object is already in FROM through its parent,
        and the join it declares is an ordinary keyed one.
        """
        errors: list[SemanticError] = []
        nested = {name: obj for name, obj in model.data_objects.items() if obj.is_nested}

        for name, obj in nested.items():
            assert obj.nested_in is not None
            parent = obj.nested_in.data_object
            path = f"dataObjects.{name}.nestedIn"
            if parent == name:
                errors.append(
                    SemanticError(
                        code="INVALID_NESTED_SOURCE",
                        message=f"Data object '{name}' is nested in itself.",
                        path=path,
                    )
                )
                continue
            if parent not in model.data_objects:
                errors.append(
                    SemanticError(
                        code="UNKNOWN_DATA_OBJECT",
                        message=(
                            f"Data object '{name}' is nested in '{parent}', which is not "
                            f"a data object in this model."
                        ),
                        path=path,
                    )
                )
                continue
            # Walk to a non-nested ancestor. An array inside an array is
            # supported, so depth is fine; a cycle is not.
            seen = {name}
            cursor = parent
            while cursor in nested:
                if cursor in seen:
                    errors.append(
                        SemanticError(
                            code="INVALID_NESTED_SOURCE",
                            message=(
                                f"Data object '{name}' has a cyclic nestedIn chain through "
                                f"'{cursor}', so it never reaches a table."
                            ),
                            path=path,
                        )
                    )
                    break
                seen.add(cursor)
                next_parent = nested[cursor].nested_in
                assert next_parent is not None
                cursor = next_parent.data_object

        for name, obj in model.data_objects.items():
            for idx, join in enumerate(obj.joins):
                target = nested.get(join.join_to)
                if target is not None and target.nested_in is not None:
                    errors.append(
                        SemanticError(
                            code="INVALID_NESTED_SOURCE",
                            message=(
                                f"Data object '{name}' joins to '{join.join_to}', which is "
                                f"nested in '{target.nested_in.data_object}'. "
                                f"A nested object has no key to join on - its rows exist "
                                f"only inside its parent's - so it can only be reached "
                                f"through that parent."
                            ),
                            path=f"dataObjects.{name}.joins[{idx}].joinTo",
                        )
                    )
        return errors

    def _check_unique_identifiers(self, model: SemanticModel) -> list[SemanticError]:
        """Ensure no duplicate names across dimensions, measures, and metrics.

        Data object names live in a separate namespace — a dimension may share
        its name with a data object (e.g. dimension "Region" on data object "Region").
        """
        errors: list[SemanticError] = []
        all_names: dict[str, str] = {}  # name -> type

        def _register(name: str, kind: str, path: str) -> None:
            existing = all_names.get(name)
            if existing is not None:
                errors.append(
                    SemanticError(
                        code="DUPLICATE_IDENTIFIER",
                        message=(
                            f"{kind.title()} '{name}' conflicts with existing {existing} '{name}'"
                        ),
                        path=path,
                    )
                )
            all_names[name] = kind

        for name in model.dimensions:
            _register(name, "dimension", f"dimensions.{name}")

        for name in model.measures:
            _register(name, "measure", f"measures.{name}")

        for name in model.metrics:
            _register(name, "metric", f"metrics.{name}")

        # Synthesized count measures (name == resolved count label, e.g.
        # "Sales Count") occupy the measure namespace too (models/synthesis.py).
        # A declared measure of the same name is the intended override (D4) and
        # is fine; but a dimension or metric with that name would be shadowed by
        # the synthesized measure at query time, so reject the collision. Two
        # countable objects that resolve to the same count name also collide.
        if getattr(model, "expose_counts", True):
            pattern = model_count_pattern(model)
            seen_counts: dict[str, str] = {}  # count name -> data object key
            for obj_key, obj in model.data_objects.items():
                if not obj.countable:
                    continue
                cid = count_label(obj_key, obj, pattern)
                clashing = all_names.get(cid)
                if clashing in ("dimension", "metric"):
                    errors.append(
                        SemanticError(
                            code="DUPLICATE_IDENTIFIER",
                            message=(
                                f"{str(clashing).title()} '{cid}' conflicts with the synthesized "
                                f"count measure for data object '{obj_key}'. Rename it, set "
                                f"'countLabel'/'countLabelPattern', or 'countable: false'."
                            ),
                            path=f"{clashing}s.{cid}",
                        )
                    )
                elif cid in seen_counts:
                    errors.append(
                        SemanticError(
                            code="DUPLICATE_IDENTIFIER",
                            message=(
                                f"Data objects '{seen_counts[cid]}' and '{obj_key}' both "
                                f"synthesize a count measure named '{cid}'. Give one a distinct "
                                f"'countLabel' or set 'countable: false'."
                            ),
                            path=f"dataObjects.{obj_key}.countLabel",
                        )
                    )
                else:
                    seen_counts[cid] = obj_key

        return errors

    def _check_unique_column_names(self, model: SemanticModel) -> list[SemanticError]:
        """Column names must be unique within each data object.

        Duplicate YAML keys are now rejected at parse time by TrackedLoader
        (``allow_duplicate_keys = False``). This validator is retained as a
        structural hook in case models are constructed programmatically.
        """
        return []

    def _check_secondary_joins(self, model: SemanticModel) -> list[SemanticError]:
        """Validate secondary join constraints.

        - Every secondary join MUST have a pathName.
        - pathName must be unique per (source, target) pair.
        """
        errors: list[SemanticError] = []
        # Track pathName per (source, target) pair
        path_names: dict[tuple[str, str], set[str]] = {}

        for obj_name, obj in model.data_objects.items():
            for i, join in enumerate(obj.joins):
                if join.secondary and not join.path_name:
                    errors.append(
                        SemanticError(
                            code="SECONDARY_JOIN_MISSING_PATH_NAME",
                            message=(
                                f"Data object '{obj_name}' join[{i}] is secondary "
                                f"but has no pathName"
                            ),
                            path=f"dataObjects.{obj_name}.joins[{i}]",
                        )
                    )
                if join.path_name:
                    pair = (obj_name, join.join_to)
                    if pair not in path_names:
                        path_names[pair] = set()
                    if join.path_name in path_names[pair]:
                        errors.append(
                            SemanticError(
                                code="DUPLICATE_JOIN_PATH_NAME",
                                message=(
                                    f"Data object '{obj_name}' join[{i}] has duplicate "
                                    f"pathName '{join.path_name}' for target '{join.join_to}'"
                                ),
                                path=f"dataObjects.{obj_name}.joins[{i}]",
                            )
                        )
                    else:
                        path_names[pair].add(join.path_name)

        return errors

    def _check_no_cyclic_joins(self, model: SemanticModel) -> list[SemanticError]:
        """Detect cyclic join paths."""
        errors: list[SemanticError] = []

        # Build adjacency list from joins (skip secondary joins)
        adj: dict[str, set[str]] = {}
        for obj_name, obj in model.data_objects.items():
            if obj_name not in adj:
                adj[obj_name] = set()
            for join in obj.joins:
                if not join.secondary:
                    adj[obj_name].add(join.join_to)

        # Iterative DFS cycle detection (avoids RecursionError on large models)
        visited: set[str] = set()
        rec_stack: set[str] = set()

        for start in adj:
            if start in visited:
                continue
            stack: list[tuple[str, list[str]]] = [(start, iter(adj.get(start, set())))]  # type: ignore[list-item]
            path: list[str] = [start]
            visited.add(start)
            rec_stack.add(start)

            while stack:
                node, neighbors = stack[-1]
                advanced = False
                for neighbor in neighbors:
                    if neighbor not in visited:
                        visited.add(neighbor)
                        rec_stack.add(neighbor)
                        path.append(neighbor)
                        stack.append((neighbor, iter(adj.get(neighbor, set()))))  # type: ignore[arg-type]
                        advanced = True
                        break
                    elif neighbor in rec_stack:
                        if neighbor in path:
                            cycle = path[path.index(neighbor) :] + [neighbor]
                        else:
                            cycle = [node, neighbor]
                        errors.append(
                            SemanticError(
                                code="CYCLIC_JOIN",
                                message=f"Cyclic join detected: {' -> '.join(cycle)}",
                                path=f"dataObjects.{node}.joins",
                            )
                        )
                if not advanced:
                    stack.pop()
                    rec_stack.discard(node)
                    if path:
                        path.pop()

        return errors

    def _check_no_multipath_joins(self, model: SemanticModel) -> list[SemanticError]:
        """Detect multiple distinct paths between any pair of nodes in the join DAG.

        Only flags true diamonds where both paths go through intermediaries.
        A direct edge from start to target is canonical, so an additional
        indirect path (e.g. Purchases→Suppliers direct + Purchases→Products→Suppliers)
        is not ambiguous and is not flagged.
        """
        errors: list[SemanticError] = []

        # Build adjacency list from joins (skip secondary joins)
        adj: dict[str, list[str]] = {}
        for obj_name, obj in model.data_objects.items():
            if obj_name not in adj:
                adj[obj_name] = []
            for join in obj.joins:
                if not join.secondary:
                    adj[obj_name].append(join.join_to)

        reported: set[tuple[str, str]] = set()

        for start in adj:
            if not adj[start]:
                continue
            # BFS from start; track first parent that reached each node
            direct_neighbors: set[str] = set()
            first_parent: dict[str, str] = {}
            queue: deque[tuple[str, str]] = deque()
            for neighbor in adj[start]:
                if neighbor == start:
                    continue
                direct_neighbors.add(neighbor)
                if neighbor not in first_parent:
                    first_parent[neighbor] = start
                    queue.append((neighbor, start))

            while queue:
                node, _parent = queue.popleft()
                for neighbor in adj.get(node, []):
                    if neighbor == start:
                        continue
                    if neighbor not in first_parent:
                        first_parent[neighbor] = node
                        queue.append((neighbor, node))
                    elif first_parent[neighbor] != node:
                        # Skip if target has a direct edge from start —
                        # the direct join is the canonical path.
                        if neighbor in direct_neighbors:
                            continue
                        pair = (start, neighbor)
                        if pair not in reported:
                            reported.add(pair)
                            errors.append(
                                SemanticError(
                                    code="MULTIPATH_JOIN",
                                    message=(
                                        f"Multiple join paths from '{start}' to "
                                        f"'{neighbor}' (via '{first_parent[neighbor]}' "
                                        f"and '{node}'). "
                                        f"Join paths must be unambiguous."
                                    ),
                                    path=f"dataObjects.{start}.joins",
                                )
                            )

        return errors

    def _check_column_ref(
        self,
        ref: DataColumnRef,
        model: SemanticModel,
        *,
        subject: str,
        path: str,
    ) -> list[SemanticError]:
        """Validate one ``DataColumnRef``: both halves present, and both resolving.

        The JSON schema makes ``dataObject`` and ``column`` required, but the
        Pydantic type leaves both optional so models can be built in Python,
        and ``ModelStore.load_model`` does not run the schema. A missing half
        is not inert: codegen renders it as an empty identifier, so a ref
        without a column compiles to ``ORDER BY "Sales".""`` and one without a
        data object to ``ORDER BY ""."Product Name"``.
        """
        obj_name, col_name = ref.view, ref.column

        missing = [
            field for field, value in (("dataObject", obj_name), ("column", col_name)) if not value
        ]
        if missing:
            return [
                SemanticError(
                    code="INCOMPLETE_COLUMN_REF",
                    message=(
                        f"{subject} is missing {' and '.join(missing)}. A column "
                        f"reference needs both dataObject and column; an omitted "
                        f"one compiles to an empty SQL identifier."
                    ),
                    path=path,
                )
            ]

        if obj_name not in model.data_objects:
            return [
                SemanticError(
                    code="UNKNOWN_DATA_OBJECT",
                    message=f"{subject} references unknown data object '{obj_name}'",
                    path=path,
                )
            ]

        if col_name not in model.data_objects[obj_name].columns:
            return [
                SemanticError(
                    code="UNKNOWN_COLUMN",
                    message=(
                        f"{subject} references unknown column '{col_name}' "
                        f"in data object '{obj_name}'"
                    ),
                    path=path,
                )
            ]

        return []

    def _check_measures_resolve(self, model: SemanticModel) -> list[SemanticError]:
        """Ensure measure column references resolve to actual data object columns."""
        errors: list[SemanticError] = []
        for name, measure in model.measures.items():
            for i, col_ref in enumerate(measure.columns):
                errors.extend(
                    self._check_column_ref(
                        col_ref,
                        model,
                        subject=f"Measure '{name}' column[{i}]",
                        path=f"measures.{name}.columns[{i}]",
                    )
                )
        return errors

    #: Aggregations whose result stays in the neighbourhood of the source
    #: column's values, so a target too narrow for the column is too narrow for
    #: the answer. COUNT is absent because its magnitude is a row count and says
    #: nothing about the column, and so are the statistical aggregates, whose
    #: results are ratios rather than values.
    _SOURCE_SCALED_AGGREGATIONS = frozenset(
        {"sum", "avg", "min", "max", "any_value", "median", "mode"}
    )

    def _check_narrowing_data_types(self, model: SemanticModel) -> list[SemanticError]:
        """Warn when a measure's ``dataType`` cannot hold what its column can.

        OBML's ``int`` is a 64-bit integer, 19 digits, and a declared
        ``integer`` is 32 bits on every engine that has a distinct one. So
        ``dataType: integer`` over an ``int`` column is a narrowing the model
        states about its own data, and the value that outgrows it is answered
        differently by every engine: DuckDB, PostgreSQL, BigQuery, Databricks
        and Snowflake raise, MySQL saturates and ClickHouse wraps (#336, #356).

        **Integer targets only.** A decimal target is narrower than ``int`` on
        this arithmetic too - ``decimal(18, 2)`` holds 16 integer digits - but
        warning about those is noise rather than signal: ``decimal(18, 2)`` is
        what ``defaultNumericDataType`` hands out, and a quantity column typed
        ``int`` does not reach 10^16. Measured before restricting it: the rule
        without this fired eight times on ``examples/tpcds.obml.yml`` alone, all
        of them on quantities that cannot overflow, and a warning that fires on
        the project's own flagship model teaches readers to ignore warnings.
        Between two *integer* types the narrowing is unambiguous, which is the
        case this exists for.

        A warning rather than an error, because narrowing is a legitimate thing
        to ask for when the modeller knows the range, and because existing
        models declare it. What it removes is the silence.
        """
        errors: list[SemanticError] = []
        for name, measure in model.measures.items():
            if not measure.data_type:
                continue
            if measure.aggregation.lower() not in self._SOURCE_SCALED_AGGREGATIONS:
                continue
            try:
                declared = parse_data_type(measure.data_type)
            except ValueError:
                continue  # Reported by the schema layer; not this check's business.
            if isinstance(declared, DecimalType):
                continue
            capacity = _integer_digits(declared)
            if capacity is None or capacity >= _INT64_DIGITS:
                continue
            for obj_name, col_name in _measure_source_columns(measure):
                obj = model.data_objects.get(obj_name)
                column = obj.columns.get(col_name) if obj else None
                if column is None or column.abstract_type is not DataType.INT:
                    continue
                errors.append(
                    SemanticError(
                        code=WarningCode.NARROWING_DATA_TYPE,
                        message=(
                            f"Measure '{name}' declares dataType "
                            f"'{measure.data_type}', which holds {capacity} integer "
                            f"digits, over column '{obj_name}.{col_name}' "
                            f"of type int, which holds {_INT64_DIGITS}"
                        ),
                        path=f"measures.{name}.dataType",
                        hint=(
                            "Widen dataType (bigint, or a decimal with more integer "
                            "digits) if the column can really reach those values. A "
                            "value that outgrows the declared type raises on most "
                            "engines, saturates on MySQL and wraps on ClickHouse."
                        ),
                        severity="warning",
                        context={
                            "measure": name,
                            "dataType": measure.data_type,
                            "column": f"{obj_name}.{col_name}",
                        },
                    )
                )
        return errors

    def _check_join_targets_exist(self, model: SemanticModel) -> list[SemanticError]:
        """Ensure join targets reference existing data objects."""
        errors: list[SemanticError] = []
        for obj_name, obj in model.data_objects.items():
            for i, join in enumerate(obj.joins):
                if not join.columns_from or not join.columns_to:
                    errors.append(
                        SemanticError(
                            code="EMPTY_JOIN_COLUMNS",
                            message=(
                                f"Data object '{obj_name}' join[{i}] to "
                                f"'{join.join_to}' has empty join columns"
                            ),
                            path=f"dataObjects.{obj_name}.joins[{i}]",
                        )
                    )
                elif len(join.columns_from) != len(join.columns_to):
                    errors.append(
                        SemanticError(
                            code="JOIN_COLUMN_COUNT_MISMATCH",
                            message=(
                                f"Data object '{obj_name}' join[{i}] has "
                                f"{len(join.columns_from)} columnsFrom and "
                                f"{len(join.columns_to)} columnsTo"
                            ),
                            path=f"dataObjects.{obj_name}.joins[{i}]",
                        )
                    )
                if join.join_to not in model.data_objects:
                    errors.append(
                        SemanticError(
                            code="UNKNOWN_JOIN_TARGET",
                            message=(
                                f"Data object '{obj_name}' join[{i}] references "
                                f"unknown data object '{join.join_to}'"
                            ),
                            path=f"dataObjects.{obj_name}.joins[{i}]",
                        )
                    )
                else:
                    # Validate join columns exist
                    for col_name in join.columns_from:
                        if col_name not in obj.columns:
                            errors.append(
                                SemanticError(
                                    code="UNKNOWN_JOIN_COLUMN",
                                    message=(
                                        f"Data object '{obj_name}' join[{i}] columnsFrom "
                                        f"references unknown column '{col_name}'"
                                    ),
                                    path=f"dataObjects.{obj_name}.joins[{i}].columnsFrom",
                                )
                            )
                    target_obj = model.data_objects[join.join_to]
                    for col_name in join.columns_to:
                        if col_name not in target_obj.columns:
                            errors.append(
                                SemanticError(
                                    code="UNKNOWN_JOIN_COLUMN",
                                    message=(
                                        f"Data object '{obj_name}' join[{i}] columnsTo "
                                        f"references unknown column '{col_name}' "
                                        f"in data object '{join.join_to}'"
                                    ),
                                    path=f"dataObjects.{obj_name}.joins[{i}].columnsTo",
                                )
                            )
        return errors

    def _check_references_resolve(self, model: SemanticModel) -> list[SemanticError]:
        """Ensure dimension references resolve."""
        errors: list[SemanticError] = []
        for name, dim in model.dimensions.items():
            errors.extend(
                self._check_column_ref(
                    DataColumnRef(view=dim.view, column=dim.column),
                    model,
                    subject=f"Dimension '{name}'",
                    path=f"dimensions.{name}",
                )
            )
        return errors

    _NUMERIC_TYPES = {DataType.INT, DataType.FLOAT}
    _TIME_GRAIN_TYPES = {DataType.DATE, DataType.TIMESTAMP, DataType.TIMESTAMP_TZ}

    def _check_time_grain_on_temporal_columns(self, model: SemanticModel) -> list[SemanticError]:
        """Ensure timeGrain is only set when the underlying column is temporal.

        ``timeGrain`` compiles to ``date_trunc(grain, column)``, which fails at
        runtime if the column's abstractType is not date/timestamp/timestamp_tz.
        Reject at model-load time so the error surfaces during validation rather
        than during the first query.
        """
        errors: list[SemanticError] = []
        for name, dim in model.dimensions.items():
            if dim.time_grain is None:
                continue
            obj_name = dim.view
            col_name = dim.column
            if not obj_name or not col_name:
                continue
            obj = model.data_objects.get(obj_name)
            if obj is None or col_name not in obj.columns:
                # Caught by _check_references_resolve.
                continue
            col = obj.columns[col_name]
            if col.abstract_type not in self._TIME_GRAIN_TYPES:
                errors.append(
                    SemanticError(
                        code="TIME_GRAIN_ON_NON_TEMPORAL",
                        message=(
                            f"Dimension '{name}' has timeGrain "
                            f"'{dim.time_grain.value}' but underlying column "
                            f"'{obj_name}.{col_name}' has abstractType "
                            f"'{col.abstract_type.value}'. timeGrain requires "
                            f"the column to be date, timestamp, or timestamp_tz. "
                            f"Drop timeGrain, fix the column's abstractType, or "
                            f"define a computed column with to_date()."
                        ),
                        path=f"dimensions.{name}",
                    )
                )
        return errors

    def _check_num_class_on_numeric_columns(self, model: SemanticModel) -> list[SemanticError]:
        """Ensure numClass is only set on numeric columns (int or float)."""
        errors: list[SemanticError] = []
        for obj_name, obj in model.data_objects.items():
            for col_name, col in obj.columns.items():
                if col.num_class and col.abstract_type not in self._NUMERIC_TYPES:
                    errors.append(
                        SemanticError(
                            code="NUM_CLASS_ON_NON_NUMERIC",
                            message=(
                                f"Column '{col_name}' in data object '{obj_name}' "
                                f"has numClass '{col.num_class}' but abstractType "
                                f"'{col.abstract_type}' is not numeric (int or float)"
                            ),
                            path=f"dataObjects.{obj_name}.columns.{col_name}",
                        )
                    )
        return errors

    def _check_distinct_within_group(self, model: SemanticModel) -> list[SemanticError]:
        """Reject ``distinct: true`` + a ``withinGroup`` that is not the aggregated column.

        SQL restricts a DISTINCT aggregate's ORDER BY to expressions that appear
        in its argument list: the engine sorts the deduplicated values, so it
        cannot order them by something it has just collapsed away. Postgres,
        DuckDB and BigQuery all reject it outright ("In a DISTINCT aggregate,
        ORDER BY expressions must appear in the argument list").

        Without this check the model loads happily and every query touching the
        measure fails at execution time with a driver-level binder error, which
        points at generated SQL rather than at the two lines of OBML that caused
        it.
        """
        errors: list[SemanticError] = []
        for measure_name, measure in model.measures.items():
            if not measure.distinct or measure.within_group is None:
                continue

            ordered = measure.within_group.column
            ordered_ref = (ordered.view or "", ordered.column or "")
            if ordered_ref in self._aggregated_column_refs(measure):
                continue

            aggregated = self._describe_aggregated_columns(measure)
            errors.append(
                SemanticError(
                    code="WITHIN_GROUP_NOT_IN_DISTINCT_ARGS",
                    message=(
                        f"Measure '{measure_name}' is DISTINCT but orders by "
                        f"'{ordered_ref[0]}.{ordered_ref[1]}', which is not among the "
                        f"columns it aggregates ({aggregated}). A DISTINCT aggregate "
                        f"can only be ordered by an expression in its argument list, "
                        f"so this fails at execution time on Postgres, DuckDB and "
                        f"BigQuery among others."
                    ),
                    path=f"measures.{measure_name}.withinGroup",
                    hint=(
                        "Order by the aggregated column itself, or drop "
                        "`distinct: true` if the ordering matters more than "
                        "deduplication."
                    ),
                )
            )
        return errors

    @staticmethod
    def _aggregated_column_refs(measure: Measure) -> set[tuple[str, str]]:
        """The ``(dataObject, column)`` pairs that form a measure's aggregate argument.

        Only a bare column reference can be matched against a ``withinGroup``
        column. An ``expression`` that computes something (``a || b``) aggregates
        that computed value, not its parts, so ordering by any single part is
        still outside the argument list — hence the empty set.
        """
        if measure.columns:
            return {(c.view or "", c.column or "") for c in measure.columns}
        if measure.expression:
            body = measure.expression.strip()
            refs = find_qualified_refs(body)
            if len(refs) == 1 and QUALIFIED_COLUMN_REF.fullmatch(body) is not None:
                return {refs[0]}
        return set()

    @staticmethod
    def _describe_aggregated_columns(measure: Measure) -> str:
        refs = SemanticValidator._aggregated_column_refs(measure)
        if refs:
            return ", ".join(f"'{obj}.{col}'" for obj, col in sorted(refs))
        return "a computed expression, which cannot be matched by column"

    def _check_measure_filter_refs(self, model: SemanticModel) -> list[SemanticError]:
        """Verify that measure filter columns reference existing data objects and columns."""
        errors: list[SemanticError] = []
        for meas_name, measure in model.measures.items():
            for fi in measure.filters:
                self._validate_filter_item(fi, model, meas_name, errors)
        return errors

    def _validate_filter_item(
        self,
        item: MeasureFilterItem,
        model: SemanticModel,
        meas_name: str,
        errors: list[SemanticError],
    ) -> None:
        """Recursively validate a measure filter item."""
        if isinstance(item, MeasureFilter):
            if item.column is None:
                return
            view, column = item.column.view, item.column.column
            # An omitted half reaches codegen as an empty identifier, the same
            # way it does for a dimension or a withinGroup column.
            missing = [
                field for field, value in (("dataObject", view), ("column", column)) if not value
            ]
            if missing or not view or not column:
                errors.append(
                    SemanticError(
                        code="INCOMPLETE_COLUMN_REF",
                        message=(
                            f"Measure '{meas_name}' filter is missing "
                            f"{' and '.join(missing)}. A column reference needs both "
                            f"dataObject and column; an omitted one compiles to an "
                            f"empty SQL identifier."
                        ),
                        path=f"measures.{meas_name}.filters",
                    )
                )
                return
            obj = model.data_objects.get(view)
            if not obj:
                errors.append(
                    SemanticError(
                        code="UNKNOWN_FILTER_DATA_OBJECT",
                        message=(
                            f"Measure '{meas_name}' filter references unknown data object '{view}'"
                        ),
                        path=f"measures.{meas_name}.filters",
                    )
                )
                return
            if column not in obj.columns:
                errors.append(
                    SemanticError(
                        code="UNKNOWN_FILTER_COLUMN",
                        message=(
                            f"Measure '{meas_name}' filter references unknown "
                            f"column '{column}' in '{view}'"
                        ),
                        path=f"measures.{meas_name}.filters",
                    )
                )
        elif isinstance(item, MeasureFilterGroup):
            for child in item.filters:
                self._validate_filter_item(child, model, meas_name, errors)

    def _check_within_group_refs(self, model: SemanticModel) -> list[SemanticError]:
        """Verify that a ``withinGroup`` ordering column exists.

        ``withinGroup`` is the one ``DataColumnRef`` site on a measure that no
        other check covers: ``columns:`` goes through
        :meth:`_check_measures_resolve` and filter columns through
        :meth:`_check_measure_filter_refs`. Left unchecked, a typo compiles
        straight into ``ORDER BY "Sales"."no_such_col"`` inside the LISTAGG and
        only fails at the database.
        """
        errors: list[SemanticError] = []
        for name, measure in model.measures.items():
            if measure.within_group is None:
                continue
            errors.extend(
                self._check_column_ref(
                    measure.within_group.column,
                    model,
                    subject=f"Measure '{name}' withinGroup",
                    path=f"measures.{name}.withinGroup",
                )
            )
        return errors

    def _check_computed_column_refs(self, model: SemanticModel) -> list[SemanticError]:
        """Ensure a computed column's references name columns that exist.

        Two forms, checked the same way: ``{name}`` must name a sibling column
        of the same data object, and ``{[Data Object].[Column]}`` must name a
        column of a data object the model declares.

        A reference the compiler cannot resolve is not dropped and not
        reported — it survives into codegen as a *string literal*, so
        ``{amount} * {no_such_col}`` emits ``"Sales"."amount" * 'no_such_col'``,
        and a qualified reference to nothing emits the labels as identifiers.
        The model validates clean and the wrongness surfaces, at best, as a
        type error from the database.
        """
        errors: list[SemanticError] = []
        for obj_name, obj in model.data_objects.items():
            for col_name, col in obj.columns.items():
                if not col.expression:
                    continue
                path = f"dataObjects.{obj_name}.columns.{col_name}.expression"
                for ref in find_placeholders(col.expression):
                    if ref in obj.columns:
                        continue
                    errors.append(
                        SemanticError(
                            code="UNKNOWN_COLUMN_IN_EXPRESSION",
                            message=(
                                f"Computed column '{col_name}' in data object "
                                f"'{obj_name}' references unknown column '{ref}'"
                            ),
                            path=path,
                            hint=(
                                "A computed column's {placeholder} must name another "
                                f"column of '{obj_name}'. To reference a column of a "
                                "different data object, use the "
                                "{[Data Object].[Column]} form instead."
                            ),
                        )
                    )
                for ref_object, ref_column in find_qualified_refs(col.expression):
                    target = model.data_objects.get(ref_object)
                    if target is None:
                        errors.append(
                            SemanticError(
                                code="UNKNOWN_DATA_OBJECT_IN_EXPRESSION",
                                message=(
                                    f"Computed column '{col_name}' in data object "
                                    f"'{obj_name}' references unknown data object "
                                    f"'{ref_object}'"
                                ),
                                path=path,
                            )
                        )
                    elif ref_column not in target.columns:
                        errors.append(
                            SemanticError(
                                code="UNKNOWN_COLUMN_IN_EXPRESSION",
                                message=(
                                    f"Computed column '{col_name}' in data object "
                                    f"'{obj_name}' references unknown column "
                                    f"'{ref_column}' in data object '{ref_object}'"
                                ),
                                path=path,
                            )
                        )
        return errors

    @staticmethod
    def _check_query_timezone_coverage(model: SemanticModel) -> list[SemanticError]:
        """Warn when a query time zone cannot reach the model's naive columns.

        ``queryTimezone`` converts timestamp columns so the model, not the
        warehouse session, decides which day or week a row falls in. A column
        that carries no zone cannot be converted until the model says which
        zone it was written in, which is what ``defaultTimezone`` states, and
        the session's own zone is not an answer: it is a fact about the
        connection rather than about the data, and reading it into the SQL
        would make the same query mean different things on different
        connections.

        So those columns are left alone, and this says so rather than leaving
        a model half-converted in silence.
        """
        settings = model.settings
        if settings is None or not settings.query_timezone or settings.default_timezone:
            return []
        naive = [
            f"{obj_name}.{col_name}"
            for obj_name, obj in model.data_objects.items()
            for col_name, col in obj.columns.items()
            if col.abstract_type is DataType.TIMESTAMP
        ]
        if not naive:
            return []
        return [
            SemanticError(
                code=WarningCode.UNDECLARED_TIMESTAMP_ZONE,
                message=(
                    f"settings.queryTimezone is '{settings.query_timezone}', but "
                    f"{len(naive)} timestamp column(s) carry no time zone and "
                    f"settings.defaultTimezone does not say which zone they were "
                    f"written in, so they are read as the warehouse session sees "
                    f"them: {', '.join(sorted(naive)[:5])}"
                ),
                path="settings.queryTimezone",
                hint=(
                    "Set settings.defaultTimezone to the zone those columns are "
                    "stored in, or declare the columns as timestamp_tz if they "
                    "carry one."
                ),
                severity="warning",
                context={"columns": sorted(naive)},
            )
        ]

    @staticmethod
    def _expression_bodies(model: SemanticModel) -> Iterator[tuple[str, str, str]]:
        """``(path, subject, expression)`` for every expression body in *model*.

        The three places an author can write one: a computed column, a measure
        expression, and a metric formula.
        """
        for obj_name, obj in model.data_objects.items():
            for col_name, col in obj.columns.items():
                if col.expression:
                    yield (
                        f"dataObjects.{obj_name}.columns.{col_name}.expression",
                        f"Computed column '{col_name}' in data object '{obj_name}'",
                        col.expression,
                    )
        for measure_name, measure in model.measures.items():
            if measure.expression:
                yield (
                    f"measures.{measure_name}.expression",
                    f"Measure '{measure_name}'",
                    measure.expression,
                )
        for metric_name, metric in model.metrics.items():
            if metric.expression:
                yield (
                    f"metrics.{metric_name}.expression",
                    f"Metric '{metric_name}'",
                    metric.expression,
                )

    def _check_computed_column_expressions(
        self, model: SemanticModel, already_reported: set[str]
    ) -> list[SemanticError]:
        """A computed column's expression has to parse, or the column is nothing.

        A computed column *is* its expression: there is no ``code`` to fall back
        to, so a body the parser cannot read leaves nothing to select. The
        compiler used to invent something anyway - a reference to the column's
        display name, as though it were a physical column - and the model
        loaded, the query compiled, ``sql_valid`` came back true, and the
        database rejected a statement naming an object that only exists in the
        model (#359).

        Reported here as well as at compile time so it reaches whoever wrote the
        model rather than whoever runs the report. Reachable through ordinary
        SQL the format invites: ``||``, ``INTERVAL``, ``CAST(x AS t)`` (#355)
        and the simple ``CASE`` form (#360) are all bodies the parser does not
        take today.

        A cycle is left to :meth:`_check_no_cyclic_computed_columns`, which
        names both ends of it rather than wherever the recursion happened to
        stop, and an unresolvable reference to
        :meth:`_check_computed_column_refs` - *already_reported* carries the
        paths it claimed. Both of those fail to parse too, and neither is
        better described as a syntax error.
        """
        # Imported here rather than at module scope: the tokenizer lives in the
        # compiler, and the parser package does not depend on it to be imported.
        # It is the compiler's own entry point on purpose - a check that parsed
        # the body its own way could answer differently from the code that has
        # to build it.
        from orionbelt.compiler.resolution import parse_column_expression

        errors: list[SemanticError] = []
        for obj_name, obj in model.data_objects.items():
            for col_name, column in obj.columns.items():
                path = f"dataObjects.{obj_name}.columns.{col_name}.expression"
                if not column.expression or path in already_reported:
                    continue
                try:
                    parse_column_expression(column, obj, model)
                except RecursionError:
                    continue
                except Exception as exc:  # noqa: BLE001 - any parse failure, reported as one
                    errors.append(
                        SemanticError(
                            code="INVALID_COLUMN_EXPRESSION",
                            message=(
                                f"Computed column '{col_name}' in data object "
                                f"'{obj_name}' has invalid expression: {exc}"
                            ),
                            path=path,
                            hint=(
                                "The expression parser reads a subset of SQL. A "
                                "construct it does not accept has to be written "
                                "another way, or moved into the source view."
                            ),
                            context={"dataObject": obj_name, "column": col_name},
                        )
                    )
        return errors

    def _check_expression_functions(self, model: SemanticModel) -> list[SemanticError]:
        """Reject a portable-catalog function called with the wrong arity.

        Expression bodies used to be pass-through in both directions: any
        ``IDENT(`` became a function call and every dialect emitted it
        verbatim, so ``substring({Zip}, 1, 5, 9)`` validated clean and failed
        at the database — on whichever engine the query happened to run.

        Only functions the catalog defines (``models/functions.py``) are
        checked. Everything else stays the escape hatch it has always been:
        the catalog cannot know a vendor function's arity, and rejecting names
        it does not carry would break every model written before it existed.
        """
        errors: list[SemanticError] = []
        portable = (
            model.settings is not None and model.settings.expression_mode is ExpressionMode.PORTABLE
        )
        for path, subject, expression in self._expression_bodies(model):
            reported: set[str] = set()
            for call in find_function_calls(expression):
                spec = lookup_function(call.name)
                if spec is None:
                    # Outside the catalog: emitted verbatim, so the model runs
                    # only where that function exists. A warning by default, an
                    # error when the model has asked to stay portable. Reported
                    # once per name per expression, since repeating a call is
                    # not a second problem.
                    if call.name.lower() in reported:
                        continue
                    reported.add(call.name.lower())
                    errors.append(
                        SemanticError(
                            code=WarningCode.NON_PORTABLE_FUNCTION,
                            message=(
                                f"{subject} calls '{call.name}', which the portable "
                                f"function catalog does not carry, so it is emitted "
                                f"as written and the model runs only on engines "
                                f"that have it"
                            ),
                            path=path,
                            hint=(
                                "Use a catalog function (GET /v1/reference/functions) "
                                "if one fits, or keep this call and accept the "
                                "dependency. settings.expressionMode: portable turns "
                                "this into an error."
                            ),
                            severity="error" if portable else "warning",
                            context={"function": call.name},
                        )
                    )
                    continue
                if spec.accepts(call.arg_count):
                    continue
                plural = "" if call.arg_count == 1 else "s"
                errors.append(
                    SemanticError(
                        code="WRONG_FUNCTION_ARITY",
                        message=(
                            f"{subject} calls '{call.name}' with {call.arg_count} "
                            f"argument{plural}, but it takes {spec.arity_text}"
                        ),
                        path=path,
                        hint=f"Canonical signature: {spec.signature}.",
                        context={
                            "function": spec.name,
                            "argCount": call.arg_count,
                            "signature": spec.signature,
                        },
                    )
                )
        errors.extend(self._check_expression_units(model))
        errors.extend(self._check_expression_cast_targets(model))
        errors.extend(self._check_expression_json_paths(model))
        return errors

    @staticmethod
    def _unit_literal(argument: str) -> str | None:
        """The time unit a source argument names, or ``None`` if it names none."""
        text = argument.strip()
        if len(text) >= 2 and text.startswith("'") and text.endswith("'"):
            inner = text[1:-1].lower()
            if inner in TIME_UNITS:
                return inner
        return None

    @staticmethod
    def _cast_target_literal(argument: str) -> str | None:
        """The OBML type a source argument names, or ``None`` if it names none.

        ``None`` for a non-literal, for text that is not an OBML type, and for
        a type the catalog does not pin - the caller reports all three the same
        way, since all three leave the call unrenderable.
        """
        text = argument.strip()
        if not (len(text) >= 2 and text.startswith("'") and text.endswith("'")):
            return None
        inner = text[1:-1].strip().lower()
        try:
            obml_type = parse_data_type(inner)
        except ValueError:
            return None
        if isinstance(obml_type, DecimalType) or obml_type.name in CAST_TARGETS:
            return inner
        return None

    @staticmethod
    def _json_path_literal(argument: str) -> str | None:
        """The JSONPath a source argument names, or ``None`` if it names none.

        The accepted subset is object member access and array subscripts rooted
        at ``$``. Filters and wildcards are excluded because the engines diverge
        on them and a catalog entry has to pin one meaning.
        """
        text = argument.strip()
        if len(text) >= 2 and text.startswith("'") and text.endswith("'"):
            inner = text[1:-1]
            if JSON_PATH_RE.match(inner):
                return inner
        return None

    def _check_expression_json_paths(self, model: SemanticModel) -> list[SemanticError]:
        """Reject a json call whose path is not a literal the catalog accepts.

        The path cannot be an expression, and for a sharper reason than the
        time unit: the engines take it *apart* differently. Postgres wants the
        segments as separate arguments and Snowflake wants array subscripts
        bracketed, rejecting ``arr.0`` outright. None of that is derivable from
        a runtime value.

        Without this the call still compiles - codegen falls through to the
        pass-through path and emits it verbatim - which is worse than an error:
        it slips past ``expressionMode: portable`` and past a dialect's
        unsupported-function guard, so a model can acquire an engine dependency
        precisely where it asked not to.
        """
        errors: list[SemanticError] = []
        for path, subject, expression in self._expression_bodies(model):
            for call in find_function_calls(expression):
                spec = lookup_function(call.name)
                if spec is None or spec.path_argument is None or not spec.accepts(call.arg_count):
                    continue
                argument = call.arguments[spec.path_argument]
                if self._json_path_literal(argument) is not None:
                    continue
                errors.append(
                    SemanticError(
                        code="INVALID_JSON_PATH",
                        message=(
                            f"{subject} calls '{call.name}' with path {argument}, "
                            f"which is not a literal JSONPath"
                        ),
                        path=path,
                        hint=(
                            "The path is a quoted literal such as '$.a', '$.a.b' or "
                            "'$.a[0]': the dialects take it apart differently, so it "
                            "cannot come from an expression. Filters and wildcards "
                            "are not supported."
                        ),
                        context={"function": spec.name, "path": argument},
                    )
                )
        return errors

    def _check_expression_units(self, model: SemanticModel) -> list[SemanticError]:
        """Reject a date/time call whose unit is not one of the catalog's.

        The unit cannot be an expression, and not for want of trying: every
        dialect switches on it to render the call at all, as a keyword on
        BigQuery and ClickHouse, a quoted string on Snowflake, an interval
        qualifier on MySQL, and a different expression per unit on Postgres.
        A misspelling is caught here rather than compiling to a call the
        engine rejects, or worse, silently accepts as something else.
        """
        errors: list[SemanticError] = []
        for path, subject, expression in self._expression_bodies(model):
            for call in find_function_calls(expression):
                spec = lookup_function(call.name)
                if spec is None or spec.unit_argument is None or not spec.accepts(call.arg_count):
                    continue
                argument = call.arguments[spec.unit_argument]
                if self._unit_literal(argument) is not None:
                    continue
                errors.append(
                    SemanticError(
                        code="UNKNOWN_TIME_UNIT",
                        message=(
                            f"{subject} calls '{call.name}' with unit {argument}, "
                            f"which is not one of {', '.join(TIME_UNITS)}"
                        ),
                        path=path,
                        hint=(
                            "The unit is a quoted literal, not an expression: every "
                            "dialect renders the call differently per unit."
                        ),
                        context={
                            "function": spec.name,
                            "unit": argument,
                            "units": list(TIME_UNITS),
                        },
                    )
                )
        return errors

    def _check_expression_cast_targets(self, model: SemanticModel) -> list[SemanticError]:
        """Refuse a ``cast`` to something the catalog does not pin.

        The target is a quoted OBML type, not an expression and not a SQL type
        name, for a sharper version of the reason a time unit is: the engines
        do not merely spell a cast differently, they disagree about the value
        it produces. A float to an integer rounds on five engines and
        truncates on two; 2.50 to a string keeps its trailing zero on four and
        drops it on three. Only the targets the catalog can pin are accepted,
        and the rest are named here rather than compiled into a query that
        answers differently per engine.

        A target that is not a literal at all falls in here too. There is
        nothing to render it from: the type has to be known when the SQL is
        built, and this call would otherwise pass through verbatim as
        ``cast(x, y)``, which no engine accepts.
        """
        errors: list[SemanticError] = []
        accepted = ", ".join(sorted([*CAST_TARGETS, "decimal(p, s)"]))
        for path, subject, expression in self._expression_bodies(model):
            for call in find_function_calls(expression):
                spec = lookup_function(call.name)
                if spec is None or spec.type_argument is None or not spec.accepts(call.arg_count):
                    continue
                argument = call.arguments[spec.type_argument]
                if self._cast_target_literal(argument) is not None:
                    continue
                errors.append(
                    SemanticError(
                        code="UNSUPPORTED_CAST_TARGET",
                        message=(
                            f"{subject} calls '{call.name}' with target {argument}, "
                            f"which is not one of {accepted}"
                        ),
                        path=path,
                        hint=(
                            "The target is a quoted OBML type, not a SQL type and not "
                            "an expression. The types left out are the ones the engines "
                            "answer differently: see the catalog entry for which, and "
                            "what each of them does."
                        ),
                        context={
                            "function": spec.name,
                            "target": argument,
                            "targets": sorted([*CAST_TARGETS, "decimal(p, s)"]),
                        },
                    )
                )
        return errors

    def _check_reference_name_collisions(self, model: SemanticModel) -> list[SemanticError]:
        """Refuse an expression reference that two names answer to.

        ``{[Data Object].[Column]}`` is read with the brackets' padding
        stripped, so a reference to ``[ Zip 5 ]`` addresses ``Zip 5``. Where a
        model holds both ``Zip 5`` and ``" Zip 5 "`` the reference names them
        both, and silently binding to one is how an expression comes to read a
        different column than the author wrote.

        Only references are refused, not the names themselves: both columns are
        still addressable by the exact ``dataObject``/``column`` pair a
        dimension or measure uses. It is the bracket syntax that cannot tell
        them apart.
        """
        errors: list[SemanticError] = []

        def collisions(names: list[str], wanted: str) -> list[str]:
            return sorted(name for name in names if name.strip() == wanted)

        def check(refs: list[tuple[str, str]], path: str, subject: str) -> None:
            for ref_object, ref_column in refs:
                matches = collisions(list(model.data_objects), ref_object)
                if len(matches) > 1:
                    errors.append(
                        SemanticError(
                            code="AMBIGUOUS_NAME",
                            message=(
                                f"{subject} references data object '{ref_object}', which "
                                f"{len(matches)} names answer to "
                                f"({', '.join(repr(m) for m in matches)}) — they differ "
                                f"only in surrounding whitespace"
                            ),
                            path=path,
                            hint=(
                                "Bracket references are read with the padding stripped, so "
                                "rename one of them to something a reference can single out."
                            ),
                        )
                    )
                    continue
                target = model.data_objects.get(ref_object)
                if target is None:
                    continue
                column_matches = collisions(list(target.columns), ref_column)
                if len(column_matches) > 1:
                    errors.append(
                        SemanticError(
                            code="AMBIGUOUS_NAME",
                            message=(
                                f"{subject} references column '{ref_column}' on "
                                f"'{ref_object}', which {len(column_matches)} names answer to "
                                f"({', '.join(repr(m) for m in column_matches)}) — they differ "
                                f"only in surrounding whitespace"
                            ),
                            path=path,
                            hint=(
                                "Bracket references are read with the padding stripped, so "
                                "rename one of them to something a reference can single out."
                            ),
                        )
                    )

        for obj_name, obj in model.data_objects.items():
            for col_name, col in obj.columns.items():
                if col.expression:
                    check(
                        find_qualified_refs(col.expression),
                        f"dataObjects.{obj_name}.columns.{col_name}.expression",
                        f"Computed column '{col_name}' in data object '{obj_name}'",
                    )
        for measure_name, measure in model.measures.items():
            if measure.expression:
                check(
                    find_qualified_refs(measure.expression),
                    f"measures.{measure_name}.expression",
                    f"Measure '{measure_name}'",
                )
        return errors

    def _check_no_cyclic_computed_columns(self, model: SemanticModel) -> list[SemanticError]:
        """Detect computed columns whose expressions reference each other in a loop.

        The compiler raises ``RecursionError`` on such a chain, but
        ``_build_computed_column_expr`` catches every exception and falls back
        to a plain reference to the column's own ``code`` — which for a computed
        column is empty, so the *label* is emitted as a physical column name.
        A cycle therefore compiles to SQL naming a column that does not exist.

        Model-wide rather than per data object: a qualified reference lets a
        cycle leave and re-enter an object, and the compiler's cycle guard is
        keyed on ``(object, column)`` for exactly that reason.
        """
        errors: list[SemanticError] = []
        g = self._computed_column_graph(model)
        # Each strongly connected component that is not a single acyclic
        # node is exactly one reference cycle. Using SCCs rather than
        # walking from every column keeps this linear and reports each
        # cycle once, however many columns sit on it.
        for scc in nx.strongly_connected_components(g):
            if len(scc) == 1:
                only = next(iter(scc))
                if not g.has_edge(only, only):
                    continue
            cycle = self._describe_cycle(g, scc)
            obj_name, _, col_name = cycle[0].partition(".")
            errors.append(
                SemanticError(
                    code="CYCLIC_COMPUTED_COLUMN",
                    message=(f"Cyclic computed-column reference: {' -> '.join(cycle)}"),
                    path=f"dataObjects.{obj_name}.columns.{col_name}.expression",
                )
            )
        return errors

    def _check_join_key_expressions(self, model: SemanticModel) -> list[SemanticError]:
        """Refuse a join key whose expression reads another data object.

        A computed column is legal as a join key — ``build_join_condition``
        inlines it — but only while it reads its own object. Reading another
        one puts that object's alias in the ON clause of the join that would
        introduce it: unbound at best, and circular whenever the reference is
        reachable only *through* this join. Nothing downstream can repair that,
        so it is rejected here rather than compiled into SQL the database
        rejects (or, worse, a plan that silently drops the reference).
        """
        errors: list[SemanticError] = []
        for obj_name, obj in model.data_objects.items():
            for i, join in enumerate(obj.joins):
                sides = [(obj_name, col) for col in join.columns_from]
                sides += [(join.join_to, col) for col in join.columns_to]
                for side, col_name in sides:
                    read = model.column_reference_objects(side, col_name)
                    if not read:
                        continue
                    reads = ", ".join(f"'{name}'" for name in sorted(read))
                    errors.append(
                        SemanticError(
                            code="CROSS_OBJECT_JOIN_KEY",
                            message=(
                                f"Join '{obj_name}' → '{join.join_to}' uses computed column "
                                f"'{col_name}' on '{side}' as a key, but its expression reads "
                                f"{reads}. A join key cannot depend on another data object."
                            ),
                            path=f"dataObjects.{obj_name}.joins[{i}]",
                            hint=(
                                "The ON clause is evaluated as the join is made, so a key "
                                f"reading {reads} would need that object joined first — which "
                                "is circular when it is reachable only through this join. Use "
                                f"a physical column of '{side}', or an expression over its own "
                                "columns."
                            ),
                        )
                    )
        return errors

    def _computed_column_graph(self, model: SemanticModel) -> nx.DiGraph[str]:
        """Dependency graph over every computed column in *model*.

        Nodes are ``"Data Object.Column"``; an edge ``a -> b`` means computed
        column ``a``'s expression references ``b``, whether as a ``{sibling}``
        or as a qualified ``{[Data Object].[Column]}``. Only computed columns
        become nodes: a reference to a physical column terminates the chain and
        cannot be part of a cycle.
        """
        g: nx.DiGraph[str] = nx.DiGraph()
        computed = {
            (obj_name, col_name)
            for obj_name, obj in model.data_objects.items()
            for col_name, col in obj.columns.items()
            if col.expression
        }
        for obj_name, col_name in computed:
            g.add_node(f"{obj_name}.{col_name}")
        for obj_name, col_name in computed:
            expression = model.data_objects[obj_name].columns[col_name].expression or ""
            refs = [(obj_name, sibling) for sibling in find_placeholders(expression)]
            refs.extend(find_qualified_refs(expression))
            for ref in refs:
                if ref in computed:
                    g.add_edge(f"{obj_name}.{col_name}", f"{ref[0]}.{ref[1]}")
        return g

    @staticmethod
    def _describe_cycle(g: nx.DiGraph[str], scc: set[str]) -> list[str]:
        """A readable ``a -> b -> a`` walk through one cyclic component."""
        try:
            edges = nx.find_cycle(g.subgraph(scc))
        except nx.NetworkXNoCycle:  # pragma: no cover - scc is cyclic by construction
            return sorted(scc)
        return [source for source, _target in edges] + [edges[0][0]]

    def _build_directed_graph(self, model: SemanticModel) -> nx.DiGraph[str]:
        """Build a directed graph from primary (non-secondary) joins."""
        g: nx.DiGraph[str] = nx.DiGraph()
        for name in model.data_objects:
            g.add_node(name)
        for obj_name, obj in model.data_objects.items():
            for join in obj.joins:
                if not join.secondary and join.join_to in model.data_objects:
                    g.add_edge(obj_name, join.join_to)
        return g

    def _check_via_reachability(self, model: SemanticModel) -> list[SemanticError]:
        """Validate that each dimension's dataObject is reachable from its via."""
        errors: list[SemanticError] = []
        dims_with_via = [(name, dim) for name, dim in model.dimensions.items() if dim.via]
        if not dims_with_via:
            return errors

        g = self._build_directed_graph(model)
        for name, dim in dims_with_via:
            if dim.via not in model.data_objects:
                errors.append(
                    SemanticError(
                        code="INVALID_VIA_DATA_OBJECT",
                        message=(
                            f"Dimension '{name}': via references unknown data object '{dim.via}'"
                        ),
                        path=f"dimensions.{name}",
                    )
                )
                continue
            if dim.via == dim.view:
                continue
            reachable = nx.descendants(g, dim.via) if dim.via in g else set()
            if dim.view not in reachable:
                errors.append(
                    SemanticError(
                        code="INVALID_VIA_DATA_OBJECT",
                        message=(
                            f"Dimension '{name}': data object '{dim.view}' is not "
                            f"reachable from via data object '{dim.via}'"
                        ),
                        path=f"dimensions.{name}",
                    )
                )
        return errors

    def _check_measure_anchors(self, model: SemanticModel) -> list[SemanticError]:
        """Validate each measure's ``anchor``: it must exist and be one it reads.

        The anchor names the data object whose rows the expression is evaluated
        over, so an anchor the expression never reads would leave every column
        conformed in and the anchor acting as a bare row multiplier. That is
        never what was meant, and it is what a typo looks like.
        """
        errors: list[SemanticError] = []
        for name, measure in model.measures.items():
            if not measure.anchor:
                continue
            if measure.anchor not in model.data_objects:
                errors.append(
                    SemanticError(
                        code="INVALID_ANCHOR_DATA_OBJECT",
                        message=(
                            f"Measure '{name}': anchor references unknown data object "
                            f"'{measure.anchor}'"
                        ),
                        path=f"measures.{name}",
                    )
                )
                continue
            sources = measure.source_objects
            if not sources or measure.anchor in sources:
                continue
            # An anchor may also name a data object every source joins to: that
            # conforms all of them to its grain, which is the reading a model
            # picks when the facts share several dimensions and no single one
            # can be assumed.
            shared = model.common_join_targets(sorted(sources))
            if measure.anchor in shared:
                continue
            options = sorted(sources) + shared
            errors.append(
                SemanticError(
                    code="INVALID_ANCHOR_DATA_OBJECT",
                    message=(
                        f"Measure '{name}': anchor '{measure.anchor}' is neither a data object "
                        f"it reads nor one they all join to. The anchor sets the grain the "
                        f"expression is evaluated at, so it has to be one of: "
                        f"{', '.join(options)}."
                    ),
                    path=f"measures.{name}",
                )
            )
        return errors

    def _check_missing_via(self, model: SemanticModel) -> list[SemanticError]:
        """Warn when a dimension's target has direct joins from multiple fact tables.

        A fact table is a data object that is the source of at least one measure.
        Only direct joins (one hop) from a fact table to the dimension's target
        count — transitive reachability through other fact tables does not create
        real ambiguity and should not trigger a warning.  Dimensions whose target
        IS a fact table (e.g. Sales Date on Sales) are also skipped because the
        column lives on the fact table itself.

        Path-invariance heuristic: when every reaching fact joins to the target
        on the target's primary key, the dim attribute is path-invariant — the
        same Client ID (or Calendar.date) from any fact resolves to the same
        target row, so the dim attribute value is identical regardless of
        which fact drove the join. Role-playing semantics (Sales Year Month
        vs Purchase Year Month) are a choice the modeller makes by adding
        explicit ``via:`` on a per-dimension basis, not a correctness concern
        the validator should flag for every shared dim table.
        """
        warnings: list[SemanticError] = []

        measure_sources: set[str] = set()
        for meas in model.measures.values():
            for col_ref in meas.columns:
                if col_ref.view:
                    measure_sources.add(col_ref.view)
        if len(measure_sources) < 2:
            return warnings

        g = self._build_directed_graph(model)
        fact_tables = sorted(measure_sources & set(g.nodes))

        direct_children: dict[str, set[str]] = {}
        for ft in fact_tables:
            direct_children[ft] = set(g.successors(ft))

        for dim_name, dim in model.dimensions.items():
            if dim.via:
                continue
            target = dim.view
            if not target or target not in g:
                continue
            if target in measure_sources:
                continue
            reaching_facts = [ft for ft in fact_tables if target in direct_children[ft]]
            if len(reaching_facts) <= 1:
                continue

            if self._is_path_invariant(model, target, reaching_facts):
                continue

            warnings.append(
                SemanticError(
                    code="MISSING_VIA",
                    message=(
                        f"Dimension '{dim_name}' on '{target}' has direct "
                        f"joins from multiple fact tables "
                        f"({', '.join(reaching_facts)}). "
                        f"Consider adding role-playing dimensions with 'via' "
                        f"to disambiguate join paths."
                    ),
                    path=f"dimensions.{dim_name}",
                    severity="warning",
                )
            )
        return warnings

    @staticmethod
    def _is_path_invariant(model: SemanticModel, target: str, reaching_facts: list[str]) -> bool:
        """True when every reaching fact joins to the target on its primary key.

        Same Client ID (or Calendar date) from any fact resolves to the same
        target row, so the dim attribute value is identical regardless of which
        fact drove the join — there's no correctness ambiguity to warn about.
        Joins on non-PK columns CAN resolve to different rows from different
        facts and are kept under the warning.
        """
        target_obj = model.data_objects.get(target)
        if target_obj is None:
            return False

        pk_cols = {col_name for col_name, col in target_obj.columns.items() if col.primary_key}
        if not pk_cols:
            return False

        for ft_name in reaching_facts:
            ft_obj = model.data_objects.get(ft_name)
            if ft_obj is None:
                return False
            joins_to_target = [j for j in ft_obj.joins if j.join_to == target]
            if not joins_to_target:
                return False
            for j in joins_to_target:
                # Every column on the target side of the join must be a PK column.
                if not j.columns_to or any(c not in pk_cols for c in j.columns_to):
                    return False

        return True

validate(model)

Source code in src/orionbelt/parser/validator.py
def validate(self, model: SemanticModel) -> list[SemanticError]:
    errors: list[SemanticError] = []
    errors.extend(self._check_unique_identifiers(model))
    errors.extend(self._check_unique_column_names(model))
    errors.extend(self._check_secondary_joins(model))
    errors.extend(self._check_no_cyclic_joins(model))
    errors.extend(self._check_no_multipath_joins(model))
    errors.extend(self._check_measures_resolve(model))
    errors.extend(self._check_join_targets_exist(model))
    errors.extend(self._check_references_resolve(model))
    errors.extend(self._check_num_class_on_numeric_columns(model))
    errors.extend(self._check_time_grain_on_temporal_columns(model))
    errors.extend(self._check_measure_filter_refs(model))
    errors.extend(self._check_within_group_refs(model))
    # An expression whose references do not resolve is reported once, by the
    # check that names the reference: it does not parse either, and
    # "unknown column 'X'" is the useful half of that pair.
    reference_errors = self._check_computed_column_refs(model)
    errors.extend(reference_errors)
    errors.extend(
        self._check_computed_column_expressions(
            model, {e.path for e in reference_errors if e.path}
        )
    )
    errors.extend(self._check_expression_functions(model))
    errors.extend(self._check_query_timezone_coverage(model))
    errors.extend(self._check_reference_name_collisions(model))
    errors.extend(self._check_no_cyclic_computed_columns(model))
    errors.extend(self._check_join_key_expressions(model))
    errors.extend(self._check_distinct_within_group(model))
    errors.extend(self._check_via_reachability(model))
    errors.extend(self._check_missing_via(model))
    errors.extend(self._check_measure_anchors(model))
    errors.extend(self._check_nested_objects(model))
    errors.extend(self._check_narrowing_data_types(model))
    return errors

Semantic Model

orionbelt.models.semantic.SemanticModel

Bases: BaseModel

Complete semantic model parsed from OBML YAML.

Source code in src/orionbelt/models/semantic.py
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
class SemanticModel(BaseModel):
    """Complete semantic model parsed from OBML YAML."""

    version: float = 1.0
    name: str | None = Field(
        default=None,
        description=(
            "Optional addressing identifier for multi-model mode (v2.4.0+). "
            "When unset, the multi-model loader uses the filename stem. "
            "After normalization (lowercase + spaces/dots/dashes → "
            "underscores + trim) must match ``^[a-z][a-z0-9_]{0,62}$``. "
            "BI tools select this model via the Flight `database` catalog "
            "or pgwire `database=` URL parameter."
        ),
    )
    description: str | None = None
    settings: ModelSettings | None = None
    data_objects: dict[str, DataObject] = Field(default={}, alias="dataObjects")
    dimensions: dict[str, Dimension] = {}
    measures: dict[str, Measure] = {}
    metrics: dict[str, Metric] = {}
    filters: list[ModelFilter] = Field(default_factory=list)
    examples: list[ModelExample] = Field(default_factory=list)
    extends_sources: list[str] = Field(default_factory=list)
    inherits_source: str | None = None
    owner: str | None = None
    expose_counts: bool = Field(
        True,
        alias="exposeCounts",
        description=(
            "When true (default), synthesize a row-count measure for every countable "
            "data object. Set false to suppress all synthesized counts (e.g. wide "
            "models where N facts would balloon the measure list). Declared measures "
            "are unaffected."
        ),
    )
    count_label_pattern: str = Field(
        "{object} Count",
        alias="countLabelPattern",
        description=(
            "Name/label template for synthesized count measures (the count's id is "
            "its label). The only valid token is ``{object}``, which interpolates each "
            "object's display label (e.g. 'Sales' -> 'Sales Count'). A per-object "
            "``countLabel`` overrides it."
        ),
    )
    custom_extensions: list[CustomExtension] = Field(default_factory=list, alias="customExtensions")

    model_config = {"populate_by_name": True, "extra": "forbid"}

    @field_validator("count_label_pattern", mode="before")
    @classmethod
    def _validate_count_label_pattern(cls, v: object) -> object:
        """The pattern may reference only the ``{object}`` token.

        Reject any other field access (``{name}``, ``{object.__class__}``, ...) —
        cheap insurance even though OBML is author-controlled. Bare/escaped braces
        and positional ``{}`` are rejected too; only the named ``{object}`` field
        is allowed. Delegates to the shared ``count_pattern_error`` so the OBML
        resolver reports the same rule as a structured error.
        """
        from orionbelt.models.synthesis import count_pattern_error

        if not isinstance(v, str):
            return v
        msg = count_pattern_error(v)
        if msg is not None:
            raise ValueError(msg)
        return v

    def effective_joins(
        self,
        object_name: str,
        path_overrides: dict[tuple[str, str], str] | None = None,
    ) -> list[DataObjectJoin]:
        """The joins of *object_name* that are active under *path_overrides*.

        One named secondary path per ``(source, target)`` pair replaces that
        pair's primary join; every other pair keeps its primary. This is the
        rule :class:`~orionbelt.compiler.graph.JoinGraph` traverses by, and
        anything deriving join columns has to use the same one. Filtering
        secondary joins out unconditionally instead read a conformed subquery's
        key off the primary join even when the query had asked for the secondary
        path, which silently answered at the wrong grain.

        Takes a plain mapping rather than the query's ``usePathNames`` because
        ``models.query`` imports this module, not the other way round.
        """
        overrides = path_overrides or {}
        active: list[DataObjectJoin] = []
        obj = self.data_objects.get(object_name)
        for join in obj.joins if obj else []:
            pair = (object_name, join.join_to)
            if join.secondary:
                if overrides.get(pair) == join.path_name:
                    active.append(join)
            elif pair not in overrides:
                active.append(join)
        return active

    def column_reference_objects(self, object_name: str, column_label: str) -> set[str]:
        """Other data objects a column's ``expression`` reads.

        A computed column names a sibling with ``{Column}`` and a column of
        another data object with the qualified ``{[Data Object].[Column]}``
        form. Reading the latter means joining that object in, so callers add
        what this returns to a query's join requirements — it is deliberately
        *not* part of ``measure_source_objects``, which drives multi-fact
        detection: a cross-object computed column is one row of a star, not a
        second fact.

        Follows nested computed columns, across objects as well as within one,
        and returns every object involved except the owning one — which stays
        out however many hops away the walk reaches it again, because it is
        joined already by virtue of owning the column.

        Empty for a plain column and for a computed one that reads only
        siblings, which is what makes this cheap to call on every column.
        """
        found: set[str] = set()
        seen: set[tuple[str, str]] = set()

        def walk(obj_name: str, col_label: str) -> None:
            key = (obj_name, col_label)
            if key in seen:
                return
            seen.add(key)
            obj = self.data_objects.get(obj_name)
            column = obj.columns.get(col_label) if obj else None
            if obj is None or column is None or not column.expression:
                return
            for sibling in find_placeholders(column.expression):
                if sibling in obj.columns:
                    walk(obj_name, sibling)
            for ref_object, ref_column in find_qualified_refs(column.expression):
                if ref_object not in self.data_objects:
                    continue
                if ref_object != object_name:
                    found.add(ref_object)
                walk(ref_object, ref_column)

        walk(object_name, column_label)
        return found

    def measure_join_objects(self, name: str) -> set[str]:
        """Objects a measure needs *joined* without sourcing values from them.

        Two kinds. A ``withinGroup`` column becomes the aggregate's ORDER BY,
        so it has to resolve while contributing no value. And any column the
        measure touches — its own, its filters', its filter context's — may be
        computed from another data object, which the expression names directly
        and so has to be joined.

        Deliberately separate from the objects a measure *sources*: that set
        drives multi-fact detection, and neither an ordering column nor an
        expression's neighbour is a second fact.

        Lives on the model because two callers need the same answer — the
        planner, which adds these to a query's join requirements, and
        composability, which must not advertise a measure the planner will
        then refuse. Computed independently, they drifted.
        """
        measure = self.effective_measures.get(name)
        if measure is None:
            return set()

        result: set[str] = set()
        columns: list[tuple[str, str]] = [
            (c.view, c.column) for c in measure.columns if c.view and c.column
        ]

        within = measure.within_group.column if measure.within_group is not None else None
        if within is not None and within.view:
            result.add(within.view)
            if within.column:
                columns.append((within.view, within.column))

        def collect(item: MeasureFilterItem) -> None:
            if isinstance(item, MeasureFilter):
                if item.column and item.column.view and item.column.column:
                    columns.append((item.column.view, item.column.column))
            elif isinstance(item, MeasureFilterGroup):
                for child in item.filters:
                    collect(child)

        for fi in measure.filters:
            collect(fi)

        # filterContext.include is resolved by a wrapper that runs after join
        # planning, over the joins planning chose — so its dependencies have to
        # be known here. Both field forms the wrapper accepts.
        if measure.filter_context is not None:
            for incl in measure.filter_context.include:
                dim = self.dimensions.get(incl.field)
                if dim is not None:
                    if dim.view and dim.column:
                        result.add(dim.view)
                        columns.append((dim.view, dim.column))
                    continue
                obj_name, _, col_name = incl.field.partition(".")
                obj_name, col_name = obj_name.strip(), col_name.strip()
                obj = self.data_objects.get(obj_name)
                if obj is not None and col_name in obj.columns:
                    result.add(obj_name)
                    columns.append((obj_name, col_name))

        if measure.expression:
            columns.extend(find_qualified_refs(measure.expression))

        for object_name, column_label in columns:
            result |= self.column_reference_objects(object_name, column_label)
        return result

    def dimension_join_objects(self, name: str) -> set[str]:
        """Objects a dimension needs joined besides the one it belongs to.

        Its column may be computed from another data object's column, which is
        inlined wherever the dimension is projected or filtered.
        """
        dim = self.dimensions.get(name)
        if dim is None or not dim.view or not dim.column:
            return set()
        return self.column_reference_objects(dim.view, dim.column)

    def common_join_targets(
        self,
        objects: list[str],
        path_overrides: dict[tuple[str, str], str] | None = None,
    ) -> list[str]:
        """Every data object all of *objects* join to directly, in name order.

        The candidates for conforming independent facts to a shared grain. More
        than one is an ambiguity rather than a tie to break: two facts sharing
        both a calendar and a store conform to different numbers depending which
        is used, so callers refuse rather than pick.

        Which joins count depends on the query's active ``usePathNames``: see
        :meth:`effective_joins`.
        """
        reachable_targets = {
            name: {join.join_to for join in self.effective_joins(name, path_overrides)}
            for name in objects
        }
        # A candidate has to be reached by every object, and counts as reaching
        # itself: an expression may read a column of the very object the facts
        # conform to (``Sales.Qty * Returns.Qty * Calendar.Factor``), and
        # intersecting Calendar's own join targets in would leave nothing, since
        # a conformed dimension typically joins to nothing.
        pool = set(objects) | {
            target for targets in reachable_targets.values() for target in targets
        }
        return sorted(
            candidate
            for candidate in pool
            if all(name == candidate or candidate in reachable_targets[name] for name in objects)
        )

    def unnest_root(self, name: str) -> str:
        """The nearest ancestor of *name* that a FROM clause can actually name.

        A ``nestedIn`` object's rows are an array column on its parent, so they
        exist only inside the parent's row: it can never be a query's base
        object nor a CFL leg's root, and it is never what a plan selects *from*.
        Walking up gives the object that can be, and it always covers what the
        nested one does, since the parent reaches the child and not the reverse.

        Returns *name* unchanged for an ordinary object, and stops on a chain
        that loops - the semantic validator refuses one, but this is reached
        from the compiler, which must terminate on any model it is handed.
        """
        seen: set[str] = set()
        cursor = name
        while cursor not in seen:
            obj = self.data_objects.get(cursor)
            if obj is None or obj.nested_in is None:
                return cursor
            seen.add(cursor)
            cursor = obj.nested_in.data_object
        return cursor

    @property
    def effective_measures(self) -> dict[str, Measure]:
        """Declared measures plus synthesized row-count measures (declared win).

        The single source of truth for the model's queryable measure namespace.
        Synthesized counts are *not* persisted on ``measures`` (they never
        roundtrip through YAML/OSI) — they are computed on demand here so every
        read/resolve surface sees them as ordinary named measures.
        """
        from orionbelt.models.synthesis import synthesize_count_measures

        merged = dict(self.measures)
        merged.update(synthesize_count_measures(self))
        return merged

    @field_validator("name", mode="before")
    @classmethod
    def _validate_name(cls, v: str | None) -> str | None:
        """Reject invalid names early. Pydantic validators raise ValueError
        which the loader turns into a model-validation error.

        Empty / whitespace-only strings are treated as ``None`` rather than
        passed through, so an empty ``name:`` in YAML falls back to the
        filename stem at startup.
        """
        if v is None:
            return None
        if not isinstance(v, str):
            raise ValueError("name must be a string")
        if not v.strip():
            return None
        # Use the same normalization pipeline the loader uses, so an OBML
        # `name:` that's invalid surfaces during parse-time rather than
        # only at startup. The normalized value is stored on the model.
        from orionbelt.models.identifiers import (
            ModelNameError,
            normalize_model_name,
        )

        try:
            return normalize_model_name(v, source="OBML `name:` field")
        except ModelNameError as exc:
            raise ValueError(str(exc)) from None

effective_measures property

Declared measures plus synthesized row-count measures (declared win).

The single source of truth for the model's queryable measure namespace. Synthesized counts are not persisted on measures (they never roundtrip through YAML/OSI) — they are computed on demand here so every read/resolve surface sees them as ordinary named measures.

effective_joins(object_name, path_overrides=None)

The joins of object_name that are active under path_overrides.

One named secondary path per (source, target) pair replaces that pair's primary join; every other pair keeps its primary. This is the rule :class:~orionbelt.compiler.graph.JoinGraph traverses by, and anything deriving join columns has to use the same one. Filtering secondary joins out unconditionally instead read a conformed subquery's key off the primary join even when the query had asked for the secondary path, which silently answered at the wrong grain.

Takes a plain mapping rather than the query's usePathNames because models.query imports this module, not the other way round.

Source code in src/orionbelt/models/semantic.py
def effective_joins(
    self,
    object_name: str,
    path_overrides: dict[tuple[str, str], str] | None = None,
) -> list[DataObjectJoin]:
    """The joins of *object_name* that are active under *path_overrides*.

    One named secondary path per ``(source, target)`` pair replaces that
    pair's primary join; every other pair keeps its primary. This is the
    rule :class:`~orionbelt.compiler.graph.JoinGraph` traverses by, and
    anything deriving join columns has to use the same one. Filtering
    secondary joins out unconditionally instead read a conformed subquery's
    key off the primary join even when the query had asked for the secondary
    path, which silently answered at the wrong grain.

    Takes a plain mapping rather than the query's ``usePathNames`` because
    ``models.query`` imports this module, not the other way round.
    """
    overrides = path_overrides or {}
    active: list[DataObjectJoin] = []
    obj = self.data_objects.get(object_name)
    for join in obj.joins if obj else []:
        pair = (object_name, join.join_to)
        if join.secondary:
            if overrides.get(pair) == join.path_name:
                active.append(join)
        elif pair not in overrides:
            active.append(join)
    return active

column_reference_objects(object_name, column_label)

Other data objects a column's expression reads.

A computed column names a sibling with {Column} and a column of another data object with the qualified {[Data Object].[Column]} form. Reading the latter means joining that object in, so callers add what this returns to a query's join requirements — it is deliberately not part of measure_source_objects, which drives multi-fact detection: a cross-object computed column is one row of a star, not a second fact.

Follows nested computed columns, across objects as well as within one, and returns every object involved except the owning one — which stays out however many hops away the walk reaches it again, because it is joined already by virtue of owning the column.

Empty for a plain column and for a computed one that reads only siblings, which is what makes this cheap to call on every column.

Source code in src/orionbelt/models/semantic.py
def column_reference_objects(self, object_name: str, column_label: str) -> set[str]:
    """Other data objects a column's ``expression`` reads.

    A computed column names a sibling with ``{Column}`` and a column of
    another data object with the qualified ``{[Data Object].[Column]}``
    form. Reading the latter means joining that object in, so callers add
    what this returns to a query's join requirements — it is deliberately
    *not* part of ``measure_source_objects``, which drives multi-fact
    detection: a cross-object computed column is one row of a star, not a
    second fact.

    Follows nested computed columns, across objects as well as within one,
    and returns every object involved except the owning one — which stays
    out however many hops away the walk reaches it again, because it is
    joined already by virtue of owning the column.

    Empty for a plain column and for a computed one that reads only
    siblings, which is what makes this cheap to call on every column.
    """
    found: set[str] = set()
    seen: set[tuple[str, str]] = set()

    def walk(obj_name: str, col_label: str) -> None:
        key = (obj_name, col_label)
        if key in seen:
            return
        seen.add(key)
        obj = self.data_objects.get(obj_name)
        column = obj.columns.get(col_label) if obj else None
        if obj is None or column is None or not column.expression:
            return
        for sibling in find_placeholders(column.expression):
            if sibling in obj.columns:
                walk(obj_name, sibling)
        for ref_object, ref_column in find_qualified_refs(column.expression):
            if ref_object not in self.data_objects:
                continue
            if ref_object != object_name:
                found.add(ref_object)
            walk(ref_object, ref_column)

    walk(object_name, column_label)
    return found

measure_join_objects(name)

Objects a measure needs joined without sourcing values from them.

Two kinds. A withinGroup column becomes the aggregate's ORDER BY, so it has to resolve while contributing no value. And any column the measure touches — its own, its filters', its filter context's — may be computed from another data object, which the expression names directly and so has to be joined.

Deliberately separate from the objects a measure sources: that set drives multi-fact detection, and neither an ordering column nor an expression's neighbour is a second fact.

Lives on the model because two callers need the same answer — the planner, which adds these to a query's join requirements, and composability, which must not advertise a measure the planner will then refuse. Computed independently, they drifted.

Source code in src/orionbelt/models/semantic.py
def measure_join_objects(self, name: str) -> set[str]:
    """Objects a measure needs *joined* without sourcing values from them.

    Two kinds. A ``withinGroup`` column becomes the aggregate's ORDER BY,
    so it has to resolve while contributing no value. And any column the
    measure touches — its own, its filters', its filter context's — may be
    computed from another data object, which the expression names directly
    and so has to be joined.

    Deliberately separate from the objects a measure *sources*: that set
    drives multi-fact detection, and neither an ordering column nor an
    expression's neighbour is a second fact.

    Lives on the model because two callers need the same answer — the
    planner, which adds these to a query's join requirements, and
    composability, which must not advertise a measure the planner will
    then refuse. Computed independently, they drifted.
    """
    measure = self.effective_measures.get(name)
    if measure is None:
        return set()

    result: set[str] = set()
    columns: list[tuple[str, str]] = [
        (c.view, c.column) for c in measure.columns if c.view and c.column
    ]

    within = measure.within_group.column if measure.within_group is not None else None
    if within is not None and within.view:
        result.add(within.view)
        if within.column:
            columns.append((within.view, within.column))

    def collect(item: MeasureFilterItem) -> None:
        if isinstance(item, MeasureFilter):
            if item.column and item.column.view and item.column.column:
                columns.append((item.column.view, item.column.column))
        elif isinstance(item, MeasureFilterGroup):
            for child in item.filters:
                collect(child)

    for fi in measure.filters:
        collect(fi)

    # filterContext.include is resolved by a wrapper that runs after join
    # planning, over the joins planning chose — so its dependencies have to
    # be known here. Both field forms the wrapper accepts.
    if measure.filter_context is not None:
        for incl in measure.filter_context.include:
            dim = self.dimensions.get(incl.field)
            if dim is not None:
                if dim.view and dim.column:
                    result.add(dim.view)
                    columns.append((dim.view, dim.column))
                continue
            obj_name, _, col_name = incl.field.partition(".")
            obj_name, col_name = obj_name.strip(), col_name.strip()
            obj = self.data_objects.get(obj_name)
            if obj is not None and col_name in obj.columns:
                result.add(obj_name)
                columns.append((obj_name, col_name))

    if measure.expression:
        columns.extend(find_qualified_refs(measure.expression))

    for object_name, column_label in columns:
        result |= self.column_reference_objects(object_name, column_label)
    return result

dimension_join_objects(name)

Objects a dimension needs joined besides the one it belongs to.

Its column may be computed from another data object's column, which is inlined wherever the dimension is projected or filtered.

Source code in src/orionbelt/models/semantic.py
def dimension_join_objects(self, name: str) -> set[str]:
    """Objects a dimension needs joined besides the one it belongs to.

    Its column may be computed from another data object's column, which is
    inlined wherever the dimension is projected or filtered.
    """
    dim = self.dimensions.get(name)
    if dim is None or not dim.view or not dim.column:
        return set()
    return self.column_reference_objects(dim.view, dim.column)

common_join_targets(objects, path_overrides=None)

Every data object all of objects join to directly, in name order.

The candidates for conforming independent facts to a shared grain. More than one is an ambiguity rather than a tie to break: two facts sharing both a calendar and a store conform to different numbers depending which is used, so callers refuse rather than pick.

Which joins count depends on the query's active usePathNames: see :meth:effective_joins.

Source code in src/orionbelt/models/semantic.py
def common_join_targets(
    self,
    objects: list[str],
    path_overrides: dict[tuple[str, str], str] | None = None,
) -> list[str]:
    """Every data object all of *objects* join to directly, in name order.

    The candidates for conforming independent facts to a shared grain. More
    than one is an ambiguity rather than a tie to break: two facts sharing
    both a calendar and a store conform to different numbers depending which
    is used, so callers refuse rather than pick.

    Which joins count depends on the query's active ``usePathNames``: see
    :meth:`effective_joins`.
    """
    reachable_targets = {
        name: {join.join_to for join in self.effective_joins(name, path_overrides)}
        for name in objects
    }
    # A candidate has to be reached by every object, and counts as reaching
    # itself: an expression may read a column of the very object the facts
    # conform to (``Sales.Qty * Returns.Qty * Calendar.Factor``), and
    # intersecting Calendar's own join targets in would leave nothing, since
    # a conformed dimension typically joins to nothing.
    pool = set(objects) | {
        target for targets in reachable_targets.values() for target in targets
    }
    return sorted(
        candidate
        for candidate in pool
        if all(name == candidate or candidate in reachable_targets[name] for name in objects)
    )

unnest_root(name)

The nearest ancestor of name that a FROM clause can actually name.

A nestedIn object's rows are an array column on its parent, so they exist only inside the parent's row: it can never be a query's base object nor a CFL leg's root, and it is never what a plan selects from. Walking up gives the object that can be, and it always covers what the nested one does, since the parent reaches the child and not the reverse.

Returns name unchanged for an ordinary object, and stops on a chain that loops - the semantic validator refuses one, but this is reached from the compiler, which must terminate on any model it is handed.

Source code in src/orionbelt/models/semantic.py
def unnest_root(self, name: str) -> str:
    """The nearest ancestor of *name* that a FROM clause can actually name.

    A ``nestedIn`` object's rows are an array column on its parent, so they
    exist only inside the parent's row: it can never be a query's base
    object nor a CFL leg's root, and it is never what a plan selects *from*.
    Walking up gives the object that can be, and it always covers what the
    nested one does, since the parent reaches the child and not the reverse.

    Returns *name* unchanged for an ordinary object, and stops on a chain
    that loops - the semantic validator refuses one, but this is reached
    from the compiler, which must terminate on any model it is handed.
    """
    seen: set[str] = set()
    cursor = name
    while cursor not in seen:
        obj = self.data_objects.get(cursor)
        if obj is None or obj.nested_in is None:
            return cursor
        seen.add(cursor)
        cursor = obj.nested_in.data_object
    return cursor

orionbelt.models.semantic.DataObject

Bases: BaseModel

A database table or view with its columns and joins.

Source code in src/orionbelt/models/semantic.py
class DataObject(BaseModel):
    """A database table or view with its columns and joins."""

    name: str
    code: str
    database: str
    schema_name: str = Field(alias="schema")
    columns: dict[str, DataObjectColumn] = {}
    joins: list[DataObjectJoin] = []
    description: str | None = None
    comment: str | None = None
    owner: str | None = None
    countable: bool = Field(
        True,
        description=(
            "When true (default), the model synthesizes a grain-anchored row-count "
            "measure for this data object (name == label, e.g. 'Sales Count'). Set "
            "false to opt out (no count measure is added to the model's measure list)."
        ),
    )
    count_label: str | None = Field(
        None,
        alias="countLabel",
        description=(
            "Optional name/label for this object's synthesized count measure (the "
            "count's id is its label). Overrides the model-level ``countLabelPattern``. "
            "The ``{object}`` token interpolates the object's display label. Ignored "
            "when ``countable`` is false."
        ),
    )
    synonyms: list[str] = Field(default_factory=list)
    custom_extensions: list[CustomExtension] = Field(default_factory=list, alias="customExtensions")
    refresh: RefreshPolicy | None = Field(
        default=None,
        description=(
            "Optional freshness contract for the physical table this dataObject maps to. "
            "Drives result-cache TTL composition. PLAN_freshness_driven_cache.md §5."
        ),
    )
    nested_in: NestedSource | None = Field(
        default=None,
        alias="nestedIn",
        description=(
            "Take this object's rows by unnesting an array column on another object "
            "rather than from a table of its own. Declared alongside ``code`` rather "
            "than instead of it: where both are present the unnest is used and the "
            "table is the fallback, which is what makes moving a model off a "
            "hand-written flattening view incremental."
        ),
    )

    @property
    def is_nested(self) -> bool:
        """Whether this object's rows come from unnesting a parent's column."""
        return self.nested_in is not None

    @property
    def qualified_code(self) -> str:
        """Full qualified table reference: database.schema.code."""
        self.require_table_source()
        return f"{self.database}.{self.schema_name}.{self.code}"

    def require_table_source(self) -> None:
        """Raise unless this object has a table a FROM clause can name.

        A ``nestedIn`` object without ``code`` has no table: its rows are an
        array column on its parent, reached by an unnest that names the parent
        rather than by selecting from anything. The planner puts it in the FROM
        clause that way and never asks for a table, so reaching here means
        something tried to *select from* it - which is what this refuses, rather
        than falling through to an empty ``code`` and emitting
        ``FROM "" AS "Charge Labels"``.
        """
        if self.code:
            return
        source = self.nested_in
        raise UnrenderableDataObjectError(
            f"Data object '{self.name}' takes its rows by unnesting "
            f"'{source.data_object}.{source.column}', so it has no table to select "
            f"from - its rows exist only inside its parent's. Reach it through "
            f"'{source.data_object}', or declare 'code' alongside 'nestedIn' to read "
            f"a flattening view."
            if source is not None
            else f"Data object '{self.name}' has no 'code' to select from."
        )

    @model_validator(mode="after")
    def _validate_has_a_source(self) -> DataObject:
        """An object needs somewhere for its rows to come from.

        ``code`` or ``nestedIn``, and both together is the supported case rather
        than an error: an object that can be unnested *and* has a flattening
        view behind it stays queryable on an engine that cannot unnest, and
        lets a model migrate one object at a time instead of all at once.
        """
        if not self.code and self.nested_in is None:
            raise ValueError(
                f"Data object '{self.name}' declares neither 'code' nor 'nestedIn', "
                "so it has no rows. Give it a table name, or nest it in a parent "
                "object's array column."
            )
        return self

    @model_validator(mode="after")
    def _validate_count_label(self) -> DataObject:
        """``countLabel`` only has an effect when the object is countable.

        Warn (do not error) so a stray override on a non-countable object is
        surfaced without breaking model load.
        """
        if self.count_label is not None and not self.countable:
            import warnings

            warnings.warn(
                f"Data object '{self.name}' sets 'countLabel' but 'countable' is false; "
                "the label is ignored because no count measure is synthesized.",
                stacklevel=2,
            )
        return self

    model_config = {"populate_by_name": True, "extra": "forbid"}

is_nested property

Whether this object's rows come from unnesting a parent's column.

qualified_code property

Full qualified table reference: database.schema.code.

require_table_source()

Raise unless this object has a table a FROM clause can name.

A nestedIn object without code has no table: its rows are an array column on its parent, reached by an unnest that names the parent rather than by selecting from anything. The planner puts it in the FROM clause that way and never asks for a table, so reaching here means something tried to select from it - which is what this refuses, rather than falling through to an empty code and emitting FROM "" AS "Charge Labels".

Source code in src/orionbelt/models/semantic.py
def require_table_source(self) -> None:
    """Raise unless this object has a table a FROM clause can name.

    A ``nestedIn`` object without ``code`` has no table: its rows are an
    array column on its parent, reached by an unnest that names the parent
    rather than by selecting from anything. The planner puts it in the FROM
    clause that way and never asks for a table, so reaching here means
    something tried to *select from* it - which is what this refuses, rather
    than falling through to an empty ``code`` and emitting
    ``FROM "" AS "Charge Labels"``.
    """
    if self.code:
        return
    source = self.nested_in
    raise UnrenderableDataObjectError(
        f"Data object '{self.name}' takes its rows by unnesting "
        f"'{source.data_object}.{source.column}', so it has no table to select "
        f"from - its rows exist only inside its parent's. Reach it through "
        f"'{source.data_object}', or declare 'code' alongside 'nestedIn' to read "
        f"a flattening view."
        if source is not None
        else f"Data object '{self.name}' has no 'code' to select from."
    )

orionbelt.models.semantic.Dimension

Bases: BaseModel

A named dimension referencing a data object column.

Source code in src/orionbelt/models/semantic.py
class Dimension(BaseModel):
    """A named dimension referencing a data object column."""

    name: str
    view: str = Field(alias="dataObject")
    column: str = ""
    result_type: DataType = Field(DataType.STRING, alias="resultType")
    time_grain: TimeGrain | None = Field(None, alias="timeGrain")
    description: str | None = None
    format: str | None = None
    via: str | None = None
    owner: str | None = None
    synonyms: list[str] = Field(default_factory=list)
    custom_extensions: list[CustomExtension] = Field(default_factory=list, alias="customExtensions")

    model_config = {"populate_by_name": True, "extra": "forbid"}

orionbelt.models.semantic.Measure

Bases: BaseModel

An aggregation measure with optional expression template.

Source code in src/orionbelt/models/semantic.py
class Measure(BaseModel):
    """An aggregation measure with optional expression template."""

    name: str
    columns: list[DataColumnRef] = []
    result_type: DataType = Field(DataType.FLOAT, alias="resultType")
    aggregation: AggregationType
    expression: str | None = None
    distinct: bool = False
    total: bool = False
    default_value: str | int | float | bool | None = Field(None, alias="defaultValue")
    """Value to report when the aggregate has nothing to add up.

    An aggregate over no rows is NULL in standard SQL, and a filtered measure
    reaches that state routinely — the group exists, the filter matches none of
    it. Whether the answer should read as NULL or as zero is the modeller's
    call, not the engine's, and engines disagree: ClickHouse returns 0 where
    Postgres, DuckDB and the rest return NULL for an aggregate over an empty
    row set. Setting this pins the value on every dialect; leaving it unset
    keeps the SQL-standard NULL.

    Same slot as :attr:`Metric.default_value`, which does the same job for a
    window function with nothing to look at.
    """
    anchor: str | None = None
    """Data object whose grain this measure's expression is evaluated at.

    Only meaningful for an expression reading columns from *independent facts*
    — objects no single join path reaches together. Without it such a measure
    has no defined value: ``UNION ALL`` stacks the facts rather than joining
    them, so no row carries both columns. Naming an anchor says which fact's
    rows the expression runs over; the other facts are aggregated to the key
    they share with it and joined on many-to-one, so nothing fans out.

    It cannot be inferred. ``{[Returns].[Qty]} / {[Sales].[Qty]}`` is symmetric,
    and anchoring it on Returns rather than the shared calendar key changes
    ``AVG`` from 0.5 to 0.3333 (different row populations), so a wrong guess is
    a wrong number rather than an error.

    A bare data-object name, like :attr:`Dimension.via`.
    """

    grain: GrainOverride | None = None
    filter_context: FilterContext | None = Field(None, alias="filterContext")
    filters: list[MeasureFilterItem] = []
    data_type: str | None = Field(None, alias="dataType")
    description: str | None = None
    format: str | None = None
    allow_fan_out: bool = Field(False, alias="allowFanOut")
    delimiter: str | None = None
    within_group: WithinGroup | None = Field(None, alias="withinGroup")
    owner: str | None = None
    synonyms: list[str] = Field(default_factory=list)
    custom_extensions: list[CustomExtension] = Field(default_factory=list, alias="customExtensions")

    model_config = {"populate_by_name": True, "extra": "forbid"}

    @field_validator("aggregation", mode="before")
    @classmethod
    def _normalize_aggregation(cls, v: object) -> object:
        """Lowercase aggregation names so ``SUM`` / ``Sum`` / ``sum`` all
        resolve to the same ``AggregationType.SUM``. The enum's canonical
        spelling is lowercase, but uppercase SQL-style is a common BI/LLM
        convention that pre-v2.7.5 worked by accident (``aggregation``
        was a plain ``str``) — keep accepting it now that the field is
        a validated enum.

        ``AGG`` and ``AGGREGATE`` are accepted as aliases for ``MEASURE``
        (v2.7.7+) so OBML reads naturally for users coming from
        Databricks (``measure``), older Spark docs (``aggregate``), or
        the shorthand most BI tools default to (``agg``).
        """
        if isinstance(v, str):
            lowered = v.lower()
            if lowered in ("agg", "aggregate"):
                return "measure"
            return lowered
        return v

    @field_validator("data_type", mode="before")
    @classmethod
    def _validate_data_type(cls, v: str | None) -> str | None:
        if v is not None:
            parse_data_type(v)
        return v

    @property
    def source_objects(self) -> set[str]:
        """Data objects whose columns this measure reads.

        Covers both declaration forms: the structured ``columns:`` list and
        ``{[Object].[Column]}`` references inside ``expression:``.
        """
        return set(self.referenced_objects)

    @property
    def referenced_objects(self) -> list[str]:
        """:attr:`source_objects`, in the order the declaration mentions them.

        Ordered for determinism, not for meaning. An earlier design used the
        first entry as the default :attr:`anchor` and was removed: it made a
        commutative rewrite change the answer, since
        ``{[Sales].[Qty]} * {[Returns].[Qty]}`` and the operands swapped would
        anchor on different facts and return different averages. Nothing reads
        position now, and nothing should.
        """
        ordered: list[str] = []
        for cref in self.columns:
            if cref.view and cref.view not in ordered:
                ordered.append(cref.view)
        if self.expression:
            for obj, _col in find_qualified_refs(self.expression):
                if obj not in ordered:
                    ordered.append(obj)
        return ordered

    @model_validator(mode="after")
    def _validate_total_grain_exclusion(self) -> Measure:
        if self.total and self.grain is not None:
            raise ValueError("'total: true' and 'grain' are mutually exclusive")
        return self

    @model_validator(mode="after")
    def _validate_measure_delegation(self) -> Measure:
        """``aggregation: measure`` delegates the aggregation to the
        engine's metric-view resolver, so the OBML measure declaration
        must NOT specify ``columns:`` or ``expression:`` — there is no
        source column for OBSL to read; the engine resolves the measure
        by name. Reject the combination at model-load time rather than
        emitting SQL that would silently ignore the column reference.
        """
        if self.aggregation == AggregationType.MEASURE:
            if self.columns:
                raise ValueError(
                    "aggregation: measure delegates resolution to the engine "
                    "(Databricks Metric View); 'columns:' must be omitted. "
                    "The engine resolves the measure by its OBML label."
                )
            if self.expression is not None:
                raise ValueError(
                    "aggregation: measure delegates resolution to the engine "
                    "(Databricks Metric View); 'expression:' must be omitted. "
                    "The engine resolves the measure by its OBML label."
                )
            if self.filters:
                raise ValueError(
                    "aggregation: measure delegates resolution to the engine "
                    "(Databricks Metric View); 'filters:' is not applicable. "
                    "Define the filter inside the metric view itself."
                )
            if self.total:
                raise ValueError(
                    "aggregation: measure cannot be combined with 'total: true' "
                    "(OBSL cannot wrap the engine-resolved aggregation in "
                    "a window function — define the total at the metric-view level)."
                )
            if self.default_value is not None:
                raise ValueError(
                    "aggregation: measure cannot be combined with 'defaultValue' "
                    "(the engine resolves the aggregation, so OBSL never sees "
                    "the empty set it would substitute for — declare the "
                    "default in the metric view itself)."
                )
        return self

    @model_validator(mode="after")
    def _validate_statistical_aggregation_arity(self) -> Measure:
        """Reject malformed statistical aggregates at model-load time.

        Two-column aggregates (``corr``, ``covar_*``, ``regr_*``) require
        exactly two entries in ``columns``. Single-column statistical
        aggregates (``stddev``, ``stddev_pop``, ``variance``, ``var_pop``)
        require exactly one.

        ``expression:`` form is **not allowed** for two-column
        aggregates — a single expression string collapses to one scalar
        argument, producing invalid SQL like ``CORR((a + b))`` instead
        of ``CORR(a, b)``. To express per-argument transformations on
        two-column aggregates, define the inputs as computed columns on
        the data object and reference them via ``columns:``.

        Single-column statistical aggregates (``stddev`` etc.) DO accept
        ``expression:`` — the result ``STDDEV(<scalar expression>)`` is
        valid SQL.
        """
        agg = self.aggregation.lower()
        if self.expression is not None:
            if agg in TWO_COLUMN_AGGREGATIONS:
                raise ValueError(
                    f"Aggregation '{agg}' requires exactly 2 columns and cannot be "
                    "combined with 'expression:'. Use the 'columns:' list with two "
                    "entries (define computed columns on the data object if you need "
                    "per-argument transformations) so the aggregate's argument order "
                    "is explicit."
                )
            return self
        if agg in TWO_COLUMN_AGGREGATIONS and len(self.columns) != 2:
            raise ValueError(
                f"Aggregation '{agg}' requires exactly 2 columns, got {len(self.columns)}"
            )
        if agg in SINGLE_COLUMN_STATISTICAL_AGGREGATIONS and len(self.columns) != 1:
            raise ValueError(
                f"Aggregation '{agg}' requires exactly 1 column, got {len(self.columns)}"
            )
        return self

default_value = Field(None, alias='defaultValue') class-attribute instance-attribute

Value to report when the aggregate has nothing to add up.

An aggregate over no rows is NULL in standard SQL, and a filtered measure reaches that state routinely — the group exists, the filter matches none of it. Whether the answer should read as NULL or as zero is the modeller's call, not the engine's, and engines disagree: ClickHouse returns 0 where Postgres, DuckDB and the rest return NULL for an aggregate over an empty row set. Setting this pins the value on every dialect; leaving it unset keeps the SQL-standard NULL.

Same slot as :attr:Metric.default_value, which does the same job for a window function with nothing to look at.

anchor = None class-attribute instance-attribute

Data object whose grain this measure's expression is evaluated at.

Only meaningful for an expression reading columns from independent facts — objects no single join path reaches together. Without it such a measure has no defined value: UNION ALL stacks the facts rather than joining them, so no row carries both columns. Naming an anchor says which fact's rows the expression runs over; the other facts are aggregated to the key they share with it and joined on many-to-one, so nothing fans out.

It cannot be inferred. {[Returns].[Qty]} / {[Sales].[Qty]} is symmetric, and anchoring it on Returns rather than the shared calendar key changes AVG from 0.5 to 0.3333 (different row populations), so a wrong guess is a wrong number rather than an error.

A bare data-object name, like :attr:Dimension.via.

source_objects property

Data objects whose columns this measure reads.

Covers both declaration forms: the structured columns: list and {[Object].[Column]} references inside expression:.

referenced_objects property

:attr:source_objects, in the order the declaration mentions them.

Ordered for determinism, not for meaning. An earlier design used the first entry as the default :attr:anchor and was removed: it made a commutative rewrite change the answer, since {[Sales].[Qty]} * {[Returns].[Qty]} and the operands swapped would anchor on different facts and return different averages. Nothing reads position now, and nothing should.

orionbelt.models.semantic.Metric

Bases: BaseModel

A metric: derived expression, cumulative window, or period-over-period comparison.

Derived (default): references measures by name using {[Measure Name]} syntax. Cumulative: applies a window function to an existing measure, ordered by a time dimension. Supports running totals, rolling windows, and grain-to-date resets. Period-over-Period: compares a measure's value against a prior time period using a synthetical date spine. Supports ratio, difference, previous value, and percent change.

Source code in src/orionbelt/models/semantic.py
class Metric(BaseModel):
    """A metric: derived expression, cumulative window, or period-over-period comparison.

    **Derived** (default): references measures by name using ``{[Measure Name]}`` syntax.
    **Cumulative**: applies a window function to an existing measure, ordered by a time
    dimension.  Supports running totals, rolling windows, and grain-to-date resets.
    **Period-over-Period**: compares a measure's value against a prior time period using
    a synthetical date spine.  Supports ratio, difference, previous value, and percent change.
    """

    name: str
    type: MetricType = MetricType.DERIVED
    # Derived metrics
    expression: str | None = None
    # Cumulative metrics
    measure: str | None = None
    time_dimension: str | None = Field(None, alias="timeDimension")
    cumulative_type: CumulativeAggType = Field(CumulativeAggType.SUM, alias="cumulativeType")
    window: int | None = None
    grain_to_date: GrainToDate | None = Field(None, alias="grainToDate")
    # Per-dimension partitioning for cumulative + window metrics. Each entry
    # must be a model dimension reachable from the measure's source object.
    partition_by: list[str] = Field(default_factory=list, alias="partitionBy")
    # Period-over-Period metrics
    period_over_period: PeriodOverPeriod | None = Field(None, alias="periodOverPeriod")
    # Window metrics (rank / lag / lead / ntile / first_value / last_value)
    window_function: WindowFunctionKind | None = Field(None, alias="windowFunction")
    offset: int | None = None
    buckets: int | None = None
    order_direction: str = Field("desc", alias="orderDirection")
    default_value: str | int | float | bool | None = Field(None, alias="defaultValue")
    # Common
    data_type: str | None = Field(None, alias="dataType")
    description: str | None = None
    format: str | None = None
    owner: str | None = None
    synonyms: list[str] = Field(default_factory=list)
    custom_extensions: list[CustomExtension] = Field(default_factory=list, alias="customExtensions")

    model_config = {"populate_by_name": True, "extra": "forbid"}

    @field_validator("data_type", mode="before")
    @classmethod
    def _validate_data_type(cls, v: str | None) -> str | None:
        if v is not None:
            parse_data_type(v)
        return v

    @model_validator(mode="after")
    def _validate_metric_type(self) -> Metric:
        if self.type == MetricType.DERIVED:
            if not self.expression:
                raise ValueError("Derived metrics require 'expression'")
            if self.partition_by:
                raise ValueError("Derived metrics must not have 'partitionBy'")
        elif self.type == MetricType.CUMULATIVE:
            if not self.measure:
                raise ValueError("Cumulative metrics require 'measure'")
            if not self.time_dimension:
                raise ValueError("Cumulative metrics require 'timeDimension'")
            if self.expression:
                raise ValueError("Cumulative metrics must not have 'expression'")
            if self.window is not None and self.grain_to_date is not None:
                raise ValueError("'window' and 'grainToDate' are mutually exclusive")
            if self.window is not None and self.window < 1:
                raise ValueError("'window' must be >= 1")
        elif self.type == MetricType.PERIOD_OVER_PERIOD:
            if not self.expression:
                raise ValueError("Period-over-period metrics require 'expression'")
            if not self.period_over_period:
                raise ValueError("Period-over-period metrics require 'periodOverPeriod'")
            if self.measure:
                raise ValueError(
                    "Period-over-period metrics must not have 'measure' "
                    "(use 'expression' to reference measures)"
                )
            if self.window is not None or self.grain_to_date is not None:
                raise ValueError(
                    "Period-over-period metrics must not have 'window' or 'grainToDate'"
                )
            if self.partition_by:
                raise ValueError("Period-over-period metrics must not have 'partitionBy'")
        elif self.type == MetricType.WINDOW:
            if self.window_function is None:
                raise ValueError("Window metrics require 'windowFunction'")
            if not self.measure and self.window_function not in {
                WindowFunctionKind.ROW_NUMBER,
                WindowFunctionKind.NTILE,
            }:
                # row_number / ntile can rank without an explicit measure, falling back
                # to ordering on the time dimension. All other window functions take
                # the measure as their argument or ORDER BY input.
                raise ValueError(
                    f"Window metric with function '{self.window_function.value}' requires 'measure'"
                )
            if self.expression:
                raise ValueError("Window metrics must not have 'expression'")
            if self.window is not None or self.grain_to_date is not None:
                raise ValueError("Window metrics must not have 'window' or 'grainToDate'")
            if self.window_function in {WindowFunctionKind.LAG, WindowFunctionKind.LEAD}:
                if self.offset is None or self.offset < 1:
                    raise ValueError(
                        f"Window metric with function '{self.window_function.value}' "
                        f"requires positive 'offset'"
                    )
                if not self.time_dimension:
                    raise ValueError(
                        f"Window metric with function '{self.window_function.value}' "
                        f"requires 'timeDimension'"
                    )
            if self.window_function == WindowFunctionKind.NTILE and (
                self.buckets is None or self.buckets < 2
            ):
                raise ValueError("Window metric with function 'ntile' requires 'buckets' >= 2")
            if self.order_direction.lower() not in {"asc", "desc"}:
                raise ValueError("'orderDirection' must be 'asc' or 'desc'")
        return self

Query Models

orionbelt.models.query.QueryObject

Bases: BaseModel

A complete YAML analytical query.

Source code in src/orionbelt/models/query.py
class QueryObject(BaseModel):
    """A complete YAML analytical query."""

    select: QuerySelect
    where: list[QueryFilterItem] = []
    having: list[QueryFilterItem] = []
    order_by: list[QueryOrderBy] = Field([], alias="orderBy")
    limit: int | None = None
    offset: int | None = None
    use_path_names: list[UsePathName] = Field([], alias="usePathNames")
    allow_fan_out: bool = Field(False, alias="allowFanOut")
    """Silence fan-out warnings for this query.

    The query-level counterpart of ``Measure.allowFanOut``. A measure reading
    both a base-grain column and one from an object the joins replicate is
    evaluated per base row: right for an extended price
    (``quantity * list price``), wrong for anything that reads the replicated
    row's own magnitude. Nothing in the declarations separates the two, so the
    compiler warns rather than refusing, and this says the duplication is
    understood and intended for this query.
    """

    dimensions_exclude: bool = Field(False, alias="dimensionsExclude")
    grouping: Grouping | None = Field(
        default=None,
        description=(
            "Hierarchical grouping modifier. 'rollup' emits GROUP BY ROLLUP(...) "
            "for hierarchical subtotals + grand total. 'cube' emits GROUP BY CUBE(...) "
            "for the full cross-tab. Adds one GROUPING(dim) AS _g_<dim> column per "
            "selected dimension so callers can distinguish subtotal/grand-total rows."
        ),
    )

    model_config = {"populate_by_name": True, "extra": "forbid"}

    @model_validator(mode="after")
    def _validate_grouping(self) -> QueryObject:
        """Reject grouping with no dimensions or in raw mode."""
        if self.grouping is None:
            return self
        if self.select.is_raw:
            raise ValueError(
                "select.fields (raw mode) cannot be combined with grouping (rollup/cube)"
            )
        if not self.select.dimensions:
            raise ValueError(
                "grouping (rollup/cube) requires at least one dimension in select.dimensions"
            )
        return self

    @model_validator(mode="after")
    def _validate_raw_mode_exclusivity(self) -> QueryObject:
        """Raw mode (``select.fields``) is mutually exclusive with aggregate
        features. Catch misuse early so the resolver can assume a clean shape.
        """
        if self.select.is_raw:
            if self.select.dimensions:
                raise ValueError(
                    "select.fields (raw mode) cannot be combined with select.dimensions"
                )
            if self.select.measures:
                raise ValueError("select.fields (raw mode) cannot be combined with select.measures")
            if self.having:
                raise ValueError("select.fields (raw mode) cannot be combined with having")
            if self.dimensions_exclude:
                raise ValueError(
                    "select.fields (raw mode) cannot be combined with dimensionsExclude"
                )
        elif self.select.distinct:
            raise ValueError("select.distinct is only valid in raw mode (with select.fields)")
        return self

allow_fan_out = Field(False, alias='allowFanOut') class-attribute instance-attribute

Silence fan-out warnings for this query.

The query-level counterpart of Measure.allowFanOut. A measure reading both a base-grain column and one from an object the joins replicate is evaluated per base row: right for an extended price (quantity * list price), wrong for anything that reads the replicated row's own magnitude. Nothing in the declarations separates the two, so the compiler warns rather than refusing, and this says the duplication is understood and intended for this query.

orionbelt.models.query.QuerySelect

Bases: BaseModel

The SELECT part of a query.

Two mutually exclusive modes:

  • Aggregate mode (default): dimensions + measures produce a grouped, aggregated result (GROUP BY dimensions, aggregate measures).
  • Raw mode: fields returns un-aggregated rows from one or more data objects joined per the model. Set distinct: true for SELECT DISTINCT. Raw mode rejects dimensions, measures, metrics, and HAVING.
Source code in src/orionbelt/models/query.py
class QuerySelect(BaseModel):
    """The SELECT part of a query.

    Two mutually exclusive modes:

    * **Aggregate mode** (default): ``dimensions`` + ``measures`` produce a
      grouped, aggregated result (GROUP BY dimensions, aggregate measures).
    * **Raw mode**: ``fields`` returns un-aggregated rows from one or more
      data objects joined per the model. Set ``distinct: true`` for
      ``SELECT DISTINCT``. Raw mode rejects ``dimensions``, ``measures``,
      ``metrics``, and ``HAVING``.
    """

    dimensions: list[str | CoalesceDimension] = []
    measures: list[str] = []
    fields: list[str] = []
    distinct: bool = False

    model_config = {"populate_by_name": True, "extra": "forbid"}

    @property
    def is_raw(self) -> bool:
        """True when this select is in raw mode (fields-based projection)."""
        return bool(self.fields)

is_raw property

True when this select is in raw mode (fields-based projection).

orionbelt.models.query.QueryFilter

Bases: BaseModel

A filter condition in a query.

Source code in src/orionbelt/models/query.py
class QueryFilter(BaseModel):
    """A filter condition in a query."""

    field: str
    op: FilterOperator
    value: Any = None
    subquery: Subquery | None = None

    model_config = {"populate_by_name": True, "extra": "forbid"}

    @field_validator("value", mode="before")
    @classmethod
    def _validate_filter_value(cls, v: Any) -> Any:
        """Reject arbitrary nested objects — allow scalars, lists of scalars, and dicts
        (for RELATIVE filters which use ``{unit, count, direction}`` objects).
        Date/datetime values are coerced to ISO strings.
        """
        if v is None:
            return v
        if isinstance(v, datetime):
            return v.isoformat()
        if isinstance(v, date):
            return v.isoformat()
        if isinstance(v, (str, int, float, bool)):
            return v
        if isinstance(v, list):
            coerced = [i.isoformat() if isinstance(i, (date, datetime)) else i for i in v]
            if all(isinstance(i, (str, int, float, bool)) for i in coerced):
                return coerced
        if isinstance(v, dict) and all(isinstance(k, str) for k in v):
            return v
        msg = "Filter value must be a scalar, list of scalars, or object"
        raise ValueError(msg)

    @model_validator(mode="after")
    def _validate_subquery_exclusivity(self) -> QueryFilter:
        """``exists`` / ``nonexists`` require ``subquery`` (and reject ``value``).

        All other operators reject ``subquery`` — the payload would be silently
        ignored, which would mask typos.
        """
        is_subquery_op = self.op in (FilterOperator.EXISTS, FilterOperator.NONEXISTS)
        if is_subquery_op:
            if self.subquery is None:
                raise ValueError(
                    f"Operator '{self.op}' requires a 'subquery' object with 'dataObject'"
                )
            if self.value is not None:
                raise ValueError(f"Operator '{self.op}' takes 'subquery', not 'value' / 'values'")
        elif self.subquery is not None:
            raise ValueError(
                f"Operator '{self.op}' does not accept 'subquery' — use 'exists' or 'nonexists'"
            )
        return self

orionbelt.models.query.UsePathName

Bases: BaseModel

Selects a named secondary join path for a specific (source, target) pair.

Source code in src/orionbelt/models/query.py
class UsePathName(BaseModel):
    """Selects a named secondary join path for a specific (source, target) pair."""

    source: str
    target: str
    path_name: str = Field(alias="pathName")

    model_config = {"populate_by_name": True, "extra": "forbid"}

orionbelt.models.query.DimensionRef

Bases: BaseModel

Reference to a dimension, optionally with time grain.

Supports notation like "customer.country" or "order.order_date:month".

Source code in src/orionbelt/models/query.py
class DimensionRef(BaseModel):
    """Reference to a dimension, optionally with time grain.

    Supports notation like "customer.country" or "order.order_date:month".
    """

    name: str
    grain: TimeGrain | None = None

    model_config = {"populate_by_name": True, "extra": "forbid"}

    @classmethod
    def parse(cls, raw: str) -> DimensionRef:
        """Parse 'name:grain' notation."""
        if ":" in raw:
            name, grain_str = raw.rsplit(":", 1)
            return cls(name=name, grain=TimeGrain(grain_str))
        return cls(name=raw)

parse(raw) classmethod

Parse 'name:grain' notation.

Source code in src/orionbelt/models/query.py
@classmethod
def parse(cls, raw: str) -> DimensionRef:
    """Parse 'name:grain' notation."""
    if ":" in raw:
        name, grain_str = raw.rsplit(":", 1)
        return cls(name=name, grain=TimeGrain(grain_str))
    return cls(name=raw)

Error Models

orionbelt.models.errors.SemanticError

Bases: BaseModel

A structured error or warning with optional source position and remediation.

Used uniformly for errors (severity="error") and warnings (severity="warning"). See models/warnings.py for the stable warning code taxonomy.

Source code in src/orionbelt/models/errors.py
class SemanticError(BaseModel):
    """A structured error or warning with optional source position and remediation.

    Used uniformly for errors (``severity="error"``) and warnings (``severity="warning"``).
    See ``models/warnings.py`` for the stable warning code taxonomy.
    """

    code: str
    message: str
    path: str | None = None
    span: SourceSpan | None = None
    suggestions: list[str] = Field(default_factory=list)
    severity: str = "error"
    hint: str | None = Field(
        default=None,
        description="Optional remediation suggestion (single sentence)",
    )
    context: dict[str, Any] | None = Field(
        default=None,
        description=(
            "Optional structured detail (e.g. which measure / dataObject / column) so "
            "agents can branch on the data without parsing the message."
        ),
    )

orionbelt.models.errors.ValidationResult

Bases: BaseModel

Result of semantic model validation.

Source code in src/orionbelt/models/errors.py
class ValidationResult(BaseModel):
    """Result of semantic model validation."""

    valid: bool
    errors: list[SemanticError] = Field(default_factory=list)
    warnings: list[SemanticError] = Field(default_factory=list)

orionbelt.models.errors.SourceSpan

Bases: BaseModel

Points to exact location in YAML source for error reporting.

Source code in src/orionbelt/models/errors.py
class SourceSpan(BaseModel):
    """Points to exact location in YAML source for error reporting."""

    file: str
    line: int
    column: int
    end_line: int | None = None
    end_column: int | None = None

SQL AST Nodes

orionbelt.ast.nodes.Select dataclass

A complete SELECT statement.

Source code in src/orionbelt/ast/nodes.py
@dataclass(frozen=True)
class Select:
    """A complete SELECT statement."""

    columns: list[Expr] = field(default_factory=list)
    from_: From | None = None
    joins: list[Join | Unnest] = field(default_factory=list)
    """Join clauses and unnests, in the order the planner walked the path.

    One list rather than two, because the order between them matters: an unnest
    names its parent, so it has to follow whatever put that parent in scope.
    Keeping them apart would make the planner interleave them again at render
    time, from information it no longer has.
    """
    where: Expr | None = None
    group_by: list[Expr] = field(default_factory=list)
    having: Expr | None = None
    order_by: list[OrderByItem] = field(default_factory=list)
    limit: int | None = None
    offset: int | None = None
    ctes: list[CTE] = field(default_factory=list)
    distinct: bool = False
    grouping: str | None = None
    """Hierarchical grouping modifier: 'rollup' or 'cube'.

    When set, the dialect emits ``GROUP BY ROLLUP(...)`` / ``GROUP BY CUBE(...)``
    (or ClickHouse-style ``GROUP BY ... WITH ROLLUP``) instead of plain
    ``GROUP BY``. The planner is responsible for appending the
    ``GROUPING(dim) AS _g_<dim>`` columns to the SELECT projection."""

joins = field(default_factory=list) class-attribute instance-attribute

Join clauses and unnests, in the order the planner walked the path.

One list rather than two, because the order between them matters: an unnest names its parent, so it has to follow whatever put that parent in scope. Keeping them apart would make the planner interleave them again at render time, from information it no longer has.

grouping = None class-attribute instance-attribute

Hierarchical grouping modifier: 'rollup' or 'cube'.

When set, the dialect emits GROUP BY ROLLUP(...) / GROUP BY CUBE(...) (or ClickHouse-style GROUP BY ... WITH ROLLUP) instead of plain GROUP BY. The planner is responsible for appending the GROUPING(dim) AS _g_<dim> columns to the SELECT projection.

orionbelt.ast.nodes.ColumnRef dataclass

Reference to a column, optionally qualified by table/alias.

abstract_type is the OBML type of the column this names, when the node was built somewhere that knew it - the two places that resolve a name against the model, resolution.make_column_expr for the columns: form and compiler.expr_parser for the expression: one. A ref invented by a planner or a wrapper (a CTE alias, a projected measure) leaves it None, because at that point the type genuinely is not known. Carried for the same reason :class:NestedField carries one: a dialect sometimes has to know whether it is looking at a number, and the compiler models no types over expression bodies.

It is excluded from equality and hashing. Structural comparison of expressions is load-bearing in the planner - cfl_projection, grain_dedup, filter_wrap, total_wrap and the three wrappers all compare an expression against a freshly built one - and a ref that came through the funnel would otherwise stop matching a hand-built one naming the same column. That failure would be silent and would change results, which is a worse defect than any this field exists to fix.

Source code in src/orionbelt/ast/nodes.py
@dataclass(frozen=True)
class ColumnRef:
    """Reference to a column, optionally qualified by table/alias.

    ``abstract_type`` is the OBML type of the column this names, when the node
    was built somewhere that knew it - the two places that resolve a name against
    the model, ``resolution.make_column_expr`` for the ``columns:`` form and
    ``compiler.expr_parser`` for the ``expression:`` one. A ref invented by a
    planner or a wrapper (a CTE alias, a projected measure) leaves it ``None``,
    because at that point the type genuinely is not known. Carried for the same
    reason :class:`NestedField` carries one: a dialect sometimes has to know
    whether it is looking at a number, and the compiler models no types over
    expression bodies.

    It is **excluded from equality and hashing**. Structural comparison of
    expressions is load-bearing in the planner - ``cfl_projection``,
    ``grain_dedup``, ``filter_wrap``, ``total_wrap`` and the three wrappers all
    compare an expression against a freshly built one - and a ref that came
    through the funnel would otherwise stop matching a hand-built one naming the
    same column. That failure would be silent and would change results, which is
    a worse defect than any this field exists to fix.
    """

    name: str
    table: str | None = None
    abstract_type: str | None = field(default=None, compare=False)

orionbelt.ast.nodes.FunctionCall dataclass

SQL function call, e.g. SUM(col), DATE_TRUNC('month', col).

Source code in src/orionbelt/ast/nodes.py
@dataclass(frozen=True)
class FunctionCall:
    """SQL function call, e.g. SUM(col), DATE_TRUNC('month', col)."""

    name: str
    args: list[Expr] = field(default_factory=list)
    distinct: bool = False
    order_by: list[OrderByItem] = field(default_factory=list)
    separator: str | None = None

orionbelt.ast.nodes.BinaryOp dataclass

Binary operation: left op right.

Source code in src/orionbelt/ast/nodes.py
@dataclass(frozen=True)
class BinaryOp:
    """Binary operation: left op right."""

    left: Expr
    op: str  # +, -, *, /, =, <>, AND, OR, LIKE, etc.
    right: Expr

orionbelt.ast.nodes.Literal dataclass

A literal value: number, string, boolean, or NULL.

Source code in src/orionbelt/ast/nodes.py
@dataclass(frozen=True)
class Literal:
    """A literal value: number, string, boolean, or NULL."""

    value: str | int | float | bool | None

    @classmethod
    def string(cls, v: str) -> Literal:
        return cls(value=v)

    @classmethod
    def number(cls, v: int | float) -> Literal:
        return cls(value=v)

    @classmethod
    def null(cls) -> Literal:
        return cls(value=None)

    @classmethod
    def boolean(cls, v: bool) -> Literal:
        return cls(value=v)

AST Builder

orionbelt.ast.builder.QueryBuilder

Fluent builder for ergonomic AST construction.

Source code in src/orionbelt/ast/builder.py
class QueryBuilder:
    """Fluent builder for ergonomic AST construction."""

    def __init__(self) -> None:
        self._columns: list[Expr] = []
        self._from: From | None = None
        self._joins: list[Join | Unnest] = []
        self._where: Expr | None = None
        self._group_by: list[Expr] = []
        self._having: Expr | None = None
        self._order_by: list[OrderByItem] = []
        self._limit: int | None = None
        self._offset: int | None = None
        self._ctes: list[CTE] = []
        self._distinct: bool = False
        self._grouping: str | None = None

    def select(self, *columns: Expr) -> Self:
        self._columns.extend(columns)
        return self

    def select_aliased(self, expr: Expr, alias: str) -> Self:
        self._columns.append(AliasedExpr(expr=expr, alias=alias))
        return self

    def from_(self, table: str, alias: str | None = None) -> Self:
        self._from = From(source=table, alias=alias)
        return self

    def from_subquery(self, subquery: Select, alias: str) -> Self:
        self._from = From(source=subquery, alias=alias)
        return self

    def join(
        self,
        table: str | Select,
        on: Expr,
        join_type: JoinType = JoinType.LEFT,
        alias: str | None = None,
    ) -> Self:
        self._joins.append(Join(join_type=join_type, source=table, alias=alias, on=on))
        return self

    def unnest(self, node: Unnest) -> Self:
        """Append an unnest in path order, alongside the joins.

        Its parent has to already be in scope - the base object, or an earlier
        join - because the fragment names it.
        """
        self._joins.append(node)
        return self

    def where(self, condition: Expr) -> Self:
        if self._where is None:
            self._where = condition
        else:
            self._where = BinaryOp(left=self._where, op="AND", right=condition)
        return self

    def group_by(self, *exprs: Expr) -> Self:
        self._group_by.extend(exprs)
        return self

    def having(self, condition: Expr) -> Self:
        if self._having is None:
            self._having = condition
        else:
            self._having = BinaryOp(left=self._having, op="AND", right=condition)
        return self

    def order_by(self, expr: Expr, desc: bool = False, nulls_last: bool | None = None) -> Self:
        self._order_by.append(OrderByItem(expr=expr, desc=desc, nulls_last=nulls_last))
        return self

    def limit(self, n: int) -> Self:
        self._limit = n
        return self

    def offset(self, n: int) -> Self:
        self._offset = n
        return self

    def with_cte(self, name: str, query: Select | UnionAll | Except | RawSQL) -> Self:
        self._ctes.append(CTE(name=name, query=query))
        return self

    def distinct(self, value: bool = True) -> Self:
        self._distinct = value
        return self

    def grouping(self, mode: str | None) -> Self:
        """Set the hierarchical grouping modifier ('rollup' or 'cube')."""
        self._grouping = mode
        return self

    def build(self) -> Select:
        return Select(
            columns=self._columns,
            from_=self._from,
            joins=self._joins,
            where=self._where,
            group_by=self._group_by,
            having=self._having,
            order_by=self._order_by,
            limit=self._limit,
            offset=self._offset,
            ctes=self._ctes,
            distinct=self._distinct,
            grouping=self._grouping,
        )

unnest(node)

Append an unnest in path order, alongside the joins.

Its parent has to already be in scope - the base object, or an earlier join - because the fragment names it.

Source code in src/orionbelt/ast/builder.py
def unnest(self, node: Unnest) -> Self:
    """Append an unnest in path order, alongside the joins.

    Its parent has to already be in scope - the base object, or an earlier
    join - because the fragment names it.
    """
    self._joins.append(node)
    return self

grouping(mode)

Set the hierarchical grouping modifier ('rollup' or 'cube').

Source code in src/orionbelt/ast/builder.py
def grouping(self, mode: str | None) -> Self:
    """Set the hierarchical grouping modifier ('rollup' or 'cube')."""
    self._grouping = mode
    return self

API Schemas

orionbelt.api.schemas

API request/response Pydantic schemas.

SessionCreateRequest

Bases: BaseModel

Request body for POST /sessions.

Source code in src/orionbelt/api/schemas.py
class SessionCreateRequest(BaseModel):
    """Request body for POST /sessions."""

    metadata: dict[str, str] = Field(default_factory=dict)

SessionResponse

Bases: BaseModel

Single session info.

Source code in src/orionbelt/api/schemas.py
class SessionResponse(BaseModel):
    """Single session info."""

    session_id: str
    created_at: datetime
    last_accessed_at: datetime
    model_count: int
    metadata: dict[str, str] = Field(default_factory=dict)
    expires_at: datetime = Field(description="Idle TTL deadline (refreshed on each access)")
    max_expires_at: datetime = Field(description="Absolute lifetime deadline (fixed at creation)")

SessionListResponse

Bases: BaseModel

Response for GET /sessions.

Source code in src/orionbelt/api/schemas.py
class SessionListResponse(BaseModel):
    """Response for GET /sessions."""

    sessions: list[SessionResponse]

ModelLoadRequest

Bases: BaseModel

Request body for POST /sessions/{session_id}/models.

Source code in src/orionbelt/api/schemas.py
class ModelLoadRequest(BaseModel):
    """Request body for POST /sessions/{session_id}/models."""

    model_yaml: str | None = Field(
        default=None,
        description="OBML model as YAML string (provide model_yaml OR model_json)",
        max_length=5_000_000,
    )
    model_json: dict[str, object] | str | None = Field(
        default=None,
        description="OBML model as JSON object or JSON string (auto-parsed)",
    )
    extends: list[str] | None = Field(
        default=None,
        description="Optional inline YAML strings of analytical fragments to merge",
    )
    inherits: str | None = Field(
        default=None,
        description="Optional model ID of an already-loaded parent model in the session",
    )
    dedup: bool = Field(
        default=True,
        description=(
            "When True (default), identical OBML content already loaded in this session "
            "reuses the existing model_id (response.model_load == 'reused'). "
            "When False, always loads fresh."
        ),
    )

    @model_validator(mode="after")
    def _parse_model_json_string(self) -> ModelLoadRequest:
        if isinstance(self.model_json, str):
            self.model_json = json.loads(self.model_json)
        return self

ModelLoadResponse

Bases: BaseModel

Response for POST /sessions/{session_id}/models.

Source code in src/orionbelt/api/schemas.py
class ModelLoadResponse(BaseModel):
    """Response for POST /sessions/{session_id}/models."""

    model_id: str
    data_objects: int
    dimensions: int
    measures: int
    metrics: int
    warnings: list[StructuredWarning] = Field(default_factory=list)
    model_load: str = Field(
        default="fresh",
        description=(
            "Whether the load parsed a fresh model or reused an existing one. "
            "Values: 'fresh' | 'reused'."
        ),
    )
    health: ModelHealth | None = Field(
        default=None,
        description=(
            "Structural health of the model's join graph: orphan dataObjects, "
            "fan-trap risks, unreachable dimensions. Always present on a fresh load."
        ),
    )

ModelSummaryResponse

Bases: BaseModel

Short model summary for listing.

Source code in src/orionbelt/api/schemas.py
class ModelSummaryResponse(BaseModel):
    """Short model summary for listing."""

    model_id: str
    data_objects: int
    dimensions: int
    measures: int
    metrics: int

SessionQueryRequest

Bases: BaseModel

Request body for POST /sessions/{session_id}/query/sql.

Source code in src/orionbelt/api/schemas.py
class SessionQueryRequest(BaseModel):
    """Request body for POST /sessions/{session_id}/query/sql."""

    model_id: str
    query: QueryObject
    dialect: str | None = Field(
        default=None,
        description=(
            "SQL dialect. Resolution: explicit value → model.settings.defaultDialect → "
            "DB_VENDOR env → 'postgres'."
        ),
    )

QueryCompileResponse

Bases: BaseModel

Response body for POST /query/sql.

Source code in src/orionbelt/api/schemas.py
class QueryCompileResponse(BaseModel):
    """Response body for POST /query/sql."""

    sql: str
    dialect: str
    resolved: ResolvedInfoResponse
    warnings: list[StructuredWarning] = Field(default_factory=list)
    sql_valid: bool = True
    explain: ExplainPlanResponse | None = None
    physical_tables: list[str] = Field(
        default_factory=list,
        description=(
            "Deduplicated DATABASE.SCHEMA.CODE strings the query touches. "
            "Drives freshness-cache TTL composition and heartbeat invalidation."
        ),
    )

ValidateRequest

Bases: BaseModel

Request body for POST /validate.

Source code in src/orionbelt/api/schemas.py
class ValidateRequest(BaseModel):
    """Request body for POST /validate."""

    model_yaml: str | None = Field(
        default=None,
        description="OBML model as YAML string (provide model_yaml OR model_json)",
        max_length=5_000_000,
    )
    model_json: dict[str, object] | str | None = Field(
        default=None,
        description="OBML model as JSON object or JSON string (auto-parsed)",
    )
    extends: list[str] | None = Field(
        default=None,
        description="Optional inline YAML strings of analytical fragments to merge",
    )
    inherits: str | None = Field(
        default=None,
        description="Optional model ID of an already-loaded parent model in the session",
    )

    @model_validator(mode="after")
    def _parse_model_json_string(self) -> ValidateRequest:
        if isinstance(self.model_json, str):
            self.model_json = json.loads(self.model_json)
        return self

ValidateResponse

Bases: BaseModel

Response body for POST /validate.

Source code in src/orionbelt/api/schemas.py
class ValidateResponse(BaseModel):
    """Response body for POST /validate."""

    valid: bool
    errors: list[ErrorDetail] = Field(default_factory=list)
    warnings: list[ErrorDetail] = Field(default_factory=list)

DialectListResponse

Bases: BaseModel

Response for GET /dialects.

Source code in src/orionbelt/api/schemas.py
class DialectListResponse(BaseModel):
    """Response for GET /dialects."""

    dialects: list[DialectInfo] = Field(default_factory=list)

HealthResponse

Bases: BaseModel

Health check response.

Source code in src/orionbelt/api/schemas.py
class HealthResponse(BaseModel):
    """Health check response."""

    status: str = "ok"
    version: str = ""
    auth_mode: str = Field(
        default="none",
        description="Effective auth mode: 'none', 'api_key', or 'oidc'. "
        "Clients check this to know whether a credential is required.",
    )

Settings

orionbelt.settings.Settings

Bases: BaseSettings

Configuration for OrionBelt REST API server.

Values are read from environment variables and from a .env file in the working directory. See .env.template for all options.

Source code in src/orionbelt/settings.py
class Settings(BaseSettings):
    """Configuration for OrionBelt REST API server.

    Values are read from environment variables and from a ``.env`` file
    in the working directory.  See ``.env.template`` for all options.
    """

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    # Shared
    log_level: str = "INFO"
    # Log format:
    #   "console"  — pretty-printed for local dev (default)
    #   "json"     — structured JSON for log aggregators (ELK, Datadog, etc.)
    #   "cloudrun" — JSON + disables uvicorn access logs (Cloud Run provides its own)
    log_format: str = "console"

    # REST API
    api_server_host: str = "localhost"
    api_server_port: int = 8000
    port: int | None = None  # Cloud Run injects PORT; takes precedence over api_server_port

    # Authentication. Single AUTH_MODE selector drives every direct surface
    # (REST now; Flight + pgwire in Phase 2). Off by default to preserve the
    # public-demo / local-dev behaviour. See design/PLAN_authentication.md §1.
    #   "none"    — no auth (default)
    #   "api_key" — validate API_KEYS against the shared key store
    #   "oidc"    — Phase 4 (not implemented; rejected loudly at startup)
    auth_mode: str = "none"
    api_keys: str = ""  # comma-separated; required when auth_mode=api_key (>=16 chars each)
    api_key_header: str = "X-API-Key"  # REST header name; Bearer is always accepted as fallback
    # Legacy alias for auth_mode=api_key. Deprecated; honoured one release with
    # a startup warning. Only takes effect when AUTH_MODE is left at "none".
    auth_enabled: bool = False

    # Public-doc surfaces. Default True preserves current public-demo behaviour.
    # Set EXPOSE_API_DOCS=false on non-demo deployments to disable Swagger UI,
    # ReDoc, and the OpenAPI schema endpoint. EXPOSE_OPENAPI_SCHEMA can be
    # toggled independently to keep /openapi.json live (e.g. for client codegen)
    # while hiding the human-facing /docs and /redoc pages.
    expose_api_docs: bool = True
    expose_openapi_schema: bool = True

    @property
    def effective_port(self) -> int:
        """Return the port to listen on (Cloud Run PORT takes precedence)."""
        return self.port if self.port is not None else self.api_server_port

    # Sessions
    session_ttl_seconds: int = 1800  # 30 min inactivity
    session_max_age_seconds: int = 86400  # 24 h absolute max lifetime
    session_cleanup_interval: int = 60  # seconds between cleanup sweeps
    max_sessions: int = 500  # global concurrent session cap (429 when full)
    max_models_per_session: int = 10  # max models a single session may hold
    disable_session_list: bool = False  # hide GET /sessions endpoint
    session_rate_limit: int = 10  # max POST /sessions per IP per minute
    trusted_proxy_count: int = 0  # number of trusted reverse proxies in front of the app

    # Admin-curated model pre-loading. When MODEL_FILES is set, REST POST
    # /models returns 403 (the catalog is admin-managed) and the models are
    # loaded into named protected sessions at startup.
    #
    # MODEL_FILES (comma-separated paths):
    #     Each OBML YAML loads into its own internal session, addressable
    #     by the OBML `name:` field (fallback: filename stem, normalized to
    #     a valid identifier). BI tools select via the Flight `database`
    #     catalog or pgwire `database=` URL parameter. A single path is
    #     fine — it just means one named protected session.
    #     See design/PLAN_flight_natural_sql.md §3.x multi-model.
    model_dir: str | None = None  # base directory (set by Docker)
    model_files: str | None = None  # comma-separated paths

    # Query execution
    query_execute: bool = False  # enable POST /v1/query/execute
    query_default_limit: int = 1000  # max rows when query has no LIMIT
    db_pool_size: int = 5  # connection pool size per dialect

    # Default locale for /v1/query/execute?format_values=true (and TSV output).
    # Used when the request omits the ``locale`` query param. BCP-47 tag
    # (e.g. "de", "en-US"). Empty → en-style separators ("," / ".").
    default_locale: str = ""

    # Arrow Flight SQL server (requires ob-flight-extension)
    flight_enabled: bool = False  # start gRPC Flight server on FLIGHT_PORT (implies query_execute)
    flight_port: int = 8815
    flight_auth_mode: str = "none"  # "none" or "token"
    flight_api_token: str | None = None
    db_vendor: str = "duckdb"  # default vendor driver for Flight query execution

    # Flight Semantic QL governance. See design/PLAN_flight_natural_sql.md.
    # Semantic QL / OBSQL (SELECT dim, measure FROM <model>) is always enabled.
    # Raw SQL pass-through and write operations are **not** configurable —
    # OBSL is a semantic layer, not a JDBC proxy. There are no env flags
    # that allow arbitrary SQL through to the warehouse.

    # Postgres wire surface. Today: trust auth only, simple-query protocol.
    # Auth modes "password" / "scram-sha-256" land in Phase 2 alongside the
    # shared auth subsystem (see design/PLAN_authentication.md §3.3).
    pgwire_enabled: bool = False
    pgwire_host: str = "0.0.0.0"  # noqa: S104 — server bind address
    pgwire_port: int = 5432
    pgwire_auth_mode: str = "trust"  # "trust" (Step 1) | "password" | "scram-sha-256" (Step 6)
    pgwire_max_connections: int = 64
    pgwire_query_timeout_seconds: int = 60
    # Hard deadline for the pre-auth handshake (startup + password/SCRAM
    # exchange). Bounds how long an unauthenticated client can hold a
    # connection slot, preventing slot-exhaustion DoS.
    pgwire_auth_timeout_seconds: int = 10

    # One-shot batch endpoint (POST /v1/oneshot/batch). See PLAN_oneshot_batch.md.
    oneshot_batch_max_queries: int = 50
    oneshot_batch_max_parallelism: int = 8
    oneshot_batch_default_timeout_ms: int = 30000  # per-query
    oneshot_batch_batch_timeout_ms: int = 120000  # whole batch

    # Freshness-driven result cache. See design/PLAN_freshness_driven_cache.md.
    cache_backend: str = "noop"  # "noop" or "file"
    cache_dir: str = "./cache"
    cache_max_ttl_seconds: int = 86400
    cache_min_ttl_seconds: int = 5
    cache_max_value_bytes: int = 10 * 1024 * 1024  # 10 MB
    cache_max_disk_bytes: int = 5 * 1024 * 1024 * 1024  # 5 GB
    cache_sweep_interval_seconds: int = 86400
    cache_unknown_freshness_policy: str = "no_cache"  # or "default_ttl"
    cache_unknown_freshness_default_ttl: int = 300
    heartbeat_auth_token: str | None = None  # endpoint disabled (404) when unset

effective_port property

Return the port to listen on (Cloud Run PORT takes precedence).