Reports built by configuration, and the two things that make it harder than a group by
- Dominant language
- C#
- Stars
- 6
- Forks
- 7
- Avg merge
- 4h 42m
- Merged PRs (30d)
- 307
Description
"I need an inventory system" is answered by spinning up Docker, designing the objects in barakoBrew (#347) and exposing a portal (#344). Then the first question the client asks is "what is my stock on hand", and there is no answer.
Nothing in the codebase aggregates. `DeliveryQuery` filters, sorts and pages. There is no group by, no sum, no count over a field, anywhere.
## What an inventory system actually asks for
Stock on hand by product. Movements by month. Valuation by category. Items below reorder level. Aging. Every one of those is a grouping plus an aggregate plus a time bucket, over related objects.
## The two things that make this harder than it looks
### It is the most likely place to bypass every access control already built
This is the part worth designing first, because a reporting engine reaches data directly and by construction sidesteps the paths where the rules live.
`ISensitivityService` masks fields per caller on the read paths. A report that groups by a Sensitive field, or sums one, leaks it without ever returning the field: "average salary by department" over three people discloses more than the field would. Tenancy is worse, because a report is exactly the shape of query that forgets a tenant filter and nobody notices until it returns someone else's numbers.
So the rules are not optional extras here:
- A report may not group by, or aggregate over, a field the caller may not read. Refused, not silently dropped, because a report missing a column looks like a report with no matching data.
- Every report query is tenant-scoped through the same session as everything else, never through raw SQL that reconstructs the filter by hand.
- Small-group suppression is worth deciding on rather than discovering. A count of one is a record.
### Content is JSONB, so aggregation has no index to stand on
`mt_doc_contents` indexes `ContentType`, `CreatedAt`, `UpdatedAt`, `Status` and a composite. Those are top-level `Content` properties. Everything a report groups by lives in `data` as JSONB, and there is no index on `data ->> 'anything'`.
Postgres will aggregate JSONB happily and will sequentially scan the table to do it. That is fine for a thousand rows and not fine for a million, and the failure arrives as a slow admin screen rather than an error.
Marten's duplicated fields and computed indexes are the answer, and they mean a report definition has a schema consequence: declaring a report over a field should be able to declare the index for it. That is a design decision, not an optimisation to add later, because retrofitting indexes onto a live table is the expensive version.
## The shape: a query model, not a query language
Same discipline as BaryoDev/barakoCMS#328, and for the same reason. This is edited by whoever configures the system, so an expression field is an injection surface and an unbounded-cost surface in the hands of a non-technical user.
```csharp
public class ReportDefinition
{
public string ContentType { get; set; } = "";
public List Filters { get; set; } = new();
public List GroupBy { get; set; } = new(); // field names, validated against the schema
public List Aggregates { get; set; } = new(); // Sum, Count, Avg, Min, Max over a named field
public TimeBucket? Bucket { get; set; } // day, week, month, quarter, year
public int Limit { get; set; } = 1000;
}
```
Fixed aggregate set, fields validated against the content type, a hard limit with a ceiling. `DeliveryQuery` already validates field names against the schema and binds both name and value as parameters, and this should be built on that rather than beside it.
**An escape hatch, deliberately.** Some reports are genuinely too heavy for this, and pretending otherwise means the answer to a real requirement is "you cannot". A module can register a Marten projection that maintains a purpose-built read model, and a report definition can target it. That keeps configure-not-code for the common case without capping the ceiling, and it is the same core-plus-modules shape as everything else here.
## Output
A table, a chart, and CSV. The chart is a rendering concern over the same result and should not become a second query path. PDF is a bigger question that belongs with document rendering, not here.
## What this needs first
**#141, relations.** "Stock by product category" crosses two objects. Without references, every report is confined to one flat type, which rules out most of the questions above.
## Done when
- A configurer builds "stock on hand by product" and "movements by month" without writing code, and both are correct against a seeded fixture.
- A report grouping by a field the caller may not read is refused, with a test, and a report over readable fields succeeds, so a check that refuses everything cannot pass.
- A report cannot return another tenant's rows, tested at the same join the other tenancy tests use.
- A report over a field with no index either declares one or is refused above a row threshold, rather than quietly scanning.
- A report with no explicit limit does not return an unbounded set.
Part of the 5.0 goal. Related: BaryoDev/barakoCMS#328 (saved queries, the same foundation), BaryoDev/barakoCMS#141, BaryoDev/barakoBrew#4.
Contributor guide
Assessment
This issue has not been assessed yet.