You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@ossie.apache.org by "MikeNitsenko (via GitHub)" <gi...@apache.org> on 2026/09/25 19:38:19 UTC

[PR] feat(cube): project one Cube view's public surface onto Ossie [ossie]

MikeNitsenko opened a new pull request, #471:
URL: https://github.com/apache/ossie/pull/471

   ## Summary
   
   Adds **view projection** to the Cube converter: a public way to convert **one Cube view's public surface** to an Ossie model. It publishes exactly the members the view exposes, under the view's names, with every hidden dependency inlined. The existing round-trip import (`convert_cube_to_ossie`) is unchanged.
   
   ```python
   from ossie_cube import convert_cube_view_to_ossie
   
   ossie_yaml, resolved_source, issues = convert_cube_view_to_ossie(
       files, "sales", source=None, strict_fanout=True)
   ```
   
   ```bash
   ossie-cube import -i model/ --view sales --project [--source orders] [--no-strict-fanout]
   ```
   
   `convert_cube_to_ossie` is a lossless round trip: every cube member becomes a field or metric, and view curation rides in `custom_extensions`. Publishing a view to another tool needs the opposite boundary. Consumers should see what Cube users see through the view, and nothing the view keeps private. `resolved_source` is the root of the view's join paths, so a converter that needs a single fact (Databricks' `--source`) can start from it.
   
   ### How it works
   
   `view_projection.py` resolves the view (members, join tree, dependency closures), builds a reduced cube set holding only those, runs the **existing import** over it, and trims the model to the published surface.
   
   - **Dimension references** are inlined *in Cube space*, before the import. The import's own join decomposition, fan-out analysis and datatype mapping then read the columns a member really uses. They never see a member name that the projected model does not contain.
   - **Measure references** are inlined afterwards by the import's own `_MeasureResolver` (`inlined()`).
   
   Nothing in the import is copied: the refactor commits split out `_build_model`, `_metric_names_of` and `_report_fanout`, and add optional callables to `_case_expression` and `primary_key_operand`. The module is 719 code lines (1,010 with docs), against the reference implementation's 1,022 (1,184 with docs), while adding refusals the reference did not have.
   
   ### Supported semantics
   
   - `includes` as a list or `*`; `excludes`; member aliases (`{name, alias}`); `prefix` with the entry's `alias` (or the cube name).
   - Member `title`, `description`, `meta.ai_context` and `format`/`currency` overrides. A measure `title` rides in the stash, since an Ossie metric has no label.
   - The view supplies the model's name, description and AI context, as in the round-trip import.
   - A wildcard skips private members: `public: false`, or the deprecated `visible`/`shown: false`, with Cube's precedence. It also skips measures a previous export generated as a decomposition part (`meta.ossie.part_of`, corroborated as the import requires). This is deliberately stricter than Cube, whose `*` includes private members too, because publishing is where privacy matters.
   - Naming a private member explicitly publishes it.
   - A measure referencing another measure is inlined.
   - A dimension referencing another dimension is inlined, parenthesized when needed for precedence.
   - A `case` dimension referenced elsewhere is inlined as its `CASE`.
   - A measure reading another cube's dimension or raw `{cube}.column` is inlined, and that cube's join is added.
   - A cube the view names no path to is joined along the unique declared join path from the view's root.
   - A join through a hidden key member (`{CUBE.user_key} = {users.id}`) resolves to the column that member reads.
   - A bare `type: count` counts the distinct values of its primary key's own SQL, with composite keys combined as `CONCAT(CAST … AS VARCHAR)` as in the import.
   - Every column in a metric is qualified by its dataset. A raw multi-part path is a struct field of the declaring cube (`payload.amount` → `orders.payload.amount`). A path starting with the cube's own name is its table's column (`orders.amount`), since Cube aliases the table by the cube's name. `{accounts}.balance` is the joined cube.
   - Unit and type arguments are read the way the warehouse reads them (`DATEDIFF(day, a, b)`, `CONVERT(VARCHAR, x)`), trying the Databricks and T-SQL grammars when the portable one misreads them. Only positions are read; the text is never regenerated.
   - Comments are dropped, so an inlined `-- note` cannot swallow the rest of an expression.
   - A dataset's `primary_key` names the columns its key members read.
   - One member may be published under two names.
   - Accepts backtick- and bracket-quoted identifiers of the data source's SQL.
   - `source` may be any dataset in the projection, including a hidden dependency's.
   - Fan-out is strict by default. Hidden measures a published one depends on are checked too. So is a dataset multiplied further along the tree: with `orders` many-to-one `users` and `users` one-to-many `addresses`, a `SUM` over `orders` is caught, although no single relationship has `orders` on its one side.
   - A struct path inside a non-idempotent aggregate is attributed to its dataset. sqlglot reads the middle part as the table, which the existing check would miss.
   - The CUBE stash keeps only what describes the published model: a member's `format`/`currency`/measure `title`/dimension `type`, a join's declared cardinality (`one_to_one`, `belongsTo`, …), and a cube's `data_source`.
   - Cube-only properties of the view, a projected cube or a published member are reported as a new issue type, `DROPPED_FROM_PROJECTION`. Examples are an access policy, pre-aggregations, drill members and extra `meta`.
   - Output is the flat document (no `semantic_model`).
   
   ### Refusals (`ConversionError`, naming the view and member)
   
   - a view with no `cubes` entries (including the legacy view-level `includes` form)
   - a `cubes` entry that is not a mapping, or has no or an empty `join_path`
   - a `join_path` with an empty segment, or one that visits a cube twice
   - a `join_path` naming an unknown cube
   - a `join_path` step the cube does not declare a join for
   - a `join_path` step the cube declares more than one join for
   - a split entry (`split: true`)
   - a view with several join roots
   - a cube reached through two join paths (two parents)
   - a hidden dependency on a cube with no declared join path from the root
   - a hidden dependency on a cube reached by several declared join paths
   - a hidden dependency whose path would give a cube a second parent
   - two published names that collide, case-insensitively
   - an included member the cube does not have
   - an excluded member the cube does not have
   - an include written as a path (`orders.status`)
   - an include mapping with no member name
   - `includes` that is neither `*` nor a list, or holds something other than names or mappings
   - `excludes` that is not a list of names
   - a non-boolean `prefix`
   - an empty or non-string cube or member alias
   - a view that publishes nothing
   - a selected segment or hierarchy (a wildcard reaches them too, as in Cube)
   - a selected `geo`, `switch` or `sub_query` dimension
   - an explicitly included generated decomposition part
   - a published or hidden measure with an active `rolling_window` (including `{}`, Cube's default 30-day window)
   - a published or hidden measure with an active `multi_stage`, `group_by`, `reduce_by`, `add_group_by`, `time_shift` or `grain`
   - a malformed value for any of those keys (no-op defaults `false`/`[]`/`{}`/`null` are allowed)
   - a reference with an unknown cube qualifier (including `{SECURITY_CONTEXT.…}`)
   - a reference to a member the cube does not have
   - a bare cube alias (`{CUBE}`) with no `.column`
   - a dimension referencing a measure (Cube's correlated subquery)
   - a dimension reference cycle
   - a reference to a `geo`, `switch` or `sub_query` dimension
   - SQL whose source columns cannot be proven statically: it does not parse, or it has a subquery or a lambda
   - SQL where no grammar reads a unit or type argument as anything but a column
   - a raw path starting with another cube's name (`accounts.balance` inside `orders`). Cube reads it as that cube's table when joined, and as a struct field otherwise; the error suggests `{CUBE}.accounts.balance` or `{accounts}.balance`
   - a published dimension reading another cube, directly or through a hidden dimension (a dataset-scoped field cannot carry the join)
   - a join the projection needs that has no relationship form (not an AND of column equalities), with the import's reason
   - a published measure with no static Ossie expression (e.g. a nested aggregate), with the import's reason
   - a bare `type: count` on a cube with no primary key
   - a `source` outside the projection
   - a `source` that would reach a published dimension through a one-to-many join
   - under `strict_fanout` (the default), a published metric, or a hidden measure it depends on, that can over-count
   
   ### Deliberate differences from the reference implementation
   
   The reference lives in `cubedevinc/cube-databricks-metric-view-bridge` and could not edit the converter. These are the places this version differs:
   
   - **Composite-key bare count.** It uses the converter's portable `COUNT(DISTINCT CONCAT(CAST(a AS VARCHAR), CAST(b AS VARCHAR)))`, not `COUNT(DISTINCT a, b)`. The expression language defines `COUNT(DISTINCT expr)` with a single argument.
   - **`part_of` markers.** A measure is a decomposition part only when corroborated (the named measure records `decomposed`), as the round-trip import already requires.
   - **Paths starting with another cube's name.** The reference read `accounts.balance` inside `orders` as a struct field. Cube aliases every joined cube's table by its name, so that path is ambiguous and is refused here, with the two unambiguous spellings in the error.
   - **Source re-rooting.** A `source` that would multiply a published dimension is refused here, generically. Before, the bridge only discovered it after the Databricks conversion dropped the member.
   - **No Databricks or Cube Cloud specifics.** No catalog/schema completion, `expected_data_source` checks, post-conversion member checks, Databricks warning routing, or provenance markers. The projected Ossie carries what the bridge needs: every metric column starts with its dataset, and `data_source` rides in the dataset's stash. I checked this by running the bridge's suite against this branch with its projection module replaced by this API; the only differences are the ones listed here.
   
   ### Known limitation
   
   As in the round-trip import, an expression is the data source's own SQL labelled `ANSI_SQL`. Backtick- or bracket-quoted identifiers are attributed correctly, but `validation/validate.py` parses the text as ANSI and rejects them. Every other projected document in the tests passes the validator.
   
   ### Commits (review order)
   
   1. `refactor(cube): split model building out of convert_cube_to_ossie`
   2. `refactor(cube): extract metric naming and the fan-out report`
   3. `refactor(cube): let case rendering and key operands take other forms`
   4. `feat(cube): add column-path helpers for view projection`
   5. `feat(cube): project one Cube view's public surface onto Ossie`
   6. `feat(cube): add --project to ossie-cube import`
   7. `docs(cube): document view projection`
   
   ### Testing
   
   - **Existing suite.** `uv run pytest` in `converters/cube` passes, with no existing test edited.
   - **Byte-identity.** I snapshotted `convert_cube_to_ossie` (YAML and issue list, strict and non-strict) over every fixture, 300 generated Cube models and 150 generated Ossie models exported to Cube (914 cases). At every commit the result is byte-identical to `main`.
   - **New tests.** `tests/test_view_projection.py` and `tests/test_cli_project.py` port the in-scope reference cases against the public API, plus the refusals above.
   - **Validator and export.** Every projection in the tests goes through `validation/validate.py` (`assert_ossie_is_valid`) and back out through `convert_ossie_to_cube`.
   - **Review.** An independent review pass over the diff found five defects, all fixed here with regression tests: deprecated visibility flags, fan-out through a chain, own/other cube-name path heads, misread unit arguments, and trailing comments.
   - **Property test.** Over 80 seeded generated models with a random view (wildcards, excludes, member aliases, prefixes), the published fields and metrics are exactly the view's surface. No hidden member is published, and no metric keeps a member or metric reference.
   - **Cube compile gate.** With `OSSIE_CUBE_REPO` set, the exported projections of the reference model and of 10 seeds compile in Cube. One seed is skipped because Cube refuses its generated *input* (a one-letter title `F` trips Cube's f-string parser).
   - **Lint.** `ruff` reports no new findings.
   
   ## Checklist
   
   ### Converters
   - [x] New converter functionality includes tests under the converter's test directory
   
   ### Documentation
   - [x] New features or behaviors are documented with examples where appropriate (`converters/cube/README.md`, "View projection")
   
   ### Tests
   - [x] All existing tests pass
   - [x] New functionality is covered by tests
   
   ### Compliance
   - [x] ASF license headers are present on all new source files
   - [x] No third-party dependencies are added
   
   🤖 Generated with [Claude Code](https://claude.com/claude-code)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat(cube): project one Cube view's public surface onto Ossie [ossie]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #471:
URL: https://github.com/apache/ossie/pull/471#discussion_r4108045926


##########
converters/cube/src/ossie_cube/expressions.py:
##########
@@ -451,6 +474,136 @@ def qualify_bare_columns(cube_sql):
         text, {name: "{CUBE}." + name for name in names})
 
 
+# The portable reading first, then grammars that quote identifiers with backticks
+# (Databricks, BigQuery, MySQL) or brackets (T-SQL) and read unit arguments the way
+# those warehouses do -- `DATEDIFF(day, a, b)`, `CONVERT(VARCHAR, x)`.
+_READINGS = (None, "databricks", "tsql")
+
+# Words a function takes as a date part or type name rather than a column. sqlglot's
+# portable grammar reads `DATEDIFF(day, a, b)` with `day` as a column and `b` as the
+# unit, and a qualification built on that reading would be wrong.
+_UNIT_WORDS = frozenset({
+    "YEAR", "YEARS", "QUARTER", "MONTH", "MONTHS", "WEEK", "WEEKS", "WEEKDAY",
+    "ISOWEEK", "ISOYEAR", "DAY", "DAYS", "DAYOFWEEK", "DAYOFYEAR", "DOW", "DOY", "HOUR",
+    "HOURS", "MINUTE", "MINUTES", "SECOND", "SECONDS", "MILLISECOND", "MILLISECONDS",
+    "MICROSECOND", "MICROSECONDS", "NANOSECOND", "NANOSECONDS", "EPOCH", "DECADE",
+    "CENTURY", "MILLENNIUM", "TIMEZONE", "TIMEZONE_HOUR", "TIMEZONE_MINUTE", "YYYY",
+    "YY", "MM", "DD", "HH", "MI", "SS", "MS",
+})
+_TYPE_WORDS = frozenset({
+    "VARCHAR", "NVARCHAR", "CHAR", "NCHAR", "INT", "INTEGER", "BIGINT", "SMALLINT",
+    "TINYINT", "DECIMAL", "NUMERIC", "FLOAT", "REAL", "DOUBLE", "DATETIME", "DATETIME2",
+    "TIMESTAMP", "BOOLEAN",
+})
+
+
+class UnattributableSQL(ValueError):
+    """Cube SQL whose columns cannot be attributed to a dataset statically."""
+
+
+def _parse_as(text, dialect):
+    """`text` parsed as `dialect`, or None when it does not parse as that."""
+    try:
+        return sqlglot.parse_one(text, read=dialect)
+    except (sqlglot.errors.SqlglotError, ValueError, RecursionError):
+        return None
+
+
+def _misread(tree):
+    """True when a reading takes a unit or type argument for a column, or a column for
+    a unit -- what the portable grammar does to `DATEDIFF(day, a, b)`."""
+    if any(var.name.upper() not in _UNIT_WORDS for var in tree.find_all(exp.Var)):
+        return True
+    return any(not column.table and isinstance(column.parent, exp.Func)
+               and column.name.upper() in _UNIT_WORDS | _TYPE_WORDS
+               for column in tree.find_all(exp.Column))
+
+
+def strip_sql_comments(sql):
+    """`sql` with its `--` and `/* */` comments replaced by a space.
+
+    A trailing `-- note` is harmless in a member's own SQL, but inlined into another
+    expression it comments out everything after it.
+    """
+    text = str(sql)
+    mask = quoted_char_mask(text)
+    out, i = [], 0
+    while i < len(text):
+        if not mask[i] and text.startswith("--", i):

Review Comment:
   The projection advertises support for bracket-quoted identifiers, but `quoted_char_mask` does not mark `[...]` as quoted here. A legal T-SQL identifier such as `[amount--gross]` is therefore treated as starting a `--` comment and truncated before parsing, causing a valid expression to be rejected or misattributed. Extend the comment stripper's quoted-region handling to cover bracket identifiers (including escaped `]]`).



##########
converters/cube/src/ossie_cube/view_projection.py:
##########
@@ -0,0 +1,1010 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Project one Cube view's public surface onto an Apache Ossie semantic model.
+
+`convert_cube_to_ossie` is a lossless round-trip import: every cube member becomes an
+Ossie field or metric, and the view's curation rides in `custom_extensions`. Publishing
+a view to another tool needs the opposite boundary -- exactly the members the view
+exposes, under the names it exposes them by, and nothing the view keeps private.
+
+The projection resolves the view (its members, join tree and hidden dependencies),
+builds a reduced cube set holding only those, runs the ordinary import over it, and
+trims the result to the published surface. Dimension references are inlined while the
+set is built, in Cube space, so the import's own join decomposition and fan-out analysis
+read the columns a member really uses; measure references are inlined afterwards by the
+import's `_MeasureResolver`. Anything the published model could not say faithfully is
+refused with a `ConversionError`, never dropped. See README "View projection".
+
+Usage (CLI):
+    ossie-cube import -i model/ --view sales --project [--source orders]
+"""
+
+import copy
+import dataclasses
+from collections import deque
+from itertools import pairwise
+
+from ._common import (
+    _CUBE_REF_RE,
+    OSSIE_VERSION,
+    VENDOR,
+    ConversionError,
+    dump_yaml,
+    lookup_map,
+    normalize_identifier,
+    primary_key_operand,
+    read_stash,
+    requalify_self_refs,
+    resolve_identifier,
+    snake,
+    snake_keys,
+    unescape_braces_from_cube,
+    write_stash,
+)
+from .converter_issues import IssueLog, IssueType
+from .cube_to_osi import (
+    _CUBE_NATIVE_KEYS,
+    _DIM_NATIVE_KEYS,
+    _MEASURE_NATIVE_KEYS,
+    _RELATIONSHIP_ALIASES,
+    _ai_context_from_meta,
+    _as_named_list,
+    _build_model,
+    _case_expression,
+    _collect,
+    _column_of,
+    _decomposed_measure_names,
+    _fanned_out_datasets,
+    _fanout_unsafe_datasets,
+    _is_generated_part,
+    _MeasureResolver,
+    _metric_names_of,
+    _no_cubes_message,
+    _primary_key_of,
+    _report_fanout,
+    parked_of,
+)
+from .expressions import (
+    UnattributableSQL,
+    has_top_level_operator,
+    qualify_column_paths,
+    unsafe_aggregate_path_heads,
+)
+
+_SELF_REFS = ("CUBE", "TABLE")
+
+# Stash keys a published member keeps: how it looks to its reader. The rest of the
+# import's stash rebuilds the original Cube member, naming members that are not here.
+_PUBLISHED_MEMBER_KEYS = ("dim_type", "format", "currency", "title")
+
+# The declared Cube cardinality, which Ossie's many/one orientation cannot carry.
+_PUBLISHED_JOIN_KEYS = ("declared_on", "relationship")
+
+# Presentation keys a view `includes` entry may override, and a member carries along.
+_PRESENTATION_KEYS = ("format", "currency")
+
+# Cube keys the projection consumes, so not reported as dropped: members are the surface,
+# visibility is the view's to decide, and `data_source` rides on the dataset.
+_VISIBILITY_KEYS = ("public", "visible", "shown")
+_CONSUMED_CUBE_KEYS = frozenset({
+    "segments", "hierarchies", "data_source", *_VISIBILITY_KEYS})
+_CONSUMED_MEMBER_KEYS = frozenset({"case", *_VISIBILITY_KEYS, *_PRESENTATION_KEYS})
+_VIEW_KEYS = frozenset({"name", "cubes", "description", "meta", *_VISIBILITY_KEYS})
+
+# Multi-stage measure keys and the value shape Cube accepts. An empty value is Cube's own
+# default and changes nothing; anything else computes over another grain. Covers every
+# key in `_WINDOWING_KEYS` (a test checks), plus `grain`, which the import does not know.
+_MULTI_STAGE_SHAPES = {
+    "multi_stage": bool,
+    "group_by": list,
+    "reduce_by": list,
+    "add_group_by": list,
+    "time_shift": list,
+    "grain": dict,
+}
+
+
+@dataclasses.dataclass(frozen=True)
+class _Member:
+    """One member the view publishes: `name` on `cube`, published as `output`, with its
+    Cube `definition` and the view's `includes` entry for it, when that is a mapping."""
+
+    cube: str
+    name: str
+    output: str
+    kind: str
+    definition: dict
+    override: dict
+
+
+@dataclasses.dataclass
+class _JoinTree:
+    """The view's join tree: `root`, every dataset in presentation order, and
+    `parent[child] = (parent, the join declared on the parent)`."""
+
+    root: str
+    order: list
+    parent: dict
+
+
+def convert_cube_view_to_ossie(files, view, source=None, strict_fanout=True):
+    """Convert one Cube view's public surface to Ossie YAML.
+
+    `files` is a Cube model as {relative filename: YAML str}, the same input
+    `convert_cube_to_ossie` takes. Returns (ossie_yaml_str, resolved_source, IssueLog).
+
+    Unlike `convert_cube_to_ossie`, this is a publication transform, not a round trip:
+    the model holds exactly the dimensions and measures `view` exposes, under the names
+    it exposes them by, with every hidden dependency inlined. `resolved_source` is the
+    root of the view's join paths -- the dataset a downstream converter needing a single
+    fact should start from -- unless `source` names another dataset in the projection.
+
+    Fan-out is strict by default: a published metric that can over-count is refused
+    rather than emitted. Pass `strict_fanout=False` to convert it with a
+    FANOUT_UNSAFE_METRIC issue instead.
+    """
+    if not isinstance(files, dict) or not files:
+        raise ConversionError("expected a non-empty mapping of {filename: YAML}")
+    if not isinstance(view, str) or not view.strip():
+        raise ConversionError("view projection needs the name of the view to project")
+    if source is not None and (not isinstance(source, str) or not source):
+        raise ConversionError(f"view '{view}': source must be a dataset name")
+
+    # The collection's own issues are about the lossless import -- a file preserved in
+    # custom_extensions only -- which a projection does not do.
+    cubes, _, views, _, _ = _collect(files, IssueLog())
+    if view not in views:
+        raise ConversionError(
+            f"requested view '{view}' not found; views present: "
+            f"{sorted(views) or 'none'}")
+    if not cubes:
+        raise ConversionError(_no_cubes_message(views))
+
+    strict = {IssueType.FANOUT_UNSAFE_METRIC} if strict_fanout else set()
+    issues = IssueLog(strict_types=frozenset(strict))
+    view_def = views[view]
+    _report_dropped(issues, f"view '{view}'",
+                    [k for k in map(snake, view_def) if k not in _VIEW_KEYS])
+
+    tree = _view_tree(view, view_def, cubes)
+    members = _published_members(view, view_def, cubes)
+    inliner = _Inliner(cubes)
+
+    measures = _needed_measures(members, cubes, inliner)
+    dimensions = {}
+    for member in members:
+        key = (member.cube, member.name)
+        if member.kind == "dimension" and key not in dimensions:
+            dimensions[key] = _published_dimension(inliner, cubes, member)
+    dependencies = {cname for cname, _ in measures}
+    for (cname, _), measure in measures.items():
+        for sql in [measure.get("sql"), *(f["sql"] for f in measure.get("filters", ()))]:
+            dependencies |= _joined_cubes(sql, cname, cubes)
+    _attach_dependencies(view, tree, sorted(dependencies), cubes)
+    resolved_source = _resolve_source(view, source, tree, members)
+
+    reduced = _reduced_cubes(tree, cubes, dimensions, measures, inliner, issues)
+    reduced_view = {k: v for k, v in view_def.items() if k != "cubes"}
+    model = _build_model(reduced, {}, {view: reduced_view}, {}, {}, None, view, issues)
+    _refuse_unjoined(view, model, issues)
+    # A second resolver over the same cubes, for the fully inlined forms: the import
+    # emits a reference to a hidden measure by its metric name, and that metric is not
+    # published. Its report was made by the import's own; this one only reads.
+    resolver = _MeasureResolver(reduced, {}, IssueLog(), qualify=False)
+    _report_hidden_fanout(resolver, tree, reduced, model, measures, issues)
+    _publish_surface(model, resolver, reduced, cubes, members, issues)
+    return dump_yaml({"version": OSSIE_VERSION, **model}), resolved_source, issues
+
+
+# --- the view ---------------------------------------------------------------------
+
+def _view_tree(vname, view, cubes):
+    """The join tree the view's `join_path`s spell out.
+
+    Cube joins every view member through the path its entry names, so the paths have to
+    form one tree: a single root, and each cube reached one way. A second path to a cube
+    would give its members two meanings, which a published model cannot hold.
+    """
+    entries = view.get("cubes")
+    if not isinstance(entries, list) or not entries:
+        raise ConversionError(
+            f"view '{vname}' has no `cubes` entries to project (the legacy view-level "
+            f"`includes` form is not supported)")
+    tree = _JoinTree(root=None, order=[], parent={})
+    roots = []
+    for index, raw in enumerate(entries):
+        if not isinstance(raw, dict):
+            raise ConversionError(f"view '{vname}': cubes[{index}] must be a mapping")
+        entry = snake_keys(raw)
+        if entry.get("split"):
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] is split, and split view projections "
+                f"publish as more than one model; project each split view on its own")
+        path = entry.get("join_path")
+        if not isinstance(path, str) or not path.strip():
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] has no non-empty `join_path`")
+        parts = path.split(".")
+        if not all(parts):
+            raise ConversionError(f"view '{vname}': invalid join_path '{path}'")
+        if len(set(parts)) != len(parts):
+            raise ConversionError(
+                f"view '{vname}': join_path '{path}' visits a cube twice")
+        for part in parts:
+            if part not in cubes:
+                raise ConversionError(
+                    f"view '{vname}': join_path '{path}' references unknown cube "
+                    f"'{part}'")
+        if parts[0] not in roots:
+            roots.append(parts[0])
+        for left, right in pairwise(parts):
+            _add_edge(vname, tree, cubes, left, right, "reached through two join paths")
+        for part in parts:
+            if part not in tree.order:
+                tree.order.append(part)
+    if len(roots) != 1:
+        raise ConversionError(
+            f"view '{vname}' has multiple join roots {roots}; a published model joins "
+            f"everything from one dataset, so project a single-root view")
+    tree.root = roots[0]
+    return tree
+
+
+def _add_edge(vname, tree, cubes, left, right, conflict):
+    """Join `right` under `left`, refusing a second parent for it."""
+    prior = tree.parent.get(right)
+    if prior is not None:
+        if prior[0] != left:
+            raise ConversionError(
+                f"view '{vname}': cube '{right}' is {conflict} (from '{prior[0]}' and "
+                f"from '{left}'), which would give its members two meanings")
+        return
+    joins = [j for j in _as_named_list(cubes[left].get("joins"), f"cube '{left}' joins")
+             if j.get("name") == right]
+    if not joins:
+        raise ConversionError(
+            f"view '{vname}': joining '{right}' needs a join '{left}' -> '{right}', but "
+            f"cube '{left}' declares no join to '{right}'")
+    if len(joins) > 1:
+        raise ConversionError(
+            f"view '{vname}': cube '{left}' declares more than one join to '{right}'")
+    tree.parent[right] = (left, joins[0])
+
+
+def _published_members(vname, view, cubes):
+    """Every member the view publishes, in view order, with its published name."""
+    decomposed = _decomposed_measure_names(cubes)
+    members, seen, taken = [], set(), {}
+    for raw in view["cubes"]:
+        entry = snake_keys(raw)
+        cname = entry["join_path"].split(".")[-1]
+        for member in _entry_members(vname, entry, cname, cubes[cname], decomposed):
+            identity = (member.cube, member.name, member.output, member.kind)
+            if identity in seen:
+                continue
+            seen.add(identity)
+            key = normalize_identifier(member.output)
+            if key in taken:
+                other = taken[key]
+                raise ConversionError(
+                    f"view '{vname}': published name '{member.output}' from "
+                    f"'{member.cube}.{member.name}' collides with '{other.output}' from "
+                    f"'{other.cube}.{other.name}' (identifiers are case-insensitive); "
+                    f"use an alias or a prefix")
+            taken[key] = member
+            members.append(member)
+    if not members:
+        raise ConversionError(f"view '{vname}' publishes no dimensions or measures")
+    return members
+
+
+def _entry_members(vname, entry, cname, cube, decomposed):
+    """The members one `cubes:` entry publishes, honouring `includes`, `excludes`,
+    member aliases and `prefix`."""
+    prefix = entry.get("prefix", False)
+    if not isinstance(prefix, bool):
+        raise ConversionError(
+            f"view '{vname}': prefix for cube '{cname}' must be true or false")
+    alias = entry.get("alias", cname)
+    if not isinstance(alias, str) or not alias:
+        raise ConversionError(
+            f"view '{vname}': alias for cube '{cname}' must be a non-empty string")
+
+    by_name = {}
+    for kind, key in (("dimension", "dimensions"), ("measure", "measures"),
+                      ("segment", "segments"), ("hierarchy", "hierarchies")):
+        for member in _as_named_list(cube.get(key), f"cube '{cname}' {key}"):
+            if member.get("name"):
+                by_name.setdefault(member["name"], (kind, member))
+
+    includes = entry.get("includes", [])
+    if includes == "*":
+        # A wildcard publishes what the cube itself makes public. A measure a previous
+        # export generated to hold one part of a composite metric is an implementation
+        # detail of the metric that references it, never a member of its own.
+        requested = [
+            (name, name, {}) for name, (kind, member) in by_name.items()
+            if not _is_private(member)
+            and not (kind == "measure" and _is_generated_part(member, decomposed))]
+    elif isinstance(includes, list):
+        requested = [_include(vname, cname, item) for item in includes]
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must be '*' or a list")
+
+    excludes = entry.get("excludes") or []

Review Comment:
   Using `or []` converts falsy non-list values such as `excludes: false`, `0`, or `''` into an empty exclusion list, so malformed view input is silently accepted even though projection promises to refuse any non-list `excludes` value. Read the key with a default instead (`entry.get("excludes", [])`) so the existing type check runs.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat(cube): project one Cube view's public surface onto Ossie [ossie]

Posted by "jbonofre (via GitHub)" <gi...@apache.org>.
jbonofre merged PR #471:
URL: https://github.com/apache/ossie/pull/471


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat(cube): project one Cube view's public surface onto Ossie [ossie]

Posted by "MikeNitsenko (via GitHub)" <gi...@apache.org>.
MikeNitsenko commented on code in PR #471:
URL: https://github.com/apache/ossie/pull/471#discussion_r4124772584


##########
converters/cube/src/ossie_cube/expressions.py:
##########
@@ -451,6 +474,136 @@ def qualify_bare_columns(cube_sql):
         text, {name: "{CUBE}." + name for name in names})
 
 
+# The portable reading first, then grammars that quote identifiers with backticks
+# (Databricks, BigQuery, MySQL) or brackets (T-SQL) and read unit arguments the way
+# those warehouses do -- `DATEDIFF(day, a, b)`, `CONVERT(VARCHAR, x)`.
+_READINGS = (None, "databricks", "tsql")
+
+# Words a function takes as a date part or type name rather than a column. sqlglot's
+# portable grammar reads `DATEDIFF(day, a, b)` with `day` as a column and `b` as the
+# unit, and a qualification built on that reading would be wrong.
+_UNIT_WORDS = frozenset({
+    "YEAR", "YEARS", "QUARTER", "MONTH", "MONTHS", "WEEK", "WEEKS", "WEEKDAY",
+    "ISOWEEK", "ISOYEAR", "DAY", "DAYS", "DAYOFWEEK", "DAYOFYEAR", "DOW", "DOY", "HOUR",
+    "HOURS", "MINUTE", "MINUTES", "SECOND", "SECONDS", "MILLISECOND", "MILLISECONDS",
+    "MICROSECOND", "MICROSECONDS", "NANOSECOND", "NANOSECONDS", "EPOCH", "DECADE",
+    "CENTURY", "MILLENNIUM", "TIMEZONE", "TIMEZONE_HOUR", "TIMEZONE_MINUTE", "YYYY",
+    "YY", "MM", "DD", "HH", "MI", "SS", "MS",
+})
+_TYPE_WORDS = frozenset({
+    "VARCHAR", "NVARCHAR", "CHAR", "NCHAR", "INT", "INTEGER", "BIGINT", "SMALLINT",
+    "TINYINT", "DECIMAL", "NUMERIC", "FLOAT", "REAL", "DOUBLE", "DATETIME", "DATETIME2",
+    "TIMESTAMP", "BOOLEAN",
+})
+
+
+class UnattributableSQL(ValueError):
+    """Cube SQL whose columns cannot be attributed to a dataset statically."""
+
+
+def _parse_as(text, dialect):
+    """`text` parsed as `dialect`, or None when it does not parse as that."""
+    try:
+        return sqlglot.parse_one(text, read=dialect)
+    except (sqlglot.errors.SqlglotError, ValueError, RecursionError):
+        return None
+
+
+def _misread(tree):
+    """True when a reading takes a unit or type argument for a column, or a column for
+    a unit -- what the portable grammar does to `DATEDIFF(day, a, b)`."""
+    if any(var.name.upper() not in _UNIT_WORDS for var in tree.find_all(exp.Var)):
+        return True
+    return any(not column.table and isinstance(column.parent, exp.Func)
+               and column.name.upper() in _UNIT_WORDS | _TYPE_WORDS
+               for column in tree.find_all(exp.Column))
+
+
+def strip_sql_comments(sql):
+    """`sql` with its `--` and `/* */` comments replaced by a space.
+
+    A trailing `-- note` is harmless in a member's own SQL, but inlined into another
+    expression it comments out everything after it.
+    """
+    text = str(sql)
+    mask = quoted_char_mask(text)
+    out, i = [], 0
+    while i < len(text):
+        if not mask[i] and text.startswith("--", i):

Review Comment:
   Fixed in ec46436: `strip_sql_comments` now treats T-SQL `[...]` identifiers (including escaped `]]`) as opaque, so `[amount--gross]` is kept as a name rather than cut at `--`. Covered in `test_quoted_identifiers_of_the_data_sources_dialect_are_qualified`.



##########
converters/cube/src/ossie_cube/view_projection.py:
##########
@@ -0,0 +1,1010 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Project one Cube view's public surface onto an Apache Ossie semantic model.
+
+`convert_cube_to_ossie` is a lossless round-trip import: every cube member becomes an
+Ossie field or metric, and the view's curation rides in `custom_extensions`. Publishing
+a view to another tool needs the opposite boundary -- exactly the members the view
+exposes, under the names it exposes them by, and nothing the view keeps private.
+
+The projection resolves the view (its members, join tree and hidden dependencies),
+builds a reduced cube set holding only those, runs the ordinary import over it, and
+trims the result to the published surface. Dimension references are inlined while the
+set is built, in Cube space, so the import's own join decomposition and fan-out analysis
+read the columns a member really uses; measure references are inlined afterwards by the
+import's `_MeasureResolver`. Anything the published model could not say faithfully is
+refused with a `ConversionError`, never dropped. See README "View projection".
+
+Usage (CLI):
+    ossie-cube import -i model/ --view sales --project [--source orders]
+"""
+
+import copy
+import dataclasses
+from collections import deque
+from itertools import pairwise
+
+from ._common import (
+    _CUBE_REF_RE,
+    OSSIE_VERSION,
+    VENDOR,
+    ConversionError,
+    dump_yaml,
+    lookup_map,
+    normalize_identifier,
+    primary_key_operand,
+    read_stash,
+    requalify_self_refs,
+    resolve_identifier,
+    snake,
+    snake_keys,
+    unescape_braces_from_cube,
+    write_stash,
+)
+from .converter_issues import IssueLog, IssueType
+from .cube_to_osi import (
+    _CUBE_NATIVE_KEYS,
+    _DIM_NATIVE_KEYS,
+    _MEASURE_NATIVE_KEYS,
+    _RELATIONSHIP_ALIASES,
+    _ai_context_from_meta,
+    _as_named_list,
+    _build_model,
+    _case_expression,
+    _collect,
+    _column_of,
+    _decomposed_measure_names,
+    _fanned_out_datasets,
+    _fanout_unsafe_datasets,
+    _is_generated_part,
+    _MeasureResolver,
+    _metric_names_of,
+    _no_cubes_message,
+    _primary_key_of,
+    _report_fanout,
+    parked_of,
+)
+from .expressions import (
+    UnattributableSQL,
+    has_top_level_operator,
+    qualify_column_paths,
+    unsafe_aggregate_path_heads,
+)
+
+_SELF_REFS = ("CUBE", "TABLE")
+
+# Stash keys a published member keeps: how it looks to its reader. The rest of the
+# import's stash rebuilds the original Cube member, naming members that are not here.
+_PUBLISHED_MEMBER_KEYS = ("dim_type", "format", "currency", "title")
+
+# The declared Cube cardinality, which Ossie's many/one orientation cannot carry.
+_PUBLISHED_JOIN_KEYS = ("declared_on", "relationship")
+
+# Presentation keys a view `includes` entry may override, and a member carries along.
+_PRESENTATION_KEYS = ("format", "currency")
+
+# Cube keys the projection consumes, so not reported as dropped: members are the surface,
+# visibility is the view's to decide, and `data_source` rides on the dataset.
+_VISIBILITY_KEYS = ("public", "visible", "shown")
+_CONSUMED_CUBE_KEYS = frozenset({
+    "segments", "hierarchies", "data_source", *_VISIBILITY_KEYS})
+_CONSUMED_MEMBER_KEYS = frozenset({"case", *_VISIBILITY_KEYS, *_PRESENTATION_KEYS})
+_VIEW_KEYS = frozenset({"name", "cubes", "description", "meta", *_VISIBILITY_KEYS})
+
+# Multi-stage measure keys and the value shape Cube accepts. An empty value is Cube's own
+# default and changes nothing; anything else computes over another grain. Covers every
+# key in `_WINDOWING_KEYS` (a test checks), plus `grain`, which the import does not know.
+_MULTI_STAGE_SHAPES = {
+    "multi_stage": bool,
+    "group_by": list,
+    "reduce_by": list,
+    "add_group_by": list,
+    "time_shift": list,
+    "grain": dict,
+}
+
+
+@dataclasses.dataclass(frozen=True)
+class _Member:
+    """One member the view publishes: `name` on `cube`, published as `output`, with its
+    Cube `definition` and the view's `includes` entry for it, when that is a mapping."""
+
+    cube: str
+    name: str
+    output: str
+    kind: str
+    definition: dict
+    override: dict
+
+
+@dataclasses.dataclass
+class _JoinTree:
+    """The view's join tree: `root`, every dataset in presentation order, and
+    `parent[child] = (parent, the join declared on the parent)`."""
+
+    root: str
+    order: list
+    parent: dict
+
+
+def convert_cube_view_to_ossie(files, view, source=None, strict_fanout=True):
+    """Convert one Cube view's public surface to Ossie YAML.
+
+    `files` is a Cube model as {relative filename: YAML str}, the same input
+    `convert_cube_to_ossie` takes. Returns (ossie_yaml_str, resolved_source, IssueLog).
+
+    Unlike `convert_cube_to_ossie`, this is a publication transform, not a round trip:
+    the model holds exactly the dimensions and measures `view` exposes, under the names
+    it exposes them by, with every hidden dependency inlined. `resolved_source` is the
+    root of the view's join paths -- the dataset a downstream converter needing a single
+    fact should start from -- unless `source` names another dataset in the projection.
+
+    Fan-out is strict by default: a published metric that can over-count is refused
+    rather than emitted. Pass `strict_fanout=False` to convert it with a
+    FANOUT_UNSAFE_METRIC issue instead.
+    """
+    if not isinstance(files, dict) or not files:
+        raise ConversionError("expected a non-empty mapping of {filename: YAML}")
+    if not isinstance(view, str) or not view.strip():
+        raise ConversionError("view projection needs the name of the view to project")
+    if source is not None and (not isinstance(source, str) or not source):
+        raise ConversionError(f"view '{view}': source must be a dataset name")
+
+    # The collection's own issues are about the lossless import -- a file preserved in
+    # custom_extensions only -- which a projection does not do.
+    cubes, _, views, _, _ = _collect(files, IssueLog())
+    if view not in views:
+        raise ConversionError(
+            f"requested view '{view}' not found; views present: "
+            f"{sorted(views) or 'none'}")
+    if not cubes:
+        raise ConversionError(_no_cubes_message(views))
+
+    strict = {IssueType.FANOUT_UNSAFE_METRIC} if strict_fanout else set()
+    issues = IssueLog(strict_types=frozenset(strict))
+    view_def = views[view]
+    _report_dropped(issues, f"view '{view}'",
+                    [k for k in map(snake, view_def) if k not in _VIEW_KEYS])
+
+    tree = _view_tree(view, view_def, cubes)
+    members = _published_members(view, view_def, cubes)
+    inliner = _Inliner(cubes)
+
+    measures = _needed_measures(members, cubes, inliner)
+    dimensions = {}
+    for member in members:
+        key = (member.cube, member.name)
+        if member.kind == "dimension" and key not in dimensions:
+            dimensions[key] = _published_dimension(inliner, cubes, member)
+    dependencies = {cname for cname, _ in measures}
+    for (cname, _), measure in measures.items():
+        for sql in [measure.get("sql"), *(f["sql"] for f in measure.get("filters", ()))]:
+            dependencies |= _joined_cubes(sql, cname, cubes)
+    _attach_dependencies(view, tree, sorted(dependencies), cubes)
+    resolved_source = _resolve_source(view, source, tree, members)
+
+    reduced = _reduced_cubes(tree, cubes, dimensions, measures, inliner, issues)
+    reduced_view = {k: v for k, v in view_def.items() if k != "cubes"}
+    model = _build_model(reduced, {}, {view: reduced_view}, {}, {}, None, view, issues)
+    _refuse_unjoined(view, model, issues)
+    # A second resolver over the same cubes, for the fully inlined forms: the import
+    # emits a reference to a hidden measure by its metric name, and that metric is not
+    # published. Its report was made by the import's own; this one only reads.
+    resolver = _MeasureResolver(reduced, {}, IssueLog(), qualify=False)
+    _report_hidden_fanout(resolver, tree, reduced, model, measures, issues)
+    _publish_surface(model, resolver, reduced, cubes, members, issues)
+    return dump_yaml({"version": OSSIE_VERSION, **model}), resolved_source, issues
+
+
+# --- the view ---------------------------------------------------------------------
+
+def _view_tree(vname, view, cubes):
+    """The join tree the view's `join_path`s spell out.
+
+    Cube joins every view member through the path its entry names, so the paths have to
+    form one tree: a single root, and each cube reached one way. A second path to a cube
+    would give its members two meanings, which a published model cannot hold.
+    """
+    entries = view.get("cubes")
+    if not isinstance(entries, list) or not entries:
+        raise ConversionError(
+            f"view '{vname}' has no `cubes` entries to project (the legacy view-level "
+            f"`includes` form is not supported)")
+    tree = _JoinTree(root=None, order=[], parent={})
+    roots = []
+    for index, raw in enumerate(entries):
+        if not isinstance(raw, dict):
+            raise ConversionError(f"view '{vname}': cubes[{index}] must be a mapping")
+        entry = snake_keys(raw)
+        if entry.get("split"):
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] is split, and split view projections "
+                f"publish as more than one model; project each split view on its own")
+        path = entry.get("join_path")
+        if not isinstance(path, str) or not path.strip():
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] has no non-empty `join_path`")
+        parts = path.split(".")
+        if not all(parts):
+            raise ConversionError(f"view '{vname}': invalid join_path '{path}'")
+        if len(set(parts)) != len(parts):
+            raise ConversionError(
+                f"view '{vname}': join_path '{path}' visits a cube twice")
+        for part in parts:
+            if part not in cubes:
+                raise ConversionError(
+                    f"view '{vname}': join_path '{path}' references unknown cube "
+                    f"'{part}'")
+        if parts[0] not in roots:
+            roots.append(parts[0])
+        for left, right in pairwise(parts):
+            _add_edge(vname, tree, cubes, left, right, "reached through two join paths")
+        for part in parts:
+            if part not in tree.order:
+                tree.order.append(part)
+    if len(roots) != 1:
+        raise ConversionError(
+            f"view '{vname}' has multiple join roots {roots}; a published model joins "
+            f"everything from one dataset, so project a single-root view")
+    tree.root = roots[0]
+    return tree
+
+
+def _add_edge(vname, tree, cubes, left, right, conflict):
+    """Join `right` under `left`, refusing a second parent for it."""
+    prior = tree.parent.get(right)
+    if prior is not None:
+        if prior[0] != left:
+            raise ConversionError(
+                f"view '{vname}': cube '{right}' is {conflict} (from '{prior[0]}' and "
+                f"from '{left}'), which would give its members two meanings")
+        return
+    joins = [j for j in _as_named_list(cubes[left].get("joins"), f"cube '{left}' joins")
+             if j.get("name") == right]
+    if not joins:
+        raise ConversionError(
+            f"view '{vname}': joining '{right}' needs a join '{left}' -> '{right}', but "
+            f"cube '{left}' declares no join to '{right}'")
+    if len(joins) > 1:
+        raise ConversionError(
+            f"view '{vname}': cube '{left}' declares more than one join to '{right}'")
+    tree.parent[right] = (left, joins[0])
+
+
+def _published_members(vname, view, cubes):
+    """Every member the view publishes, in view order, with its published name."""
+    decomposed = _decomposed_measure_names(cubes)
+    members, seen, taken = [], set(), {}
+    for raw in view["cubes"]:
+        entry = snake_keys(raw)
+        cname = entry["join_path"].split(".")[-1]
+        for member in _entry_members(vname, entry, cname, cubes[cname], decomposed):
+            identity = (member.cube, member.name, member.output, member.kind)
+            if identity in seen:
+                continue
+            seen.add(identity)
+            key = normalize_identifier(member.output)
+            if key in taken:
+                other = taken[key]
+                raise ConversionError(
+                    f"view '{vname}': published name '{member.output}' from "
+                    f"'{member.cube}.{member.name}' collides with '{other.output}' from "
+                    f"'{other.cube}.{other.name}' (identifiers are case-insensitive); "
+                    f"use an alias or a prefix")
+            taken[key] = member
+            members.append(member)
+    if not members:
+        raise ConversionError(f"view '{vname}' publishes no dimensions or measures")
+    return members
+
+
+def _entry_members(vname, entry, cname, cube, decomposed):
+    """The members one `cubes:` entry publishes, honouring `includes`, `excludes`,
+    member aliases and `prefix`."""
+    prefix = entry.get("prefix", False)
+    if not isinstance(prefix, bool):
+        raise ConversionError(
+            f"view '{vname}': prefix for cube '{cname}' must be true or false")
+    alias = entry.get("alias", cname)
+    if not isinstance(alias, str) or not alias:
+        raise ConversionError(
+            f"view '{vname}': alias for cube '{cname}' must be a non-empty string")
+
+    by_name = {}
+    for kind, key in (("dimension", "dimensions"), ("measure", "measures"),
+                      ("segment", "segments"), ("hierarchy", "hierarchies")):
+        for member in _as_named_list(cube.get(key), f"cube '{cname}' {key}"):
+            if member.get("name"):
+                by_name.setdefault(member["name"], (kind, member))
+
+    includes = entry.get("includes", [])
+    if includes == "*":
+        # A wildcard publishes what the cube itself makes public. A measure a previous
+        # export generated to hold one part of a composite metric is an implementation
+        # detail of the metric that references it, never a member of its own.
+        requested = [
+            (name, name, {}) for name, (kind, member) in by_name.items()
+            if not _is_private(member)
+            and not (kind == "measure" and _is_generated_part(member, decomposed))]
+    elif isinstance(includes, list):
+        requested = [_include(vname, cname, item) for item in includes]
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must be '*' or a list")
+
+    excludes = entry.get("excludes") or []

Review Comment:
   Fixed in ec46436: `excludes` is now read with no coercion, so `excludes: false` (or `0`, `''`) fails the list check and is refused; an empty `excludes:` (null) still means no exclusions. Covered in `test_view_shape_refusals`.



##########
converters/cube/src/ossie_cube/expressions.py:
##########
@@ -451,6 +474,136 @@ def qualify_bare_columns(cube_sql):
         text, {name: "{CUBE}." + name for name in names})
 
 
+# The portable reading first, then grammars that quote identifiers with backticks
+# (Databricks, BigQuery, MySQL) or brackets (T-SQL) and read unit arguments the way
+# those warehouses do -- `DATEDIFF(day, a, b)`, `CONVERT(VARCHAR, x)`.
+_READINGS = (None, "databricks", "tsql")
+
+# Words a function takes as a date part or type name rather than a column. sqlglot's
+# portable grammar reads `DATEDIFF(day, a, b)` with `day` as a column and `b` as the
+# unit, and a qualification built on that reading would be wrong.
+_UNIT_WORDS = frozenset({
+    "YEAR", "YEARS", "QUARTER", "MONTH", "MONTHS", "WEEK", "WEEKS", "WEEKDAY",
+    "ISOWEEK", "ISOYEAR", "DAY", "DAYS", "DAYOFWEEK", "DAYOFYEAR", "DOW", "DOY", "HOUR",
+    "HOURS", "MINUTE", "MINUTES", "SECOND", "SECONDS", "MILLISECOND", "MILLISECONDS",
+    "MICROSECOND", "MICROSECONDS", "NANOSECOND", "NANOSECONDS", "EPOCH", "DECADE",
+    "CENTURY", "MILLENNIUM", "TIMEZONE", "TIMEZONE_HOUR", "TIMEZONE_MINUTE", "YYYY",
+    "YY", "MM", "DD", "HH", "MI", "SS", "MS",
+})
+_TYPE_WORDS = frozenset({
+    "VARCHAR", "NVARCHAR", "CHAR", "NCHAR", "INT", "INTEGER", "BIGINT", "SMALLINT",
+    "TINYINT", "DECIMAL", "NUMERIC", "FLOAT", "REAL", "DOUBLE", "DATETIME", "DATETIME2",
+    "TIMESTAMP", "BOOLEAN",
+})
+
+
+class UnattributableSQL(ValueError):
+    """Cube SQL whose columns cannot be attributed to a dataset statically."""
+
+
+def _parse_as(text, dialect):
+    """`text` parsed as `dialect`, or None when it does not parse as that."""
+    try:
+        return sqlglot.parse_one(text, read=dialect)
+    except (sqlglot.errors.SqlglotError, ValueError, RecursionError):
+        return None
+
+
+def _misread(tree):
+    """True when a reading takes a unit or type argument for a column, or a column for
+    a unit -- what the portable grammar does to `DATEDIFF(day, a, b)`."""
+    if any(var.name.upper() not in _UNIT_WORDS for var in tree.find_all(exp.Var)):
+        return True
+    return any(not column.table and isinstance(column.parent, exp.Func)
+               and column.name.upper() in _UNIT_WORDS | _TYPE_WORDS
+               for column in tree.find_all(exp.Column))
+
+
+def strip_sql_comments(sql):
+    """`sql` with its `--` and `/* */` comments replaced by a space.
+
+    A trailing `-- note` is harmless in a member's own SQL, but inlined into another
+    expression it comments out everything after it.
+    """
+    text = str(sql)
+    mask = quoted_char_mask(text)

Review Comment:
   Fixed in ec46436: bracket-quoted identifiers are masked before comment stripping, including escaped `]]`. `[amount--gross] + 1` and `[col/*note*/bar]` now come through unchanged, and `[amount--gross] + [col/*x*/y]` qualifies to `orders.[amount--gross] + orders.[col/*x*/y]` in `test_quoted_identifiers_of_the_data_sources_dialect_are_qualified`.



##########
converters/cube/src/ossie_cube/expressions.py:
##########
@@ -451,6 +474,136 @@ def qualify_bare_columns(cube_sql):
         text, {name: "{CUBE}." + name for name in names})
 
 
+# The portable reading first, then grammars that quote identifiers with backticks
+# (Databricks, BigQuery, MySQL) or brackets (T-SQL) and read unit arguments the way
+# those warehouses do -- `DATEDIFF(day, a, b)`, `CONVERT(VARCHAR, x)`.
+_READINGS = (None, "databricks", "tsql")
+
+# Words a function takes as a date part or type name rather than a column. sqlglot's
+# portable grammar reads `DATEDIFF(day, a, b)` with `day` as a column and `b` as the
+# unit, and a qualification built on that reading would be wrong.
+_UNIT_WORDS = frozenset({
+    "YEAR", "YEARS", "QUARTER", "MONTH", "MONTHS", "WEEK", "WEEKS", "WEEKDAY",
+    "ISOWEEK", "ISOYEAR", "DAY", "DAYS", "DAYOFWEEK", "DAYOFYEAR", "DOW", "DOY", "HOUR",
+    "HOURS", "MINUTE", "MINUTES", "SECOND", "SECONDS", "MILLISECOND", "MILLISECONDS",
+    "MICROSECOND", "MICROSECONDS", "NANOSECOND", "NANOSECONDS", "EPOCH", "DECADE",
+    "CENTURY", "MILLENNIUM", "TIMEZONE", "TIMEZONE_HOUR", "TIMEZONE_MINUTE", "YYYY",
+    "YY", "MM", "DD", "HH", "MI", "SS", "MS",
+})
+_TYPE_WORDS = frozenset({
+    "VARCHAR", "NVARCHAR", "CHAR", "NCHAR", "INT", "INTEGER", "BIGINT", "SMALLINT",
+    "TINYINT", "DECIMAL", "NUMERIC", "FLOAT", "REAL", "DOUBLE", "DATETIME", "DATETIME2",
+    "TIMESTAMP", "BOOLEAN",
+})
+
+
+class UnattributableSQL(ValueError):
+    """Cube SQL whose columns cannot be attributed to a dataset statically."""
+
+
+def _parse_as(text, dialect):
+    """`text` parsed as `dialect`, or None when it does not parse as that."""
+    try:
+        return sqlglot.parse_one(text, read=dialect)
+    except (sqlglot.errors.SqlglotError, ValueError, RecursionError):
+        return None
+
+
+def _misread(tree):
+    """True when a reading takes a unit or type argument for a column, or a column for
+    a unit -- what the portable grammar does to `DATEDIFF(day, a, b)`."""
+    if any(var.name.upper() not in _UNIT_WORDS for var in tree.find_all(exp.Var)):
+        return True
+    return any(not column.table and isinstance(column.parent, exp.Func)
+               and column.name.upper() in _UNIT_WORDS | _TYPE_WORDS
+               for column in tree.find_all(exp.Column))
+
+
+def strip_sql_comments(sql):
+    """`sql` with its `--` and `/* */` comments replaced by a space.
+
+    A trailing `-- note` is harmless in a member's own SQL, but inlined into another
+    expression it comments out everything after it.
+    """
+    text = str(sql)
+    mask = quoted_char_mask(text)
+    out, i = [], 0
+    while i < len(text):
+        if not mask[i] and text.startswith("--", i):
+            end = text.find("\n", i)
+            i = len(text) if end < 0 else end
+            out.append(" ")
+        elif not mask[i] and text.startswith("/*", i):
+            end = text.find("*/", i + 2)
+            i = len(text) if end < 0 else end + 2
+            out.append(" ")
+        else:
+            out.append(text[i])
+            i += 1
+    return "".join(out)
+
+
+def qualify_column_paths(cube_sql, own_cube=None, cube_names=()):
+    """Qualify every physical column path in Cube SQL as `{CUBE}.path`.
+
+    `qualify_bare_columns` leaves a dotted path as written, which suits the lossless
+    import: the text is carried across, not reasoned about. View projection has to say
+    which dataset every column belongs to, and a path in a cube's own SQL is that
+    cube's: `payload.amount` is a struct field of the cube's table, so the whole path is
+    qualified. A path rooted at a `{...}` reference is left alone. Cube aliases each
+    cube's table by the cube's name, so a path starting with `own_cube` reads the own
+    table (`orders.amount` becomes `{CUBE}.amount`), and one starting with another of
+    `cube_names` could be that cube's table or a struct field, and is refused.
+
+    Cube SQL is the data source's own, so other grammars are tried when the portable one
+    fails or misreads a unit argument. Only positions are read and the text is never
+    regenerated, so which grammar reads it does not matter. Comments are dropped.
+
+    Raises UnattributableSQL saying why when the columns cannot be attributed statically.
+    """
+    masked, saved = _mask_references(strip_sql_comments(cube_sql))
+    # The positions sqlglot reports are into the text it parsed, so it gets no other.
+    masked = masked.strip()
+    readings = [t for t in (_parse_as(masked, d) for d in _READINGS) if t is not None]
+    if not readings:
+        raise UnattributableSQL("it does not parse")
+    tree = next((t for t in readings if not _misread(t)), None)
+    if tree is None:

Review Comment:
   Fixed in ec46436: `_misread` now treats a bracketed name read as an array of one column as a misread, so the T-SQL reading wins. `[amount]` → `{CUBE}.[amount]` and `[orders].amount` → `{CUBE}.amount`, both covered in `test_quoted_identifiers_of_the_data_sources_dialect_are_qualified`.



##########
converters/cube/src/ossie_cube/expressions.py:
##########
@@ -451,6 +474,136 @@ def qualify_bare_columns(cube_sql):
         text, {name: "{CUBE}." + name for name in names})
 
 
+# The portable reading first, then grammars that quote identifiers with backticks
+# (Databricks, BigQuery, MySQL) or brackets (T-SQL) and read unit arguments the way
+# those warehouses do -- `DATEDIFF(day, a, b)`, `CONVERT(VARCHAR, x)`.
+_READINGS = (None, "databricks", "tsql")
+
+# Words a function takes as a date part or type name rather than a column. sqlglot's
+# portable grammar reads `DATEDIFF(day, a, b)` with `day` as a column and `b` as the
+# unit, and a qualification built on that reading would be wrong.
+_UNIT_WORDS = frozenset({
+    "YEAR", "YEARS", "QUARTER", "MONTH", "MONTHS", "WEEK", "WEEKS", "WEEKDAY",
+    "ISOWEEK", "ISOYEAR", "DAY", "DAYS", "DAYOFWEEK", "DAYOFYEAR", "DOW", "DOY", "HOUR",
+    "HOURS", "MINUTE", "MINUTES", "SECOND", "SECONDS", "MILLISECOND", "MILLISECONDS",
+    "MICROSECOND", "MICROSECONDS", "NANOSECOND", "NANOSECONDS", "EPOCH", "DECADE",
+    "CENTURY", "MILLENNIUM", "TIMEZONE", "TIMEZONE_HOUR", "TIMEZONE_MINUTE", "YYYY",
+    "YY", "MM", "DD", "HH", "MI", "SS", "MS",
+})
+_TYPE_WORDS = frozenset({
+    "VARCHAR", "NVARCHAR", "CHAR", "NCHAR", "INT", "INTEGER", "BIGINT", "SMALLINT",
+    "TINYINT", "DECIMAL", "NUMERIC", "FLOAT", "REAL", "DOUBLE", "DATETIME", "DATETIME2",
+    "TIMESTAMP", "BOOLEAN",
+})
+
+
+class UnattributableSQL(ValueError):
+    """Cube SQL whose columns cannot be attributed to a dataset statically."""
+
+
+def _parse_as(text, dialect):
+    """`text` parsed as `dialect`, or None when it does not parse as that."""
+    try:
+        return sqlglot.parse_one(text, read=dialect)
+    except (sqlglot.errors.SqlglotError, ValueError, RecursionError):
+        return None
+
+
+def _misread(tree):
+    """True when a reading takes a unit or type argument for a column, or a column for
+    a unit -- what the portable grammar does to `DATEDIFF(day, a, b)`."""
+    if any(var.name.upper() not in _UNIT_WORDS for var in tree.find_all(exp.Var)):
+        return True
+    return any(not column.table and isinstance(column.parent, exp.Func)
+               and column.name.upper() in _UNIT_WORDS | _TYPE_WORDS
+               for column in tree.find_all(exp.Column))
+
+
+def strip_sql_comments(sql):
+    """`sql` with its `--` and `/* */` comments replaced by a space.
+
+    A trailing `-- note` is harmless in a member's own SQL, but inlined into another
+    expression it comments out everything after it.
+    """
+    text = str(sql)
+    mask = quoted_char_mask(text)
+    out, i = [], 0
+    while i < len(text):
+        if not mask[i] and text.startswith("--", i):
+            end = text.find("\n", i)
+            i = len(text) if end < 0 else end
+            out.append(" ")
+        elif not mask[i] and text.startswith("/*", i):
+            end = text.find("*/", i + 2)
+            i = len(text) if end < 0 else end + 2
+            out.append(" ")
+        else:
+            out.append(text[i])
+            i += 1
+    return "".join(out)
+
+
+def qualify_column_paths(cube_sql, own_cube=None, cube_names=()):
+    """Qualify every physical column path in Cube SQL as `{CUBE}.path`.
+
+    `qualify_bare_columns` leaves a dotted path as written, which suits the lossless
+    import: the text is carried across, not reasoned about. View projection has to say
+    which dataset every column belongs to, and a path in a cube's own SQL is that
+    cube's: `payload.amount` is a struct field of the cube's table, so the whole path is
+    qualified. A path rooted at a `{...}` reference is left alone. Cube aliases each
+    cube's table by the cube's name, so a path starting with `own_cube` reads the own
+    table (`orders.amount` becomes `{CUBE}.amount`), and one starting with another of
+    `cube_names` could be that cube's table or a struct field, and is refused.
+
+    Cube SQL is the data source's own, so other grammars are tried when the portable one
+    fails or misreads a unit argument. Only positions are read and the text is never
+    regenerated, so which grammar reads it does not matter. Comments are dropped.
+
+    Raises UnattributableSQL saying why when the columns cannot be attributed statically.
+    """
+    masked, saved = _mask_references(strip_sql_comments(cube_sql))
+    # The positions sqlglot reports are into the text it parsed, so it gets no other.
+    masked = masked.strip()
+    readings = [t for t in (_parse_as(masked, d) for d in _READINGS) if t is not None]
+    if not readings:
+        raise UnattributableSQL("it does not parse")
+    tree = next((t for t in readings if not _misread(t)), None)
+    if tree is None:
+        raise UnattributableSQL(
+            "a unit or type argument reads as a column (or a column as a unit); write "
+            "a column that shares a unit's name as {CUBE}.name")
+    if tree.find(exp.Select, exp.Lambda) is not None:
+        raise UnattributableSQL("it binds names of its own (a subquery or a lambda)")
+    others = {name.casefold() for name in cube_names if name != own_cube}

Review Comment:
   Fixed in ec46436: the own cube is now matched case-insensitively, quoted or not, and removed from `others` the same way, so `"Orders".amount` on cube `orders` is its own column (`{CUBE}.amount`) rather than refused as another cube's. Covered in `test_quoted_identifiers_of_the_data_sources_dialect_are_qualified`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat(cube): project one Cube view's public surface onto Ossie [ossie]

Posted by "jbonofre (via GitHub)" <gi...@apache.org>.
jbonofre commented on code in PR #471:
URL: https://github.com/apache/ossie/pull/471#discussion_r4114088535


##########
converters/cube/src/ossie_cube/expressions.py:
##########
@@ -451,6 +474,136 @@ def qualify_bare_columns(cube_sql):
         text, {name: "{CUBE}." + name for name in names})
 
 
+# The portable reading first, then grammars that quote identifiers with backticks
+# (Databricks, BigQuery, MySQL) or brackets (T-SQL) and read unit arguments the way
+# those warehouses do -- `DATEDIFF(day, a, b)`, `CONVERT(VARCHAR, x)`.
+_READINGS = (None, "databricks", "tsql")
+
+# Words a function takes as a date part or type name rather than a column. sqlglot's
+# portable grammar reads `DATEDIFF(day, a, b)` with `day` as a column and `b` as the
+# unit, and a qualification built on that reading would be wrong.
+_UNIT_WORDS = frozenset({
+    "YEAR", "YEARS", "QUARTER", "MONTH", "MONTHS", "WEEK", "WEEKS", "WEEKDAY",
+    "ISOWEEK", "ISOYEAR", "DAY", "DAYS", "DAYOFWEEK", "DAYOFYEAR", "DOW", "DOY", "HOUR",
+    "HOURS", "MINUTE", "MINUTES", "SECOND", "SECONDS", "MILLISECOND", "MILLISECONDS",
+    "MICROSECOND", "MICROSECONDS", "NANOSECOND", "NANOSECONDS", "EPOCH", "DECADE",
+    "CENTURY", "MILLENNIUM", "TIMEZONE", "TIMEZONE_HOUR", "TIMEZONE_MINUTE", "YYYY",
+    "YY", "MM", "DD", "HH", "MI", "SS", "MS",
+})
+_TYPE_WORDS = frozenset({
+    "VARCHAR", "NVARCHAR", "CHAR", "NCHAR", "INT", "INTEGER", "BIGINT", "SMALLINT",
+    "TINYINT", "DECIMAL", "NUMERIC", "FLOAT", "REAL", "DOUBLE", "DATETIME", "DATETIME2",
+    "TIMESTAMP", "BOOLEAN",
+})
+
+
+class UnattributableSQL(ValueError):
+    """Cube SQL whose columns cannot be attributed to a dataset statically."""
+
+
+def _parse_as(text, dialect):
+    """`text` parsed as `dialect`, or None when it does not parse as that."""
+    try:
+        return sqlglot.parse_one(text, read=dialect)
+    except (sqlglot.errors.SqlglotError, ValueError, RecursionError):
+        return None
+
+
+def _misread(tree):
+    """True when a reading takes a unit or type argument for a column, or a column for
+    a unit -- what the portable grammar does to `DATEDIFF(day, a, b)`."""
+    if any(var.name.upper() not in _UNIT_WORDS for var in tree.find_all(exp.Var)):
+        return True
+    return any(not column.table and isinstance(column.parent, exp.Func)
+               and column.name.upper() in _UNIT_WORDS | _TYPE_WORDS
+               for column in tree.find_all(exp.Column))
+
+
+def strip_sql_comments(sql):
+    """`sql` with its `--` and `/* */` comments replaced by a space.
+
+    A trailing `-- note` is harmless in a member's own SQL, but inlined into another
+    expression it comments out everything after it.
+    """
+    text = str(sql)
+    mask = quoted_char_mask(text)
+    out, i = [], 0
+    while i < len(text):
+        if not mask[i] and text.startswith("--", i):
+            end = text.find("\n", i)
+            i = len(text) if end < 0 else end
+            out.append(" ")
+        elif not mask[i] and text.startswith("/*", i):
+            end = text.find("*/", i + 2)
+            i = len(text) if end < 0 else end + 2
+            out.append(" ")
+        else:
+            out.append(text[i])
+            i += 1
+    return "".join(out)
+
+
+def qualify_column_paths(cube_sql, own_cube=None, cube_names=()):
+    """Qualify every physical column path in Cube SQL as `{CUBE}.path`.
+
+    `qualify_bare_columns` leaves a dotted path as written, which suits the lossless
+    import: the text is carried across, not reasoned about. View projection has to say
+    which dataset every column belongs to, and a path in a cube's own SQL is that
+    cube's: `payload.amount` is a struct field of the cube's table, so the whole path is
+    qualified. A path rooted at a `{...}` reference is left alone. Cube aliases each
+    cube's table by the cube's name, so a path starting with `own_cube` reads the own
+    table (`orders.amount` becomes `{CUBE}.amount`), and one starting with another of
+    `cube_names` could be that cube's table or a struct field, and is refused.
+
+    Cube SQL is the data source's own, so other grammars are tried when the portable one
+    fails or misreads a unit argument. Only positions are read and the text is never
+    regenerated, so which grammar reads it does not matter. Comments are dropped.
+
+    Raises UnattributableSQL saying why when the columns cannot be attributed statically.
+    """
+    masked, saved = _mask_references(strip_sql_comments(cube_sql))
+    # The positions sqlglot reports are into the text it parsed, so it gets no other.
+    masked = masked.strip()
+    readings = [t for t in (_parse_as(masked, d) for d in _READINGS) if t is not None]
+    if not readings:
+        raise UnattributableSQL("it does not parse")
+    tree = next((t for t in readings if not _misread(t)), None)
+    if tree is None:

Review Comment:
   Under `_READINGS = (None, "databricks", "tsql")`, sqlglot's default dialect (`None`) parses `[amount]` and `[orders].amount` without throwing a syntax error, but it parses them into `exp.Array` or `exp.Dot(Array(...), Identifier(...))`.
   
   Because `_misread` does not flag `exp.Array`, the portable grammar is selected over `tsql`. This results in invalid replacement position:
   
   ```python
       qualify_column_paths("[amount]", own_cube="orders")
       # Output: '[{CUBE}.amount]' instead of '{CUBE}.[amount]'
   
       qualify_column_paths("[orders].amount", own_cube="orders", cube_names=["orders"])
       # Output: '[{CUBE}.orders].amount' instead of '{CUBE}.amount'
   ```
   
   Could we update `_misread` to detect when bracketed tokens were misparsed as `exp.Array` (or ensure `tsql` is preferred when bracketed identifiers are present)?



##########
converters/cube/src/ossie_cube/view_projection.py:
##########
@@ -0,0 +1,1010 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Project one Cube view's public surface onto an Apache Ossie semantic model.
+
+`convert_cube_to_ossie` is a lossless round-trip import: every cube member becomes an
+Ossie field or metric, and the view's curation rides in `custom_extensions`. Publishing
+a view to another tool needs the opposite boundary -- exactly the members the view
+exposes, under the names it exposes them by, and nothing the view keeps private.
+
+The projection resolves the view (its members, join tree and hidden dependencies),
+builds a reduced cube set holding only those, runs the ordinary import over it, and
+trims the result to the published surface. Dimension references are inlined while the
+set is built, in Cube space, so the import's own join decomposition and fan-out analysis
+read the columns a member really uses; measure references are inlined afterwards by the
+import's `_MeasureResolver`. Anything the published model could not say faithfully is
+refused with a `ConversionError`, never dropped. See README "View projection".
+
+Usage (CLI):
+    ossie-cube import -i model/ --view sales --project [--source orders]
+"""
+
+import copy
+import dataclasses
+from collections import deque
+from itertools import pairwise
+
+from ._common import (
+    _CUBE_REF_RE,
+    OSSIE_VERSION,
+    VENDOR,
+    ConversionError,
+    dump_yaml,
+    lookup_map,
+    normalize_identifier,
+    primary_key_operand,
+    read_stash,
+    requalify_self_refs,
+    resolve_identifier,
+    snake,
+    snake_keys,
+    unescape_braces_from_cube,
+    write_stash,
+)
+from .converter_issues import IssueLog, IssueType
+from .cube_to_osi import (
+    _CUBE_NATIVE_KEYS,
+    _DIM_NATIVE_KEYS,
+    _MEASURE_NATIVE_KEYS,
+    _RELATIONSHIP_ALIASES,
+    _ai_context_from_meta,
+    _as_named_list,
+    _build_model,
+    _case_expression,
+    _collect,
+    _column_of,
+    _decomposed_measure_names,
+    _fanned_out_datasets,
+    _fanout_unsafe_datasets,
+    _is_generated_part,
+    _MeasureResolver,
+    _metric_names_of,
+    _no_cubes_message,
+    _primary_key_of,
+    _report_fanout,
+    parked_of,
+)
+from .expressions import (
+    UnattributableSQL,
+    has_top_level_operator,
+    qualify_column_paths,
+    unsafe_aggregate_path_heads,
+)
+
+_SELF_REFS = ("CUBE", "TABLE")
+
+# Stash keys a published member keeps: how it looks to its reader. The rest of the
+# import's stash rebuilds the original Cube member, naming members that are not here.
+_PUBLISHED_MEMBER_KEYS = ("dim_type", "format", "currency", "title")
+
+# The declared Cube cardinality, which Ossie's many/one orientation cannot carry.
+_PUBLISHED_JOIN_KEYS = ("declared_on", "relationship")
+
+# Presentation keys a view `includes` entry may override, and a member carries along.
+_PRESENTATION_KEYS = ("format", "currency")
+
+# Cube keys the projection consumes, so not reported as dropped: members are the surface,
+# visibility is the view's to decide, and `data_source` rides on the dataset.
+_VISIBILITY_KEYS = ("public", "visible", "shown")
+_CONSUMED_CUBE_KEYS = frozenset({
+    "segments", "hierarchies", "data_source", *_VISIBILITY_KEYS})
+_CONSUMED_MEMBER_KEYS = frozenset({"case", *_VISIBILITY_KEYS, *_PRESENTATION_KEYS})
+_VIEW_KEYS = frozenset({"name", "cubes", "description", "meta", *_VISIBILITY_KEYS})
+
+# Multi-stage measure keys and the value shape Cube accepts. An empty value is Cube's own
+# default and changes nothing; anything else computes over another grain. Covers every
+# key in `_WINDOWING_KEYS` (a test checks), plus `grain`, which the import does not know.
+_MULTI_STAGE_SHAPES = {
+    "multi_stage": bool,
+    "group_by": list,
+    "reduce_by": list,
+    "add_group_by": list,
+    "time_shift": list,
+    "grain": dict,
+}
+
+
+@dataclasses.dataclass(frozen=True)
+class _Member:
+    """One member the view publishes: `name` on `cube`, published as `output`, with its
+    Cube `definition` and the view's `includes` entry for it, when that is a mapping."""
+
+    cube: str
+    name: str
+    output: str
+    kind: str
+    definition: dict
+    override: dict
+
+
+@dataclasses.dataclass
+class _JoinTree:
+    """The view's join tree: `root`, every dataset in presentation order, and
+    `parent[child] = (parent, the join declared on the parent)`."""
+
+    root: str
+    order: list
+    parent: dict
+
+
+def convert_cube_view_to_ossie(files, view, source=None, strict_fanout=True):
+    """Convert one Cube view's public surface to Ossie YAML.
+
+    `files` is a Cube model as {relative filename: YAML str}, the same input
+    `convert_cube_to_ossie` takes. Returns (ossie_yaml_str, resolved_source, IssueLog).
+
+    Unlike `convert_cube_to_ossie`, this is a publication transform, not a round trip:
+    the model holds exactly the dimensions and measures `view` exposes, under the names
+    it exposes them by, with every hidden dependency inlined. `resolved_source` is the
+    root of the view's join paths -- the dataset a downstream converter needing a single
+    fact should start from -- unless `source` names another dataset in the projection.
+
+    Fan-out is strict by default: a published metric that can over-count is refused
+    rather than emitted. Pass `strict_fanout=False` to convert it with a
+    FANOUT_UNSAFE_METRIC issue instead.
+    """
+    if not isinstance(files, dict) or not files:
+        raise ConversionError("expected a non-empty mapping of {filename: YAML}")
+    if not isinstance(view, str) or not view.strip():
+        raise ConversionError("view projection needs the name of the view to project")
+    if source is not None and (not isinstance(source, str) or not source):
+        raise ConversionError(f"view '{view}': source must be a dataset name")
+
+    # The collection's own issues are about the lossless import -- a file preserved in
+    # custom_extensions only -- which a projection does not do.
+    cubes, _, views, _, _ = _collect(files, IssueLog())
+    if view not in views:
+        raise ConversionError(
+            f"requested view '{view}' not found; views present: "
+            f"{sorted(views) or 'none'}")
+    if not cubes:
+        raise ConversionError(_no_cubes_message(views))
+
+    strict = {IssueType.FANOUT_UNSAFE_METRIC} if strict_fanout else set()
+    issues = IssueLog(strict_types=frozenset(strict))
+    view_def = views[view]
+    _report_dropped(issues, f"view '{view}'",
+                    [k for k in map(snake, view_def) if k not in _VIEW_KEYS])
+
+    tree = _view_tree(view, view_def, cubes)
+    members = _published_members(view, view_def, cubes)
+    inliner = _Inliner(cubes)
+
+    measures = _needed_measures(members, cubes, inliner)
+    dimensions = {}
+    for member in members:
+        key = (member.cube, member.name)
+        if member.kind == "dimension" and key not in dimensions:
+            dimensions[key] = _published_dimension(inliner, cubes, member)
+    dependencies = {cname for cname, _ in measures}
+    for (cname, _), measure in measures.items():
+        for sql in [measure.get("sql"), *(f["sql"] for f in measure.get("filters", ()))]:
+            dependencies |= _joined_cubes(sql, cname, cubes)
+    _attach_dependencies(view, tree, sorted(dependencies), cubes)
+    resolved_source = _resolve_source(view, source, tree, members)
+
+    reduced = _reduced_cubes(tree, cubes, dimensions, measures, inliner, issues)
+    reduced_view = {k: v for k, v in view_def.items() if k != "cubes"}
+    model = _build_model(reduced, {}, {view: reduced_view}, {}, {}, None, view, issues)
+    _refuse_unjoined(view, model, issues)
+    # A second resolver over the same cubes, for the fully inlined forms: the import
+    # emits a reference to a hidden measure by its metric name, and that metric is not
+    # published. Its report was made by the import's own; this one only reads.
+    resolver = _MeasureResolver(reduced, {}, IssueLog(), qualify=False)
+    _report_hidden_fanout(resolver, tree, reduced, model, measures, issues)
+    _publish_surface(model, resolver, reduced, cubes, members, issues)
+    return dump_yaml({"version": OSSIE_VERSION, **model}), resolved_source, issues
+
+
+# --- the view ---------------------------------------------------------------------
+
+def _view_tree(vname, view, cubes):
+    """The join tree the view's `join_path`s spell out.
+
+    Cube joins every view member through the path its entry names, so the paths have to
+    form one tree: a single root, and each cube reached one way. A second path to a cube
+    would give its members two meanings, which a published model cannot hold.
+    """
+    entries = view.get("cubes")
+    if not isinstance(entries, list) or not entries:
+        raise ConversionError(
+            f"view '{vname}' has no `cubes` entries to project (the legacy view-level "
+            f"`includes` form is not supported)")
+    tree = _JoinTree(root=None, order=[], parent={})
+    roots = []
+    for index, raw in enumerate(entries):
+        if not isinstance(raw, dict):
+            raise ConversionError(f"view '{vname}': cubes[{index}] must be a mapping")
+        entry = snake_keys(raw)
+        if entry.get("split"):
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] is split, and split view projections "
+                f"publish as more than one model; project each split view on its own")
+        path = entry.get("join_path")
+        if not isinstance(path, str) or not path.strip():
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] has no non-empty `join_path`")
+        parts = path.split(".")
+        if not all(parts):
+            raise ConversionError(f"view '{vname}': invalid join_path '{path}'")
+        if len(set(parts)) != len(parts):
+            raise ConversionError(
+                f"view '{vname}': join_path '{path}' visits a cube twice")
+        for part in parts:
+            if part not in cubes:
+                raise ConversionError(
+                    f"view '{vname}': join_path '{path}' references unknown cube "
+                    f"'{part}'")
+        if parts[0] not in roots:
+            roots.append(parts[0])
+        for left, right in pairwise(parts):
+            _add_edge(vname, tree, cubes, left, right, "reached through two join paths")
+        for part in parts:
+            if part not in tree.order:
+                tree.order.append(part)
+    if len(roots) != 1:
+        raise ConversionError(
+            f"view '{vname}' has multiple join roots {roots}; a published model joins "
+            f"everything from one dataset, so project a single-root view")
+    tree.root = roots[0]
+    return tree
+
+
+def _add_edge(vname, tree, cubes, left, right, conflict):
+    """Join `right` under `left`, refusing a second parent for it."""
+    prior = tree.parent.get(right)
+    if prior is not None:
+        if prior[0] != left:
+            raise ConversionError(
+                f"view '{vname}': cube '{right}' is {conflict} (from '{prior[0]}' and "
+                f"from '{left}'), which would give its members two meanings")
+        return
+    joins = [j for j in _as_named_list(cubes[left].get("joins"), f"cube '{left}' joins")
+             if j.get("name") == right]
+    if not joins:
+        raise ConversionError(
+            f"view '{vname}': joining '{right}' needs a join '{left}' -> '{right}', but "
+            f"cube '{left}' declares no join to '{right}'")
+    if len(joins) > 1:
+        raise ConversionError(
+            f"view '{vname}': cube '{left}' declares more than one join to '{right}'")
+    tree.parent[right] = (left, joins[0])
+
+
+def _published_members(vname, view, cubes):
+    """Every member the view publishes, in view order, with its published name."""
+    decomposed = _decomposed_measure_names(cubes)
+    members, seen, taken = [], set(), {}
+    for raw in view["cubes"]:
+        entry = snake_keys(raw)
+        cname = entry["join_path"].split(".")[-1]
+        for member in _entry_members(vname, entry, cname, cubes[cname], decomposed):
+            identity = (member.cube, member.name, member.output, member.kind)
+            if identity in seen:
+                continue
+            seen.add(identity)
+            key = normalize_identifier(member.output)
+            if key in taken:
+                other = taken[key]
+                raise ConversionError(
+                    f"view '{vname}': published name '{member.output}' from "
+                    f"'{member.cube}.{member.name}' collides with '{other.output}' from "
+                    f"'{other.cube}.{other.name}' (identifiers are case-insensitive); "
+                    f"use an alias or a prefix")
+            taken[key] = member
+            members.append(member)
+    if not members:
+        raise ConversionError(f"view '{vname}' publishes no dimensions or measures")
+    return members
+
+
+def _entry_members(vname, entry, cname, cube, decomposed):
+    """The members one `cubes:` entry publishes, honouring `includes`, `excludes`,
+    member aliases and `prefix`."""
+    prefix = entry.get("prefix", False)
+    if not isinstance(prefix, bool):
+        raise ConversionError(
+            f"view '{vname}': prefix for cube '{cname}' must be true or false")
+    alias = entry.get("alias", cname)
+    if not isinstance(alias, str) or not alias:
+        raise ConversionError(
+            f"view '{vname}': alias for cube '{cname}' must be a non-empty string")
+
+    by_name = {}
+    for kind, key in (("dimension", "dimensions"), ("measure", "measures"),
+                      ("segment", "segments"), ("hierarchy", "hierarchies")):
+        for member in _as_named_list(cube.get(key), f"cube '{cname}' {key}"):
+            if member.get("name"):
+                by_name.setdefault(member["name"], (kind, member))
+
+    includes = entry.get("includes", [])
+    if includes == "*":
+        # A wildcard publishes what the cube itself makes public. A measure a previous
+        # export generated to hold one part of a composite metric is an implementation
+        # detail of the metric that references it, never a member of its own.
+        requested = [
+            (name, name, {}) for name, (kind, member) in by_name.items()
+            if not _is_private(member)
+            and not (kind == "measure" and _is_generated_part(member, decomposed))]
+    elif isinstance(includes, list):
+        requested = [_include(vname, cname, item) for item in includes]
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must be '*' or a list")
+
+    excludes = entry.get("excludes") or []

Review Comment:
   I agree with copilot here 😄 



##########
converters/cube/src/ossie_cube/expressions.py:
##########
@@ -451,6 +474,136 @@ def qualify_bare_columns(cube_sql):
         text, {name: "{CUBE}." + name for name in names})
 
 
+# The portable reading first, then grammars that quote identifiers with backticks
+# (Databricks, BigQuery, MySQL) or brackets (T-SQL) and read unit arguments the way
+# those warehouses do -- `DATEDIFF(day, a, b)`, `CONVERT(VARCHAR, x)`.
+_READINGS = (None, "databricks", "tsql")
+
+# Words a function takes as a date part or type name rather than a column. sqlglot's
+# portable grammar reads `DATEDIFF(day, a, b)` with `day` as a column and `b` as the
+# unit, and a qualification built on that reading would be wrong.
+_UNIT_WORDS = frozenset({
+    "YEAR", "YEARS", "QUARTER", "MONTH", "MONTHS", "WEEK", "WEEKS", "WEEKDAY",
+    "ISOWEEK", "ISOYEAR", "DAY", "DAYS", "DAYOFWEEK", "DAYOFYEAR", "DOW", "DOY", "HOUR",
+    "HOURS", "MINUTE", "MINUTES", "SECOND", "SECONDS", "MILLISECOND", "MILLISECONDS",
+    "MICROSECOND", "MICROSECONDS", "NANOSECOND", "NANOSECONDS", "EPOCH", "DECADE",
+    "CENTURY", "MILLENNIUM", "TIMEZONE", "TIMEZONE_HOUR", "TIMEZONE_MINUTE", "YYYY",
+    "YY", "MM", "DD", "HH", "MI", "SS", "MS",
+})
+_TYPE_WORDS = frozenset({
+    "VARCHAR", "NVARCHAR", "CHAR", "NCHAR", "INT", "INTEGER", "BIGINT", "SMALLINT",
+    "TINYINT", "DECIMAL", "NUMERIC", "FLOAT", "REAL", "DOUBLE", "DATETIME", "DATETIME2",
+    "TIMESTAMP", "BOOLEAN",
+})
+
+
+class UnattributableSQL(ValueError):
+    """Cube SQL whose columns cannot be attributed to a dataset statically."""
+
+
+def _parse_as(text, dialect):
+    """`text` parsed as `dialect`, or None when it does not parse as that."""
+    try:
+        return sqlglot.parse_one(text, read=dialect)
+    except (sqlglot.errors.SqlglotError, ValueError, RecursionError):
+        return None
+
+
+def _misread(tree):
+    """True when a reading takes a unit or type argument for a column, or a column for
+    a unit -- what the portable grammar does to `DATEDIFF(day, a, b)`."""
+    if any(var.name.upper() not in _UNIT_WORDS for var in tree.find_all(exp.Var)):
+        return True
+    return any(not column.table and isinstance(column.parent, exp.Func)
+               and column.name.upper() in _UNIT_WORDS | _TYPE_WORDS
+               for column in tree.find_all(exp.Column))
+
+
+def strip_sql_comments(sql):
+    """`sql` with its `--` and `/* */` comments replaced by a space.
+
+    A trailing `-- note` is harmless in a member's own SQL, but inlined into another
+    expression it comments out everything after it.
+    """
+    text = str(sql)
+    mask = quoted_char_mask(text)

Review Comment:
   `quoted_char_mask` only treats `'`, `*` and `` ` `` as quote boundaries, omitting `[...]` bracket-quoted identifiers (which are explicitly supported via T-SQL in `_READING`).
   
   If a T-SQL identifier contains `--` or `/* */` (such as `[amount--gross] + 1` or `[col/*note*/bar]`), `strip_sql_comments` treats the characters inside the branches as comments:
   - `strip_sql_comments("[amount--gross] + 1")` truncates the string to `'[amount ']`, leading to an unparseable expression error.
   - `strip_sql_comments("[col/*note*/bar]")` turns into `'[col bar]"`.
   
   Could we extend the quoted region mask (or add bracket handling here, including escaped `]]`) so bracketed identifiers are protected from comment stripping?



##########
converters/cube/src/ossie_cube/expressions.py:
##########
@@ -451,6 +474,136 @@ def qualify_bare_columns(cube_sql):
         text, {name: "{CUBE}." + name for name in names})
 
 
+# The portable reading first, then grammars that quote identifiers with backticks
+# (Databricks, BigQuery, MySQL) or brackets (T-SQL) and read unit arguments the way
+# those warehouses do -- `DATEDIFF(day, a, b)`, `CONVERT(VARCHAR, x)`.
+_READINGS = (None, "databricks", "tsql")
+
+# Words a function takes as a date part or type name rather than a column. sqlglot's
+# portable grammar reads `DATEDIFF(day, a, b)` with `day` as a column and `b` as the
+# unit, and a qualification built on that reading would be wrong.
+_UNIT_WORDS = frozenset({
+    "YEAR", "YEARS", "QUARTER", "MONTH", "MONTHS", "WEEK", "WEEKS", "WEEKDAY",
+    "ISOWEEK", "ISOYEAR", "DAY", "DAYS", "DAYOFWEEK", "DAYOFYEAR", "DOW", "DOY", "HOUR",
+    "HOURS", "MINUTE", "MINUTES", "SECOND", "SECONDS", "MILLISECOND", "MILLISECONDS",
+    "MICROSECOND", "MICROSECONDS", "NANOSECOND", "NANOSECONDS", "EPOCH", "DECADE",
+    "CENTURY", "MILLENNIUM", "TIMEZONE", "TIMEZONE_HOUR", "TIMEZONE_MINUTE", "YYYY",
+    "YY", "MM", "DD", "HH", "MI", "SS", "MS",
+})
+_TYPE_WORDS = frozenset({
+    "VARCHAR", "NVARCHAR", "CHAR", "NCHAR", "INT", "INTEGER", "BIGINT", "SMALLINT",
+    "TINYINT", "DECIMAL", "NUMERIC", "FLOAT", "REAL", "DOUBLE", "DATETIME", "DATETIME2",
+    "TIMESTAMP", "BOOLEAN",
+})
+
+
+class UnattributableSQL(ValueError):
+    """Cube SQL whose columns cannot be attributed to a dataset statically."""
+
+
+def _parse_as(text, dialect):
+    """`text` parsed as `dialect`, or None when it does not parse as that."""
+    try:
+        return sqlglot.parse_one(text, read=dialect)
+    except (sqlglot.errors.SqlglotError, ValueError, RecursionError):
+        return None
+
+
+def _misread(tree):
+    """True when a reading takes a unit or type argument for a column, or a column for
+    a unit -- what the portable grammar does to `DATEDIFF(day, a, b)`."""
+    if any(var.name.upper() not in _UNIT_WORDS for var in tree.find_all(exp.Var)):
+        return True
+    return any(not column.table and isinstance(column.parent, exp.Func)
+               and column.name.upper() in _UNIT_WORDS | _TYPE_WORDS
+               for column in tree.find_all(exp.Column))
+
+
+def strip_sql_comments(sql):
+    """`sql` with its `--` and `/* */` comments replaced by a space.
+
+    A trailing `-- note` is harmless in a member's own SQL, but inlined into another
+    expression it comments out everything after it.
+    """
+    text = str(sql)
+    mask = quoted_char_mask(text)
+    out, i = [], 0
+    while i < len(text):
+        if not mask[i] and text.startswith("--", i):
+            end = text.find("\n", i)
+            i = len(text) if end < 0 else end
+            out.append(" ")
+        elif not mask[i] and text.startswith("/*", i):
+            end = text.find("*/", i + 2)
+            i = len(text) if end < 0 else end + 2
+            out.append(" ")
+        else:
+            out.append(text[i])
+            i += 1
+    return "".join(out)
+
+
+def qualify_column_paths(cube_sql, own_cube=None, cube_names=()):
+    """Qualify every physical column path in Cube SQL as `{CUBE}.path`.
+
+    `qualify_bare_columns` leaves a dotted path as written, which suits the lossless
+    import: the text is carried across, not reasoned about. View projection has to say
+    which dataset every column belongs to, and a path in a cube's own SQL is that
+    cube's: `payload.amount` is a struct field of the cube's table, so the whole path is
+    qualified. A path rooted at a `{...}` reference is left alone. Cube aliases each
+    cube's table by the cube's name, so a path starting with `own_cube` reads the own
+    table (`orders.amount` becomes `{CUBE}.amount`), and one starting with another of
+    `cube_names` could be that cube's table or a struct field, and is refused.
+
+    Cube SQL is the data source's own, so other grammars are tried when the portable one
+    fails or misreads a unit argument. Only positions are read and the text is never
+    regenerated, so which grammar reads it does not matter. Comments are dropped.
+
+    Raises UnattributableSQL saying why when the columns cannot be attributed statically.
+    """
+    masked, saved = _mask_references(strip_sql_comments(cube_sql))
+    # The positions sqlglot reports are into the text it parsed, so it gets no other.
+    masked = masked.strip()
+    readings = [t for t in (_parse_as(masked, d) for d in _READINGS) if t is not None]
+    if not readings:
+        raise UnattributableSQL("it does not parse")
+    tree = next((t for t in readings if not _misread(t)), None)
+    if tree is None:
+        raise UnattributableSQL(
+            "a unit or type argument reads as a column (or a column as a unit); write "
+            "a column that shares a unit's name as {CUBE}.name")
+    if tree.find(exp.Select, exp.Lambda) is not None:
+        raise UnattributableSQL("it binds names of its own (a subquery or a lambda)")
+    others = {name.casefold() for name in cube_names if name != own_cube}

Review Comment:
   There are two subtle case-sensitivity issues when qualifying columns referencing `own_cube`:
   
   1. This line (577): `others = {name.casefold() for name in cube_names if name != own_cube}` checks `name != own_cube` with casefolding. If `cube_names` has `["Orders"]` and `own_cube` is `"orders"`, `'orders'` is added into `others`.
   2. Line 588: `folded = head.name if head.args.get("quoted") else head.name.casefold()` preserves the exact case if `head` is quoted (e.g. `"Orders".amount`). If `own_cube` is `"orders"`, `folded in (own_cube, own_cube.casefold())` evaluates to `False`, and it hits `elif len(column.parts) > 1 and folded .casefold() in others`, falsely raising `UnattribuableSQL` thinking it is another cube.
   
   I believe we should normalize `own_cube` case-insensitively when constructing `others` and when checking whether a quoted head refers to `own_cube`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat(cube): project one Cube view's public surface onto Ossie [ossie]

Posted by "MikeNitsenko (via GitHub)" <gi...@apache.org>.
MikeNitsenko commented on code in PR #471:
URL: https://github.com/apache/ossie/pull/471#discussion_r4125686840


##########
converters/cube/src/ossie_cube/view_projection.py:
##########
@@ -0,0 +1,1039 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Project one Cube view's public surface onto an Apache Ossie semantic model.
+
+`convert_cube_to_ossie` is a lossless round-trip import: every cube member becomes an
+Ossie field or metric, and the view's curation rides in `custom_extensions`. Publishing
+a view to another tool needs the opposite boundary -- exactly the members the view
+exposes, under the names it exposes them by, and nothing the view keeps private.
+
+The projection resolves the view (its members, join tree and hidden dependencies),
+builds a reduced cube set holding only those, runs the ordinary import over it, and
+trims the result to the published surface. Dimension references are inlined while the
+set is built, in Cube space, so the import's own join decomposition and fan-out analysis
+read the columns a member really uses; measure references are inlined afterwards by the
+import's `_MeasureResolver`. Anything the published model could not say faithfully is
+refused with a `ConversionError`, never dropped. See README "View projection".
+
+Usage (CLI):
+    ossie-cube import -i model/ --view sales --project [--source orders]
+"""
+
+import copy
+import dataclasses
+from collections import deque
+from itertools import pairwise
+
+from ._common import (
+    _CUBE_REF_RE,
+    OSSIE_VERSION,
+    VENDOR,
+    ConversionError,
+    dump_yaml,
+    lookup_map,
+    normalize_identifier,
+    primary_key_operand,
+    read_stash,
+    requalify_self_refs,
+    resolve_identifier,
+    snake,
+    snake_keys,
+    unescape_braces_from_cube,
+    write_stash,
+)
+from .converter_issues import IssueLog, IssueType
+from .cube_to_osi import (
+    _CUBE_NATIVE_KEYS,
+    _DIM_NATIVE_KEYS,
+    _MEASURE_NATIVE_KEYS,
+    _RELATIONSHIP_ALIASES,
+    _ai_context_from_meta,
+    _as_named_list,
+    _build_model,
+    _case_expression,
+    _collect,
+    _column_of,
+    _decomposed_measure_names,
+    _fanned_out_datasets,
+    _fanout_unsafe_datasets,
+    _is_generated_part,
+    _MeasureResolver,
+    _metric_names_of,
+    _no_cubes_message,
+    _primary_key_of,
+    _report_fanout,
+    parked_of,
+)
+from .expressions import (
+    UnattributableSQL,
+    has_top_level_operator,
+    qualify_column_paths,
+    unsafe_aggregate_heads,
+)
+
+_SELF_REFS = ("CUBE", "TABLE")
+
+# Stash keys a published member keeps: how it looks to its reader. The rest of the
+# import's stash rebuilds the original Cube member, naming members that are not here.
+_PUBLISHED_MEMBER_KEYS = ("dim_type", "format", "currency", "title")
+
+# The declared Cube cardinality, which Ossie's many/one orientation cannot carry.
+_PUBLISHED_JOIN_KEYS = ("declared_on", "relationship")
+
+# Presentation keys a view `includes` entry may override, and a member carries along.
+_PRESENTATION_KEYS = ("format", "currency")
+
+# Cube keys the projection consumes, so not reported as dropped: members are the surface,
+# visibility is the view's to decide, and `data_source` rides on the dataset.
+_VISIBILITY_KEYS = ("public", "visible", "shown")
+_CONSUMED_CUBE_KEYS = frozenset({
+    "segments", "hierarchies", "data_source", *_VISIBILITY_KEYS})
+_CONSUMED_MEMBER_KEYS = frozenset({"case", *_VISIBILITY_KEYS, *_PRESENTATION_KEYS})
+_VIEW_KEYS = frozenset({"name", "cubes", "description", "meta", *_VISIBILITY_KEYS})
+
+# Multi-stage measure keys and the value shape Cube accepts. An empty value is Cube's own
+# default and changes nothing; anything else computes over another grain. Covers every
+# key in `_WINDOWING_KEYS` (a test checks), plus `grain`, which the import does not know.
+_MULTI_STAGE_SHAPES = {
+    "multi_stage": bool,
+    "group_by": list,
+    "reduce_by": list,
+    "add_group_by": list,
+    "time_shift": list,
+    "grain": dict,
+}
+
+
+@dataclasses.dataclass(frozen=True)
+class _Member:
+    """One member the view publishes: `name` on `cube`, published as `output`, with its
+    Cube `definition` and the view's `includes` entry for it, when that is a mapping."""
+
+    cube: str
+    name: str
+    output: str
+    kind: str
+    definition: dict
+    override: dict
+
+
+@dataclasses.dataclass
+class _JoinTree:
+    """The view's join tree: `root`, every dataset in presentation order, and
+    `parent[child] = (parent, the join declared on the parent)`."""
+
+    root: str
+    order: list
+    parent: dict
+
+
+def convert_cube_view_to_ossie(files, view, source=None, strict_fanout=True):
+    """Convert one Cube view's public surface to Ossie YAML.
+
+    `files` is a Cube model as {relative filename: YAML str}, the same input
+    `convert_cube_to_ossie` takes. Returns (ossie_yaml_str, resolved_source, IssueLog).
+
+    Unlike `convert_cube_to_ossie`, this is a publication transform, not a round trip:
+    the model holds exactly the dimensions and measures `view` exposes, under the names
+    it exposes them by, with every hidden dependency inlined. `resolved_source` is the
+    root of the view's join paths -- the dataset a downstream converter needing a single
+    fact should start from -- unless `source` names another dataset in the projection.
+
+    Fan-out is strict by default: a published metric that can over-count is refused
+    rather than emitted. Pass `strict_fanout=False` to convert it with a
+    FANOUT_UNSAFE_METRIC issue instead.
+    """
+    if not isinstance(files, dict) or not files:
+        raise ConversionError("expected a non-empty mapping of {filename: YAML}")
+    if not isinstance(view, str) or not view.strip():
+        raise ConversionError("view projection needs the name of the view to project")
+    if source is not None and (not isinstance(source, str) or not source):
+        raise ConversionError(f"view '{view}': source must be a dataset name")
+
+    # The collection's own issues are about the lossless import -- a file preserved in
+    # custom_extensions only -- which a projection does not do.
+    cubes, _, views, _, _ = _collect(files, IssueLog())
+    if view not in views:
+        raise ConversionError(
+            f"requested view '{view}' not found; views present: "
+            f"{sorted(views) or 'none'}")
+    if not cubes:
+        raise ConversionError(_no_cubes_message(views))
+
+    strict = {IssueType.FANOUT_UNSAFE_METRIC} if strict_fanout else set()
+    issues = IssueLog(strict_types=frozenset(strict))
+    view_def = views[view]
+    _report_dropped(issues, f"view '{view}'",
+                    [k for k in map(snake, view_def) if k not in _VIEW_KEYS])
+
+    tree = _view_tree(view, view_def, cubes)
+    members = _published_members(view, view_def, cubes)
+    inliner = _Inliner(cubes)
+
+    measures = _needed_measures(members, cubes, inliner)
+    dimensions = {}
+    for member in members:
+        key = (member.cube, member.name)
+        if member.kind == "dimension" and key not in dimensions:
+            dimensions[key] = _published_dimension(inliner, cubes, member)
+    dependencies = {cname for cname, _ in measures}
+    for (cname, _), measure in measures.items():
+        for sql in [measure.get("sql"), *(f["sql"] for f in measure.get("filters", ()))]:
+            dependencies |= _joined_cubes(sql, cname, cubes)
+    _attach_dependencies(view, tree, sorted(dependencies), cubes)
+    _refuse_mixed_data_sources(view, tree, cubes)
+    resolved_source = _resolve_source(view, source, tree, members)
+
+    reduced = _reduced_cubes(tree, cubes, dimensions, measures, inliner, issues)
+    reduced_view = {k: v for k, v in view_def.items() if k != "cubes"}
+    # The import runs lenient, and its fan-out reports are replaced by the projection's
+    # own: it attributes a struct path (`orders.accounts.balance`) to its second part,
+    # and checks each join alone rather than the whole tree.
+    imported = IssueLog()
+    model = _build_model(reduced, {}, {view: reduced_view}, {}, {}, None, view, imported)
+    _refuse_unjoined(view, model, imported)
+    for issue in imported:
+        if issue.issue_type is not IssueType.FANOUT_UNSAFE_METRIC:
+            issues.add(issue.issue_type, issue.element_name, issue.detail)
+    # A second resolver over the same cubes, for the fully inlined forms: the import
+    # emits a reference to a hidden measure by its metric name, and that metric is not
+    # published.
+    resolver = _MeasureResolver(reduced, {}, IssueLog(), qualify=False)
+    _report_fanout_of(resolver, tree, reduced, model, measures, issues)
+    _publish_surface(model, resolver, reduced, cubes, members, issues)
+    return dump_yaml({"version": OSSIE_VERSION, **model}), resolved_source, issues
+
+
+# --- the view ---------------------------------------------------------------------
+
+def _view_tree(vname, view, cubes):
+    """The join tree the view's `join_path`s spell out.
+
+    Cube joins every view member through the path its entry names, so the paths have to
+    form one tree: a single root, and each cube reached one way. A second path to a cube
+    would give its members two meanings, which a published model cannot hold.
+    """
+    entries = view.get("cubes")
+    if not isinstance(entries, list) or not entries:
+        raise ConversionError(
+            f"view '{vname}' has no `cubes` entries to project (the legacy view-level "
+            f"`includes` form is not supported)")
+    tree = _JoinTree(root=None, order=[], parent={})
+    roots = []
+    for index, raw in enumerate(entries):
+        if not isinstance(raw, dict):
+            raise ConversionError(f"view '{vname}': cubes[{index}] must be a mapping")
+        entry = snake_keys(raw)
+        if entry.get("split"):
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] is split, and split view projections "
+                f"publish as more than one model; project each split view on its own")
+        path = entry.get("join_path")
+        if not isinstance(path, str) or not path.strip():
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] has no non-empty `join_path`")
+        parts = path.split(".")
+        if not all(parts):
+            raise ConversionError(f"view '{vname}': invalid join_path '{path}'")
+        if len(set(parts)) != len(parts):
+            raise ConversionError(
+                f"view '{vname}': join_path '{path}' visits a cube twice")
+        for part in parts:
+            if part not in cubes:
+                raise ConversionError(
+                    f"view '{vname}': join_path '{path}' references unknown cube "
+                    f"'{part}'")
+        if parts[0] not in roots:
+            roots.append(parts[0])
+        for left, right in pairwise(parts):
+            _add_edge(vname, tree, cubes, left, right, "reached through two join paths")
+        for part in parts:
+            if part not in tree.order:
+                tree.order.append(part)
+    if len(roots) != 1:
+        raise ConversionError(
+            f"view '{vname}' has multiple join roots {roots}; a published model joins "
+            f"everything from one dataset, so project a single-root view")
+    tree.root = roots[0]
+    return tree
+
+
+def _add_edge(vname, tree, cubes, left, right, conflict):
+    """Join `right` under `left`, refusing a second parent for it."""
+    prior = tree.parent.get(right)
+    if prior is not None:
+        if prior[0] != left:
+            raise ConversionError(
+                f"view '{vname}': cube '{right}' is {conflict} (from '{prior[0]}' and "
+                f"from '{left}'), which would give its members two meanings")
+        return
+    joins = [j for j in _as_named_list(cubes[left].get("joins"), f"cube '{left}' joins")
+             if j.get("name") == right]
+    if not joins:
+        raise ConversionError(
+            f"view '{vname}': joining '{right}' needs a join '{left}' -> '{right}', but "
+            f"cube '{left}' declares no join to '{right}'")
+    if len(joins) > 1:
+        raise ConversionError(
+            f"view '{vname}': cube '{left}' declares more than one join to '{right}'")
+    tree.parent[right] = (left, joins[0])
+
+
+def _published_members(vname, view, cubes):
+    """Every member the view publishes, in view order, with its published name."""
+    decomposed = _decomposed_measure_names(cubes)
+    members, seen, taken = [], set(), {}
+    for raw in view["cubes"]:
+        entry = snake_keys(raw)
+        cname = entry["join_path"].split(".")[-1]
+        for member in _entry_members(vname, entry, cname, cubes[cname], decomposed):
+            identity = (member.cube, member.name, member.output, member.kind)
+            if identity in seen:
+                continue
+            seen.add(identity)
+            key = normalize_identifier(member.output)
+            if key in taken:
+                other = taken[key]
+                raise ConversionError(
+                    f"view '{vname}': published name '{member.output}' from "
+                    f"'{member.cube}.{member.name}' collides with '{other.output}' from "
+                    f"'{other.cube}.{other.name}' (identifiers are case-insensitive); "
+                    f"use an alias or a prefix")
+            taken[key] = member
+            members.append(member)
+    if not members:
+        raise ConversionError(f"view '{vname}' publishes no dimensions or measures")
+    return members
+
+
+def _entry_members(vname, entry, cname, cube, decomposed):
+    """The members one `cubes:` entry publishes, honouring `includes`, `excludes`,
+    member aliases and `prefix`."""
+    prefix = entry.get("prefix", False)
+    if not isinstance(prefix, bool):
+        raise ConversionError(
+            f"view '{vname}': prefix for cube '{cname}' must be true or false")
+    alias = entry.get("alias", cname)
+    if not isinstance(alias, str) or not alias:
+        raise ConversionError(
+            f"view '{vname}': alias for cube '{cname}' must be a non-empty string")
+
+    by_name = {}
+    for kind, key in (("dimension", "dimensions"), ("measure", "measures"),
+                      ("segment", "segments"), ("hierarchy", "hierarchies")):
+        for member in _as_named_list(cube.get(key), f"cube '{cname}' {key}"):
+            if member.get("name"):
+                by_name.setdefault(member["name"], (kind, member))
+
+    includes = entry.get("includes", [])
+    if includes == "*":
+        # A wildcard publishes what the cube itself makes public. A measure a previous
+        # export generated to hold one part of a composite metric is an implementation
+        # detail of the metric that references it, never a member of its own.
+        requested = [
+            (name, name, {}) for name, (kind, member) in by_name.items()
+            if not _is_private(member)
+            and not (kind == "measure" and _is_generated_part(member, decomposed))]
+    elif isinstance(includes, list):
+        requested = [_include(vname, cname, item) for item in includes]
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must be '*' or a list")
+
+    excludes = entry.get("excludes")
+    if excludes is None:
+        excludes = []
+    if not isinstance(excludes, list) or not all(isinstance(x, str) for x in excludes):
+        raise ConversionError(
+            f"view '{vname}': excludes for cube '{cname}' must be a list of member "
+            f"names")
+    for name in excludes:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': excluded member '{cname}.{name}' does not exist")
+
+    out = []
+    for name, output, override in requested:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': member '{cname}.{name}' does not exist")
+        if name in excludes:
+            continue
+        kind, member = by_name[name]
+        scope = f"view '{vname}': selected {kind} '{cname}.{name}'"
+        if kind in ("segment", "hierarchy"):
+            raise ConversionError(
+                f"{scope} has no Ossie form; exclude it from the view to project it")
+        if kind == "measure" and _is_generated_part(member, decomposed):
+            raise ConversionError(
+                f"{scope} is an aggregate a previous Ossie export generated as part of "
+                f"'{parked_of(member.get('meta')).get('part_of')}', and cannot be "
+                f"published on its own")
+        if kind == "dimension":
+            feature = _unpublishable_dimension(member)
+            if feature:
+                raise ConversionError(
+                    f"{scope} uses {feature}, which has no single Ossie field form")
+        out.append(_Member(cname, name, f"{alias}_{output}" if prefix else output,
+                           kind, member, override))
+    return out
+
+
+def _is_private(member):
+    """Whether Cube hides a member: `public`, else the deprecated `visible` or `shown`,
+    decides -- the precedence of Cube's own `isVisible`."""
+    for key in _VISIBILITY_KEYS:
+        if member.get(key) is not None:
+            return member[key] is False
+    return False
+
+
+def _include(vname, cname, item):
+    """(member name, published name, override) for one `includes` item."""
+    if isinstance(item, str):
+        name, output, override = item, item, {}
+    elif isinstance(item, dict):
+        override = snake_keys(item)
+        name = override.get("name")
+        output = override.get("alias", name)
+        if not isinstance(name, str) or not name:
+            raise ConversionError(
+                f"view '{vname}': an include for cube '{cname}' must name a member")
+        if not isinstance(output, str) or not output:
+            raise ConversionError(
+                f"view '{vname}': alias for member '{cname}.{name}' must be a "
+                f"non-empty string")
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must hold member names or "
+            f"mappings")
+    if "." in name or "." in output:
+        raise ConversionError(
+            f"view '{vname}': include '{name}' for cube '{cname}' is a path; list "
+            f"the member on the entry for the cube that defines it")
+    return name, output, override
+
+
+def _unpublishable_dimension(dim):
+    """Why a dimension has no single Ossie field, or None when it has one."""
+    if dim.get("sub_query"):
+        return "sub_query"
+    dtype = snake(dim.get("type") or "")
+    return dtype if dtype in ("geo", "switch") else None
+
+
+# --- dependencies -----------------------------------------------------------------
+
+class _Inliner:
+    """Rewrites member SQL so every dimension reference is the SQL it stands for.
+
+    Cube inlines a referenced dimension's SQL when it renders a query, the only reading
+    of `{net_amount}` that survives into a model where `net_amount` is not published.
+    The result stays Cube SQL -- `{CUBE}.column`, `{users}.column` -- so the import
+    translates it as it would hand-written SQL; measure references are left to
+    `_MeasureResolver`. Columns are qualified first (`qualify_column_paths`), so a
+    dimension inlined into another cube's measure keeps its columns' owner. A reference
+    to an unknown cube or member is refused, as Cube would refuse to compile it.
+    """
+
+    def __init__(self, cubes):
+        self._cubes = cubes
+        self._dimensions = {}
+        self._measures = set()
+        for cname, cube in cubes.items():
+            for dim in _as_named_list(cube.get("dimensions"),
+                                      f"cube '{cname}' dimensions"):
+                self._dimensions[(cname, dim.get("name"))] = dim
+            for measure in _as_named_list(cube.get("measures"),
+                                          f"cube '{cname}' measures"):
+                self._measures.add((cname, measure.get("name")))

Review Comment:
   Fixed in a2753ba: every dimension and measure name is now validated once, up front, with `require_str` — the import's own helper and message — so a nameless measure is refused with `cube 'orders': measure is missing required 'name'` instead of a raw `KeyError`, and a nameless dimension or a non-string name is refused too rather than accepted. The lookups below can then rely on it. Covered in `test_a_member_without_a_name_is_refused_as_the_import_refuses_it`.



##########
converters/cube/src/ossie_cube/view_projection.py:
##########
@@ -0,0 +1,1039 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Project one Cube view's public surface onto an Apache Ossie semantic model.
+
+`convert_cube_to_ossie` is a lossless round-trip import: every cube member becomes an
+Ossie field or metric, and the view's curation rides in `custom_extensions`. Publishing
+a view to another tool needs the opposite boundary -- exactly the members the view
+exposes, under the names it exposes them by, and nothing the view keeps private.
+
+The projection resolves the view (its members, join tree and hidden dependencies),
+builds a reduced cube set holding only those, runs the ordinary import over it, and
+trims the result to the published surface. Dimension references are inlined while the
+set is built, in Cube space, so the import's own join decomposition and fan-out analysis
+read the columns a member really uses; measure references are inlined afterwards by the
+import's `_MeasureResolver`. Anything the published model could not say faithfully is
+refused with a `ConversionError`, never dropped. See README "View projection".
+
+Usage (CLI):
+    ossie-cube import -i model/ --view sales --project [--source orders]
+"""
+
+import copy
+import dataclasses
+from collections import deque
+from itertools import pairwise
+
+from ._common import (
+    _CUBE_REF_RE,
+    OSSIE_VERSION,
+    VENDOR,
+    ConversionError,
+    dump_yaml,
+    lookup_map,
+    normalize_identifier,
+    primary_key_operand,
+    read_stash,
+    requalify_self_refs,
+    resolve_identifier,
+    snake,
+    snake_keys,
+    unescape_braces_from_cube,
+    write_stash,
+)
+from .converter_issues import IssueLog, IssueType
+from .cube_to_osi import (
+    _CUBE_NATIVE_KEYS,
+    _DIM_NATIVE_KEYS,
+    _MEASURE_NATIVE_KEYS,
+    _RELATIONSHIP_ALIASES,
+    _ai_context_from_meta,
+    _as_named_list,
+    _build_model,
+    _case_expression,
+    _collect,
+    _column_of,
+    _decomposed_measure_names,
+    _fanned_out_datasets,
+    _fanout_unsafe_datasets,
+    _is_generated_part,
+    _MeasureResolver,
+    _metric_names_of,
+    _no_cubes_message,
+    _primary_key_of,
+    _report_fanout,
+    parked_of,
+)
+from .expressions import (
+    UnattributableSQL,
+    has_top_level_operator,
+    qualify_column_paths,
+    unsafe_aggregate_heads,
+)
+
+_SELF_REFS = ("CUBE", "TABLE")
+
+# Stash keys a published member keeps: how it looks to its reader. The rest of the
+# import's stash rebuilds the original Cube member, naming members that are not here.
+_PUBLISHED_MEMBER_KEYS = ("dim_type", "format", "currency", "title")
+
+# The declared Cube cardinality, which Ossie's many/one orientation cannot carry.
+_PUBLISHED_JOIN_KEYS = ("declared_on", "relationship")
+
+# Presentation keys a view `includes` entry may override, and a member carries along.
+_PRESENTATION_KEYS = ("format", "currency")
+
+# Cube keys the projection consumes, so not reported as dropped: members are the surface,
+# visibility is the view's to decide, and `data_source` rides on the dataset.
+_VISIBILITY_KEYS = ("public", "visible", "shown")
+_CONSUMED_CUBE_KEYS = frozenset({
+    "segments", "hierarchies", "data_source", *_VISIBILITY_KEYS})
+_CONSUMED_MEMBER_KEYS = frozenset({"case", *_VISIBILITY_KEYS, *_PRESENTATION_KEYS})
+_VIEW_KEYS = frozenset({"name", "cubes", "description", "meta", *_VISIBILITY_KEYS})
+
+# Multi-stage measure keys and the value shape Cube accepts. An empty value is Cube's own
+# default and changes nothing; anything else computes over another grain. Covers every
+# key in `_WINDOWING_KEYS` (a test checks), plus `grain`, which the import does not know.
+_MULTI_STAGE_SHAPES = {
+    "multi_stage": bool,
+    "group_by": list,
+    "reduce_by": list,
+    "add_group_by": list,
+    "time_shift": list,
+    "grain": dict,
+}
+
+
+@dataclasses.dataclass(frozen=True)
+class _Member:
+    """One member the view publishes: `name` on `cube`, published as `output`, with its
+    Cube `definition` and the view's `includes` entry for it, when that is a mapping."""
+
+    cube: str
+    name: str
+    output: str
+    kind: str
+    definition: dict
+    override: dict
+
+
+@dataclasses.dataclass
+class _JoinTree:
+    """The view's join tree: `root`, every dataset in presentation order, and
+    `parent[child] = (parent, the join declared on the parent)`."""
+
+    root: str
+    order: list
+    parent: dict
+
+
+def convert_cube_view_to_ossie(files, view, source=None, strict_fanout=True):
+    """Convert one Cube view's public surface to Ossie YAML.
+
+    `files` is a Cube model as {relative filename: YAML str}, the same input
+    `convert_cube_to_ossie` takes. Returns (ossie_yaml_str, resolved_source, IssueLog).
+
+    Unlike `convert_cube_to_ossie`, this is a publication transform, not a round trip:
+    the model holds exactly the dimensions and measures `view` exposes, under the names
+    it exposes them by, with every hidden dependency inlined. `resolved_source` is the
+    root of the view's join paths -- the dataset a downstream converter needing a single
+    fact should start from -- unless `source` names another dataset in the projection.
+
+    Fan-out is strict by default: a published metric that can over-count is refused
+    rather than emitted. Pass `strict_fanout=False` to convert it with a
+    FANOUT_UNSAFE_METRIC issue instead.
+    """
+    if not isinstance(files, dict) or not files:
+        raise ConversionError("expected a non-empty mapping of {filename: YAML}")
+    if not isinstance(view, str) or not view.strip():
+        raise ConversionError("view projection needs the name of the view to project")
+    if source is not None and (not isinstance(source, str) or not source):
+        raise ConversionError(f"view '{view}': source must be a dataset name")
+
+    # The collection's own issues are about the lossless import -- a file preserved in
+    # custom_extensions only -- which a projection does not do.
+    cubes, _, views, _, _ = _collect(files, IssueLog())
+    if view not in views:
+        raise ConversionError(
+            f"requested view '{view}' not found; views present: "
+            f"{sorted(views) or 'none'}")
+    if not cubes:
+        raise ConversionError(_no_cubes_message(views))
+
+    strict = {IssueType.FANOUT_UNSAFE_METRIC} if strict_fanout else set()
+    issues = IssueLog(strict_types=frozenset(strict))
+    view_def = views[view]
+    _report_dropped(issues, f"view '{view}'",
+                    [k for k in map(snake, view_def) if k not in _VIEW_KEYS])
+
+    tree = _view_tree(view, view_def, cubes)
+    members = _published_members(view, view_def, cubes)
+    inliner = _Inliner(cubes)
+
+    measures = _needed_measures(members, cubes, inliner)
+    dimensions = {}
+    for member in members:
+        key = (member.cube, member.name)
+        if member.kind == "dimension" and key not in dimensions:
+            dimensions[key] = _published_dimension(inliner, cubes, member)
+    dependencies = {cname for cname, _ in measures}
+    for (cname, _), measure in measures.items():
+        for sql in [measure.get("sql"), *(f["sql"] for f in measure.get("filters", ()))]:
+            dependencies |= _joined_cubes(sql, cname, cubes)
+    _attach_dependencies(view, tree, sorted(dependencies), cubes)
+    _refuse_mixed_data_sources(view, tree, cubes)
+    resolved_source = _resolve_source(view, source, tree, members)
+
+    reduced = _reduced_cubes(tree, cubes, dimensions, measures, inliner, issues)
+    reduced_view = {k: v for k, v in view_def.items() if k != "cubes"}
+    # The import runs lenient, and its fan-out reports are replaced by the projection's
+    # own: it attributes a struct path (`orders.accounts.balance`) to its second part,
+    # and checks each join alone rather than the whole tree.
+    imported = IssueLog()
+    model = _build_model(reduced, {}, {view: reduced_view}, {}, {}, None, view, imported)
+    _refuse_unjoined(view, model, imported)
+    for issue in imported:
+        if issue.issue_type is not IssueType.FANOUT_UNSAFE_METRIC:
+            issues.add(issue.issue_type, issue.element_name, issue.detail)
+    # A second resolver over the same cubes, for the fully inlined forms: the import
+    # emits a reference to a hidden measure by its metric name, and that metric is not
+    # published.
+    resolver = _MeasureResolver(reduced, {}, IssueLog(), qualify=False)
+    _report_fanout_of(resolver, tree, reduced, model, measures, issues)
+    _publish_surface(model, resolver, reduced, cubes, members, issues)
+    return dump_yaml({"version": OSSIE_VERSION, **model}), resolved_source, issues
+
+
+# --- the view ---------------------------------------------------------------------
+
+def _view_tree(vname, view, cubes):
+    """The join tree the view's `join_path`s spell out.
+
+    Cube joins every view member through the path its entry names, so the paths have to
+    form one tree: a single root, and each cube reached one way. A second path to a cube
+    would give its members two meanings, which a published model cannot hold.
+    """
+    entries = view.get("cubes")
+    if not isinstance(entries, list) or not entries:
+        raise ConversionError(
+            f"view '{vname}' has no `cubes` entries to project (the legacy view-level "
+            f"`includes` form is not supported)")
+    tree = _JoinTree(root=None, order=[], parent={})
+    roots = []
+    for index, raw in enumerate(entries):
+        if not isinstance(raw, dict):
+            raise ConversionError(f"view '{vname}': cubes[{index}] must be a mapping")
+        entry = snake_keys(raw)
+        if entry.get("split"):
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] is split, and split view projections "
+                f"publish as more than one model; project each split view on its own")
+        path = entry.get("join_path")
+        if not isinstance(path, str) or not path.strip():
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] has no non-empty `join_path`")
+        parts = path.split(".")
+        if not all(parts):
+            raise ConversionError(f"view '{vname}': invalid join_path '{path}'")
+        if len(set(parts)) != len(parts):
+            raise ConversionError(
+                f"view '{vname}': join_path '{path}' visits a cube twice")
+        for part in parts:
+            if part not in cubes:
+                raise ConversionError(
+                    f"view '{vname}': join_path '{path}' references unknown cube "
+                    f"'{part}'")
+        if parts[0] not in roots:
+            roots.append(parts[0])
+        for left, right in pairwise(parts):
+            _add_edge(vname, tree, cubes, left, right, "reached through two join paths")
+        for part in parts:
+            if part not in tree.order:
+                tree.order.append(part)
+    if len(roots) != 1:
+        raise ConversionError(
+            f"view '{vname}' has multiple join roots {roots}; a published model joins "
+            f"everything from one dataset, so project a single-root view")
+    tree.root = roots[0]
+    return tree
+
+
+def _add_edge(vname, tree, cubes, left, right, conflict):
+    """Join `right` under `left`, refusing a second parent for it."""
+    prior = tree.parent.get(right)
+    if prior is not None:
+        if prior[0] != left:
+            raise ConversionError(
+                f"view '{vname}': cube '{right}' is {conflict} (from '{prior[0]}' and "
+                f"from '{left}'), which would give its members two meanings")
+        return
+    joins = [j for j in _as_named_list(cubes[left].get("joins"), f"cube '{left}' joins")
+             if j.get("name") == right]
+    if not joins:
+        raise ConversionError(
+            f"view '{vname}': joining '{right}' needs a join '{left}' -> '{right}', but "
+            f"cube '{left}' declares no join to '{right}'")
+    if len(joins) > 1:
+        raise ConversionError(
+            f"view '{vname}': cube '{left}' declares more than one join to '{right}'")
+    tree.parent[right] = (left, joins[0])
+
+
+def _published_members(vname, view, cubes):
+    """Every member the view publishes, in view order, with its published name."""
+    decomposed = _decomposed_measure_names(cubes)
+    members, seen, taken = [], set(), {}
+    for raw in view["cubes"]:
+        entry = snake_keys(raw)
+        cname = entry["join_path"].split(".")[-1]
+        for member in _entry_members(vname, entry, cname, cubes[cname], decomposed):
+            identity = (member.cube, member.name, member.output, member.kind)
+            if identity in seen:
+                continue
+            seen.add(identity)
+            key = normalize_identifier(member.output)
+            if key in taken:
+                other = taken[key]
+                raise ConversionError(
+                    f"view '{vname}': published name '{member.output}' from "
+                    f"'{member.cube}.{member.name}' collides with '{other.output}' from "
+                    f"'{other.cube}.{other.name}' (identifiers are case-insensitive); "
+                    f"use an alias or a prefix")
+            taken[key] = member
+            members.append(member)
+    if not members:
+        raise ConversionError(f"view '{vname}' publishes no dimensions or measures")
+    return members
+
+
+def _entry_members(vname, entry, cname, cube, decomposed):
+    """The members one `cubes:` entry publishes, honouring `includes`, `excludes`,
+    member aliases and `prefix`."""
+    prefix = entry.get("prefix", False)
+    if not isinstance(prefix, bool):
+        raise ConversionError(
+            f"view '{vname}': prefix for cube '{cname}' must be true or false")
+    alias = entry.get("alias", cname)
+    if not isinstance(alias, str) or not alias:
+        raise ConversionError(
+            f"view '{vname}': alias for cube '{cname}' must be a non-empty string")
+
+    by_name = {}
+    for kind, key in (("dimension", "dimensions"), ("measure", "measures"),
+                      ("segment", "segments"), ("hierarchy", "hierarchies")):
+        for member in _as_named_list(cube.get(key), f"cube '{cname}' {key}"):
+            if member.get("name"):
+                by_name.setdefault(member["name"], (kind, member))
+
+    includes = entry.get("includes", [])
+    if includes == "*":
+        # A wildcard publishes what the cube itself makes public. A measure a previous
+        # export generated to hold one part of a composite metric is an implementation
+        # detail of the metric that references it, never a member of its own.
+        requested = [
+            (name, name, {}) for name, (kind, member) in by_name.items()
+            if not _is_private(member)
+            and not (kind == "measure" and _is_generated_part(member, decomposed))]
+    elif isinstance(includes, list):
+        requested = [_include(vname, cname, item) for item in includes]
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must be '*' or a list")
+
+    excludes = entry.get("excludes")
+    if excludes is None:
+        excludes = []
+    if not isinstance(excludes, list) or not all(isinstance(x, str) for x in excludes):
+        raise ConversionError(
+            f"view '{vname}': excludes for cube '{cname}' must be a list of member "
+            f"names")
+    for name in excludes:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': excluded member '{cname}.{name}' does not exist")
+
+    out = []
+    for name, output, override in requested:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': member '{cname}.{name}' does not exist")
+        if name in excludes:
+            continue
+        kind, member = by_name[name]
+        scope = f"view '{vname}': selected {kind} '{cname}.{name}'"
+        if kind in ("segment", "hierarchy"):
+            raise ConversionError(
+                f"{scope} has no Ossie form; exclude it from the view to project it")
+        if kind == "measure" and _is_generated_part(member, decomposed):
+            raise ConversionError(
+                f"{scope} is an aggregate a previous Ossie export generated as part of "
+                f"'{parked_of(member.get('meta')).get('part_of')}', and cannot be "
+                f"published on its own")
+        if kind == "dimension":
+            feature = _unpublishable_dimension(member)
+            if feature:
+                raise ConversionError(
+                    f"{scope} uses {feature}, which has no single Ossie field form")
+        out.append(_Member(cname, name, f"{alias}_{output}" if prefix else output,
+                           kind, member, override))
+    return out
+
+
+def _is_private(member):
+    """Whether Cube hides a member: `public`, else the deprecated `visible` or `shown`,
+    decides -- the precedence of Cube's own `isVisible`."""
+    for key in _VISIBILITY_KEYS:
+        if member.get(key) is not None:
+            return member[key] is False
+    return False
+
+
+def _include(vname, cname, item):
+    """(member name, published name, override) for one `includes` item."""
+    if isinstance(item, str):
+        name, output, override = item, item, {}
+    elif isinstance(item, dict):
+        override = snake_keys(item)
+        name = override.get("name")
+        output = override.get("alias", name)
+        if not isinstance(name, str) or not name:
+            raise ConversionError(
+                f"view '{vname}': an include for cube '{cname}' must name a member")
+        if not isinstance(output, str) or not output:
+            raise ConversionError(
+                f"view '{vname}': alias for member '{cname}.{name}' must be a "
+                f"non-empty string")
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must hold member names or "
+            f"mappings")
+    if "." in name or "." in output:
+        raise ConversionError(
+            f"view '{vname}': include '{name}' for cube '{cname}' is a path; list "
+            f"the member on the entry for the cube that defines it")
+    return name, output, override
+
+
+def _unpublishable_dimension(dim):
+    """Why a dimension has no single Ossie field, or None when it has one."""
+    if dim.get("sub_query"):
+        return "sub_query"
+    dtype = snake(dim.get("type") or "")
+    return dtype if dtype in ("geo", "switch") else None
+
+
+# --- dependencies -----------------------------------------------------------------
+
+class _Inliner:
+    """Rewrites member SQL so every dimension reference is the SQL it stands for.
+
+    Cube inlines a referenced dimension's SQL when it renders a query, the only reading
+    of `{net_amount}` that survives into a model where `net_amount` is not published.
+    The result stays Cube SQL -- `{CUBE}.column`, `{users}.column` -- so the import
+    translates it as it would hand-written SQL; measure references are left to
+    `_MeasureResolver`. Columns are qualified first (`qualify_column_paths`), so a
+    dimension inlined into another cube's measure keeps its columns' owner. A reference
+    to an unknown cube or member is refused, as Cube would refuse to compile it.
+    """
+
+    def __init__(self, cubes):
+        self._cubes = cubes
+        self._dimensions = {}
+        self._measures = set()
+        for cname, cube in cubes.items():
+            for dim in _as_named_list(cube.get("dimensions"),
+                                      f"cube '{cname}' dimensions"):
+                self._dimensions[(cname, dim.get("name"))] = dim
+            for measure in _as_named_list(cube.get("measures"),
+                                          f"cube '{cname}' measures"):
+                self._measures.add((cname, measure.get("name")))
+        self._cache = {}
+
+    def dimension(self, cname, dname, stack=()):
+        """A dimension's SQL with every reference inlined, relative to its own cube."""
+        key = (cname, dname)
+        if key in self._cache:
+            return self._cache[key]
+        if key in stack:
+            chain = " -> ".join(f"{c}.{d}" for c, d in stack + (key,))
+            raise ConversionError(f"dimension reference cycle: {chain}")
+        dim = self._dimensions[key]
+        scope = f"dimension '{cname}.{dname}'"
+        feature = _unpublishable_dimension(dim)
+        if feature:
+            raise ConversionError(
+                f"{scope} uses {feature}, which has no single SQL expression to inline "
+                f"where it is referenced")
+        stack += (key,)
+
+        def rewrite(sql):
+            return self.rewrite(sql, cname, scope, stack)
+
+        if dim.get("case") is not None:
+            # Rendered the way Cube renders it. A plain label stays as written, braces
+            # still escaped, since the result is Cube SQL until the import reads it.
+            sql = _case_expression(cname, dname, dim["case"], rewrite, str)
+        else:
+            sql = rewrite(dname if dim.get("sql") is None else dim["sql"])
+        self._cache[key] = sql
+        return sql
+
+    def rewrite(self, sql, cname, scope, stack=(), measures=None):
+        """`sql`, written on cube `cname`, with columns qualified and dimension
+        references inlined.
+
+        Measure references are kept as written and added to `measures`; with
+        `measures` None they are refused, since only a measure's SQL may name one --
+        a dimension reading a measure is Cube's correlated subquery.
+        """
+        try:
+            qualified = qualify_column_paths(sql, cname, self._cubes)
+        except UnattributableSQL as reason:
+            raise ConversionError(
+                f"{scope}: the source of every column in {str(sql).strip()!r} cannot "
+                f"be proven statically: {reason}") from None
+        protected = qualified.replace("\\{", "\x00lb\x00").replace("\\}", "\x00rb\x00")
+
+        def replace(match):
+            body = match.group(1).strip()
+            head, dot, rest = body.partition(".")
+            if not dot:
+                aliased = protected[match.end():].lstrip().startswith(".")
+                if body in _SELF_REFS or body == cname or (
+                        aliased and body in self._cubes):
+                    if not aliased:
+                        raise ConversionError(
+                            f"{scope}: '{{{body}}}' names a cube, not a column; write "
+                            f"'{{{body}}}.column'")
+                    return match.group(0)
+                target = (cname, body)
+            elif head in _SELF_REFS or head == cname:
+                target = (cname, rest)
+            elif head in self._cubes:
+                target = (head, rest)
+            else:
+                raise ConversionError(
+                    f"{scope}: reference '{{{body}}}' uses unknown cube qualifier "
+                    f"'{head}'; use '{{cube}}.column' for a raw column of a joined cube")
+            if target in self._dimensions:
+                inner = self.dimension(*target, stack)
+                if target[0] != cname:
+                    inner = requalify_self_refs(inner, target[0])
+                whole = protected.strip() == match.group(0)
+                return f"({inner})" if has_top_level_operator(inner) and not whole \
+                    else inner
+            if target in self._measures:
+                if measures is None:
+                    raise ConversionError(
+                        f"{scope} references measure '{target[0]}.{target[1]}', which "
+                        f"Cube evaluates as a correlated subquery; an Ossie field has "
+                        f"no form for that")
+                measures.add(target)
+                return match.group(0)
+            raise ConversionError(
+                f"{scope}: reference '{{{body}}}' does not match a dimension or "
+                f"measure in cube '{target[0]}'; use '{{{target[0]}}}.{target[1]}' "
+                f"for a raw column")
+
+        out = _CUBE_REF_RE.sub(replace, protected)
+        return out.replace("\x00lb\x00", "\\{").replace("\x00rb\x00", "\\}")
+
+
+def _joined_cubes(sql, cname, cubes):
+    """The other cubes rewritten SQL reads raw columns of (`{users}.ltv`)."""
+    if sql is None:
+        return set()
+    text = str(sql).replace("\\{", "").replace("\\}", "")
+    return {m.group(1).strip() for m in _CUBE_REF_RE.finditer(text)
+            if m.group(1).strip() in cubes and m.group(1).strip() != cname
+            and text[m.end():].lstrip().startswith(".")}
+
+
+def _needed_measures(members, cubes, inliner):
+    """{(cube, measure): its reduced definition} for every published measure and every
+    measure one references, directly or not."""
+    raw = {(cname, m["name"]): m for cname, cube in cubes.items()
+           for m in _as_named_list(cube.get("measures"), f"cube '{cname}' measures")}

Review Comment:
   Same fix as [the thread above](https://github.com/apache/ossie/pull/471#discussion_r4125422202) (a2753ba): names are validated once up front, so this lookup only ever sees a valid one.



##########
converters/cube/src/ossie_cube/view_projection.py:
##########
@@ -0,0 +1,1039 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Project one Cube view's public surface onto an Apache Ossie semantic model.
+
+`convert_cube_to_ossie` is a lossless round-trip import: every cube member becomes an
+Ossie field or metric, and the view's curation rides in `custom_extensions`. Publishing
+a view to another tool needs the opposite boundary -- exactly the members the view
+exposes, under the names it exposes them by, and nothing the view keeps private.
+
+The projection resolves the view (its members, join tree and hidden dependencies),
+builds a reduced cube set holding only those, runs the ordinary import over it, and
+trims the result to the published surface. Dimension references are inlined while the
+set is built, in Cube space, so the import's own join decomposition and fan-out analysis
+read the columns a member really uses; measure references are inlined afterwards by the
+import's `_MeasureResolver`. Anything the published model could not say faithfully is
+refused with a `ConversionError`, never dropped. See README "View projection".
+
+Usage (CLI):
+    ossie-cube import -i model/ --view sales --project [--source orders]
+"""
+
+import copy
+import dataclasses
+from collections import deque
+from itertools import pairwise
+
+from ._common import (
+    _CUBE_REF_RE,
+    OSSIE_VERSION,
+    VENDOR,
+    ConversionError,
+    dump_yaml,
+    lookup_map,
+    normalize_identifier,
+    primary_key_operand,
+    read_stash,
+    requalify_self_refs,
+    resolve_identifier,
+    snake,
+    snake_keys,
+    unescape_braces_from_cube,
+    write_stash,
+)
+from .converter_issues import IssueLog, IssueType
+from .cube_to_osi import (
+    _CUBE_NATIVE_KEYS,
+    _DIM_NATIVE_KEYS,
+    _MEASURE_NATIVE_KEYS,
+    _RELATIONSHIP_ALIASES,
+    _ai_context_from_meta,
+    _as_named_list,
+    _build_model,
+    _case_expression,
+    _collect,
+    _column_of,
+    _decomposed_measure_names,
+    _fanned_out_datasets,
+    _fanout_unsafe_datasets,
+    _is_generated_part,
+    _MeasureResolver,
+    _metric_names_of,
+    _no_cubes_message,
+    _primary_key_of,
+    _report_fanout,
+    parked_of,
+)
+from .expressions import (
+    UnattributableSQL,
+    has_top_level_operator,
+    qualify_column_paths,
+    unsafe_aggregate_heads,
+)
+
+_SELF_REFS = ("CUBE", "TABLE")
+
+# Stash keys a published member keeps: how it looks to its reader. The rest of the
+# import's stash rebuilds the original Cube member, naming members that are not here.
+_PUBLISHED_MEMBER_KEYS = ("dim_type", "format", "currency", "title")
+
+# The declared Cube cardinality, which Ossie's many/one orientation cannot carry.
+_PUBLISHED_JOIN_KEYS = ("declared_on", "relationship")
+
+# Presentation keys a view `includes` entry may override, and a member carries along.
+_PRESENTATION_KEYS = ("format", "currency")
+
+# Cube keys the projection consumes, so not reported as dropped: members are the surface,
+# visibility is the view's to decide, and `data_source` rides on the dataset.
+_VISIBILITY_KEYS = ("public", "visible", "shown")
+_CONSUMED_CUBE_KEYS = frozenset({
+    "segments", "hierarchies", "data_source", *_VISIBILITY_KEYS})
+_CONSUMED_MEMBER_KEYS = frozenset({"case", *_VISIBILITY_KEYS, *_PRESENTATION_KEYS})
+_VIEW_KEYS = frozenset({"name", "cubes", "description", "meta", *_VISIBILITY_KEYS})
+
+# Multi-stage measure keys and the value shape Cube accepts. An empty value is Cube's own
+# default and changes nothing; anything else computes over another grain. Covers every
+# key in `_WINDOWING_KEYS` (a test checks), plus `grain`, which the import does not know.
+_MULTI_STAGE_SHAPES = {
+    "multi_stage": bool,
+    "group_by": list,
+    "reduce_by": list,
+    "add_group_by": list,
+    "time_shift": list,
+    "grain": dict,
+}
+
+
+@dataclasses.dataclass(frozen=True)
+class _Member:
+    """One member the view publishes: `name` on `cube`, published as `output`, with its
+    Cube `definition` and the view's `includes` entry for it, when that is a mapping."""
+
+    cube: str
+    name: str
+    output: str
+    kind: str
+    definition: dict
+    override: dict
+
+
+@dataclasses.dataclass
+class _JoinTree:
+    """The view's join tree: `root`, every dataset in presentation order, and
+    `parent[child] = (parent, the join declared on the parent)`."""
+
+    root: str
+    order: list
+    parent: dict
+
+
+def convert_cube_view_to_ossie(files, view, source=None, strict_fanout=True):
+    """Convert one Cube view's public surface to Ossie YAML.
+
+    `files` is a Cube model as {relative filename: YAML str}, the same input
+    `convert_cube_to_ossie` takes. Returns (ossie_yaml_str, resolved_source, IssueLog).
+
+    Unlike `convert_cube_to_ossie`, this is a publication transform, not a round trip:
+    the model holds exactly the dimensions and measures `view` exposes, under the names
+    it exposes them by, with every hidden dependency inlined. `resolved_source` is the
+    root of the view's join paths -- the dataset a downstream converter needing a single
+    fact should start from -- unless `source` names another dataset in the projection.
+
+    Fan-out is strict by default: a published metric that can over-count is refused
+    rather than emitted. Pass `strict_fanout=False` to convert it with a
+    FANOUT_UNSAFE_METRIC issue instead.
+    """
+    if not isinstance(files, dict) or not files:
+        raise ConversionError("expected a non-empty mapping of {filename: YAML}")
+    if not isinstance(view, str) or not view.strip():
+        raise ConversionError("view projection needs the name of the view to project")
+    if source is not None and (not isinstance(source, str) or not source):
+        raise ConversionError(f"view '{view}': source must be a dataset name")
+
+    # The collection's own issues are about the lossless import -- a file preserved in
+    # custom_extensions only -- which a projection does not do.
+    cubes, _, views, _, _ = _collect(files, IssueLog())
+    if view not in views:
+        raise ConversionError(
+            f"requested view '{view}' not found; views present: "
+            f"{sorted(views) or 'none'}")
+    if not cubes:
+        raise ConversionError(_no_cubes_message(views))
+
+    strict = {IssueType.FANOUT_UNSAFE_METRIC} if strict_fanout else set()
+    issues = IssueLog(strict_types=frozenset(strict))
+    view_def = views[view]
+    _report_dropped(issues, f"view '{view}'",
+                    [k for k in map(snake, view_def) if k not in _VIEW_KEYS])
+
+    tree = _view_tree(view, view_def, cubes)
+    members = _published_members(view, view_def, cubes)
+    inliner = _Inliner(cubes)
+
+    measures = _needed_measures(members, cubes, inliner)
+    dimensions = {}
+    for member in members:
+        key = (member.cube, member.name)
+        if member.kind == "dimension" and key not in dimensions:
+            dimensions[key] = _published_dimension(inliner, cubes, member)
+    dependencies = {cname for cname, _ in measures}
+    for (cname, _), measure in measures.items():
+        for sql in [measure.get("sql"), *(f["sql"] for f in measure.get("filters", ()))]:
+            dependencies |= _joined_cubes(sql, cname, cubes)
+    _attach_dependencies(view, tree, sorted(dependencies), cubes)
+    _refuse_mixed_data_sources(view, tree, cubes)
+    resolved_source = _resolve_source(view, source, tree, members)
+
+    reduced = _reduced_cubes(tree, cubes, dimensions, measures, inliner, issues)
+    reduced_view = {k: v for k, v in view_def.items() if k != "cubes"}
+    # The import runs lenient, and its fan-out reports are replaced by the projection's
+    # own: it attributes a struct path (`orders.accounts.balance`) to its second part,
+    # and checks each join alone rather than the whole tree.
+    imported = IssueLog()
+    model = _build_model(reduced, {}, {view: reduced_view}, {}, {}, None, view, imported)
+    _refuse_unjoined(view, model, imported)
+    for issue in imported:
+        if issue.issue_type is not IssueType.FANOUT_UNSAFE_METRIC:
+            issues.add(issue.issue_type, issue.element_name, issue.detail)
+    # A second resolver over the same cubes, for the fully inlined forms: the import
+    # emits a reference to a hidden measure by its metric name, and that metric is not
+    # published.
+    resolver = _MeasureResolver(reduced, {}, IssueLog(), qualify=False)
+    _report_fanout_of(resolver, tree, reduced, model, measures, issues)
+    _publish_surface(model, resolver, reduced, cubes, members, issues)
+    return dump_yaml({"version": OSSIE_VERSION, **model}), resolved_source, issues
+
+
+# --- the view ---------------------------------------------------------------------
+
+def _view_tree(vname, view, cubes):
+    """The join tree the view's `join_path`s spell out.
+
+    Cube joins every view member through the path its entry names, so the paths have to
+    form one tree: a single root, and each cube reached one way. A second path to a cube
+    would give its members two meanings, which a published model cannot hold.
+    """
+    entries = view.get("cubes")
+    if not isinstance(entries, list) or not entries:
+        raise ConversionError(
+            f"view '{vname}' has no `cubes` entries to project (the legacy view-level "
+            f"`includes` form is not supported)")
+    tree = _JoinTree(root=None, order=[], parent={})
+    roots = []
+    for index, raw in enumerate(entries):
+        if not isinstance(raw, dict):
+            raise ConversionError(f"view '{vname}': cubes[{index}] must be a mapping")
+        entry = snake_keys(raw)
+        if entry.get("split"):
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] is split, and split view projections "
+                f"publish as more than one model; project each split view on its own")
+        path = entry.get("join_path")
+        if not isinstance(path, str) or not path.strip():
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] has no non-empty `join_path`")
+        parts = path.split(".")
+        if not all(parts):
+            raise ConversionError(f"view '{vname}': invalid join_path '{path}'")
+        if len(set(parts)) != len(parts):
+            raise ConversionError(
+                f"view '{vname}': join_path '{path}' visits a cube twice")
+        for part in parts:
+            if part not in cubes:
+                raise ConversionError(
+                    f"view '{vname}': join_path '{path}' references unknown cube "
+                    f"'{part}'")
+        if parts[0] not in roots:
+            roots.append(parts[0])
+        for left, right in pairwise(parts):
+            _add_edge(vname, tree, cubes, left, right, "reached through two join paths")
+        for part in parts:
+            if part not in tree.order:
+                tree.order.append(part)
+    if len(roots) != 1:
+        raise ConversionError(
+            f"view '{vname}' has multiple join roots {roots}; a published model joins "
+            f"everything from one dataset, so project a single-root view")
+    tree.root = roots[0]
+    return tree
+
+
+def _add_edge(vname, tree, cubes, left, right, conflict):
+    """Join `right` under `left`, refusing a second parent for it."""
+    prior = tree.parent.get(right)
+    if prior is not None:
+        if prior[0] != left:
+            raise ConversionError(
+                f"view '{vname}': cube '{right}' is {conflict} (from '{prior[0]}' and "
+                f"from '{left}'), which would give its members two meanings")
+        return
+    joins = [j for j in _as_named_list(cubes[left].get("joins"), f"cube '{left}' joins")
+             if j.get("name") == right]
+    if not joins:
+        raise ConversionError(
+            f"view '{vname}': joining '{right}' needs a join '{left}' -> '{right}', but "
+            f"cube '{left}' declares no join to '{right}'")
+    if len(joins) > 1:
+        raise ConversionError(
+            f"view '{vname}': cube '{left}' declares more than one join to '{right}'")
+    tree.parent[right] = (left, joins[0])
+
+
+def _published_members(vname, view, cubes):
+    """Every member the view publishes, in view order, with its published name."""
+    decomposed = _decomposed_measure_names(cubes)
+    members, seen, taken = [], set(), {}
+    for raw in view["cubes"]:
+        entry = snake_keys(raw)
+        cname = entry["join_path"].split(".")[-1]
+        for member in _entry_members(vname, entry, cname, cubes[cname], decomposed):
+            identity = (member.cube, member.name, member.output, member.kind)
+            if identity in seen:
+                continue
+            seen.add(identity)
+            key = normalize_identifier(member.output)
+            if key in taken:
+                other = taken[key]
+                raise ConversionError(
+                    f"view '{vname}': published name '{member.output}' from "
+                    f"'{member.cube}.{member.name}' collides with '{other.output}' from "
+                    f"'{other.cube}.{other.name}' (identifiers are case-insensitive); "
+                    f"use an alias or a prefix")
+            taken[key] = member
+            members.append(member)
+    if not members:
+        raise ConversionError(f"view '{vname}' publishes no dimensions or measures")
+    return members
+
+
+def _entry_members(vname, entry, cname, cube, decomposed):
+    """The members one `cubes:` entry publishes, honouring `includes`, `excludes`,
+    member aliases and `prefix`."""
+    prefix = entry.get("prefix", False)
+    if not isinstance(prefix, bool):
+        raise ConversionError(
+            f"view '{vname}': prefix for cube '{cname}' must be true or false")
+    alias = entry.get("alias", cname)
+    if not isinstance(alias, str) or not alias:
+        raise ConversionError(
+            f"view '{vname}': alias for cube '{cname}' must be a non-empty string")
+
+    by_name = {}
+    for kind, key in (("dimension", "dimensions"), ("measure", "measures"),
+                      ("segment", "segments"), ("hierarchy", "hierarchies")):
+        for member in _as_named_list(cube.get(key), f"cube '{cname}' {key}"):
+            if member.get("name"):
+                by_name.setdefault(member["name"], (kind, member))
+
+    includes = entry.get("includes", [])
+    if includes == "*":
+        # A wildcard publishes what the cube itself makes public. A measure a previous
+        # export generated to hold one part of a composite metric is an implementation
+        # detail of the metric that references it, never a member of its own.
+        requested = [
+            (name, name, {}) for name, (kind, member) in by_name.items()
+            if not _is_private(member)
+            and not (kind == "measure" and _is_generated_part(member, decomposed))]
+    elif isinstance(includes, list):
+        requested = [_include(vname, cname, item) for item in includes]
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must be '*' or a list")
+
+    excludes = entry.get("excludes")
+    if excludes is None:
+        excludes = []
+    if not isinstance(excludes, list) or not all(isinstance(x, str) for x in excludes):
+        raise ConversionError(
+            f"view '{vname}': excludes for cube '{cname}' must be a list of member "
+            f"names")
+    for name in excludes:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': excluded member '{cname}.{name}' does not exist")
+
+    out = []
+    for name, output, override in requested:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': member '{cname}.{name}' does not exist")
+        if name in excludes:
+            continue
+        kind, member = by_name[name]
+        scope = f"view '{vname}': selected {kind} '{cname}.{name}'"
+        if kind in ("segment", "hierarchy"):
+            raise ConversionError(
+                f"{scope} has no Ossie form; exclude it from the view to project it")
+        if kind == "measure" and _is_generated_part(member, decomposed):
+            raise ConversionError(
+                f"{scope} is an aggregate a previous Ossie export generated as part of "
+                f"'{parked_of(member.get('meta')).get('part_of')}', and cannot be "
+                f"published on its own")
+        if kind == "dimension":
+            feature = _unpublishable_dimension(member)
+            if feature:
+                raise ConversionError(
+                    f"{scope} uses {feature}, which has no single Ossie field form")
+        out.append(_Member(cname, name, f"{alias}_{output}" if prefix else output,
+                           kind, member, override))
+    return out
+
+
+def _is_private(member):
+    """Whether Cube hides a member: `public`, else the deprecated `visible` or `shown`,
+    decides -- the precedence of Cube's own `isVisible`."""
+    for key in _VISIBILITY_KEYS:
+        if member.get(key) is not None:
+            return member[key] is False
+    return False
+
+
+def _include(vname, cname, item):
+    """(member name, published name, override) for one `includes` item."""
+    if isinstance(item, str):
+        name, output, override = item, item, {}
+    elif isinstance(item, dict):
+        override = snake_keys(item)
+        name = override.get("name")
+        output = override.get("alias", name)
+        if not isinstance(name, str) or not name:
+            raise ConversionError(
+                f"view '{vname}': an include for cube '{cname}' must name a member")
+        if not isinstance(output, str) or not output:
+            raise ConversionError(
+                f"view '{vname}': alias for member '{cname}.{name}' must be a "
+                f"non-empty string")
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must hold member names or "
+            f"mappings")
+    if "." in name or "." in output:
+        raise ConversionError(
+            f"view '{vname}': include '{name}' for cube '{cname}' is a path; list "
+            f"the member on the entry for the cube that defines it")
+    return name, output, override
+
+
+def _unpublishable_dimension(dim):
+    """Why a dimension has no single Ossie field, or None when it has one."""
+    if dim.get("sub_query"):
+        return "sub_query"
+    dtype = snake(dim.get("type") or "")
+    return dtype if dtype in ("geo", "switch") else None
+
+
+# --- dependencies -----------------------------------------------------------------
+
+class _Inliner:
+    """Rewrites member SQL so every dimension reference is the SQL it stands for.
+
+    Cube inlines a referenced dimension's SQL when it renders a query, the only reading
+    of `{net_amount}` that survives into a model where `net_amount` is not published.
+    The result stays Cube SQL -- `{CUBE}.column`, `{users}.column` -- so the import
+    translates it as it would hand-written SQL; measure references are left to
+    `_MeasureResolver`. Columns are qualified first (`qualify_column_paths`), so a
+    dimension inlined into another cube's measure keeps its columns' owner. A reference
+    to an unknown cube or member is refused, as Cube would refuse to compile it.
+    """
+
+    def __init__(self, cubes):
+        self._cubes = cubes
+        self._dimensions = {}
+        self._measures = set()
+        for cname, cube in cubes.items():
+            for dim in _as_named_list(cube.get("dimensions"),
+                                      f"cube '{cname}' dimensions"):
+                self._dimensions[(cname, dim.get("name"))] = dim
+            for measure in _as_named_list(cube.get("measures"),
+                                          f"cube '{cname}' measures"):
+                self._measures.add((cname, measure.get("name")))
+        self._cache = {}
+
+    def dimension(self, cname, dname, stack=()):
+        """A dimension's SQL with every reference inlined, relative to its own cube."""
+        key = (cname, dname)
+        if key in self._cache:
+            return self._cache[key]
+        if key in stack:
+            chain = " -> ".join(f"{c}.{d}" for c, d in stack + (key,))
+            raise ConversionError(f"dimension reference cycle: {chain}")
+        dim = self._dimensions[key]
+        scope = f"dimension '{cname}.{dname}'"
+        feature = _unpublishable_dimension(dim)
+        if feature:
+            raise ConversionError(
+                f"{scope} uses {feature}, which has no single SQL expression to inline "
+                f"where it is referenced")
+        stack += (key,)
+
+        def rewrite(sql):
+            return self.rewrite(sql, cname, scope, stack)
+
+        if dim.get("case") is not None:
+            # Rendered the way Cube renders it. A plain label stays as written, braces
+            # still escaped, since the result is Cube SQL until the import reads it.
+            sql = _case_expression(cname, dname, dim["case"], rewrite, str)
+        else:
+            sql = rewrite(dname if dim.get("sql") is None else dim["sql"])
+        self._cache[key] = sql
+        return sql
+
+    def rewrite(self, sql, cname, scope, stack=(), measures=None):
+        """`sql`, written on cube `cname`, with columns qualified and dimension
+        references inlined.
+
+        Measure references are kept as written and added to `measures`; with
+        `measures` None they are refused, since only a measure's SQL may name one --
+        a dimension reading a measure is Cube's correlated subquery.
+        """
+        try:
+            qualified = qualify_column_paths(sql, cname, self._cubes)
+        except UnattributableSQL as reason:
+            raise ConversionError(
+                f"{scope}: the source of every column in {str(sql).strip()!r} cannot "
+                f"be proven statically: {reason}") from None
+        protected = qualified.replace("\\{", "\x00lb\x00").replace("\\}", "\x00rb\x00")
+
+        def replace(match):
+            body = match.group(1).strip()
+            head, dot, rest = body.partition(".")
+            if not dot:
+                aliased = protected[match.end():].lstrip().startswith(".")
+                if body in _SELF_REFS or body == cname or (
+                        aliased and body in self._cubes):
+                    if not aliased:
+                        raise ConversionError(
+                            f"{scope}: '{{{body}}}' names a cube, not a column; write "
+                            f"'{{{body}}}.column'")
+                    return match.group(0)
+                target = (cname, body)
+            elif head in _SELF_REFS or head == cname:
+                target = (cname, rest)
+            elif head in self._cubes:
+                target = (head, rest)
+            else:
+                raise ConversionError(
+                    f"{scope}: reference '{{{body}}}' uses unknown cube qualifier "
+                    f"'{head}'; use '{{cube}}.column' for a raw column of a joined cube")
+            if target in self._dimensions:
+                inner = self.dimension(*target, stack)
+                if target[0] != cname:
+                    inner = requalify_self_refs(inner, target[0])
+                whole = protected.strip() == match.group(0)
+                return f"({inner})" if has_top_level_operator(inner) and not whole \
+                    else inner
+            if target in self._measures:
+                if measures is None:
+                    raise ConversionError(
+                        f"{scope} references measure '{target[0]}.{target[1]}', which "
+                        f"Cube evaluates as a correlated subquery; an Ossie field has "
+                        f"no form for that")
+                measures.add(target)
+                return match.group(0)
+            raise ConversionError(
+                f"{scope}: reference '{{{body}}}' does not match a dimension or "
+                f"measure in cube '{target[0]}'; use '{{{target[0]}}}.{target[1]}' "
+                f"for a raw column")
+
+        out = _CUBE_REF_RE.sub(replace, protected)
+        return out.replace("\x00lb\x00", "\\{").replace("\x00rb\x00", "\\}")
+
+
+def _joined_cubes(sql, cname, cubes):
+    """The other cubes rewritten SQL reads raw columns of (`{users}.ltv`)."""
+    if sql is None:
+        return set()
+    text = str(sql).replace("\\{", "").replace("\\}", "")
+    return {m.group(1).strip() for m in _CUBE_REF_RE.finditer(text)
+            if m.group(1).strip() in cubes and m.group(1).strip() != cname
+            and text[m.end():].lstrip().startswith(".")}
+
+
+def _needed_measures(members, cubes, inliner):
+    """{(cube, measure): its reduced definition} for every published measure and every
+    measure one references, directly or not."""
+    raw = {(cname, m["name"]): m for cname, cube in cubes.items()
+           for m in _as_named_list(cube.get("measures"), f"cube '{cname}' measures")}
+    queue = deque((m.cube, m.name) for m in members if m.kind == "measure")
+    needed = {}
+    while queue:
+        key = queue.popleft()
+        if key in needed:
+            continue
+        needed[key], references = _reduced_measure(inliner, cubes, raw[key], *key)
+        queue.extend(sorted(references - needed.keys()))
+    return needed
+
+
+def _reduced_measure(inliner, cubes, measure, cname, mname):
+    """A measure as the reduced cube set carries it, and the measures it references.
+
+    Dimension references are inlined and columns qualified. A bare `type: count` is
+    spelled out as the distinct count of its primary key's own SQL -- the same value,
+    and the form `primary_key_count_expression` gives -- because the projected model
+    publishes no field for a hidden key to name.
+    """
+    scope = f"measure '{cname}.{mname}'"
+    _refuse_windowing(measure, scope)
+    references = set()
+    out = _labels_of(measure)
+    if snake(measure.get("type") or "") == "count" and measure.get("sql") is None:
+        out["type"] = "count_distinct"
+        out["sql"] = _primary_key_sql(inliner, cubes, cname)
+    elif measure.get("sql") is not None:
+        out["sql"] = inliner.rewrite(measure["sql"], cname, scope, measures=references)
+    filters = [f for f in measure.get("filters") or []
+               if isinstance(f, dict) and f.get("sql")]
+    if filters:
+        out["filters"] = [
+            {"sql": inliner.rewrite(f["sql"], cname, scope, measures=references)}
+            for f in filters]
+    return out, references
+
+
+def _refuse_windowing(measure, scope):
+    """Refuse a measure computed over a grain other than the query's.
+
+    The lossless import parks these and carries on; a projection that did the same
+    would publish a view missing a member it names.
+    """
+    if measure.get("rolling_window") is not None:
+        raise ConversionError(
+            f"{scope} uses rolling_window, which aggregates over a trailing range "
+            f"rather than the query's grain; an Ossie expression has no form for that")
+    for key, shape in _MULTI_STAGE_SHAPES.items():
+        if key not in measure:
+            continue
+        if not isinstance(measure[key], shape):
+            raise ConversionError(
+                f"{scope} has malformed {key}; expected a {shape.__name__}")
+        if measure[key]:
+            raise ConversionError(
+                f"{scope} uses multi-stage {key}, which computes over a grain other "
+                f"than the query's; an Ossie expression has no form for that")
+
+
+def _primary_key_sql(inliner, cubes, cname):
+    """Cube SQL for a cube's primary key: its columns, or its key dimensions' SQL."""
+    cube = cubes[cname]
+    keys = _primary_key_of(cube, cname)
+    if "primary_key" in parked_of(cube.get("meta")):
+        # Recorded by a previous export, and columns by construction.
+        return primary_key_operand(cname, keys, lambda key: "{CUBE}." + key)
+    return primary_key_operand(
+        cname, keys, lambda key: inliner.dimension(cname, key))
+
+
+def _published_dimension(inliner, cubes, member):
+    """A published dimension as the reduced cube set carries it.
+
+    An Ossie field is scoped to its own dataset, so a dimension reading another cube --
+    directly or through a hidden dimension -- has no field form.
+    """
+    out = _labels_of(member.definition)
+    out["sql"] = inliner.dimension(member.cube, member.name)
+    joined = _joined_cubes(out["sql"], member.cube, cubes)
+    if joined:
+        raise ConversionError(
+            f"dimension '{member.cube}.{member.name}' reads cube '{min(joined)}'; a "
+            f"dataset-scoped Ossie field cannot preserve that join, so publish that "
+            f"cube's member through its own join path instead")
+    return out
+
+
+def _labels_of(member):
+    """The keys of a member the import reads besides its SQL.
+
+    A multi-dialect expression parked by a previous export is left behind: only the
+    Cube SQL can be inlined, so the other dialects would describe a different
+    expression. A `synthetic_key` marker is too, since the view publishes the member.
+    """
+    out = {k: member[k] for k in ("name", "type", "title", "description",
+                                  *_PRESENTATION_KEYS) if k in member}
+    meta = member.get("meta")
+    if isinstance(meta, dict):
+        meta = copy.deepcopy(meta)
+        parked = meta.get("ossie")
+        if isinstance(parked, dict):
+            for key in ("expression", "synthetic_key"):
+                parked.pop(key, None)
+        out["meta"] = meta
+    return out
+
+
+def _attach_dependencies(vname, tree, dependencies, cubes):
+    """Join every cube a hidden dependency reads into the tree.
+
+    A member reading `{accounts.x}` without the view naming a path to `accounts` is
+    joined the way Cube joins it: along the declared joins. Only a unique path is
+    followed; with two, the member's value would depend on which one a consumer took.
+    """
+    for target in dependencies:
+        if target in tree.order:
+            continue
+        paths = _declared_join_paths(cubes, tree.root, target)
+        if not paths:
+            raise ConversionError(
+                f"view '{vname}': published members depend on cube '{target}', but no "
+                f"declared join path leads to it from the view's root '{tree.root}'")
+        if len(paths) > 1:
+            raise ConversionError(
+                f"view '{vname}': published members depend on cube '{target}', which "
+                f"the declared joins reach through multiple declared join paths "
+                f"({' and '.join('.'.join(p) for p in paths)}); add a join_path for it "
+                f"to the view to choose one")
+        for left, right in pairwise(paths[0]):
+            _add_edge(vname, tree, cubes, left, right,
+                      "reached by a hidden dependency through another join path")
+            if right not in tree.order:
+                tree.order.append(right)
+
+
+def _declared_join_paths(cubes, start, target):
+    """One declared join path from `start` to `target`, plus a second when there is one.
+
+    Breadth-first, so an unreachable target costs one pass over the graph rather than an
+    enumeration of every simple path. Any other path omits at least one edge of the first,
+    so searching again with each of its edges removed in turn finds it if it exists.
+    """
+    adjacency = {
+        cname: [j.get("name") for j in _as_named_list(cube.get("joins"),
+                                                     f"cube '{cname}' joins")
+                if j.get("name") in cubes]
+        for cname, cube in cubes.items()}
+
+    def search(blocked=None):
+        parents, queue = {start: None}, deque([start])
+        while queue:
+            current = queue.popleft()
+            for neighbour in adjacency[current]:
+                if (current, neighbour) == blocked or neighbour in parents:
+                    continue
+                parents[neighbour] = current
+                if neighbour == target:
+                    path = [target]
+                    while parents[path[-1]] is not None:
+                        path.append(parents[path[-1]])
+                    return path[::-1]
+                queue.append(neighbour)
+        return None
+
+    first = search()
+    if first is None:
+        return []
+    for edge in pairwise(first):
+        other = search(edge)
+        if other is not None:
+            return [first, other]
+    return [first]
+
+
+def _resolve_source(vname, source, tree, members):
+    """The dataset a consumer should treat as the published model's fact.
+
+    Re-rooting at another dataset turns every join between it and the view's root
+    around. A many-to-one join read backwards is one-to-many, and a dimension beyond one
+    no longer has one value per row of the new source, so the dimension would silently
+    fall out of any consumer that requires that -- refused instead.
+    """
+    if source is None or source == tree.root:
+        return tree.root
+    if source not in tree.order:
+        raise ConversionError(
+            f"view '{vname}': source '{source}' is not in the projected datasets "
+            f"{tree.order}")
+    before = _multiplied_from(tree.root, tree)
+    after = _multiplied_from(source, tree)
+    for member in members:
+        if member.kind == "dimension" and member.cube in after - before:
+            raise ConversionError(
+                f"view '{vname}': source '{source}' reaches '{member.cube}' through a "
+                f"one-to-many join, so published dimension '{member.output}' "
+                f"('{member.cube}.{member.name}') would no longer have one value per "
+                f"row of the source")
+    return source
+
+
+def _multiplied_from(root, tree):
+    """The datasets reached from `root` through at least one one-to-many step."""
+    neighbours = {name: [] for name in tree.order}
+    for child, (parent, join) in tree.parent.items():
+        kind = _RELATIONSHIP_ALIASES.get(snake(join.get("relationship") or ""))
+        # (to, multiplies-rows-when-walked-this-way)
+        neighbours[parent].append((child, kind == "one_to_many"))
+        neighbours[child].append((parent, kind == "many_to_one"))
+    multiplied, queue, seen = set(), deque([(root, False)]), {root}
+    while queue:
+        current, fanned = queue.popleft()
+        for other, step in neighbours[current]:
+            if other in seen:
+                continue
+            seen.add(other)
+            if fanned or step:
+                multiplied.add(other)
+            queue.append((other, fanned or step))
+    return multiplied
+
+
+# --- the reduced cube set ---------------------------------------------------------
+
+def _reduced_cubes(tree, cubes, dimensions, measures, inliner, issues):
+    """The cube set the import runs over: each tree cube's source and labels, the joins
+    the tree uses (SQL inlined, so a key member reads its column), the published
+    dimensions and the measures they need -- nothing else to convert."""
+    reduced = {}
+    for cname in tree.order:
+        cube = cubes[cname]
+        _report_dropped(issues, f"cube '{cname}'", [
+            k for k in map(snake, cube)
+            if k not in _CUBE_NATIVE_KEYS and k not in _CONSUMED_CUBE_KEYS])
+        out = {"name": cname, **{k: cube[k] for k in (
+            "sql", "sql_table", "description", "meta", "data_source") if k in cube}}
+        joins = []
+        for join in _as_named_list(cube.get("joins"), f"cube '{cname}' joins"):
+            child = join.get("name")
+            if tree.parent.get(child, (None,))[0] != cname:
+                continue
+            scope = f"join '{cname}' -> '{child}'"
+            joins.append({"name": child, "relationship": join.get("relationship"),
+                          "sql": inliner.rewrite(join.get("sql") or "", cname, scope)})
+        if joins:
+            out["joins"] = joins
+        dims = [d for (c, _), d in dimensions.items() if c == cname]
+        if dims:
+            out["dimensions"] = dims
+        kept = [measures[(cname, m["name"])]
+                for m in _as_named_list(cube.get("measures"), f"cube '{cname}' measures")
+                if (cname, m["name"]) in measures]

Review Comment:
   Same fix as [the thread above](https://github.com/apache/ossie/pull/471#discussion_r4125422202) (a2753ba): names are validated once up front, so this lookup only ever sees a valid one.



##########
converters/cube/src/ossie_cube/view_projection.py:
##########
@@ -0,0 +1,1039 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Project one Cube view's public surface onto an Apache Ossie semantic model.
+
+`convert_cube_to_ossie` is a lossless round-trip import: every cube member becomes an
+Ossie field or metric, and the view's curation rides in `custom_extensions`. Publishing
+a view to another tool needs the opposite boundary -- exactly the members the view
+exposes, under the names it exposes them by, and nothing the view keeps private.
+
+The projection resolves the view (its members, join tree and hidden dependencies),
+builds a reduced cube set holding only those, runs the ordinary import over it, and
+trims the result to the published surface. Dimension references are inlined while the
+set is built, in Cube space, so the import's own join decomposition and fan-out analysis
+read the columns a member really uses; measure references are inlined afterwards by the
+import's `_MeasureResolver`. Anything the published model could not say faithfully is
+refused with a `ConversionError`, never dropped. See README "View projection".
+
+Usage (CLI):
+    ossie-cube import -i model/ --view sales --project [--source orders]
+"""
+
+import copy
+import dataclasses
+from collections import deque
+from itertools import pairwise
+
+from ._common import (
+    _CUBE_REF_RE,
+    OSSIE_VERSION,
+    VENDOR,
+    ConversionError,
+    dump_yaml,
+    lookup_map,
+    normalize_identifier,
+    primary_key_operand,
+    read_stash,
+    requalify_self_refs,
+    resolve_identifier,
+    snake,
+    snake_keys,
+    unescape_braces_from_cube,
+    write_stash,
+)
+from .converter_issues import IssueLog, IssueType
+from .cube_to_osi import (
+    _CUBE_NATIVE_KEYS,
+    _DIM_NATIVE_KEYS,
+    _MEASURE_NATIVE_KEYS,
+    _RELATIONSHIP_ALIASES,
+    _ai_context_from_meta,
+    _as_named_list,
+    _build_model,
+    _case_expression,
+    _collect,
+    _column_of,
+    _decomposed_measure_names,
+    _fanned_out_datasets,
+    _fanout_unsafe_datasets,
+    _is_generated_part,
+    _MeasureResolver,
+    _metric_names_of,
+    _no_cubes_message,
+    _primary_key_of,
+    _report_fanout,
+    parked_of,
+)
+from .expressions import (
+    UnattributableSQL,
+    has_top_level_operator,
+    qualify_column_paths,
+    unsafe_aggregate_heads,
+)
+
+_SELF_REFS = ("CUBE", "TABLE")
+
+# Stash keys a published member keeps: how it looks to its reader. The rest of the
+# import's stash rebuilds the original Cube member, naming members that are not here.
+_PUBLISHED_MEMBER_KEYS = ("dim_type", "format", "currency", "title")
+
+# The declared Cube cardinality, which Ossie's many/one orientation cannot carry.
+_PUBLISHED_JOIN_KEYS = ("declared_on", "relationship")
+
+# Presentation keys a view `includes` entry may override, and a member carries along.
+_PRESENTATION_KEYS = ("format", "currency")
+
+# Cube keys the projection consumes, so not reported as dropped: members are the surface,
+# visibility is the view's to decide, and `data_source` rides on the dataset.
+_VISIBILITY_KEYS = ("public", "visible", "shown")
+_CONSUMED_CUBE_KEYS = frozenset({
+    "segments", "hierarchies", "data_source", *_VISIBILITY_KEYS})
+_CONSUMED_MEMBER_KEYS = frozenset({"case", *_VISIBILITY_KEYS, *_PRESENTATION_KEYS})
+_VIEW_KEYS = frozenset({"name", "cubes", "description", "meta", *_VISIBILITY_KEYS})
+
+# Multi-stage measure keys and the value shape Cube accepts. An empty value is Cube's own
+# default and changes nothing; anything else computes over another grain. Covers every
+# key in `_WINDOWING_KEYS` (a test checks), plus `grain`, which the import does not know.
+_MULTI_STAGE_SHAPES = {
+    "multi_stage": bool,
+    "group_by": list,
+    "reduce_by": list,
+    "add_group_by": list,
+    "time_shift": list,
+    "grain": dict,
+}
+
+
+@dataclasses.dataclass(frozen=True)
+class _Member:
+    """One member the view publishes: `name` on `cube`, published as `output`, with its
+    Cube `definition` and the view's `includes` entry for it, when that is a mapping."""
+
+    cube: str
+    name: str
+    output: str
+    kind: str
+    definition: dict
+    override: dict
+
+
+@dataclasses.dataclass
+class _JoinTree:
+    """The view's join tree: `root`, every dataset in presentation order, and
+    `parent[child] = (parent, the join declared on the parent)`."""
+
+    root: str
+    order: list
+    parent: dict
+
+
+def convert_cube_view_to_ossie(files, view, source=None, strict_fanout=True):
+    """Convert one Cube view's public surface to Ossie YAML.
+
+    `files` is a Cube model as {relative filename: YAML str}, the same input
+    `convert_cube_to_ossie` takes. Returns (ossie_yaml_str, resolved_source, IssueLog).
+
+    Unlike `convert_cube_to_ossie`, this is a publication transform, not a round trip:
+    the model holds exactly the dimensions and measures `view` exposes, under the names
+    it exposes them by, with every hidden dependency inlined. `resolved_source` is the
+    root of the view's join paths -- the dataset a downstream converter needing a single
+    fact should start from -- unless `source` names another dataset in the projection.
+
+    Fan-out is strict by default: a published metric that can over-count is refused
+    rather than emitted. Pass `strict_fanout=False` to convert it with a
+    FANOUT_UNSAFE_METRIC issue instead.
+    """
+    if not isinstance(files, dict) or not files:
+        raise ConversionError("expected a non-empty mapping of {filename: YAML}")
+    if not isinstance(view, str) or not view.strip():
+        raise ConversionError("view projection needs the name of the view to project")
+    if source is not None and (not isinstance(source, str) or not source):
+        raise ConversionError(f"view '{view}': source must be a dataset name")
+
+    # The collection's own issues are about the lossless import -- a file preserved in
+    # custom_extensions only -- which a projection does not do.
+    cubes, _, views, _, _ = _collect(files, IssueLog())
+    if view not in views:
+        raise ConversionError(
+            f"requested view '{view}' not found; views present: "
+            f"{sorted(views) or 'none'}")
+    if not cubes:
+        raise ConversionError(_no_cubes_message(views))
+
+    strict = {IssueType.FANOUT_UNSAFE_METRIC} if strict_fanout else set()
+    issues = IssueLog(strict_types=frozenset(strict))
+    view_def = views[view]
+    _report_dropped(issues, f"view '{view}'",
+                    [k for k in map(snake, view_def) if k not in _VIEW_KEYS])
+
+    tree = _view_tree(view, view_def, cubes)
+    members = _published_members(view, view_def, cubes)
+    inliner = _Inliner(cubes)
+
+    measures = _needed_measures(members, cubes, inliner)
+    dimensions = {}
+    for member in members:
+        key = (member.cube, member.name)
+        if member.kind == "dimension" and key not in dimensions:
+            dimensions[key] = _published_dimension(inliner, cubes, member)
+    dependencies = {cname for cname, _ in measures}
+    for (cname, _), measure in measures.items():
+        for sql in [measure.get("sql"), *(f["sql"] for f in measure.get("filters", ()))]:
+            dependencies |= _joined_cubes(sql, cname, cubes)
+    _attach_dependencies(view, tree, sorted(dependencies), cubes)
+    _refuse_mixed_data_sources(view, tree, cubes)
+    resolved_source = _resolve_source(view, source, tree, members)
+
+    reduced = _reduced_cubes(tree, cubes, dimensions, measures, inliner, issues)
+    reduced_view = {k: v for k, v in view_def.items() if k != "cubes"}
+    # The import runs lenient, and its fan-out reports are replaced by the projection's
+    # own: it attributes a struct path (`orders.accounts.balance`) to its second part,
+    # and checks each join alone rather than the whole tree.
+    imported = IssueLog()
+    model = _build_model(reduced, {}, {view: reduced_view}, {}, {}, None, view, imported)
+    _refuse_unjoined(view, model, imported)
+    for issue in imported:
+        if issue.issue_type is not IssueType.FANOUT_UNSAFE_METRIC:
+            issues.add(issue.issue_type, issue.element_name, issue.detail)
+    # A second resolver over the same cubes, for the fully inlined forms: the import
+    # emits a reference to a hidden measure by its metric name, and that metric is not
+    # published.
+    resolver = _MeasureResolver(reduced, {}, IssueLog(), qualify=False)
+    _report_fanout_of(resolver, tree, reduced, model, measures, issues)
+    _publish_surface(model, resolver, reduced, cubes, members, issues)
+    return dump_yaml({"version": OSSIE_VERSION, **model}), resolved_source, issues
+
+
+# --- the view ---------------------------------------------------------------------
+
+def _view_tree(vname, view, cubes):
+    """The join tree the view's `join_path`s spell out.
+
+    Cube joins every view member through the path its entry names, so the paths have to
+    form one tree: a single root, and each cube reached one way. A second path to a cube
+    would give its members two meanings, which a published model cannot hold.
+    """
+    entries = view.get("cubes")
+    if not isinstance(entries, list) or not entries:
+        raise ConversionError(
+            f"view '{vname}' has no `cubes` entries to project (the legacy view-level "
+            f"`includes` form is not supported)")
+    tree = _JoinTree(root=None, order=[], parent={})
+    roots = []
+    for index, raw in enumerate(entries):
+        if not isinstance(raw, dict):
+            raise ConversionError(f"view '{vname}': cubes[{index}] must be a mapping")
+        entry = snake_keys(raw)
+        if entry.get("split"):
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] is split, and split view projections "
+                f"publish as more than one model; project each split view on its own")
+        path = entry.get("join_path")
+        if not isinstance(path, str) or not path.strip():
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] has no non-empty `join_path`")
+        parts = path.split(".")
+        if not all(parts):
+            raise ConversionError(f"view '{vname}': invalid join_path '{path}'")
+        if len(set(parts)) != len(parts):
+            raise ConversionError(
+                f"view '{vname}': join_path '{path}' visits a cube twice")
+        for part in parts:
+            if part not in cubes:
+                raise ConversionError(
+                    f"view '{vname}': join_path '{path}' references unknown cube "
+                    f"'{part}'")
+        if parts[0] not in roots:
+            roots.append(parts[0])
+        for left, right in pairwise(parts):
+            _add_edge(vname, tree, cubes, left, right, "reached through two join paths")
+        for part in parts:
+            if part not in tree.order:
+                tree.order.append(part)
+    if len(roots) != 1:
+        raise ConversionError(
+            f"view '{vname}' has multiple join roots {roots}; a published model joins "
+            f"everything from one dataset, so project a single-root view")
+    tree.root = roots[0]
+    return tree
+
+
+def _add_edge(vname, tree, cubes, left, right, conflict):
+    """Join `right` under `left`, refusing a second parent for it."""
+    prior = tree.parent.get(right)
+    if prior is not None:
+        if prior[0] != left:
+            raise ConversionError(
+                f"view '{vname}': cube '{right}' is {conflict} (from '{prior[0]}' and "
+                f"from '{left}'), which would give its members two meanings")
+        return
+    joins = [j for j in _as_named_list(cubes[left].get("joins"), f"cube '{left}' joins")
+             if j.get("name") == right]
+    if not joins:
+        raise ConversionError(
+            f"view '{vname}': joining '{right}' needs a join '{left}' -> '{right}', but "
+            f"cube '{left}' declares no join to '{right}'")
+    if len(joins) > 1:
+        raise ConversionError(
+            f"view '{vname}': cube '{left}' declares more than one join to '{right}'")
+    tree.parent[right] = (left, joins[0])
+
+
+def _published_members(vname, view, cubes):
+    """Every member the view publishes, in view order, with its published name."""
+    decomposed = _decomposed_measure_names(cubes)
+    members, seen, taken = [], set(), {}
+    for raw in view["cubes"]:
+        entry = snake_keys(raw)
+        cname = entry["join_path"].split(".")[-1]
+        for member in _entry_members(vname, entry, cname, cubes[cname], decomposed):
+            identity = (member.cube, member.name, member.output, member.kind)
+            if identity in seen:
+                continue
+            seen.add(identity)
+            key = normalize_identifier(member.output)
+            if key in taken:
+                other = taken[key]
+                raise ConversionError(
+                    f"view '{vname}': published name '{member.output}' from "
+                    f"'{member.cube}.{member.name}' collides with '{other.output}' from "
+                    f"'{other.cube}.{other.name}' (identifiers are case-insensitive); "
+                    f"use an alias or a prefix")
+            taken[key] = member
+            members.append(member)
+    if not members:
+        raise ConversionError(f"view '{vname}' publishes no dimensions or measures")
+    return members
+
+
+def _entry_members(vname, entry, cname, cube, decomposed):
+    """The members one `cubes:` entry publishes, honouring `includes`, `excludes`,
+    member aliases and `prefix`."""
+    prefix = entry.get("prefix", False)
+    if not isinstance(prefix, bool):
+        raise ConversionError(
+            f"view '{vname}': prefix for cube '{cname}' must be true or false")
+    alias = entry.get("alias", cname)
+    if not isinstance(alias, str) or not alias:
+        raise ConversionError(
+            f"view '{vname}': alias for cube '{cname}' must be a non-empty string")
+
+    by_name = {}
+    for kind, key in (("dimension", "dimensions"), ("measure", "measures"),
+                      ("segment", "segments"), ("hierarchy", "hierarchies")):
+        for member in _as_named_list(cube.get(key), f"cube '{cname}' {key}"):
+            if member.get("name"):
+                by_name.setdefault(member["name"], (kind, member))
+
+    includes = entry.get("includes", [])
+    if includes == "*":
+        # A wildcard publishes what the cube itself makes public. A measure a previous
+        # export generated to hold one part of a composite metric is an implementation
+        # detail of the metric that references it, never a member of its own.
+        requested = [
+            (name, name, {}) for name, (kind, member) in by_name.items()
+            if not _is_private(member)
+            and not (kind == "measure" and _is_generated_part(member, decomposed))]
+    elif isinstance(includes, list):
+        requested = [_include(vname, cname, item) for item in includes]
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must be '*' or a list")
+
+    excludes = entry.get("excludes")
+    if excludes is None:
+        excludes = []
+    if not isinstance(excludes, list) or not all(isinstance(x, str) for x in excludes):
+        raise ConversionError(
+            f"view '{vname}': excludes for cube '{cname}' must be a list of member "
+            f"names")
+    for name in excludes:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': excluded member '{cname}.{name}' does not exist")
+
+    out = []
+    for name, output, override in requested:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': member '{cname}.{name}' does not exist")
+        if name in excludes:
+            continue
+        kind, member = by_name[name]
+        scope = f"view '{vname}': selected {kind} '{cname}.{name}'"
+        if kind in ("segment", "hierarchy"):
+            raise ConversionError(
+                f"{scope} has no Ossie form; exclude it from the view to project it")
+        if kind == "measure" and _is_generated_part(member, decomposed):
+            raise ConversionError(
+                f"{scope} is an aggregate a previous Ossie export generated as part of "
+                f"'{parked_of(member.get('meta')).get('part_of')}', and cannot be "
+                f"published on its own")
+        if kind == "dimension":
+            feature = _unpublishable_dimension(member)
+            if feature:
+                raise ConversionError(
+                    f"{scope} uses {feature}, which has no single Ossie field form")
+        out.append(_Member(cname, name, f"{alias}_{output}" if prefix else output,
+                           kind, member, override))
+    return out
+
+
+def _is_private(member):
+    """Whether Cube hides a member: `public`, else the deprecated `visible` or `shown`,
+    decides -- the precedence of Cube's own `isVisible`."""
+    for key in _VISIBILITY_KEYS:
+        if member.get(key) is not None:
+            return member[key] is False
+    return False
+
+
+def _include(vname, cname, item):
+    """(member name, published name, override) for one `includes` item."""
+    if isinstance(item, str):
+        name, output, override = item, item, {}
+    elif isinstance(item, dict):
+        override = snake_keys(item)
+        name = override.get("name")
+        output = override.get("alias", name)
+        if not isinstance(name, str) or not name:
+            raise ConversionError(
+                f"view '{vname}': an include for cube '{cname}' must name a member")
+        if not isinstance(output, str) or not output:
+            raise ConversionError(
+                f"view '{vname}': alias for member '{cname}.{name}' must be a "
+                f"non-empty string")
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must hold member names or "
+            f"mappings")
+    if "." in name or "." in output:
+        raise ConversionError(
+            f"view '{vname}': include '{name}' for cube '{cname}' is a path; list "
+            f"the member on the entry for the cube that defines it")
+    return name, output, override
+
+
+def _unpublishable_dimension(dim):
+    """Why a dimension has no single Ossie field, or None when it has one."""
+    if dim.get("sub_query"):
+        return "sub_query"
+    dtype = snake(dim.get("type") or "")
+    return dtype if dtype in ("geo", "switch") else None
+
+
+# --- dependencies -----------------------------------------------------------------
+
+class _Inliner:
+    """Rewrites member SQL so every dimension reference is the SQL it stands for.
+
+    Cube inlines a referenced dimension's SQL when it renders a query, the only reading
+    of `{net_amount}` that survives into a model where `net_amount` is not published.
+    The result stays Cube SQL -- `{CUBE}.column`, `{users}.column` -- so the import
+    translates it as it would hand-written SQL; measure references are left to
+    `_MeasureResolver`. Columns are qualified first (`qualify_column_paths`), so a
+    dimension inlined into another cube's measure keeps its columns' owner. A reference
+    to an unknown cube or member is refused, as Cube would refuse to compile it.
+    """
+
+    def __init__(self, cubes):
+        self._cubes = cubes
+        self._dimensions = {}
+        self._measures = set()
+        for cname, cube in cubes.items():
+            for dim in _as_named_list(cube.get("dimensions"),
+                                      f"cube '{cname}' dimensions"):
+                self._dimensions[(cname, dim.get("name"))] = dim
+            for measure in _as_named_list(cube.get("measures"),
+                                          f"cube '{cname}' measures"):
+                self._measures.add((cname, measure.get("name")))
+        self._cache = {}
+
+    def dimension(self, cname, dname, stack=()):
+        """A dimension's SQL with every reference inlined, relative to its own cube."""
+        key = (cname, dname)
+        if key in self._cache:
+            return self._cache[key]
+        if key in stack:
+            chain = " -> ".join(f"{c}.{d}" for c, d in stack + (key,))
+            raise ConversionError(f"dimension reference cycle: {chain}")
+        dim = self._dimensions[key]
+        scope = f"dimension '{cname}.{dname}'"
+        feature = _unpublishable_dimension(dim)
+        if feature:
+            raise ConversionError(
+                f"{scope} uses {feature}, which has no single SQL expression to inline "
+                f"where it is referenced")
+        stack += (key,)
+
+        def rewrite(sql):
+            return self.rewrite(sql, cname, scope, stack)
+
+        if dim.get("case") is not None:
+            # Rendered the way Cube renders it. A plain label stays as written, braces
+            # still escaped, since the result is Cube SQL until the import reads it.
+            sql = _case_expression(cname, dname, dim["case"], rewrite, str)
+        else:
+            sql = rewrite(dname if dim.get("sql") is None else dim["sql"])
+        self._cache[key] = sql
+        return sql
+
+    def rewrite(self, sql, cname, scope, stack=(), measures=None):
+        """`sql`, written on cube `cname`, with columns qualified and dimension
+        references inlined.
+
+        Measure references are kept as written and added to `measures`; with
+        `measures` None they are refused, since only a measure's SQL may name one --
+        a dimension reading a measure is Cube's correlated subquery.
+        """
+        try:
+            qualified = qualify_column_paths(sql, cname, self._cubes)
+        except UnattributableSQL as reason:
+            raise ConversionError(
+                f"{scope}: the source of every column in {str(sql).strip()!r} cannot "
+                f"be proven statically: {reason}") from None
+        protected = qualified.replace("\\{", "\x00lb\x00").replace("\\}", "\x00rb\x00")
+
+        def replace(match):
+            body = match.group(1).strip()
+            head, dot, rest = body.partition(".")
+            if not dot:
+                aliased = protected[match.end():].lstrip().startswith(".")
+                if body in _SELF_REFS or body == cname or (
+                        aliased and body in self._cubes):
+                    if not aliased:
+                        raise ConversionError(
+                            f"{scope}: '{{{body}}}' names a cube, not a column; write "
+                            f"'{{{body}}}.column'")
+                    return match.group(0)
+                target = (cname, body)
+            elif head in _SELF_REFS or head == cname:
+                target = (cname, rest)
+            elif head in self._cubes:
+                target = (head, rest)
+            else:
+                raise ConversionError(
+                    f"{scope}: reference '{{{body}}}' uses unknown cube qualifier "
+                    f"'{head}'; use '{{cube}}.column' for a raw column of a joined cube")
+            if target in self._dimensions:
+                inner = self.dimension(*target, stack)
+                if target[0] != cname:
+                    inner = requalify_self_refs(inner, target[0])
+                whole = protected.strip() == match.group(0)
+                return f"({inner})" if has_top_level_operator(inner) and not whole \
+                    else inner
+            if target in self._measures:
+                if measures is None:
+                    raise ConversionError(
+                        f"{scope} references measure '{target[0]}.{target[1]}', which "
+                        f"Cube evaluates as a correlated subquery; an Ossie field has "
+                        f"no form for that")
+                measures.add(target)
+                return match.group(0)
+            raise ConversionError(
+                f"{scope}: reference '{{{body}}}' does not match a dimension or "
+                f"measure in cube '{target[0]}'; use '{{{target[0]}}}.{target[1]}' "
+                f"for a raw column")
+
+        out = _CUBE_REF_RE.sub(replace, protected)
+        return out.replace("\x00lb\x00", "\\{").replace("\x00rb\x00", "\\}")
+
+
+def _joined_cubes(sql, cname, cubes):
+    """The other cubes rewritten SQL reads raw columns of (`{users}.ltv`)."""
+    if sql is None:
+        return set()
+    text = str(sql).replace("\\{", "").replace("\\}", "")
+    return {m.group(1).strip() for m in _CUBE_REF_RE.finditer(text)
+            if m.group(1).strip() in cubes and m.group(1).strip() != cname
+            and text[m.end():].lstrip().startswith(".")}
+
+
+def _needed_measures(members, cubes, inliner):
+    """{(cube, measure): its reduced definition} for every published measure and every
+    measure one references, directly or not."""
+    raw = {(cname, m["name"]): m for cname, cube in cubes.items()
+           for m in _as_named_list(cube.get("measures"), f"cube '{cname}' measures")}
+    queue = deque((m.cube, m.name) for m in members if m.kind == "measure")
+    needed = {}
+    while queue:
+        key = queue.popleft()
+        if key in needed:
+            continue
+        needed[key], references = _reduced_measure(inliner, cubes, raw[key], *key)
+        queue.extend(sorted(references - needed.keys()))
+    return needed
+
+
+def _reduced_measure(inliner, cubes, measure, cname, mname):
+    """A measure as the reduced cube set carries it, and the measures it references.
+
+    Dimension references are inlined and columns qualified. A bare `type: count` is
+    spelled out as the distinct count of its primary key's own SQL -- the same value,
+    and the form `primary_key_count_expression` gives -- because the projected model
+    publishes no field for a hidden key to name.
+    """
+    scope = f"measure '{cname}.{mname}'"
+    _refuse_windowing(measure, scope)
+    references = set()
+    out = _labels_of(measure)
+    if snake(measure.get("type") or "") == "count" and measure.get("sql") is None:
+        out["type"] = "count_distinct"
+        out["sql"] = _primary_key_sql(inliner, cubes, cname)
+    elif measure.get("sql") is not None:
+        out["sql"] = inliner.rewrite(measure["sql"], cname, scope, measures=references)
+    filters = [f for f in measure.get("filters") or []
+               if isinstance(f, dict) and f.get("sql")]
+    if filters:
+        out["filters"] = [
+            {"sql": inliner.rewrite(f["sql"], cname, scope, measures=references)}
+            for f in filters]
+    return out, references
+
+
+def _refuse_windowing(measure, scope):
+    """Refuse a measure computed over a grain other than the query's.
+
+    The lossless import parks these and carries on; a projection that did the same
+    would publish a view missing a member it names.
+    """
+    if measure.get("rolling_window") is not None:
+        raise ConversionError(
+            f"{scope} uses rolling_window, which aggregates over a trailing range "
+            f"rather than the query's grain; an Ossie expression has no form for that")
+    for key, shape in _MULTI_STAGE_SHAPES.items():
+        if key not in measure:
+            continue
+        if not isinstance(measure[key], shape):
+            raise ConversionError(
+                f"{scope} has malformed {key}; expected a {shape.__name__}")
+        if measure[key]:
+            raise ConversionError(
+                f"{scope} uses multi-stage {key}, which computes over a grain other "
+                f"than the query's; an Ossie expression has no form for that")
+
+
+def _primary_key_sql(inliner, cubes, cname):
+    """Cube SQL for a cube's primary key: its columns, or its key dimensions' SQL."""
+    cube = cubes[cname]
+    keys = _primary_key_of(cube, cname)
+    if "primary_key" in parked_of(cube.get("meta")):
+        # Recorded by a previous export, and columns by construction.
+        return primary_key_operand(cname, keys, lambda key: "{CUBE}." + key)
+    return primary_key_operand(
+        cname, keys, lambda key: inliner.dimension(cname, key))
+
+
+def _published_dimension(inliner, cubes, member):
+    """A published dimension as the reduced cube set carries it.
+
+    An Ossie field is scoped to its own dataset, so a dimension reading another cube --
+    directly or through a hidden dimension -- has no field form.
+    """
+    out = _labels_of(member.definition)
+    out["sql"] = inliner.dimension(member.cube, member.name)
+    joined = _joined_cubes(out["sql"], member.cube, cubes)
+    if joined:
+        raise ConversionError(
+            f"dimension '{member.cube}.{member.name}' reads cube '{min(joined)}'; a "
+            f"dataset-scoped Ossie field cannot preserve that join, so publish that "
+            f"cube's member through its own join path instead")
+    return out
+
+
+def _labels_of(member):
+    """The keys of a member the import reads besides its SQL.
+
+    A multi-dialect expression parked by a previous export is left behind: only the
+    Cube SQL can be inlined, so the other dialects would describe a different
+    expression. A `synthetic_key` marker is too, since the view publishes the member.
+    """
+    out = {k: member[k] for k in ("name", "type", "title", "description",
+                                  *_PRESENTATION_KEYS) if k in member}
+    meta = member.get("meta")
+    if isinstance(meta, dict):
+        meta = copy.deepcopy(meta)
+        parked = meta.get("ossie")
+        if isinstance(parked, dict):
+            for key in ("expression", "synthetic_key"):
+                parked.pop(key, None)
+        out["meta"] = meta
+    return out
+
+
+def _attach_dependencies(vname, tree, dependencies, cubes):
+    """Join every cube a hidden dependency reads into the tree.
+
+    A member reading `{accounts.x}` without the view naming a path to `accounts` is
+    joined the way Cube joins it: along the declared joins. Only a unique path is
+    followed; with two, the member's value would depend on which one a consumer took.
+    """
+    for target in dependencies:
+        if target in tree.order:
+            continue
+        paths = _declared_join_paths(cubes, tree.root, target)
+        if not paths:
+            raise ConversionError(
+                f"view '{vname}': published members depend on cube '{target}', but no "
+                f"declared join path leads to it from the view's root '{tree.root}'")
+        if len(paths) > 1:
+            raise ConversionError(
+                f"view '{vname}': published members depend on cube '{target}', which "
+                f"the declared joins reach through multiple declared join paths "
+                f"({' and '.join('.'.join(p) for p in paths)}); add a join_path for it "
+                f"to the view to choose one")
+        for left, right in pairwise(paths[0]):
+            _add_edge(vname, tree, cubes, left, right,
+                      "reached by a hidden dependency through another join path")
+            if right not in tree.order:
+                tree.order.append(right)
+
+
+def _declared_join_paths(cubes, start, target):
+    """One declared join path from `start` to `target`, plus a second when there is one.
+
+    Breadth-first, so an unreachable target costs one pass over the graph rather than an
+    enumeration of every simple path. Any other path omits at least one edge of the first,
+    so searching again with each of its edges removed in turn finds it if it exists.
+    """
+    adjacency = {
+        cname: [j.get("name") for j in _as_named_list(cube.get("joins"),
+                                                     f"cube '{cname}' joins")
+                if j.get("name") in cubes]
+        for cname, cube in cubes.items()}

Review Comment:
   Fixed in a2753ba: `_attach_dependencies` builds the adjacency once and passes it to `_declared_join_paths`, so each dependency only pays for its BFS.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat(cube): project one Cube view's public surface onto Ossie [ossie]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #471:
URL: https://github.com/apache/ossie/pull/471#discussion_r4125422285


##########
converters/cube/src/ossie_cube/view_projection.py:
##########
@@ -0,0 +1,1039 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Project one Cube view's public surface onto an Apache Ossie semantic model.
+
+`convert_cube_to_ossie` is a lossless round-trip import: every cube member becomes an
+Ossie field or metric, and the view's curation rides in `custom_extensions`. Publishing
+a view to another tool needs the opposite boundary -- exactly the members the view
+exposes, under the names it exposes them by, and nothing the view keeps private.
+
+The projection resolves the view (its members, join tree and hidden dependencies),
+builds a reduced cube set holding only those, runs the ordinary import over it, and
+trims the result to the published surface. Dimension references are inlined while the
+set is built, in Cube space, so the import's own join decomposition and fan-out analysis
+read the columns a member really uses; measure references are inlined afterwards by the
+import's `_MeasureResolver`. Anything the published model could not say faithfully is
+refused with a `ConversionError`, never dropped. See README "View projection".
+
+Usage (CLI):
+    ossie-cube import -i model/ --view sales --project [--source orders]
+"""
+
+import copy
+import dataclasses
+from collections import deque
+from itertools import pairwise
+
+from ._common import (
+    _CUBE_REF_RE,
+    OSSIE_VERSION,
+    VENDOR,
+    ConversionError,
+    dump_yaml,
+    lookup_map,
+    normalize_identifier,
+    primary_key_operand,
+    read_stash,
+    requalify_self_refs,
+    resolve_identifier,
+    snake,
+    snake_keys,
+    unescape_braces_from_cube,
+    write_stash,
+)
+from .converter_issues import IssueLog, IssueType
+from .cube_to_osi import (
+    _CUBE_NATIVE_KEYS,
+    _DIM_NATIVE_KEYS,
+    _MEASURE_NATIVE_KEYS,
+    _RELATIONSHIP_ALIASES,
+    _ai_context_from_meta,
+    _as_named_list,
+    _build_model,
+    _case_expression,
+    _collect,
+    _column_of,
+    _decomposed_measure_names,
+    _fanned_out_datasets,
+    _fanout_unsafe_datasets,
+    _is_generated_part,
+    _MeasureResolver,
+    _metric_names_of,
+    _no_cubes_message,
+    _primary_key_of,
+    _report_fanout,
+    parked_of,
+)
+from .expressions import (
+    UnattributableSQL,
+    has_top_level_operator,
+    qualify_column_paths,
+    unsafe_aggregate_heads,
+)
+
+_SELF_REFS = ("CUBE", "TABLE")
+
+# Stash keys a published member keeps: how it looks to its reader. The rest of the
+# import's stash rebuilds the original Cube member, naming members that are not here.
+_PUBLISHED_MEMBER_KEYS = ("dim_type", "format", "currency", "title")
+
+# The declared Cube cardinality, which Ossie's many/one orientation cannot carry.
+_PUBLISHED_JOIN_KEYS = ("declared_on", "relationship")
+
+# Presentation keys a view `includes` entry may override, and a member carries along.
+_PRESENTATION_KEYS = ("format", "currency")
+
+# Cube keys the projection consumes, so not reported as dropped: members are the surface,
+# visibility is the view's to decide, and `data_source` rides on the dataset.
+_VISIBILITY_KEYS = ("public", "visible", "shown")
+_CONSUMED_CUBE_KEYS = frozenset({
+    "segments", "hierarchies", "data_source", *_VISIBILITY_KEYS})
+_CONSUMED_MEMBER_KEYS = frozenset({"case", *_VISIBILITY_KEYS, *_PRESENTATION_KEYS})
+_VIEW_KEYS = frozenset({"name", "cubes", "description", "meta", *_VISIBILITY_KEYS})
+
+# Multi-stage measure keys and the value shape Cube accepts. An empty value is Cube's own
+# default and changes nothing; anything else computes over another grain. Covers every
+# key in `_WINDOWING_KEYS` (a test checks), plus `grain`, which the import does not know.
+_MULTI_STAGE_SHAPES = {
+    "multi_stage": bool,
+    "group_by": list,
+    "reduce_by": list,
+    "add_group_by": list,
+    "time_shift": list,
+    "grain": dict,
+}
+
+
+@dataclasses.dataclass(frozen=True)
+class _Member:
+    """One member the view publishes: `name` on `cube`, published as `output`, with its
+    Cube `definition` and the view's `includes` entry for it, when that is a mapping."""
+
+    cube: str
+    name: str
+    output: str
+    kind: str
+    definition: dict
+    override: dict
+
+
+@dataclasses.dataclass
+class _JoinTree:
+    """The view's join tree: `root`, every dataset in presentation order, and
+    `parent[child] = (parent, the join declared on the parent)`."""
+
+    root: str
+    order: list
+    parent: dict
+
+
+def convert_cube_view_to_ossie(files, view, source=None, strict_fanout=True):
+    """Convert one Cube view's public surface to Ossie YAML.
+
+    `files` is a Cube model as {relative filename: YAML str}, the same input
+    `convert_cube_to_ossie` takes. Returns (ossie_yaml_str, resolved_source, IssueLog).
+
+    Unlike `convert_cube_to_ossie`, this is a publication transform, not a round trip:
+    the model holds exactly the dimensions and measures `view` exposes, under the names
+    it exposes them by, with every hidden dependency inlined. `resolved_source` is the
+    root of the view's join paths -- the dataset a downstream converter needing a single
+    fact should start from -- unless `source` names another dataset in the projection.
+
+    Fan-out is strict by default: a published metric that can over-count is refused
+    rather than emitted. Pass `strict_fanout=False` to convert it with a
+    FANOUT_UNSAFE_METRIC issue instead.
+    """
+    if not isinstance(files, dict) or not files:
+        raise ConversionError("expected a non-empty mapping of {filename: YAML}")
+    if not isinstance(view, str) or not view.strip():
+        raise ConversionError("view projection needs the name of the view to project")
+    if source is not None and (not isinstance(source, str) or not source):
+        raise ConversionError(f"view '{view}': source must be a dataset name")
+
+    # The collection's own issues are about the lossless import -- a file preserved in
+    # custom_extensions only -- which a projection does not do.
+    cubes, _, views, _, _ = _collect(files, IssueLog())
+    if view not in views:
+        raise ConversionError(
+            f"requested view '{view}' not found; views present: "
+            f"{sorted(views) or 'none'}")
+    if not cubes:
+        raise ConversionError(_no_cubes_message(views))
+
+    strict = {IssueType.FANOUT_UNSAFE_METRIC} if strict_fanout else set()
+    issues = IssueLog(strict_types=frozenset(strict))
+    view_def = views[view]
+    _report_dropped(issues, f"view '{view}'",
+                    [k for k in map(snake, view_def) if k not in _VIEW_KEYS])
+
+    tree = _view_tree(view, view_def, cubes)
+    members = _published_members(view, view_def, cubes)
+    inliner = _Inliner(cubes)
+
+    measures = _needed_measures(members, cubes, inliner)
+    dimensions = {}
+    for member in members:
+        key = (member.cube, member.name)
+        if member.kind == "dimension" and key not in dimensions:
+            dimensions[key] = _published_dimension(inliner, cubes, member)
+    dependencies = {cname for cname, _ in measures}
+    for (cname, _), measure in measures.items():
+        for sql in [measure.get("sql"), *(f["sql"] for f in measure.get("filters", ()))]:
+            dependencies |= _joined_cubes(sql, cname, cubes)
+    _attach_dependencies(view, tree, sorted(dependencies), cubes)
+    _refuse_mixed_data_sources(view, tree, cubes)
+    resolved_source = _resolve_source(view, source, tree, members)
+
+    reduced = _reduced_cubes(tree, cubes, dimensions, measures, inliner, issues)
+    reduced_view = {k: v for k, v in view_def.items() if k != "cubes"}
+    # The import runs lenient, and its fan-out reports are replaced by the projection's
+    # own: it attributes a struct path (`orders.accounts.balance`) to its second part,
+    # and checks each join alone rather than the whole tree.
+    imported = IssueLog()
+    model = _build_model(reduced, {}, {view: reduced_view}, {}, {}, None, view, imported)
+    _refuse_unjoined(view, model, imported)
+    for issue in imported:
+        if issue.issue_type is not IssueType.FANOUT_UNSAFE_METRIC:
+            issues.add(issue.issue_type, issue.element_name, issue.detail)
+    # A second resolver over the same cubes, for the fully inlined forms: the import
+    # emits a reference to a hidden measure by its metric name, and that metric is not
+    # published.
+    resolver = _MeasureResolver(reduced, {}, IssueLog(), qualify=False)
+    _report_fanout_of(resolver, tree, reduced, model, measures, issues)
+    _publish_surface(model, resolver, reduced, cubes, members, issues)
+    return dump_yaml({"version": OSSIE_VERSION, **model}), resolved_source, issues
+
+
+# --- the view ---------------------------------------------------------------------
+
+def _view_tree(vname, view, cubes):
+    """The join tree the view's `join_path`s spell out.
+
+    Cube joins every view member through the path its entry names, so the paths have to
+    form one tree: a single root, and each cube reached one way. A second path to a cube
+    would give its members two meanings, which a published model cannot hold.
+    """
+    entries = view.get("cubes")
+    if not isinstance(entries, list) or not entries:
+        raise ConversionError(
+            f"view '{vname}' has no `cubes` entries to project (the legacy view-level "
+            f"`includes` form is not supported)")
+    tree = _JoinTree(root=None, order=[], parent={})
+    roots = []
+    for index, raw in enumerate(entries):
+        if not isinstance(raw, dict):
+            raise ConversionError(f"view '{vname}': cubes[{index}] must be a mapping")
+        entry = snake_keys(raw)
+        if entry.get("split"):
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] is split, and split view projections "
+                f"publish as more than one model; project each split view on its own")
+        path = entry.get("join_path")
+        if not isinstance(path, str) or not path.strip():
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] has no non-empty `join_path`")
+        parts = path.split(".")
+        if not all(parts):
+            raise ConversionError(f"view '{vname}': invalid join_path '{path}'")
+        if len(set(parts)) != len(parts):
+            raise ConversionError(
+                f"view '{vname}': join_path '{path}' visits a cube twice")
+        for part in parts:
+            if part not in cubes:
+                raise ConversionError(
+                    f"view '{vname}': join_path '{path}' references unknown cube "
+                    f"'{part}'")
+        if parts[0] not in roots:
+            roots.append(parts[0])
+        for left, right in pairwise(parts):
+            _add_edge(vname, tree, cubes, left, right, "reached through two join paths")
+        for part in parts:
+            if part not in tree.order:
+                tree.order.append(part)
+    if len(roots) != 1:
+        raise ConversionError(
+            f"view '{vname}' has multiple join roots {roots}; a published model joins "
+            f"everything from one dataset, so project a single-root view")
+    tree.root = roots[0]
+    return tree
+
+
+def _add_edge(vname, tree, cubes, left, right, conflict):
+    """Join `right` under `left`, refusing a second parent for it."""
+    prior = tree.parent.get(right)
+    if prior is not None:
+        if prior[0] != left:
+            raise ConversionError(
+                f"view '{vname}': cube '{right}' is {conflict} (from '{prior[0]}' and "
+                f"from '{left}'), which would give its members two meanings")
+        return
+    joins = [j for j in _as_named_list(cubes[left].get("joins"), f"cube '{left}' joins")
+             if j.get("name") == right]
+    if not joins:
+        raise ConversionError(
+            f"view '{vname}': joining '{right}' needs a join '{left}' -> '{right}', but "
+            f"cube '{left}' declares no join to '{right}'")
+    if len(joins) > 1:
+        raise ConversionError(
+            f"view '{vname}': cube '{left}' declares more than one join to '{right}'")
+    tree.parent[right] = (left, joins[0])
+
+
+def _published_members(vname, view, cubes):
+    """Every member the view publishes, in view order, with its published name."""
+    decomposed = _decomposed_measure_names(cubes)
+    members, seen, taken = [], set(), {}
+    for raw in view["cubes"]:
+        entry = snake_keys(raw)
+        cname = entry["join_path"].split(".")[-1]
+        for member in _entry_members(vname, entry, cname, cubes[cname], decomposed):
+            identity = (member.cube, member.name, member.output, member.kind)
+            if identity in seen:
+                continue
+            seen.add(identity)
+            key = normalize_identifier(member.output)
+            if key in taken:
+                other = taken[key]
+                raise ConversionError(
+                    f"view '{vname}': published name '{member.output}' from "
+                    f"'{member.cube}.{member.name}' collides with '{other.output}' from "
+                    f"'{other.cube}.{other.name}' (identifiers are case-insensitive); "
+                    f"use an alias or a prefix")
+            taken[key] = member
+            members.append(member)
+    if not members:
+        raise ConversionError(f"view '{vname}' publishes no dimensions or measures")
+    return members
+
+
+def _entry_members(vname, entry, cname, cube, decomposed):
+    """The members one `cubes:` entry publishes, honouring `includes`, `excludes`,
+    member aliases and `prefix`."""
+    prefix = entry.get("prefix", False)
+    if not isinstance(prefix, bool):
+        raise ConversionError(
+            f"view '{vname}': prefix for cube '{cname}' must be true or false")
+    alias = entry.get("alias", cname)
+    if not isinstance(alias, str) or not alias:
+        raise ConversionError(
+            f"view '{vname}': alias for cube '{cname}' must be a non-empty string")
+
+    by_name = {}
+    for kind, key in (("dimension", "dimensions"), ("measure", "measures"),
+                      ("segment", "segments"), ("hierarchy", "hierarchies")):
+        for member in _as_named_list(cube.get(key), f"cube '{cname}' {key}"):
+            if member.get("name"):
+                by_name.setdefault(member["name"], (kind, member))
+
+    includes = entry.get("includes", [])
+    if includes == "*":
+        # A wildcard publishes what the cube itself makes public. A measure a previous
+        # export generated to hold one part of a composite metric is an implementation
+        # detail of the metric that references it, never a member of its own.
+        requested = [
+            (name, name, {}) for name, (kind, member) in by_name.items()
+            if not _is_private(member)
+            and not (kind == "measure" and _is_generated_part(member, decomposed))]
+    elif isinstance(includes, list):
+        requested = [_include(vname, cname, item) for item in includes]
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must be '*' or a list")
+
+    excludes = entry.get("excludes")
+    if excludes is None:
+        excludes = []
+    if not isinstance(excludes, list) or not all(isinstance(x, str) for x in excludes):
+        raise ConversionError(
+            f"view '{vname}': excludes for cube '{cname}' must be a list of member "
+            f"names")
+    for name in excludes:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': excluded member '{cname}.{name}' does not exist")
+
+    out = []
+    for name, output, override in requested:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': member '{cname}.{name}' does not exist")
+        if name in excludes:
+            continue
+        kind, member = by_name[name]
+        scope = f"view '{vname}': selected {kind} '{cname}.{name}'"
+        if kind in ("segment", "hierarchy"):
+            raise ConversionError(
+                f"{scope} has no Ossie form; exclude it from the view to project it")
+        if kind == "measure" and _is_generated_part(member, decomposed):
+            raise ConversionError(
+                f"{scope} is an aggregate a previous Ossie export generated as part of "
+                f"'{parked_of(member.get('meta')).get('part_of')}', and cannot be "
+                f"published on its own")
+        if kind == "dimension":
+            feature = _unpublishable_dimension(member)
+            if feature:
+                raise ConversionError(
+                    f"{scope} uses {feature}, which has no single Ossie field form")
+        out.append(_Member(cname, name, f"{alias}_{output}" if prefix else output,
+                           kind, member, override))
+    return out
+
+
+def _is_private(member):
+    """Whether Cube hides a member: `public`, else the deprecated `visible` or `shown`,
+    decides -- the precedence of Cube's own `isVisible`."""
+    for key in _VISIBILITY_KEYS:
+        if member.get(key) is not None:
+            return member[key] is False
+    return False
+
+
+def _include(vname, cname, item):
+    """(member name, published name, override) for one `includes` item."""
+    if isinstance(item, str):
+        name, output, override = item, item, {}
+    elif isinstance(item, dict):
+        override = snake_keys(item)
+        name = override.get("name")
+        output = override.get("alias", name)
+        if not isinstance(name, str) or not name:
+            raise ConversionError(
+                f"view '{vname}': an include for cube '{cname}' must name a member")
+        if not isinstance(output, str) or not output:
+            raise ConversionError(
+                f"view '{vname}': alias for member '{cname}.{name}' must be a "
+                f"non-empty string")
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must hold member names or "
+            f"mappings")
+    if "." in name or "." in output:
+        raise ConversionError(
+            f"view '{vname}': include '{name}' for cube '{cname}' is a path; list "
+            f"the member on the entry for the cube that defines it")
+    return name, output, override
+
+
+def _unpublishable_dimension(dim):
+    """Why a dimension has no single Ossie field, or None when it has one."""
+    if dim.get("sub_query"):
+        return "sub_query"
+    dtype = snake(dim.get("type") or "")
+    return dtype if dtype in ("geo", "switch") else None
+
+
+# --- dependencies -----------------------------------------------------------------
+
+class _Inliner:
+    """Rewrites member SQL so every dimension reference is the SQL it stands for.
+
+    Cube inlines a referenced dimension's SQL when it renders a query, the only reading
+    of `{net_amount}` that survives into a model where `net_amount` is not published.
+    The result stays Cube SQL -- `{CUBE}.column`, `{users}.column` -- so the import
+    translates it as it would hand-written SQL; measure references are left to
+    `_MeasureResolver`. Columns are qualified first (`qualify_column_paths`), so a
+    dimension inlined into another cube's measure keeps its columns' owner. A reference
+    to an unknown cube or member is refused, as Cube would refuse to compile it.
+    """
+
+    def __init__(self, cubes):
+        self._cubes = cubes
+        self._dimensions = {}
+        self._measures = set()
+        for cname, cube in cubes.items():
+            for dim in _as_named_list(cube.get("dimensions"),
+                                      f"cube '{cname}' dimensions"):
+                self._dimensions[(cname, dim.get("name"))] = dim
+            for measure in _as_named_list(cube.get("measures"),
+                                          f"cube '{cname}' measures"):
+                self._measures.add((cname, measure.get("name")))
+        self._cache = {}
+
+    def dimension(self, cname, dname, stack=()):
+        """A dimension's SQL with every reference inlined, relative to its own cube."""
+        key = (cname, dname)
+        if key in self._cache:
+            return self._cache[key]
+        if key in stack:
+            chain = " -> ".join(f"{c}.{d}" for c, d in stack + (key,))
+            raise ConversionError(f"dimension reference cycle: {chain}")
+        dim = self._dimensions[key]
+        scope = f"dimension '{cname}.{dname}'"
+        feature = _unpublishable_dimension(dim)
+        if feature:
+            raise ConversionError(
+                f"{scope} uses {feature}, which has no single SQL expression to inline "
+                f"where it is referenced")
+        stack += (key,)
+
+        def rewrite(sql):
+            return self.rewrite(sql, cname, scope, stack)
+
+        if dim.get("case") is not None:
+            # Rendered the way Cube renders it. A plain label stays as written, braces
+            # still escaped, since the result is Cube SQL until the import reads it.
+            sql = _case_expression(cname, dname, dim["case"], rewrite, str)
+        else:
+            sql = rewrite(dname if dim.get("sql") is None else dim["sql"])
+        self._cache[key] = sql
+        return sql
+
+    def rewrite(self, sql, cname, scope, stack=(), measures=None):
+        """`sql`, written on cube `cname`, with columns qualified and dimension
+        references inlined.
+
+        Measure references are kept as written and added to `measures`; with
+        `measures` None they are refused, since only a measure's SQL may name one --
+        a dimension reading a measure is Cube's correlated subquery.
+        """
+        try:
+            qualified = qualify_column_paths(sql, cname, self._cubes)
+        except UnattributableSQL as reason:
+            raise ConversionError(
+                f"{scope}: the source of every column in {str(sql).strip()!r} cannot "
+                f"be proven statically: {reason}") from None
+        protected = qualified.replace("\\{", "\x00lb\x00").replace("\\}", "\x00rb\x00")
+
+        def replace(match):
+            body = match.group(1).strip()
+            head, dot, rest = body.partition(".")
+            if not dot:
+                aliased = protected[match.end():].lstrip().startswith(".")
+                if body in _SELF_REFS or body == cname or (
+                        aliased and body in self._cubes):
+                    if not aliased:
+                        raise ConversionError(
+                            f"{scope}: '{{{body}}}' names a cube, not a column; write "
+                            f"'{{{body}}}.column'")
+                    return match.group(0)
+                target = (cname, body)
+            elif head in _SELF_REFS or head == cname:
+                target = (cname, rest)
+            elif head in self._cubes:
+                target = (head, rest)
+            else:
+                raise ConversionError(
+                    f"{scope}: reference '{{{body}}}' uses unknown cube qualifier "
+                    f"'{head}'; use '{{cube}}.column' for a raw column of a joined cube")
+            if target in self._dimensions:
+                inner = self.dimension(*target, stack)
+                if target[0] != cname:
+                    inner = requalify_self_refs(inner, target[0])
+                whole = protected.strip() == match.group(0)
+                return f"({inner})" if has_top_level_operator(inner) and not whole \
+                    else inner
+            if target in self._measures:
+                if measures is None:
+                    raise ConversionError(
+                        f"{scope} references measure '{target[0]}.{target[1]}', which "
+                        f"Cube evaluates as a correlated subquery; an Ossie field has "
+                        f"no form for that")
+                measures.add(target)
+                return match.group(0)
+            raise ConversionError(
+                f"{scope}: reference '{{{body}}}' does not match a dimension or "
+                f"measure in cube '{target[0]}'; use '{{{target[0]}}}.{target[1]}' "
+                f"for a raw column")
+
+        out = _CUBE_REF_RE.sub(replace, protected)
+        return out.replace("\x00lb\x00", "\\{").replace("\x00rb\x00", "\\}")
+
+
+def _joined_cubes(sql, cname, cubes):
+    """The other cubes rewritten SQL reads raw columns of (`{users}.ltv`)."""
+    if sql is None:
+        return set()
+    text = str(sql).replace("\\{", "").replace("\\}", "")
+    return {m.group(1).strip() for m in _CUBE_REF_RE.finditer(text)
+            if m.group(1).strip() in cubes and m.group(1).strip() != cname
+            and text[m.end():].lstrip().startswith(".")}
+
+
+def _needed_measures(members, cubes, inliner):
+    """{(cube, measure): its reduced definition} for every published measure and every
+    measure one references, directly or not."""
+    raw = {(cname, m["name"]): m for cname, cube in cubes.items()
+           for m in _as_named_list(cube.get("measures"), f"cube '{cname}' measures")}

Review Comment:
   `view_projection` indexes measures by `m[\"name\"]` (and also records dimension/measure names via `.get(\"name\")`) without validating that `name` is present and a non-empty string. If a Cube model contains an invalid measure entry missing `name`, this will raise a raw `KeyError` (or propagate `None` keys into internal maps) instead of producing a `ConversionError` like the rest of the converter. Align with `cube_to_osi` by validating names with the same helper used elsewhere (e.g., `require_str(..., \"name\", ...)`) before using them as dict keys / set members, so malformed models fail with a stable, user-friendly error.



##########
converters/cube/src/ossie_cube/view_projection.py:
##########
@@ -0,0 +1,1039 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Project one Cube view's public surface onto an Apache Ossie semantic model.
+
+`convert_cube_to_ossie` is a lossless round-trip import: every cube member becomes an
+Ossie field or metric, and the view's curation rides in `custom_extensions`. Publishing
+a view to another tool needs the opposite boundary -- exactly the members the view
+exposes, under the names it exposes them by, and nothing the view keeps private.
+
+The projection resolves the view (its members, join tree and hidden dependencies),
+builds a reduced cube set holding only those, runs the ordinary import over it, and
+trims the result to the published surface. Dimension references are inlined while the
+set is built, in Cube space, so the import's own join decomposition and fan-out analysis
+read the columns a member really uses; measure references are inlined afterwards by the
+import's `_MeasureResolver`. Anything the published model could not say faithfully is
+refused with a `ConversionError`, never dropped. See README "View projection".
+
+Usage (CLI):
+    ossie-cube import -i model/ --view sales --project [--source orders]
+"""
+
+import copy
+import dataclasses
+from collections import deque
+from itertools import pairwise
+
+from ._common import (
+    _CUBE_REF_RE,
+    OSSIE_VERSION,
+    VENDOR,
+    ConversionError,
+    dump_yaml,
+    lookup_map,
+    normalize_identifier,
+    primary_key_operand,
+    read_stash,
+    requalify_self_refs,
+    resolve_identifier,
+    snake,
+    snake_keys,
+    unescape_braces_from_cube,
+    write_stash,
+)
+from .converter_issues import IssueLog, IssueType
+from .cube_to_osi import (
+    _CUBE_NATIVE_KEYS,
+    _DIM_NATIVE_KEYS,
+    _MEASURE_NATIVE_KEYS,
+    _RELATIONSHIP_ALIASES,
+    _ai_context_from_meta,
+    _as_named_list,
+    _build_model,
+    _case_expression,
+    _collect,
+    _column_of,
+    _decomposed_measure_names,
+    _fanned_out_datasets,
+    _fanout_unsafe_datasets,
+    _is_generated_part,
+    _MeasureResolver,
+    _metric_names_of,
+    _no_cubes_message,
+    _primary_key_of,
+    _report_fanout,
+    parked_of,
+)
+from .expressions import (
+    UnattributableSQL,
+    has_top_level_operator,
+    qualify_column_paths,
+    unsafe_aggregate_heads,
+)
+
+_SELF_REFS = ("CUBE", "TABLE")
+
+# Stash keys a published member keeps: how it looks to its reader. The rest of the
+# import's stash rebuilds the original Cube member, naming members that are not here.
+_PUBLISHED_MEMBER_KEYS = ("dim_type", "format", "currency", "title")
+
+# The declared Cube cardinality, which Ossie's many/one orientation cannot carry.
+_PUBLISHED_JOIN_KEYS = ("declared_on", "relationship")
+
+# Presentation keys a view `includes` entry may override, and a member carries along.
+_PRESENTATION_KEYS = ("format", "currency")
+
+# Cube keys the projection consumes, so not reported as dropped: members are the surface,
+# visibility is the view's to decide, and `data_source` rides on the dataset.
+_VISIBILITY_KEYS = ("public", "visible", "shown")
+_CONSUMED_CUBE_KEYS = frozenset({
+    "segments", "hierarchies", "data_source", *_VISIBILITY_KEYS})
+_CONSUMED_MEMBER_KEYS = frozenset({"case", *_VISIBILITY_KEYS, *_PRESENTATION_KEYS})
+_VIEW_KEYS = frozenset({"name", "cubes", "description", "meta", *_VISIBILITY_KEYS})
+
+# Multi-stage measure keys and the value shape Cube accepts. An empty value is Cube's own
+# default and changes nothing; anything else computes over another grain. Covers every
+# key in `_WINDOWING_KEYS` (a test checks), plus `grain`, which the import does not know.
+_MULTI_STAGE_SHAPES = {
+    "multi_stage": bool,
+    "group_by": list,
+    "reduce_by": list,
+    "add_group_by": list,
+    "time_shift": list,
+    "grain": dict,
+}
+
+
+@dataclasses.dataclass(frozen=True)
+class _Member:
+    """One member the view publishes: `name` on `cube`, published as `output`, with its
+    Cube `definition` and the view's `includes` entry for it, when that is a mapping."""
+
+    cube: str
+    name: str
+    output: str
+    kind: str
+    definition: dict
+    override: dict
+
+
+@dataclasses.dataclass
+class _JoinTree:
+    """The view's join tree: `root`, every dataset in presentation order, and
+    `parent[child] = (parent, the join declared on the parent)`."""
+
+    root: str
+    order: list
+    parent: dict
+
+
+def convert_cube_view_to_ossie(files, view, source=None, strict_fanout=True):
+    """Convert one Cube view's public surface to Ossie YAML.
+
+    `files` is a Cube model as {relative filename: YAML str}, the same input
+    `convert_cube_to_ossie` takes. Returns (ossie_yaml_str, resolved_source, IssueLog).
+
+    Unlike `convert_cube_to_ossie`, this is a publication transform, not a round trip:
+    the model holds exactly the dimensions and measures `view` exposes, under the names
+    it exposes them by, with every hidden dependency inlined. `resolved_source` is the
+    root of the view's join paths -- the dataset a downstream converter needing a single
+    fact should start from -- unless `source` names another dataset in the projection.
+
+    Fan-out is strict by default: a published metric that can over-count is refused
+    rather than emitted. Pass `strict_fanout=False` to convert it with a
+    FANOUT_UNSAFE_METRIC issue instead.
+    """
+    if not isinstance(files, dict) or not files:
+        raise ConversionError("expected a non-empty mapping of {filename: YAML}")
+    if not isinstance(view, str) or not view.strip():
+        raise ConversionError("view projection needs the name of the view to project")
+    if source is not None and (not isinstance(source, str) or not source):
+        raise ConversionError(f"view '{view}': source must be a dataset name")
+
+    # The collection's own issues are about the lossless import -- a file preserved in
+    # custom_extensions only -- which a projection does not do.
+    cubes, _, views, _, _ = _collect(files, IssueLog())
+    if view not in views:
+        raise ConversionError(
+            f"requested view '{view}' not found; views present: "
+            f"{sorted(views) or 'none'}")
+    if not cubes:
+        raise ConversionError(_no_cubes_message(views))
+
+    strict = {IssueType.FANOUT_UNSAFE_METRIC} if strict_fanout else set()
+    issues = IssueLog(strict_types=frozenset(strict))
+    view_def = views[view]
+    _report_dropped(issues, f"view '{view}'",
+                    [k for k in map(snake, view_def) if k not in _VIEW_KEYS])
+
+    tree = _view_tree(view, view_def, cubes)
+    members = _published_members(view, view_def, cubes)
+    inliner = _Inliner(cubes)
+
+    measures = _needed_measures(members, cubes, inliner)
+    dimensions = {}
+    for member in members:
+        key = (member.cube, member.name)
+        if member.kind == "dimension" and key not in dimensions:
+            dimensions[key] = _published_dimension(inliner, cubes, member)
+    dependencies = {cname for cname, _ in measures}
+    for (cname, _), measure in measures.items():
+        for sql in [measure.get("sql"), *(f["sql"] for f in measure.get("filters", ()))]:
+            dependencies |= _joined_cubes(sql, cname, cubes)
+    _attach_dependencies(view, tree, sorted(dependencies), cubes)
+    _refuse_mixed_data_sources(view, tree, cubes)
+    resolved_source = _resolve_source(view, source, tree, members)
+
+    reduced = _reduced_cubes(tree, cubes, dimensions, measures, inliner, issues)
+    reduced_view = {k: v for k, v in view_def.items() if k != "cubes"}
+    # The import runs lenient, and its fan-out reports are replaced by the projection's
+    # own: it attributes a struct path (`orders.accounts.balance`) to its second part,
+    # and checks each join alone rather than the whole tree.
+    imported = IssueLog()
+    model = _build_model(reduced, {}, {view: reduced_view}, {}, {}, None, view, imported)
+    _refuse_unjoined(view, model, imported)
+    for issue in imported:
+        if issue.issue_type is not IssueType.FANOUT_UNSAFE_METRIC:
+            issues.add(issue.issue_type, issue.element_name, issue.detail)
+    # A second resolver over the same cubes, for the fully inlined forms: the import
+    # emits a reference to a hidden measure by its metric name, and that metric is not
+    # published.
+    resolver = _MeasureResolver(reduced, {}, IssueLog(), qualify=False)
+    _report_fanout_of(resolver, tree, reduced, model, measures, issues)
+    _publish_surface(model, resolver, reduced, cubes, members, issues)
+    return dump_yaml({"version": OSSIE_VERSION, **model}), resolved_source, issues
+
+
+# --- the view ---------------------------------------------------------------------
+
+def _view_tree(vname, view, cubes):
+    """The join tree the view's `join_path`s spell out.
+
+    Cube joins every view member through the path its entry names, so the paths have to
+    form one tree: a single root, and each cube reached one way. A second path to a cube
+    would give its members two meanings, which a published model cannot hold.
+    """
+    entries = view.get("cubes")
+    if not isinstance(entries, list) or not entries:
+        raise ConversionError(
+            f"view '{vname}' has no `cubes` entries to project (the legacy view-level "
+            f"`includes` form is not supported)")
+    tree = _JoinTree(root=None, order=[], parent={})
+    roots = []
+    for index, raw in enumerate(entries):
+        if not isinstance(raw, dict):
+            raise ConversionError(f"view '{vname}': cubes[{index}] must be a mapping")
+        entry = snake_keys(raw)
+        if entry.get("split"):
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] is split, and split view projections "
+                f"publish as more than one model; project each split view on its own")
+        path = entry.get("join_path")
+        if not isinstance(path, str) or not path.strip():
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] has no non-empty `join_path`")
+        parts = path.split(".")
+        if not all(parts):
+            raise ConversionError(f"view '{vname}': invalid join_path '{path}'")
+        if len(set(parts)) != len(parts):
+            raise ConversionError(
+                f"view '{vname}': join_path '{path}' visits a cube twice")
+        for part in parts:
+            if part not in cubes:
+                raise ConversionError(
+                    f"view '{vname}': join_path '{path}' references unknown cube "
+                    f"'{part}'")
+        if parts[0] not in roots:
+            roots.append(parts[0])
+        for left, right in pairwise(parts):
+            _add_edge(vname, tree, cubes, left, right, "reached through two join paths")
+        for part in parts:
+            if part not in tree.order:
+                tree.order.append(part)
+    if len(roots) != 1:
+        raise ConversionError(
+            f"view '{vname}' has multiple join roots {roots}; a published model joins "
+            f"everything from one dataset, so project a single-root view")
+    tree.root = roots[0]
+    return tree
+
+
+def _add_edge(vname, tree, cubes, left, right, conflict):
+    """Join `right` under `left`, refusing a second parent for it."""
+    prior = tree.parent.get(right)
+    if prior is not None:
+        if prior[0] != left:
+            raise ConversionError(
+                f"view '{vname}': cube '{right}' is {conflict} (from '{prior[0]}' and "
+                f"from '{left}'), which would give its members two meanings")
+        return
+    joins = [j for j in _as_named_list(cubes[left].get("joins"), f"cube '{left}' joins")
+             if j.get("name") == right]
+    if not joins:
+        raise ConversionError(
+            f"view '{vname}': joining '{right}' needs a join '{left}' -> '{right}', but "
+            f"cube '{left}' declares no join to '{right}'")
+    if len(joins) > 1:
+        raise ConversionError(
+            f"view '{vname}': cube '{left}' declares more than one join to '{right}'")
+    tree.parent[right] = (left, joins[0])
+
+
+def _published_members(vname, view, cubes):
+    """Every member the view publishes, in view order, with its published name."""
+    decomposed = _decomposed_measure_names(cubes)
+    members, seen, taken = [], set(), {}
+    for raw in view["cubes"]:
+        entry = snake_keys(raw)
+        cname = entry["join_path"].split(".")[-1]
+        for member in _entry_members(vname, entry, cname, cubes[cname], decomposed):
+            identity = (member.cube, member.name, member.output, member.kind)
+            if identity in seen:
+                continue
+            seen.add(identity)
+            key = normalize_identifier(member.output)
+            if key in taken:
+                other = taken[key]
+                raise ConversionError(
+                    f"view '{vname}': published name '{member.output}' from "
+                    f"'{member.cube}.{member.name}' collides with '{other.output}' from "
+                    f"'{other.cube}.{other.name}' (identifiers are case-insensitive); "
+                    f"use an alias or a prefix")
+            taken[key] = member
+            members.append(member)
+    if not members:
+        raise ConversionError(f"view '{vname}' publishes no dimensions or measures")
+    return members
+
+
+def _entry_members(vname, entry, cname, cube, decomposed):
+    """The members one `cubes:` entry publishes, honouring `includes`, `excludes`,
+    member aliases and `prefix`."""
+    prefix = entry.get("prefix", False)
+    if not isinstance(prefix, bool):
+        raise ConversionError(
+            f"view '{vname}': prefix for cube '{cname}' must be true or false")
+    alias = entry.get("alias", cname)
+    if not isinstance(alias, str) or not alias:
+        raise ConversionError(
+            f"view '{vname}': alias for cube '{cname}' must be a non-empty string")
+
+    by_name = {}
+    for kind, key in (("dimension", "dimensions"), ("measure", "measures"),
+                      ("segment", "segments"), ("hierarchy", "hierarchies")):
+        for member in _as_named_list(cube.get(key), f"cube '{cname}' {key}"):
+            if member.get("name"):
+                by_name.setdefault(member["name"], (kind, member))
+
+    includes = entry.get("includes", [])
+    if includes == "*":
+        # A wildcard publishes what the cube itself makes public. A measure a previous
+        # export generated to hold one part of a composite metric is an implementation
+        # detail of the metric that references it, never a member of its own.
+        requested = [
+            (name, name, {}) for name, (kind, member) in by_name.items()
+            if not _is_private(member)
+            and not (kind == "measure" and _is_generated_part(member, decomposed))]
+    elif isinstance(includes, list):
+        requested = [_include(vname, cname, item) for item in includes]
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must be '*' or a list")
+
+    excludes = entry.get("excludes")
+    if excludes is None:
+        excludes = []
+    if not isinstance(excludes, list) or not all(isinstance(x, str) for x in excludes):
+        raise ConversionError(
+            f"view '{vname}': excludes for cube '{cname}' must be a list of member "
+            f"names")
+    for name in excludes:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': excluded member '{cname}.{name}' does not exist")
+
+    out = []
+    for name, output, override in requested:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': member '{cname}.{name}' does not exist")
+        if name in excludes:
+            continue
+        kind, member = by_name[name]
+        scope = f"view '{vname}': selected {kind} '{cname}.{name}'"
+        if kind in ("segment", "hierarchy"):
+            raise ConversionError(
+                f"{scope} has no Ossie form; exclude it from the view to project it")
+        if kind == "measure" and _is_generated_part(member, decomposed):
+            raise ConversionError(
+                f"{scope} is an aggregate a previous Ossie export generated as part of "
+                f"'{parked_of(member.get('meta')).get('part_of')}', and cannot be "
+                f"published on its own")
+        if kind == "dimension":
+            feature = _unpublishable_dimension(member)
+            if feature:
+                raise ConversionError(
+                    f"{scope} uses {feature}, which has no single Ossie field form")
+        out.append(_Member(cname, name, f"{alias}_{output}" if prefix else output,
+                           kind, member, override))
+    return out
+
+
+def _is_private(member):
+    """Whether Cube hides a member: `public`, else the deprecated `visible` or `shown`,
+    decides -- the precedence of Cube's own `isVisible`."""
+    for key in _VISIBILITY_KEYS:
+        if member.get(key) is not None:
+            return member[key] is False
+    return False
+
+
+def _include(vname, cname, item):
+    """(member name, published name, override) for one `includes` item."""
+    if isinstance(item, str):
+        name, output, override = item, item, {}
+    elif isinstance(item, dict):
+        override = snake_keys(item)
+        name = override.get("name")
+        output = override.get("alias", name)
+        if not isinstance(name, str) or not name:
+            raise ConversionError(
+                f"view '{vname}': an include for cube '{cname}' must name a member")
+        if not isinstance(output, str) or not output:
+            raise ConversionError(
+                f"view '{vname}': alias for member '{cname}.{name}' must be a "
+                f"non-empty string")
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must hold member names or "
+            f"mappings")
+    if "." in name or "." in output:
+        raise ConversionError(
+            f"view '{vname}': include '{name}' for cube '{cname}' is a path; list "
+            f"the member on the entry for the cube that defines it")
+    return name, output, override
+
+
+def _unpublishable_dimension(dim):
+    """Why a dimension has no single Ossie field, or None when it has one."""
+    if dim.get("sub_query"):
+        return "sub_query"
+    dtype = snake(dim.get("type") or "")
+    return dtype if dtype in ("geo", "switch") else None
+
+
+# --- dependencies -----------------------------------------------------------------
+
+class _Inliner:
+    """Rewrites member SQL so every dimension reference is the SQL it stands for.
+
+    Cube inlines a referenced dimension's SQL when it renders a query, the only reading
+    of `{net_amount}` that survives into a model where `net_amount` is not published.
+    The result stays Cube SQL -- `{CUBE}.column`, `{users}.column` -- so the import
+    translates it as it would hand-written SQL; measure references are left to
+    `_MeasureResolver`. Columns are qualified first (`qualify_column_paths`), so a
+    dimension inlined into another cube's measure keeps its columns' owner. A reference
+    to an unknown cube or member is refused, as Cube would refuse to compile it.
+    """
+
+    def __init__(self, cubes):
+        self._cubes = cubes
+        self._dimensions = {}
+        self._measures = set()
+        for cname, cube in cubes.items():
+            for dim in _as_named_list(cube.get("dimensions"),
+                                      f"cube '{cname}' dimensions"):
+                self._dimensions[(cname, dim.get("name"))] = dim
+            for measure in _as_named_list(cube.get("measures"),
+                                          f"cube '{cname}' measures"):
+                self._measures.add((cname, measure.get("name")))

Review Comment:
   `view_projection` indexes measures by `m[\"name\"]` (and also records dimension/measure names via `.get(\"name\")`) without validating that `name` is present and a non-empty string. If a Cube model contains an invalid measure entry missing `name`, this will raise a raw `KeyError` (or propagate `None` keys into internal maps) instead of producing a `ConversionError` like the rest of the converter. Align with `cube_to_osi` by validating names with the same helper used elsewhere (e.g., `require_str(..., \"name\", ...)`) before using them as dict keys / set members, so malformed models fail with a stable, user-friendly error.



##########
converters/cube/src/ossie_cube/view_projection.py:
##########
@@ -0,0 +1,1039 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Project one Cube view's public surface onto an Apache Ossie semantic model.
+
+`convert_cube_to_ossie` is a lossless round-trip import: every cube member becomes an
+Ossie field or metric, and the view's curation rides in `custom_extensions`. Publishing
+a view to another tool needs the opposite boundary -- exactly the members the view
+exposes, under the names it exposes them by, and nothing the view keeps private.
+
+The projection resolves the view (its members, join tree and hidden dependencies),
+builds a reduced cube set holding only those, runs the ordinary import over it, and
+trims the result to the published surface. Dimension references are inlined while the
+set is built, in Cube space, so the import's own join decomposition and fan-out analysis
+read the columns a member really uses; measure references are inlined afterwards by the
+import's `_MeasureResolver`. Anything the published model could not say faithfully is
+refused with a `ConversionError`, never dropped. See README "View projection".
+
+Usage (CLI):
+    ossie-cube import -i model/ --view sales --project [--source orders]
+"""
+
+import copy
+import dataclasses
+from collections import deque
+from itertools import pairwise
+
+from ._common import (
+    _CUBE_REF_RE,
+    OSSIE_VERSION,
+    VENDOR,
+    ConversionError,
+    dump_yaml,
+    lookup_map,
+    normalize_identifier,
+    primary_key_operand,
+    read_stash,
+    requalify_self_refs,
+    resolve_identifier,
+    snake,
+    snake_keys,
+    unescape_braces_from_cube,
+    write_stash,
+)
+from .converter_issues import IssueLog, IssueType
+from .cube_to_osi import (
+    _CUBE_NATIVE_KEYS,
+    _DIM_NATIVE_KEYS,
+    _MEASURE_NATIVE_KEYS,
+    _RELATIONSHIP_ALIASES,
+    _ai_context_from_meta,
+    _as_named_list,
+    _build_model,
+    _case_expression,
+    _collect,
+    _column_of,
+    _decomposed_measure_names,
+    _fanned_out_datasets,
+    _fanout_unsafe_datasets,
+    _is_generated_part,
+    _MeasureResolver,
+    _metric_names_of,
+    _no_cubes_message,
+    _primary_key_of,
+    _report_fanout,
+    parked_of,
+)
+from .expressions import (
+    UnattributableSQL,
+    has_top_level_operator,
+    qualify_column_paths,
+    unsafe_aggregate_heads,
+)
+
+_SELF_REFS = ("CUBE", "TABLE")
+
+# Stash keys a published member keeps: how it looks to its reader. The rest of the
+# import's stash rebuilds the original Cube member, naming members that are not here.
+_PUBLISHED_MEMBER_KEYS = ("dim_type", "format", "currency", "title")
+
+# The declared Cube cardinality, which Ossie's many/one orientation cannot carry.
+_PUBLISHED_JOIN_KEYS = ("declared_on", "relationship")
+
+# Presentation keys a view `includes` entry may override, and a member carries along.
+_PRESENTATION_KEYS = ("format", "currency")
+
+# Cube keys the projection consumes, so not reported as dropped: members are the surface,
+# visibility is the view's to decide, and `data_source` rides on the dataset.
+_VISIBILITY_KEYS = ("public", "visible", "shown")
+_CONSUMED_CUBE_KEYS = frozenset({
+    "segments", "hierarchies", "data_source", *_VISIBILITY_KEYS})
+_CONSUMED_MEMBER_KEYS = frozenset({"case", *_VISIBILITY_KEYS, *_PRESENTATION_KEYS})
+_VIEW_KEYS = frozenset({"name", "cubes", "description", "meta", *_VISIBILITY_KEYS})
+
+# Multi-stage measure keys and the value shape Cube accepts. An empty value is Cube's own
+# default and changes nothing; anything else computes over another grain. Covers every
+# key in `_WINDOWING_KEYS` (a test checks), plus `grain`, which the import does not know.
+_MULTI_STAGE_SHAPES = {
+    "multi_stage": bool,
+    "group_by": list,
+    "reduce_by": list,
+    "add_group_by": list,
+    "time_shift": list,
+    "grain": dict,
+}
+
+
+@dataclasses.dataclass(frozen=True)
+class _Member:
+    """One member the view publishes: `name` on `cube`, published as `output`, with its
+    Cube `definition` and the view's `includes` entry for it, when that is a mapping."""
+
+    cube: str
+    name: str
+    output: str
+    kind: str
+    definition: dict
+    override: dict
+
+
+@dataclasses.dataclass
+class _JoinTree:
+    """The view's join tree: `root`, every dataset in presentation order, and
+    `parent[child] = (parent, the join declared on the parent)`."""
+
+    root: str
+    order: list
+    parent: dict
+
+
+def convert_cube_view_to_ossie(files, view, source=None, strict_fanout=True):
+    """Convert one Cube view's public surface to Ossie YAML.
+
+    `files` is a Cube model as {relative filename: YAML str}, the same input
+    `convert_cube_to_ossie` takes. Returns (ossie_yaml_str, resolved_source, IssueLog).
+
+    Unlike `convert_cube_to_ossie`, this is a publication transform, not a round trip:
+    the model holds exactly the dimensions and measures `view` exposes, under the names
+    it exposes them by, with every hidden dependency inlined. `resolved_source` is the
+    root of the view's join paths -- the dataset a downstream converter needing a single
+    fact should start from -- unless `source` names another dataset in the projection.
+
+    Fan-out is strict by default: a published metric that can over-count is refused
+    rather than emitted. Pass `strict_fanout=False` to convert it with a
+    FANOUT_UNSAFE_METRIC issue instead.
+    """
+    if not isinstance(files, dict) or not files:
+        raise ConversionError("expected a non-empty mapping of {filename: YAML}")
+    if not isinstance(view, str) or not view.strip():
+        raise ConversionError("view projection needs the name of the view to project")
+    if source is not None and (not isinstance(source, str) or not source):
+        raise ConversionError(f"view '{view}': source must be a dataset name")
+
+    # The collection's own issues are about the lossless import -- a file preserved in
+    # custom_extensions only -- which a projection does not do.
+    cubes, _, views, _, _ = _collect(files, IssueLog())
+    if view not in views:
+        raise ConversionError(
+            f"requested view '{view}' not found; views present: "
+            f"{sorted(views) or 'none'}")
+    if not cubes:
+        raise ConversionError(_no_cubes_message(views))
+
+    strict = {IssueType.FANOUT_UNSAFE_METRIC} if strict_fanout else set()
+    issues = IssueLog(strict_types=frozenset(strict))
+    view_def = views[view]
+    _report_dropped(issues, f"view '{view}'",
+                    [k for k in map(snake, view_def) if k not in _VIEW_KEYS])
+
+    tree = _view_tree(view, view_def, cubes)
+    members = _published_members(view, view_def, cubes)
+    inliner = _Inliner(cubes)
+
+    measures = _needed_measures(members, cubes, inliner)
+    dimensions = {}
+    for member in members:
+        key = (member.cube, member.name)
+        if member.kind == "dimension" and key not in dimensions:
+            dimensions[key] = _published_dimension(inliner, cubes, member)
+    dependencies = {cname for cname, _ in measures}
+    for (cname, _), measure in measures.items():
+        for sql in [measure.get("sql"), *(f["sql"] for f in measure.get("filters", ()))]:
+            dependencies |= _joined_cubes(sql, cname, cubes)
+    _attach_dependencies(view, tree, sorted(dependencies), cubes)
+    _refuse_mixed_data_sources(view, tree, cubes)
+    resolved_source = _resolve_source(view, source, tree, members)
+
+    reduced = _reduced_cubes(tree, cubes, dimensions, measures, inliner, issues)
+    reduced_view = {k: v for k, v in view_def.items() if k != "cubes"}
+    # The import runs lenient, and its fan-out reports are replaced by the projection's
+    # own: it attributes a struct path (`orders.accounts.balance`) to its second part,
+    # and checks each join alone rather than the whole tree.
+    imported = IssueLog()
+    model = _build_model(reduced, {}, {view: reduced_view}, {}, {}, None, view, imported)
+    _refuse_unjoined(view, model, imported)
+    for issue in imported:
+        if issue.issue_type is not IssueType.FANOUT_UNSAFE_METRIC:
+            issues.add(issue.issue_type, issue.element_name, issue.detail)
+    # A second resolver over the same cubes, for the fully inlined forms: the import
+    # emits a reference to a hidden measure by its metric name, and that metric is not
+    # published.
+    resolver = _MeasureResolver(reduced, {}, IssueLog(), qualify=False)
+    _report_fanout_of(resolver, tree, reduced, model, measures, issues)
+    _publish_surface(model, resolver, reduced, cubes, members, issues)
+    return dump_yaml({"version": OSSIE_VERSION, **model}), resolved_source, issues
+
+
+# --- the view ---------------------------------------------------------------------
+
+def _view_tree(vname, view, cubes):
+    """The join tree the view's `join_path`s spell out.
+
+    Cube joins every view member through the path its entry names, so the paths have to
+    form one tree: a single root, and each cube reached one way. A second path to a cube
+    would give its members two meanings, which a published model cannot hold.
+    """
+    entries = view.get("cubes")
+    if not isinstance(entries, list) or not entries:
+        raise ConversionError(
+            f"view '{vname}' has no `cubes` entries to project (the legacy view-level "
+            f"`includes` form is not supported)")
+    tree = _JoinTree(root=None, order=[], parent={})
+    roots = []
+    for index, raw in enumerate(entries):
+        if not isinstance(raw, dict):
+            raise ConversionError(f"view '{vname}': cubes[{index}] must be a mapping")
+        entry = snake_keys(raw)
+        if entry.get("split"):
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] is split, and split view projections "
+                f"publish as more than one model; project each split view on its own")
+        path = entry.get("join_path")
+        if not isinstance(path, str) or not path.strip():
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] has no non-empty `join_path`")
+        parts = path.split(".")
+        if not all(parts):
+            raise ConversionError(f"view '{vname}': invalid join_path '{path}'")
+        if len(set(parts)) != len(parts):
+            raise ConversionError(
+                f"view '{vname}': join_path '{path}' visits a cube twice")
+        for part in parts:
+            if part not in cubes:
+                raise ConversionError(
+                    f"view '{vname}': join_path '{path}' references unknown cube "
+                    f"'{part}'")
+        if parts[0] not in roots:
+            roots.append(parts[0])
+        for left, right in pairwise(parts):
+            _add_edge(vname, tree, cubes, left, right, "reached through two join paths")
+        for part in parts:
+            if part not in tree.order:
+                tree.order.append(part)
+    if len(roots) != 1:
+        raise ConversionError(
+            f"view '{vname}' has multiple join roots {roots}; a published model joins "
+            f"everything from one dataset, so project a single-root view")
+    tree.root = roots[0]
+    return tree
+
+
+def _add_edge(vname, tree, cubes, left, right, conflict):
+    """Join `right` under `left`, refusing a second parent for it."""
+    prior = tree.parent.get(right)
+    if prior is not None:
+        if prior[0] != left:
+            raise ConversionError(
+                f"view '{vname}': cube '{right}' is {conflict} (from '{prior[0]}' and "
+                f"from '{left}'), which would give its members two meanings")
+        return
+    joins = [j for j in _as_named_list(cubes[left].get("joins"), f"cube '{left}' joins")
+             if j.get("name") == right]
+    if not joins:
+        raise ConversionError(
+            f"view '{vname}': joining '{right}' needs a join '{left}' -> '{right}', but "
+            f"cube '{left}' declares no join to '{right}'")
+    if len(joins) > 1:
+        raise ConversionError(
+            f"view '{vname}': cube '{left}' declares more than one join to '{right}'")
+    tree.parent[right] = (left, joins[0])
+
+
+def _published_members(vname, view, cubes):
+    """Every member the view publishes, in view order, with its published name."""
+    decomposed = _decomposed_measure_names(cubes)
+    members, seen, taken = [], set(), {}
+    for raw in view["cubes"]:
+        entry = snake_keys(raw)
+        cname = entry["join_path"].split(".")[-1]
+        for member in _entry_members(vname, entry, cname, cubes[cname], decomposed):
+            identity = (member.cube, member.name, member.output, member.kind)
+            if identity in seen:
+                continue
+            seen.add(identity)
+            key = normalize_identifier(member.output)
+            if key in taken:
+                other = taken[key]
+                raise ConversionError(
+                    f"view '{vname}': published name '{member.output}' from "
+                    f"'{member.cube}.{member.name}' collides with '{other.output}' from "
+                    f"'{other.cube}.{other.name}' (identifiers are case-insensitive); "
+                    f"use an alias or a prefix")
+            taken[key] = member
+            members.append(member)
+    if not members:
+        raise ConversionError(f"view '{vname}' publishes no dimensions or measures")
+    return members
+
+
+def _entry_members(vname, entry, cname, cube, decomposed):
+    """The members one `cubes:` entry publishes, honouring `includes`, `excludes`,
+    member aliases and `prefix`."""
+    prefix = entry.get("prefix", False)
+    if not isinstance(prefix, bool):
+        raise ConversionError(
+            f"view '{vname}': prefix for cube '{cname}' must be true or false")
+    alias = entry.get("alias", cname)
+    if not isinstance(alias, str) or not alias:
+        raise ConversionError(
+            f"view '{vname}': alias for cube '{cname}' must be a non-empty string")
+
+    by_name = {}
+    for kind, key in (("dimension", "dimensions"), ("measure", "measures"),
+                      ("segment", "segments"), ("hierarchy", "hierarchies")):
+        for member in _as_named_list(cube.get(key), f"cube '{cname}' {key}"):
+            if member.get("name"):
+                by_name.setdefault(member["name"], (kind, member))
+
+    includes = entry.get("includes", [])
+    if includes == "*":
+        # A wildcard publishes what the cube itself makes public. A measure a previous
+        # export generated to hold one part of a composite metric is an implementation
+        # detail of the metric that references it, never a member of its own.
+        requested = [
+            (name, name, {}) for name, (kind, member) in by_name.items()
+            if not _is_private(member)
+            and not (kind == "measure" and _is_generated_part(member, decomposed))]
+    elif isinstance(includes, list):
+        requested = [_include(vname, cname, item) for item in includes]
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must be '*' or a list")
+
+    excludes = entry.get("excludes")
+    if excludes is None:
+        excludes = []
+    if not isinstance(excludes, list) or not all(isinstance(x, str) for x in excludes):
+        raise ConversionError(
+            f"view '{vname}': excludes for cube '{cname}' must be a list of member "
+            f"names")
+    for name in excludes:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': excluded member '{cname}.{name}' does not exist")
+
+    out = []
+    for name, output, override in requested:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': member '{cname}.{name}' does not exist")
+        if name in excludes:
+            continue
+        kind, member = by_name[name]
+        scope = f"view '{vname}': selected {kind} '{cname}.{name}'"
+        if kind in ("segment", "hierarchy"):
+            raise ConversionError(
+                f"{scope} has no Ossie form; exclude it from the view to project it")
+        if kind == "measure" and _is_generated_part(member, decomposed):
+            raise ConversionError(
+                f"{scope} is an aggregate a previous Ossie export generated as part of "
+                f"'{parked_of(member.get('meta')).get('part_of')}', and cannot be "
+                f"published on its own")
+        if kind == "dimension":
+            feature = _unpublishable_dimension(member)
+            if feature:
+                raise ConversionError(
+                    f"{scope} uses {feature}, which has no single Ossie field form")
+        out.append(_Member(cname, name, f"{alias}_{output}" if prefix else output,
+                           kind, member, override))
+    return out
+
+
+def _is_private(member):
+    """Whether Cube hides a member: `public`, else the deprecated `visible` or `shown`,
+    decides -- the precedence of Cube's own `isVisible`."""
+    for key in _VISIBILITY_KEYS:
+        if member.get(key) is not None:
+            return member[key] is False
+    return False
+
+
+def _include(vname, cname, item):
+    """(member name, published name, override) for one `includes` item."""
+    if isinstance(item, str):
+        name, output, override = item, item, {}
+    elif isinstance(item, dict):
+        override = snake_keys(item)
+        name = override.get("name")
+        output = override.get("alias", name)
+        if not isinstance(name, str) or not name:
+            raise ConversionError(
+                f"view '{vname}': an include for cube '{cname}' must name a member")
+        if not isinstance(output, str) or not output:
+            raise ConversionError(
+                f"view '{vname}': alias for member '{cname}.{name}' must be a "
+                f"non-empty string")
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must hold member names or "
+            f"mappings")
+    if "." in name or "." in output:
+        raise ConversionError(
+            f"view '{vname}': include '{name}' for cube '{cname}' is a path; list "
+            f"the member on the entry for the cube that defines it")
+    return name, output, override
+
+
+def _unpublishable_dimension(dim):
+    """Why a dimension has no single Ossie field, or None when it has one."""
+    if dim.get("sub_query"):
+        return "sub_query"
+    dtype = snake(dim.get("type") or "")
+    return dtype if dtype in ("geo", "switch") else None
+
+
+# --- dependencies -----------------------------------------------------------------
+
+class _Inliner:
+    """Rewrites member SQL so every dimension reference is the SQL it stands for.
+
+    Cube inlines a referenced dimension's SQL when it renders a query, the only reading
+    of `{net_amount}` that survives into a model where `net_amount` is not published.
+    The result stays Cube SQL -- `{CUBE}.column`, `{users}.column` -- so the import
+    translates it as it would hand-written SQL; measure references are left to
+    `_MeasureResolver`. Columns are qualified first (`qualify_column_paths`), so a
+    dimension inlined into another cube's measure keeps its columns' owner. A reference
+    to an unknown cube or member is refused, as Cube would refuse to compile it.
+    """
+
+    def __init__(self, cubes):
+        self._cubes = cubes
+        self._dimensions = {}
+        self._measures = set()
+        for cname, cube in cubes.items():
+            for dim in _as_named_list(cube.get("dimensions"),
+                                      f"cube '{cname}' dimensions"):
+                self._dimensions[(cname, dim.get("name"))] = dim
+            for measure in _as_named_list(cube.get("measures"),
+                                          f"cube '{cname}' measures"):
+                self._measures.add((cname, measure.get("name")))
+        self._cache = {}
+
+    def dimension(self, cname, dname, stack=()):
+        """A dimension's SQL with every reference inlined, relative to its own cube."""
+        key = (cname, dname)
+        if key in self._cache:
+            return self._cache[key]
+        if key in stack:
+            chain = " -> ".join(f"{c}.{d}" for c, d in stack + (key,))
+            raise ConversionError(f"dimension reference cycle: {chain}")
+        dim = self._dimensions[key]
+        scope = f"dimension '{cname}.{dname}'"
+        feature = _unpublishable_dimension(dim)
+        if feature:
+            raise ConversionError(
+                f"{scope} uses {feature}, which has no single SQL expression to inline "
+                f"where it is referenced")
+        stack += (key,)
+
+        def rewrite(sql):
+            return self.rewrite(sql, cname, scope, stack)
+
+        if dim.get("case") is not None:
+            # Rendered the way Cube renders it. A plain label stays as written, braces
+            # still escaped, since the result is Cube SQL until the import reads it.
+            sql = _case_expression(cname, dname, dim["case"], rewrite, str)
+        else:
+            sql = rewrite(dname if dim.get("sql") is None else dim["sql"])
+        self._cache[key] = sql
+        return sql
+
+    def rewrite(self, sql, cname, scope, stack=(), measures=None):
+        """`sql`, written on cube `cname`, with columns qualified and dimension
+        references inlined.
+
+        Measure references are kept as written and added to `measures`; with
+        `measures` None they are refused, since only a measure's SQL may name one --
+        a dimension reading a measure is Cube's correlated subquery.
+        """
+        try:
+            qualified = qualify_column_paths(sql, cname, self._cubes)
+        except UnattributableSQL as reason:
+            raise ConversionError(
+                f"{scope}: the source of every column in {str(sql).strip()!r} cannot "
+                f"be proven statically: {reason}") from None
+        protected = qualified.replace("\\{", "\x00lb\x00").replace("\\}", "\x00rb\x00")
+
+        def replace(match):
+            body = match.group(1).strip()
+            head, dot, rest = body.partition(".")
+            if not dot:
+                aliased = protected[match.end():].lstrip().startswith(".")
+                if body in _SELF_REFS or body == cname or (
+                        aliased and body in self._cubes):
+                    if not aliased:
+                        raise ConversionError(
+                            f"{scope}: '{{{body}}}' names a cube, not a column; write "
+                            f"'{{{body}}}.column'")
+                    return match.group(0)
+                target = (cname, body)
+            elif head in _SELF_REFS or head == cname:
+                target = (cname, rest)
+            elif head in self._cubes:
+                target = (head, rest)
+            else:
+                raise ConversionError(
+                    f"{scope}: reference '{{{body}}}' uses unknown cube qualifier "
+                    f"'{head}'; use '{{cube}}.column' for a raw column of a joined cube")
+            if target in self._dimensions:
+                inner = self.dimension(*target, stack)
+                if target[0] != cname:
+                    inner = requalify_self_refs(inner, target[0])
+                whole = protected.strip() == match.group(0)
+                return f"({inner})" if has_top_level_operator(inner) and not whole \
+                    else inner
+            if target in self._measures:
+                if measures is None:
+                    raise ConversionError(
+                        f"{scope} references measure '{target[0]}.{target[1]}', which "
+                        f"Cube evaluates as a correlated subquery; an Ossie field has "
+                        f"no form for that")
+                measures.add(target)
+                return match.group(0)
+            raise ConversionError(
+                f"{scope}: reference '{{{body}}}' does not match a dimension or "
+                f"measure in cube '{target[0]}'; use '{{{target[0]}}}.{target[1]}' "
+                f"for a raw column")
+
+        out = _CUBE_REF_RE.sub(replace, protected)
+        return out.replace("\x00lb\x00", "\\{").replace("\x00rb\x00", "\\}")
+
+
+def _joined_cubes(sql, cname, cubes):
+    """The other cubes rewritten SQL reads raw columns of (`{users}.ltv`)."""
+    if sql is None:
+        return set()
+    text = str(sql).replace("\\{", "").replace("\\}", "")
+    return {m.group(1).strip() for m in _CUBE_REF_RE.finditer(text)
+            if m.group(1).strip() in cubes and m.group(1).strip() != cname
+            and text[m.end():].lstrip().startswith(".")}
+
+
+def _needed_measures(members, cubes, inliner):
+    """{(cube, measure): its reduced definition} for every published measure and every
+    measure one references, directly or not."""
+    raw = {(cname, m["name"]): m for cname, cube in cubes.items()
+           for m in _as_named_list(cube.get("measures"), f"cube '{cname}' measures")}
+    queue = deque((m.cube, m.name) for m in members if m.kind == "measure")
+    needed = {}
+    while queue:
+        key = queue.popleft()
+        if key in needed:
+            continue
+        needed[key], references = _reduced_measure(inliner, cubes, raw[key], *key)
+        queue.extend(sorted(references - needed.keys()))
+    return needed
+
+
+def _reduced_measure(inliner, cubes, measure, cname, mname):
+    """A measure as the reduced cube set carries it, and the measures it references.
+
+    Dimension references are inlined and columns qualified. A bare `type: count` is
+    spelled out as the distinct count of its primary key's own SQL -- the same value,
+    and the form `primary_key_count_expression` gives -- because the projected model
+    publishes no field for a hidden key to name.
+    """
+    scope = f"measure '{cname}.{mname}'"
+    _refuse_windowing(measure, scope)
+    references = set()
+    out = _labels_of(measure)
+    if snake(measure.get("type") or "") == "count" and measure.get("sql") is None:
+        out["type"] = "count_distinct"
+        out["sql"] = _primary_key_sql(inliner, cubes, cname)
+    elif measure.get("sql") is not None:
+        out["sql"] = inliner.rewrite(measure["sql"], cname, scope, measures=references)
+    filters = [f for f in measure.get("filters") or []
+               if isinstance(f, dict) and f.get("sql")]
+    if filters:
+        out["filters"] = [
+            {"sql": inliner.rewrite(f["sql"], cname, scope, measures=references)}
+            for f in filters]
+    return out, references
+
+
+def _refuse_windowing(measure, scope):
+    """Refuse a measure computed over a grain other than the query's.
+
+    The lossless import parks these and carries on; a projection that did the same
+    would publish a view missing a member it names.
+    """
+    if measure.get("rolling_window") is not None:
+        raise ConversionError(
+            f"{scope} uses rolling_window, which aggregates over a trailing range "
+            f"rather than the query's grain; an Ossie expression has no form for that")
+    for key, shape in _MULTI_STAGE_SHAPES.items():
+        if key not in measure:
+            continue
+        if not isinstance(measure[key], shape):
+            raise ConversionError(
+                f"{scope} has malformed {key}; expected a {shape.__name__}")
+        if measure[key]:
+            raise ConversionError(
+                f"{scope} uses multi-stage {key}, which computes over a grain other "
+                f"than the query's; an Ossie expression has no form for that")
+
+
+def _primary_key_sql(inliner, cubes, cname):
+    """Cube SQL for a cube's primary key: its columns, or its key dimensions' SQL."""
+    cube = cubes[cname]
+    keys = _primary_key_of(cube, cname)
+    if "primary_key" in parked_of(cube.get("meta")):
+        # Recorded by a previous export, and columns by construction.
+        return primary_key_operand(cname, keys, lambda key: "{CUBE}." + key)
+    return primary_key_operand(
+        cname, keys, lambda key: inliner.dimension(cname, key))
+
+
+def _published_dimension(inliner, cubes, member):
+    """A published dimension as the reduced cube set carries it.
+
+    An Ossie field is scoped to its own dataset, so a dimension reading another cube --
+    directly or through a hidden dimension -- has no field form.
+    """
+    out = _labels_of(member.definition)
+    out["sql"] = inliner.dimension(member.cube, member.name)
+    joined = _joined_cubes(out["sql"], member.cube, cubes)
+    if joined:
+        raise ConversionError(
+            f"dimension '{member.cube}.{member.name}' reads cube '{min(joined)}'; a "
+            f"dataset-scoped Ossie field cannot preserve that join, so publish that "
+            f"cube's member through its own join path instead")
+    return out
+
+
+def _labels_of(member):
+    """The keys of a member the import reads besides its SQL.
+
+    A multi-dialect expression parked by a previous export is left behind: only the
+    Cube SQL can be inlined, so the other dialects would describe a different
+    expression. A `synthetic_key` marker is too, since the view publishes the member.
+    """
+    out = {k: member[k] for k in ("name", "type", "title", "description",
+                                  *_PRESENTATION_KEYS) if k in member}
+    meta = member.get("meta")
+    if isinstance(meta, dict):
+        meta = copy.deepcopy(meta)
+        parked = meta.get("ossie")
+        if isinstance(parked, dict):
+            for key in ("expression", "synthetic_key"):
+                parked.pop(key, None)
+        out["meta"] = meta
+    return out
+
+
+def _attach_dependencies(vname, tree, dependencies, cubes):
+    """Join every cube a hidden dependency reads into the tree.
+
+    A member reading `{accounts.x}` without the view naming a path to `accounts` is
+    joined the way Cube joins it: along the declared joins. Only a unique path is
+    followed; with two, the member's value would depend on which one a consumer took.
+    """
+    for target in dependencies:
+        if target in tree.order:
+            continue
+        paths = _declared_join_paths(cubes, tree.root, target)
+        if not paths:
+            raise ConversionError(
+                f"view '{vname}': published members depend on cube '{target}', but no "
+                f"declared join path leads to it from the view's root '{tree.root}'")
+        if len(paths) > 1:
+            raise ConversionError(
+                f"view '{vname}': published members depend on cube '{target}', which "
+                f"the declared joins reach through multiple declared join paths "
+                f"({' and '.join('.'.join(p) for p in paths)}); add a join_path for it "
+                f"to the view to choose one")
+        for left, right in pairwise(paths[0]):
+            _add_edge(vname, tree, cubes, left, right,
+                      "reached by a hidden dependency through another join path")
+            if right not in tree.order:
+                tree.order.append(right)
+
+
+def _declared_join_paths(cubes, start, target):
+    """One declared join path from `start` to `target`, plus a second when there is one.
+
+    Breadth-first, so an unreachable target costs one pass over the graph rather than an
+    enumeration of every simple path. Any other path omits at least one edge of the first,
+    so searching again with each of its edges removed in turn finds it if it exists.
+    """
+    adjacency = {
+        cname: [j.get("name") for j in _as_named_list(cube.get("joins"),
+                                                     f"cube '{cname}' joins")
+                if j.get("name") in cubes]
+        for cname, cube in cubes.items()}
+
+    def search(blocked=None):
+        parents, queue = {start: None}, deque([start])
+        while queue:
+            current = queue.popleft()
+            for neighbour in adjacency[current]:
+                if (current, neighbour) == blocked or neighbour in parents:
+                    continue
+                parents[neighbour] = current
+                if neighbour == target:
+                    path = [target]
+                    while parents[path[-1]] is not None:
+                        path.append(parents[path[-1]])
+                    return path[::-1]
+                queue.append(neighbour)
+        return None
+
+    first = search()
+    if first is None:
+        return []
+    for edge in pairwise(first):
+        other = search(edge)
+        if other is not None:
+            return [first, other]
+    return [first]
+
+
+def _resolve_source(vname, source, tree, members):
+    """The dataset a consumer should treat as the published model's fact.
+
+    Re-rooting at another dataset turns every join between it and the view's root
+    around. A many-to-one join read backwards is one-to-many, and a dimension beyond one
+    no longer has one value per row of the new source, so the dimension would silently
+    fall out of any consumer that requires that -- refused instead.
+    """
+    if source is None or source == tree.root:
+        return tree.root
+    if source not in tree.order:
+        raise ConversionError(
+            f"view '{vname}': source '{source}' is not in the projected datasets "
+            f"{tree.order}")
+    before = _multiplied_from(tree.root, tree)
+    after = _multiplied_from(source, tree)
+    for member in members:
+        if member.kind == "dimension" and member.cube in after - before:
+            raise ConversionError(
+                f"view '{vname}': source '{source}' reaches '{member.cube}' through a "
+                f"one-to-many join, so published dimension '{member.output}' "
+                f"('{member.cube}.{member.name}') would no longer have one value per "
+                f"row of the source")
+    return source
+
+
+def _multiplied_from(root, tree):
+    """The datasets reached from `root` through at least one one-to-many step."""
+    neighbours = {name: [] for name in tree.order}
+    for child, (parent, join) in tree.parent.items():
+        kind = _RELATIONSHIP_ALIASES.get(snake(join.get("relationship") or ""))
+        # (to, multiplies-rows-when-walked-this-way)
+        neighbours[parent].append((child, kind == "one_to_many"))
+        neighbours[child].append((parent, kind == "many_to_one"))
+    multiplied, queue, seen = set(), deque([(root, False)]), {root}
+    while queue:
+        current, fanned = queue.popleft()
+        for other, step in neighbours[current]:
+            if other in seen:
+                continue
+            seen.add(other)
+            if fanned or step:
+                multiplied.add(other)
+            queue.append((other, fanned or step))
+    return multiplied
+
+
+# --- the reduced cube set ---------------------------------------------------------
+
+def _reduced_cubes(tree, cubes, dimensions, measures, inliner, issues):
+    """The cube set the import runs over: each tree cube's source and labels, the joins
+    the tree uses (SQL inlined, so a key member reads its column), the published
+    dimensions and the measures they need -- nothing else to convert."""
+    reduced = {}
+    for cname in tree.order:
+        cube = cubes[cname]
+        _report_dropped(issues, f"cube '{cname}'", [
+            k for k in map(snake, cube)
+            if k not in _CUBE_NATIVE_KEYS and k not in _CONSUMED_CUBE_KEYS])
+        out = {"name": cname, **{k: cube[k] for k in (
+            "sql", "sql_table", "description", "meta", "data_source") if k in cube}}
+        joins = []
+        for join in _as_named_list(cube.get("joins"), f"cube '{cname}' joins"):
+            child = join.get("name")
+            if tree.parent.get(child, (None,))[0] != cname:
+                continue
+            scope = f"join '{cname}' -> '{child}'"
+            joins.append({"name": child, "relationship": join.get("relationship"),
+                          "sql": inliner.rewrite(join.get("sql") or "", cname, scope)})
+        if joins:
+            out["joins"] = joins
+        dims = [d for (c, _), d in dimensions.items() if c == cname]
+        if dims:
+            out["dimensions"] = dims
+        kept = [measures[(cname, m["name"])]
+                for m in _as_named_list(cube.get("measures"), f"cube '{cname}' measures")
+                if (cname, m["name"]) in measures]

Review Comment:
   `view_projection` indexes measures by `m[\"name\"]` (and also records dimension/measure names via `.get(\"name\")`) without validating that `name` is present and a non-empty string. If a Cube model contains an invalid measure entry missing `name`, this will raise a raw `KeyError` (or propagate `None` keys into internal maps) instead of producing a `ConversionError` like the rest of the converter. Align with `cube_to_osi` by validating names with the same helper used elsewhere (e.g., `require_str(..., \"name\", ...)`) before using them as dict keys / set members, so malformed models fail with a stable, user-friendly error.



##########
converters/cube/src/ossie_cube/view_projection.py:
##########
@@ -0,0 +1,1039 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Project one Cube view's public surface onto an Apache Ossie semantic model.
+
+`convert_cube_to_ossie` is a lossless round-trip import: every cube member becomes an
+Ossie field or metric, and the view's curation rides in `custom_extensions`. Publishing
+a view to another tool needs the opposite boundary -- exactly the members the view
+exposes, under the names it exposes them by, and nothing the view keeps private.
+
+The projection resolves the view (its members, join tree and hidden dependencies),
+builds a reduced cube set holding only those, runs the ordinary import over it, and
+trims the result to the published surface. Dimension references are inlined while the
+set is built, in Cube space, so the import's own join decomposition and fan-out analysis
+read the columns a member really uses; measure references are inlined afterwards by the
+import's `_MeasureResolver`. Anything the published model could not say faithfully is
+refused with a `ConversionError`, never dropped. See README "View projection".
+
+Usage (CLI):
+    ossie-cube import -i model/ --view sales --project [--source orders]
+"""
+
+import copy
+import dataclasses
+from collections import deque
+from itertools import pairwise
+
+from ._common import (
+    _CUBE_REF_RE,
+    OSSIE_VERSION,
+    VENDOR,
+    ConversionError,
+    dump_yaml,
+    lookup_map,
+    normalize_identifier,
+    primary_key_operand,
+    read_stash,
+    requalify_self_refs,
+    resolve_identifier,
+    snake,
+    snake_keys,
+    unescape_braces_from_cube,
+    write_stash,
+)
+from .converter_issues import IssueLog, IssueType
+from .cube_to_osi import (
+    _CUBE_NATIVE_KEYS,
+    _DIM_NATIVE_KEYS,
+    _MEASURE_NATIVE_KEYS,
+    _RELATIONSHIP_ALIASES,
+    _ai_context_from_meta,
+    _as_named_list,
+    _build_model,
+    _case_expression,
+    _collect,
+    _column_of,
+    _decomposed_measure_names,
+    _fanned_out_datasets,
+    _fanout_unsafe_datasets,
+    _is_generated_part,
+    _MeasureResolver,
+    _metric_names_of,
+    _no_cubes_message,
+    _primary_key_of,
+    _report_fanout,
+    parked_of,
+)
+from .expressions import (
+    UnattributableSQL,
+    has_top_level_operator,
+    qualify_column_paths,
+    unsafe_aggregate_heads,
+)
+
+_SELF_REFS = ("CUBE", "TABLE")
+
+# Stash keys a published member keeps: how it looks to its reader. The rest of the
+# import's stash rebuilds the original Cube member, naming members that are not here.
+_PUBLISHED_MEMBER_KEYS = ("dim_type", "format", "currency", "title")
+
+# The declared Cube cardinality, which Ossie's many/one orientation cannot carry.
+_PUBLISHED_JOIN_KEYS = ("declared_on", "relationship")
+
+# Presentation keys a view `includes` entry may override, and a member carries along.
+_PRESENTATION_KEYS = ("format", "currency")
+
+# Cube keys the projection consumes, so not reported as dropped: members are the surface,
+# visibility is the view's to decide, and `data_source` rides on the dataset.
+_VISIBILITY_KEYS = ("public", "visible", "shown")
+_CONSUMED_CUBE_KEYS = frozenset({
+    "segments", "hierarchies", "data_source", *_VISIBILITY_KEYS})
+_CONSUMED_MEMBER_KEYS = frozenset({"case", *_VISIBILITY_KEYS, *_PRESENTATION_KEYS})
+_VIEW_KEYS = frozenset({"name", "cubes", "description", "meta", *_VISIBILITY_KEYS})
+
+# Multi-stage measure keys and the value shape Cube accepts. An empty value is Cube's own
+# default and changes nothing; anything else computes over another grain. Covers every
+# key in `_WINDOWING_KEYS` (a test checks), plus `grain`, which the import does not know.
+_MULTI_STAGE_SHAPES = {
+    "multi_stage": bool,
+    "group_by": list,
+    "reduce_by": list,
+    "add_group_by": list,
+    "time_shift": list,
+    "grain": dict,
+}
+
+
+@dataclasses.dataclass(frozen=True)
+class _Member:
+    """One member the view publishes: `name` on `cube`, published as `output`, with its
+    Cube `definition` and the view's `includes` entry for it, when that is a mapping."""
+
+    cube: str
+    name: str
+    output: str
+    kind: str
+    definition: dict
+    override: dict
+
+
+@dataclasses.dataclass
+class _JoinTree:
+    """The view's join tree: `root`, every dataset in presentation order, and
+    `parent[child] = (parent, the join declared on the parent)`."""
+
+    root: str
+    order: list
+    parent: dict
+
+
+def convert_cube_view_to_ossie(files, view, source=None, strict_fanout=True):
+    """Convert one Cube view's public surface to Ossie YAML.
+
+    `files` is a Cube model as {relative filename: YAML str}, the same input
+    `convert_cube_to_ossie` takes. Returns (ossie_yaml_str, resolved_source, IssueLog).
+
+    Unlike `convert_cube_to_ossie`, this is a publication transform, not a round trip:
+    the model holds exactly the dimensions and measures `view` exposes, under the names
+    it exposes them by, with every hidden dependency inlined. `resolved_source` is the
+    root of the view's join paths -- the dataset a downstream converter needing a single
+    fact should start from -- unless `source` names another dataset in the projection.
+
+    Fan-out is strict by default: a published metric that can over-count is refused
+    rather than emitted. Pass `strict_fanout=False` to convert it with a
+    FANOUT_UNSAFE_METRIC issue instead.
+    """
+    if not isinstance(files, dict) or not files:
+        raise ConversionError("expected a non-empty mapping of {filename: YAML}")
+    if not isinstance(view, str) or not view.strip():
+        raise ConversionError("view projection needs the name of the view to project")
+    if source is not None and (not isinstance(source, str) or not source):
+        raise ConversionError(f"view '{view}': source must be a dataset name")
+
+    # The collection's own issues are about the lossless import -- a file preserved in
+    # custom_extensions only -- which a projection does not do.
+    cubes, _, views, _, _ = _collect(files, IssueLog())
+    if view not in views:
+        raise ConversionError(
+            f"requested view '{view}' not found; views present: "
+            f"{sorted(views) or 'none'}")
+    if not cubes:
+        raise ConversionError(_no_cubes_message(views))
+
+    strict = {IssueType.FANOUT_UNSAFE_METRIC} if strict_fanout else set()
+    issues = IssueLog(strict_types=frozenset(strict))
+    view_def = views[view]
+    _report_dropped(issues, f"view '{view}'",
+                    [k for k in map(snake, view_def) if k not in _VIEW_KEYS])
+
+    tree = _view_tree(view, view_def, cubes)
+    members = _published_members(view, view_def, cubes)
+    inliner = _Inliner(cubes)
+
+    measures = _needed_measures(members, cubes, inliner)
+    dimensions = {}
+    for member in members:
+        key = (member.cube, member.name)
+        if member.kind == "dimension" and key not in dimensions:
+            dimensions[key] = _published_dimension(inliner, cubes, member)
+    dependencies = {cname for cname, _ in measures}
+    for (cname, _), measure in measures.items():
+        for sql in [measure.get("sql"), *(f["sql"] for f in measure.get("filters", ()))]:
+            dependencies |= _joined_cubes(sql, cname, cubes)
+    _attach_dependencies(view, tree, sorted(dependencies), cubes)
+    _refuse_mixed_data_sources(view, tree, cubes)
+    resolved_source = _resolve_source(view, source, tree, members)
+
+    reduced = _reduced_cubes(tree, cubes, dimensions, measures, inliner, issues)
+    reduced_view = {k: v for k, v in view_def.items() if k != "cubes"}
+    # The import runs lenient, and its fan-out reports are replaced by the projection's
+    # own: it attributes a struct path (`orders.accounts.balance`) to its second part,
+    # and checks each join alone rather than the whole tree.
+    imported = IssueLog()
+    model = _build_model(reduced, {}, {view: reduced_view}, {}, {}, None, view, imported)
+    _refuse_unjoined(view, model, imported)
+    for issue in imported:
+        if issue.issue_type is not IssueType.FANOUT_UNSAFE_METRIC:
+            issues.add(issue.issue_type, issue.element_name, issue.detail)
+    # A second resolver over the same cubes, for the fully inlined forms: the import
+    # emits a reference to a hidden measure by its metric name, and that metric is not
+    # published.
+    resolver = _MeasureResolver(reduced, {}, IssueLog(), qualify=False)
+    _report_fanout_of(resolver, tree, reduced, model, measures, issues)
+    _publish_surface(model, resolver, reduced, cubes, members, issues)
+    return dump_yaml({"version": OSSIE_VERSION, **model}), resolved_source, issues
+
+
+# --- the view ---------------------------------------------------------------------
+
+def _view_tree(vname, view, cubes):
+    """The join tree the view's `join_path`s spell out.
+
+    Cube joins every view member through the path its entry names, so the paths have to
+    form one tree: a single root, and each cube reached one way. A second path to a cube
+    would give its members two meanings, which a published model cannot hold.
+    """
+    entries = view.get("cubes")
+    if not isinstance(entries, list) or not entries:
+        raise ConversionError(
+            f"view '{vname}' has no `cubes` entries to project (the legacy view-level "
+            f"`includes` form is not supported)")
+    tree = _JoinTree(root=None, order=[], parent={})
+    roots = []
+    for index, raw in enumerate(entries):
+        if not isinstance(raw, dict):
+            raise ConversionError(f"view '{vname}': cubes[{index}] must be a mapping")
+        entry = snake_keys(raw)
+        if entry.get("split"):
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] is split, and split view projections "
+                f"publish as more than one model; project each split view on its own")
+        path = entry.get("join_path")
+        if not isinstance(path, str) or not path.strip():
+            raise ConversionError(
+                f"view '{vname}': cubes[{index}] has no non-empty `join_path`")
+        parts = path.split(".")
+        if not all(parts):
+            raise ConversionError(f"view '{vname}': invalid join_path '{path}'")
+        if len(set(parts)) != len(parts):
+            raise ConversionError(
+                f"view '{vname}': join_path '{path}' visits a cube twice")
+        for part in parts:
+            if part not in cubes:
+                raise ConversionError(
+                    f"view '{vname}': join_path '{path}' references unknown cube "
+                    f"'{part}'")
+        if parts[0] not in roots:
+            roots.append(parts[0])
+        for left, right in pairwise(parts):
+            _add_edge(vname, tree, cubes, left, right, "reached through two join paths")
+        for part in parts:
+            if part not in tree.order:
+                tree.order.append(part)
+    if len(roots) != 1:
+        raise ConversionError(
+            f"view '{vname}' has multiple join roots {roots}; a published model joins "
+            f"everything from one dataset, so project a single-root view")
+    tree.root = roots[0]
+    return tree
+
+
+def _add_edge(vname, tree, cubes, left, right, conflict):
+    """Join `right` under `left`, refusing a second parent for it."""
+    prior = tree.parent.get(right)
+    if prior is not None:
+        if prior[0] != left:
+            raise ConversionError(
+                f"view '{vname}': cube '{right}' is {conflict} (from '{prior[0]}' and "
+                f"from '{left}'), which would give its members two meanings")
+        return
+    joins = [j for j in _as_named_list(cubes[left].get("joins"), f"cube '{left}' joins")
+             if j.get("name") == right]
+    if not joins:
+        raise ConversionError(
+            f"view '{vname}': joining '{right}' needs a join '{left}' -> '{right}', but "
+            f"cube '{left}' declares no join to '{right}'")
+    if len(joins) > 1:
+        raise ConversionError(
+            f"view '{vname}': cube '{left}' declares more than one join to '{right}'")
+    tree.parent[right] = (left, joins[0])
+
+
+def _published_members(vname, view, cubes):
+    """Every member the view publishes, in view order, with its published name."""
+    decomposed = _decomposed_measure_names(cubes)
+    members, seen, taken = [], set(), {}
+    for raw in view["cubes"]:
+        entry = snake_keys(raw)
+        cname = entry["join_path"].split(".")[-1]
+        for member in _entry_members(vname, entry, cname, cubes[cname], decomposed):
+            identity = (member.cube, member.name, member.output, member.kind)
+            if identity in seen:
+                continue
+            seen.add(identity)
+            key = normalize_identifier(member.output)
+            if key in taken:
+                other = taken[key]
+                raise ConversionError(
+                    f"view '{vname}': published name '{member.output}' from "
+                    f"'{member.cube}.{member.name}' collides with '{other.output}' from "
+                    f"'{other.cube}.{other.name}' (identifiers are case-insensitive); "
+                    f"use an alias or a prefix")
+            taken[key] = member
+            members.append(member)
+    if not members:
+        raise ConversionError(f"view '{vname}' publishes no dimensions or measures")
+    return members
+
+
+def _entry_members(vname, entry, cname, cube, decomposed):
+    """The members one `cubes:` entry publishes, honouring `includes`, `excludes`,
+    member aliases and `prefix`."""
+    prefix = entry.get("prefix", False)
+    if not isinstance(prefix, bool):
+        raise ConversionError(
+            f"view '{vname}': prefix for cube '{cname}' must be true or false")
+    alias = entry.get("alias", cname)
+    if not isinstance(alias, str) or not alias:
+        raise ConversionError(
+            f"view '{vname}': alias for cube '{cname}' must be a non-empty string")
+
+    by_name = {}
+    for kind, key in (("dimension", "dimensions"), ("measure", "measures"),
+                      ("segment", "segments"), ("hierarchy", "hierarchies")):
+        for member in _as_named_list(cube.get(key), f"cube '{cname}' {key}"):
+            if member.get("name"):
+                by_name.setdefault(member["name"], (kind, member))
+
+    includes = entry.get("includes", [])
+    if includes == "*":
+        # A wildcard publishes what the cube itself makes public. A measure a previous
+        # export generated to hold one part of a composite metric is an implementation
+        # detail of the metric that references it, never a member of its own.
+        requested = [
+            (name, name, {}) for name, (kind, member) in by_name.items()
+            if not _is_private(member)
+            and not (kind == "measure" and _is_generated_part(member, decomposed))]
+    elif isinstance(includes, list):
+        requested = [_include(vname, cname, item) for item in includes]
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must be '*' or a list")
+
+    excludes = entry.get("excludes")
+    if excludes is None:
+        excludes = []
+    if not isinstance(excludes, list) or not all(isinstance(x, str) for x in excludes):
+        raise ConversionError(
+            f"view '{vname}': excludes for cube '{cname}' must be a list of member "
+            f"names")
+    for name in excludes:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': excluded member '{cname}.{name}' does not exist")
+
+    out = []
+    for name, output, override in requested:
+        if name not in by_name:
+            raise ConversionError(
+                f"view '{vname}': member '{cname}.{name}' does not exist")
+        if name in excludes:
+            continue
+        kind, member = by_name[name]
+        scope = f"view '{vname}': selected {kind} '{cname}.{name}'"
+        if kind in ("segment", "hierarchy"):
+            raise ConversionError(
+                f"{scope} has no Ossie form; exclude it from the view to project it")
+        if kind == "measure" and _is_generated_part(member, decomposed):
+            raise ConversionError(
+                f"{scope} is an aggregate a previous Ossie export generated as part of "
+                f"'{parked_of(member.get('meta')).get('part_of')}', and cannot be "
+                f"published on its own")
+        if kind == "dimension":
+            feature = _unpublishable_dimension(member)
+            if feature:
+                raise ConversionError(
+                    f"{scope} uses {feature}, which has no single Ossie field form")
+        out.append(_Member(cname, name, f"{alias}_{output}" if prefix else output,
+                           kind, member, override))
+    return out
+
+
+def _is_private(member):
+    """Whether Cube hides a member: `public`, else the deprecated `visible` or `shown`,
+    decides -- the precedence of Cube's own `isVisible`."""
+    for key in _VISIBILITY_KEYS:
+        if member.get(key) is not None:
+            return member[key] is False
+    return False
+
+
+def _include(vname, cname, item):
+    """(member name, published name, override) for one `includes` item."""
+    if isinstance(item, str):
+        name, output, override = item, item, {}
+    elif isinstance(item, dict):
+        override = snake_keys(item)
+        name = override.get("name")
+        output = override.get("alias", name)
+        if not isinstance(name, str) or not name:
+            raise ConversionError(
+                f"view '{vname}': an include for cube '{cname}' must name a member")
+        if not isinstance(output, str) or not output:
+            raise ConversionError(
+                f"view '{vname}': alias for member '{cname}.{name}' must be a "
+                f"non-empty string")
+    else:
+        raise ConversionError(
+            f"view '{vname}': includes for cube '{cname}' must hold member names or "
+            f"mappings")
+    if "." in name or "." in output:
+        raise ConversionError(
+            f"view '{vname}': include '{name}' for cube '{cname}' is a path; list "
+            f"the member on the entry for the cube that defines it")
+    return name, output, override
+
+
+def _unpublishable_dimension(dim):
+    """Why a dimension has no single Ossie field, or None when it has one."""
+    if dim.get("sub_query"):
+        return "sub_query"
+    dtype = snake(dim.get("type") or "")
+    return dtype if dtype in ("geo", "switch") else None
+
+
+# --- dependencies -----------------------------------------------------------------
+
+class _Inliner:
+    """Rewrites member SQL so every dimension reference is the SQL it stands for.
+
+    Cube inlines a referenced dimension's SQL when it renders a query, the only reading
+    of `{net_amount}` that survives into a model where `net_amount` is not published.
+    The result stays Cube SQL -- `{CUBE}.column`, `{users}.column` -- so the import
+    translates it as it would hand-written SQL; measure references are left to
+    `_MeasureResolver`. Columns are qualified first (`qualify_column_paths`), so a
+    dimension inlined into another cube's measure keeps its columns' owner. A reference
+    to an unknown cube or member is refused, as Cube would refuse to compile it.
+    """
+
+    def __init__(self, cubes):
+        self._cubes = cubes
+        self._dimensions = {}
+        self._measures = set()
+        for cname, cube in cubes.items():
+            for dim in _as_named_list(cube.get("dimensions"),
+                                      f"cube '{cname}' dimensions"):
+                self._dimensions[(cname, dim.get("name"))] = dim
+            for measure in _as_named_list(cube.get("measures"),
+                                          f"cube '{cname}' measures"):
+                self._measures.add((cname, measure.get("name")))
+        self._cache = {}
+
+    def dimension(self, cname, dname, stack=()):
+        """A dimension's SQL with every reference inlined, relative to its own cube."""
+        key = (cname, dname)
+        if key in self._cache:
+            return self._cache[key]
+        if key in stack:
+            chain = " -> ".join(f"{c}.{d}" for c, d in stack + (key,))
+            raise ConversionError(f"dimension reference cycle: {chain}")
+        dim = self._dimensions[key]
+        scope = f"dimension '{cname}.{dname}'"
+        feature = _unpublishable_dimension(dim)
+        if feature:
+            raise ConversionError(
+                f"{scope} uses {feature}, which has no single SQL expression to inline "
+                f"where it is referenced")
+        stack += (key,)
+
+        def rewrite(sql):
+            return self.rewrite(sql, cname, scope, stack)
+
+        if dim.get("case") is not None:
+            # Rendered the way Cube renders it. A plain label stays as written, braces
+            # still escaped, since the result is Cube SQL until the import reads it.
+            sql = _case_expression(cname, dname, dim["case"], rewrite, str)
+        else:
+            sql = rewrite(dname if dim.get("sql") is None else dim["sql"])
+        self._cache[key] = sql
+        return sql
+
+    def rewrite(self, sql, cname, scope, stack=(), measures=None):
+        """`sql`, written on cube `cname`, with columns qualified and dimension
+        references inlined.
+
+        Measure references are kept as written and added to `measures`; with
+        `measures` None they are refused, since only a measure's SQL may name one --
+        a dimension reading a measure is Cube's correlated subquery.
+        """
+        try:
+            qualified = qualify_column_paths(sql, cname, self._cubes)
+        except UnattributableSQL as reason:
+            raise ConversionError(
+                f"{scope}: the source of every column in {str(sql).strip()!r} cannot "
+                f"be proven statically: {reason}") from None
+        protected = qualified.replace("\\{", "\x00lb\x00").replace("\\}", "\x00rb\x00")
+
+        def replace(match):
+            body = match.group(1).strip()
+            head, dot, rest = body.partition(".")
+            if not dot:
+                aliased = protected[match.end():].lstrip().startswith(".")
+                if body in _SELF_REFS or body == cname or (
+                        aliased and body in self._cubes):
+                    if not aliased:
+                        raise ConversionError(
+                            f"{scope}: '{{{body}}}' names a cube, not a column; write "
+                            f"'{{{body}}}.column'")
+                    return match.group(0)
+                target = (cname, body)
+            elif head in _SELF_REFS or head == cname:
+                target = (cname, rest)
+            elif head in self._cubes:
+                target = (head, rest)
+            else:
+                raise ConversionError(
+                    f"{scope}: reference '{{{body}}}' uses unknown cube qualifier "
+                    f"'{head}'; use '{{cube}}.column' for a raw column of a joined cube")
+            if target in self._dimensions:
+                inner = self.dimension(*target, stack)
+                if target[0] != cname:
+                    inner = requalify_self_refs(inner, target[0])
+                whole = protected.strip() == match.group(0)
+                return f"({inner})" if has_top_level_operator(inner) and not whole \
+                    else inner
+            if target in self._measures:
+                if measures is None:
+                    raise ConversionError(
+                        f"{scope} references measure '{target[0]}.{target[1]}', which "
+                        f"Cube evaluates as a correlated subquery; an Ossie field has "
+                        f"no form for that")
+                measures.add(target)
+                return match.group(0)
+            raise ConversionError(
+                f"{scope}: reference '{{{body}}}' does not match a dimension or "
+                f"measure in cube '{target[0]}'; use '{{{target[0]}}}.{target[1]}' "
+                f"for a raw column")
+
+        out = _CUBE_REF_RE.sub(replace, protected)
+        return out.replace("\x00lb\x00", "\\{").replace("\x00rb\x00", "\\}")
+
+
+def _joined_cubes(sql, cname, cubes):
+    """The other cubes rewritten SQL reads raw columns of (`{users}.ltv`)."""
+    if sql is None:
+        return set()
+    text = str(sql).replace("\\{", "").replace("\\}", "")
+    return {m.group(1).strip() for m in _CUBE_REF_RE.finditer(text)
+            if m.group(1).strip() in cubes and m.group(1).strip() != cname
+            and text[m.end():].lstrip().startswith(".")}
+
+
+def _needed_measures(members, cubes, inliner):
+    """{(cube, measure): its reduced definition} for every published measure and every
+    measure one references, directly or not."""
+    raw = {(cname, m["name"]): m for cname, cube in cubes.items()
+           for m in _as_named_list(cube.get("measures"), f"cube '{cname}' measures")}
+    queue = deque((m.cube, m.name) for m in members if m.kind == "measure")
+    needed = {}
+    while queue:
+        key = queue.popleft()
+        if key in needed:
+            continue
+        needed[key], references = _reduced_measure(inliner, cubes, raw[key], *key)
+        queue.extend(sorted(references - needed.keys()))
+    return needed
+
+
+def _reduced_measure(inliner, cubes, measure, cname, mname):
+    """A measure as the reduced cube set carries it, and the measures it references.
+
+    Dimension references are inlined and columns qualified. A bare `type: count` is
+    spelled out as the distinct count of its primary key's own SQL -- the same value,
+    and the form `primary_key_count_expression` gives -- because the projected model
+    publishes no field for a hidden key to name.
+    """
+    scope = f"measure '{cname}.{mname}'"
+    _refuse_windowing(measure, scope)
+    references = set()
+    out = _labels_of(measure)
+    if snake(measure.get("type") or "") == "count" and measure.get("sql") is None:
+        out["type"] = "count_distinct"
+        out["sql"] = _primary_key_sql(inliner, cubes, cname)
+    elif measure.get("sql") is not None:
+        out["sql"] = inliner.rewrite(measure["sql"], cname, scope, measures=references)
+    filters = [f for f in measure.get("filters") or []
+               if isinstance(f, dict) and f.get("sql")]
+    if filters:
+        out["filters"] = [
+            {"sql": inliner.rewrite(f["sql"], cname, scope, measures=references)}
+            for f in filters]
+    return out, references
+
+
+def _refuse_windowing(measure, scope):
+    """Refuse a measure computed over a grain other than the query's.
+
+    The lossless import parks these and carries on; a projection that did the same
+    would publish a view missing a member it names.
+    """
+    if measure.get("rolling_window") is not None:
+        raise ConversionError(
+            f"{scope} uses rolling_window, which aggregates over a trailing range "
+            f"rather than the query's grain; an Ossie expression has no form for that")
+    for key, shape in _MULTI_STAGE_SHAPES.items():
+        if key not in measure:
+            continue
+        if not isinstance(measure[key], shape):
+            raise ConversionError(
+                f"{scope} has malformed {key}; expected a {shape.__name__}")
+        if measure[key]:
+            raise ConversionError(
+                f"{scope} uses multi-stage {key}, which computes over a grain other "
+                f"than the query's; an Ossie expression has no form for that")
+
+
+def _primary_key_sql(inliner, cubes, cname):
+    """Cube SQL for a cube's primary key: its columns, or its key dimensions' SQL."""
+    cube = cubes[cname]
+    keys = _primary_key_of(cube, cname)
+    if "primary_key" in parked_of(cube.get("meta")):
+        # Recorded by a previous export, and columns by construction.
+        return primary_key_operand(cname, keys, lambda key: "{CUBE}." + key)
+    return primary_key_operand(
+        cname, keys, lambda key: inliner.dimension(cname, key))
+
+
+def _published_dimension(inliner, cubes, member):
+    """A published dimension as the reduced cube set carries it.
+
+    An Ossie field is scoped to its own dataset, so a dimension reading another cube --
+    directly or through a hidden dimension -- has no field form.
+    """
+    out = _labels_of(member.definition)
+    out["sql"] = inliner.dimension(member.cube, member.name)
+    joined = _joined_cubes(out["sql"], member.cube, cubes)
+    if joined:
+        raise ConversionError(
+            f"dimension '{member.cube}.{member.name}' reads cube '{min(joined)}'; a "
+            f"dataset-scoped Ossie field cannot preserve that join, so publish that "
+            f"cube's member through its own join path instead")
+    return out
+
+
+def _labels_of(member):
+    """The keys of a member the import reads besides its SQL.
+
+    A multi-dialect expression parked by a previous export is left behind: only the
+    Cube SQL can be inlined, so the other dialects would describe a different
+    expression. A `synthetic_key` marker is too, since the view publishes the member.
+    """
+    out = {k: member[k] for k in ("name", "type", "title", "description",
+                                  *_PRESENTATION_KEYS) if k in member}
+    meta = member.get("meta")
+    if isinstance(meta, dict):
+        meta = copy.deepcopy(meta)
+        parked = meta.get("ossie")
+        if isinstance(parked, dict):
+            for key in ("expression", "synthetic_key"):
+                parked.pop(key, None)
+        out["meta"] = meta
+    return out
+
+
+def _attach_dependencies(vname, tree, dependencies, cubes):
+    """Join every cube a hidden dependency reads into the tree.
+
+    A member reading `{accounts.x}` without the view naming a path to `accounts` is
+    joined the way Cube joins it: along the declared joins. Only a unique path is
+    followed; with two, the member's value would depend on which one a consumer took.
+    """
+    for target in dependencies:
+        if target in tree.order:
+            continue
+        paths = _declared_join_paths(cubes, tree.root, target)
+        if not paths:
+            raise ConversionError(
+                f"view '{vname}': published members depend on cube '{target}', but no "
+                f"declared join path leads to it from the view's root '{tree.root}'")
+        if len(paths) > 1:
+            raise ConversionError(
+                f"view '{vname}': published members depend on cube '{target}', which "
+                f"the declared joins reach through multiple declared join paths "
+                f"({' and '.join('.'.join(p) for p in paths)}); add a join_path for it "
+                f"to the view to choose one")
+        for left, right in pairwise(paths[0]):
+            _add_edge(vname, tree, cubes, left, right,
+                      "reached by a hidden dependency through another join path")
+            if right not in tree.order:
+                tree.order.append(right)
+
+
+def _declared_join_paths(cubes, start, target):
+    """One declared join path from `start` to `target`, plus a second when there is one.
+
+    Breadth-first, so an unreachable target costs one pass over the graph rather than an
+    enumeration of every simple path. Any other path omits at least one edge of the first,
+    so searching again with each of its edges removed in turn finds it if it exists.
+    """
+    adjacency = {
+        cname: [j.get("name") for j in _as_named_list(cube.get("joins"),
+                                                     f"cube '{cname}' joins")
+                if j.get("name") in cubes]
+        for cname, cube in cubes.items()}

Review Comment:
   `_declared_join_paths` rebuilds the full `adjacency` map every time it’s called, and `_attach_dependencies` calls `_declared_join_paths` once per dependency cube. For projections with many hidden dependencies, this becomes repeated O(|V|+|E|) preprocessing. Consider computing adjacency once in `_attach_dependencies` (or memoizing it) and passing it into `_declared_join_paths` / the BFS search, so each target only pays the BFS cost.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org