Skip to content

Runtime & sources

A report page makes two calls: one for its structure, once, and one for its data on every filter change. This page covers what happens on the second.

Call Returns Gate
GET /api/v1/genie/reports/{key} Filters, widgets, layout, dataset names and modes. SQL-free. Authentication only
POST /api/v1/genie/reports/{key}/data Every dataset a visible widget reads, executed once each List on the report’s resource

{key} is the report’s Name or Slug. Structure is cached client-side for the life of the page, so applying a filter re-runs only the data call. See report endpoints for the payloads.

The structure response is projected through the same [SqlBody] stripping that object metadata uses, so no query text ever reaches a browser.

And two more, for an author’s draft preview

Section titled “And two more, for an author’s draft preview”

An unpublished draft is rendered by a matching pair on the designer’s controller:

Call Returns Gate
GET /api/v1/genie/report-designer/definitions/{key}/draft/structure The draft’s structure. SQL-free, same projection. Update on ReportDefinitions
POST /api/v1/genie/report-designer/definitions/{key}/draft/data The draft’s datasets Update on ReportDefinitions

Separate endpoints rather than a ?draft=1 flag on the pair above, for two reasons. The published structure call is ungated beyond authentication — correct for a live layout, a disclosure for someone’s work in progress. And that route’s query string is the report’s filter namespace, which an author may legitimately declare a filter named draft in.

Both require Update, matching each other deliberately: a weaker gate on structure would let a client render a layout it can never fill, which reads as a bug rather than as a permission.

Everything else about execution is identical to the published path — the same read-only guard, the same {{permission-filter}} requirement, the same row caps. A preview whose rows differ from production’s would be worse than no preview.

Per request, in order:

  1. Resolve the definition (from the parse cache when warm).
  2. Authorize List — after resolution, so an unknown report is a 404 rather than a 401.
  3. Drop widgets whose RolesAllowed excludes the caller.
  4. Sanitize the caller’s arguments against the declared filter names.
  5. Bind every declared filter, as SQL NULL when blank.
  6. Gate on a blank Required filter — returning with no dataset executed.
  7. Seed session parameters and the caller’s capabilities.
  8. Execute each dataset a surviving widget reads — deduplicated.

Two consequences worth designing around:

  • A dataset no visible widget reads is never queried. A <DataSet> left declared but unreferenced costs nothing, and a widget withheld by RolesAllowed takes its dataset with it.
  • A shared dataset is queried once. Three tiles over one SingleRow dataset is one round trip.

An embedded <View> is absent from all of this: it fetches through the ordinary object endpoints under its own resource.

Parameter Source
@{FilterName} Each declared filter. A DateRange binds @{Name}From and @{Name}To.
@SessionUserId, @SessionCompanyId, @SessionTimeZone, … The session parameter providers, the same set a view’s SQL sees.
@PermissionCapabilities A comma-separated list of the caller’s capability names, so a dataset can gate on a business operation.

Anything the caller sends that the report does not declare is dropped before a single query runs — the same ParameterSanitizer the grid uses. Caller-supplied Session* keys are always discarded; the framework’s own are seeded afterwards.

A dataset can point at a named, host-owned datasource rather than the app’s own database:

<DataSet Name="DsFlow" Source="InventoryReports">
<![CDATA[ SELECT … ]]>
</DataSet>

The host resolves that name under Genie:ReportingSources:

"ConnectionStrings": {
"SqlServer": "Server=.;Database=InventorySample;Integrated Security=true;…"
},
"Genie": {
"Sources": {
"InventoryReports": { "Dialect": "SqlServer", "Connection": "SqlServer" }
}
}
Key Meaning
Dialect SqlServer, PostgreSql or ClickHouse.
Connection Either a ConnectionStrings key (as above) or a literal connection string.

Connection is looked up as a name first; a miss means the value is the connection string. That is unambiguous in practice — a literal contains = and ; and can never be a key. An empty resolution throws, naming the source.

In code, UseSource(name, dialect, connection) on the GenieBuilder overrides a configured entry, the same way UseConnectionString does.

Why the indirection. It keeps environment detail out of the model file: the same *.report.xml imports into staging and production and reads each one’s own database. It is also where a report’s read-only guarantee really comes from — point a source at a least-privilege read-only login and no authoring mistake can write.

An unknown source name throws, listing what is configured. It deliberately does not fall back to the app’s own database: that would read the wrong data and look like it worked.

Mode Rows read
SingleRow 1
MultiRow 1000

The cap is enforced while reading the result, not by rewriting the SQL. A report dataset orders in its own ORDER BY (a widget never sorts), and on SQL Server an ordered query cannot be wrapped in a derived table — so a SELECT TOP (n) * FROM (…) cap would break exactly the datasets that need one.

The MultiRow cap is a backstop against an unbounded scan reaching the browser, not a substitute for authoring a sensible query. A dashboard widget is a top-N list: say TOP 10 in the SQL.

Every dataset is checked before it reaches a database — at import and again at execution. A query is rejected when it:

  • is empty;
  • contains more than one statement (a single trailing ; is tolerated);
  • contains a SQL comment (--, /* */) or a dollar-quoted body — both can hide keywords from the check, so describe the query in an XML comment instead;
  • uses SELECT … INTO, which writes a table;
  • uses any write, DDL or execution verb (INSERT, UPDATE, DELETE, MERGE, DROP, EXEC, …);
  • reads a system catalog;
  • contains no SELECT at all.

This is a guard, not a sandbox. It stops the obvious mistake and the obvious smuggling attempt; the real boundary is the source’s own permissions.

A report honours the row filter attached to the caller’s List capability, but the author must say where it goes, using the placeholder:

<DataSet Name="DsRows">
<![CDATA[
SELECT p.Name, p.Sku
FROM [Inventory].[Products] p
WHERE p.IsDeleted = 0 AND {{permission-filter}}
ORDER BY p.Name
]]>
</DataSet>

With no filter resolved the placeholder becomes (1=1), leaving the query shape intact.

If a filter is resolved and the dataset has no placeholder, the request fails with a message naming the dataset. A grid appends a residual predicate through its query builder; a report has none, and wrapping the SQL would break the ORDER BY a report dataset relies on. Failing loudly is the only remaining honest option — the alternative would be quietly serving unscoped rows.

Report datasets are read-only, so only the Filter stage of row security applies; conditions and constraints guard writes, and a report has none.

A draft preview is not exempt from any of this. The filter is resolved against the report’s own resource name — the stored row’s, never the draft document’s, which the author is free to change — so renaming a draft is not a way out of a row filter. And the missing-placeholder failure applies too, where it is more useful than in production: it is the earliest moment an author can discover the omission, and it turns a security bug into an authoring error at design time.