File size: 8,320 Bytes
e78e601
 
75b4008
 
bf8519f
 
 
 
 
 
75b4008
 
e78e601
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75b4008
 
 
 
 
 
 
 
 
 
e78e601
75b4008
e78e601
 
75b4008
e78e601
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
"""rollup_sql.py β€” the READ-THROUGH rollup: one grouped SQL query answers every parent row.

β›” WHY THIS EXISTS. A linked rollup folds rows that live in the store, and `MAX_ROWS` bounds them
for a measured reason: every `ut_*` table lives inside ONE `user_tables.json` that is parsed and
copied on essentially every request. ⚠ THE CAP MOVED TO 60,000 ON 2026-08-09 AND THAT DID NOT
RETIRE THIS MODULE β€” the arithmetic is why. (This line said 100,000 until wave 28: that number was
a candidate REJECTED during its own derivation, and three files repeated it for a day. A comment
naming a cap is a claim about a constant eight lines away β€” check it, do not copy it.)
Measured against tenant #0's own mirror, Royal's
**256,810 order lines are 63.9 MB and 2.57 s per copy**: 4.3x the new cap and 8x any tolerable
per-request cost. Four Odoo databases that DO fit (customers, products, invoices, orders) already
total 17.81 MB / ~320 ms. Order lines never live in that document, at any cap.

⭐ SO THIS KIND NEVER COPIES THE ROWS AT ALL. It names a governed semantic TOPIC and a METRIC KEY,
and `store_query(..., group_by=[dim])` answers EVERY parent in one pass against DuckDB.
MEASURED: 1,748 customers over 256,810 order lines in **232 ms**. The row count stops being the
product's problem and becomes the database's, which is the whole of D-87's "read-through binding".

⚠ A METRIC KEY, NEVER A FILTER FRAGMENT β€” the decision this module is built around.
`model/metrics/*.yml` already carries each metric's scope, its `store_filter_sql` AND the matching
`live_domain`, whose own comment reads *"BOTH or store_parity compares two different questions"*.
Binding a rollup to the KEY inherits the scope and the live-parity oracle for free. Letting a
rollup carry SQL would mint a second definition of a number the semantic layer exists to define
once, and the two would drift in silence.

β›” TRUNCATION IS A WRONG NUMBER HERE, NOT A SHORT LIST. A row window is honest because counts and
totals are computed over the full scope beside it; a GROUP window has no such companion β€” the
groups ARE the answer. `store_query` now reports `truncated`, and this module REFUSES to write a
single cell when it is set. Half a rollup is worse than none: it looks finished.
"""
import sys
from pathlib import Path

_HERE = Path(__file__).resolve().parent
_PLATFORM = _HERE.parents[1] / "platform"
for _p in (str(_HERE), str(_PLATFORM)):
    if _p not in sys.path:
        sys.path.insert(0, _p)


class RollupSourceError(Exception):
    """The rollup could not be computed HONESTLY β€” no cells are written when this is raised."""


def _ut():
    import core.user_tables as user_tables
    return user_tables


def source_fields(table_def):
    """Every source-backed rollup field on a table definition, in declaration order."""
    out = []
    for f in ((table_def or {}).get("fields") or []):
        if not isinstance(f, dict) or f.get("type") != "rollup":
            continue
        bag = (f.get("rollup") or {}).get("source")
        if isinstance(bag, dict) and bag.get("topic") and bag.get("measure"):
            out.append(f)
    return out


def group_values(bag, today=None):
    """`{group_id: value}` for one source bag β€” ONE grouped query over the whole scope.

    ⚠ Keyed by the dim's `_id` column, never its label. Two customers can share a display name;
    `partner_id` is what the parent row actually joins on.
    """
    from harness import semantic as sem
    from harness import windows as W

    topic = str(bag.get("topic") or "")
    measure = str(bag.get("measure") or "")
    dim = str(bag.get("groupBy") or "")
    window = str(bag.get("window") or "").strip().lower()

    date_from = date_to = None
    if window and window != "all_time":
        # ⚠ RESOLVED HERE, AGAINST `today`, not stored as a date pair. `harness.windows` owns what
        # "ytd" means so there is one implementation; a literal year-start baked into the field
        # would be right until 1 January and wrong afterwards with nothing to notice.
        rng = W.resolve({"kind": window}, today or _today())
        if rng is None:
            raise RollupSourceError(
                f"window {window!r} could not be resolved β€” refusing rather than widening this "
                f"rollup to all time")
        date_from, date_to = rng

    try:
        res = sem.store_query(topic, [measure], group_by=[dim],
                              date_from=date_from, date_to=date_to,
                              limit=sem.MAX_GROUPS, today=today)
    except Exception as e:                                            # noqa: BLE001
        raise RollupSourceError(f"{type(e).__name__}: {e}") from e

    if res.get("truncated"):
        # β›” THE REFUSAL THAT MATTERS. See the module header: a truncated GROUP set is a wrong
        # answer per parent, not a shortened one, and it would look completely normal on screen.
        raise RollupSourceError(
            f"{topic}/{measure} grouped by {dim} exceeded {sem.MAX_GROUPS} groups β€” refusing to "
            f"write cells from a truncated result")

    # β›” THE KEY COLUMN IS NOT ALWAYS `<dim>_id`, and reading it unconditionally was a SILENT
    # BLANK. `store_query` emits `<dim>_id` only when the dim declares a `name_col`
    # (`f"{d['col']} AS {g}_id" if d.get("name_col") else f"{d['col']} AS {g}"`) β€” an id plus a
    # display name. A dim whose value IS its own label emits the bare `<dim>` instead, and two
    # shipped dims are that shape: `receivables.payment_state` and `gl_lines.account_type`.
    # Against either, this returned an EMPTY map and every cell was written blank β€” a configured
    # column that computes nothing, with no error anywhere. `payment_state` is not hypothetical:
    # it is a real column on `ut_odoo_invoices` and an obvious thing to roll up against.
    # ⚠ `_id` is tried FIRST so an id-keyed dim can never be keyed by its display name, which
    # would silently merge two customers who share one.
    id_col = f"{dim}_id"
    key_col = id_col if any(id_col in (r or {}) for r in (res.get("rows") or [])) else dim
    out = {}
    for row in res.get("rows") or []:
        gid = row.get(key_col)
        if gid is None:
            continue
        out[str(gid)] = row.get(measure)
    return out


def _today():
    import datetime as _dt
    return _dt.date.today().strftime("%Y-%m-%d")


def compute(rt, table_key, today=None, tables=None):
    """Write every source-backed rollup cell on `table_key`. Returns `{field_key: cells_written}`.

    `tables` (a live `user_tables` dict) is the gate's injection point β€” the same shape
    `automation_engine.compute_relation_cells` takes, so this can be proven against a fixture
    without a store.
    """
    ut = _ut()
    owned = tables is not None
    blob = tables if owned else (rt.get(ut.STORE_KEY) or {})
    tdef = (blob or {}).get(str(table_key)) or {}
    fields = source_fields(tdef)
    if not fields:
        return {}

    stamp = today or _today()
    plans = []
    for f in fields:
        bag = f["rollup"]["source"]
        # ⚠ Resolved BEFORE anything is written. A rollup that refuses must leave every cell as it
        # was β€” a partially applied pass would mix two vintages of the same column.
        plans.append((f["key"], str(bag.get("on") or ""), group_values(bag, today=stamp)))

    written = {}

    def _apply(cur):
        t = cur.get(str(table_key))
        if t is None:
            return cur
        rows = t.setdefault("rows", {})
        for fkey, on, values in plans:
            n = 0
            for row in rows.values():
                if not isinstance(row, dict):
                    continue
                join = str(row.get(on) or "").strip()
                if not join:
                    continue
                # ⚠ int-ish join keys arrive as "5280" or 5280 depending on who wrote them.
                hit = values.get(join)
                if hit is None and join.endswith(".0"):
                    hit = values.get(join[:-2])
                row[fkey] = "" if hit is None else str(hit)
                n += 1
            written[fkey] = n
        return cur

    if owned:
        _apply(blob)
    else:
        rt.update(ut.STORE_KEY, _apply, flush="async")
    return written