Virtual Entities — Reusable SQL Building Blocks for Reports & Dashboards
You've probably been here before: the same complex SQL query keeps showing up in five different report-wizard records and three dashboard widgets. Every time you tweak it, you have to remember every place that copy lives. And the wizard's table picker only ever lists real entities like SalesInvoice or CostInTransLine — so anything that needs a UNION, a hand-rolled join, or a per-row sign flip has to keep being re-written by hand inside each widget's SQL editor.
Virtual Entities solve that. You write one SELECT (as fancy as you want — UNION, JOIN, expressions, aggregates, the lot), give it a code and a name, and the system turns it into a first-class entity. From that moment on, it shows up in the Report Wizard's main-table picker and in the Dashboard Widget Wizard exactly like a real table — same field selector, same auto-joins on reference fields, same translations, same parameters panel.
The Big Idea in One Example
Suppose you want to analyze stock movements that come from two different transactions: in-transit cost arriving (CostInTransLine, positive quantities) and in-transit cost leaving (CostOutTransLine, negative quantities). There's no single physical table that gives you both with a unified sign convention. So you write:
select l.totalCost, l.totalQty, l.item_id, l.legalEntity_id, l.valueDate
from CostInTransLine l
union all
select l.totalCost * -1, l.totalQty * -1, l.item_id, l.legalEntity_id, l.valueDate
from CostOutTransLine l
You save this as a Virtual Entity called InTransitMovements. Behind the scenes, the system creates a SQL Server view named vw_InTransitMovements, registers a new entity definition pointing at that view, and adds it to the model the wizards consult.
Now in the Report Wizard, you pick "Main Table → InTransitMovements", and you get:
- A field called
itemtyped asReference → InvItem(becauseitem_idon the source table mapped to that property). - A field called
legalEntitytyped asReference → LegalEntity. - A field called
valueDatetyped asDate. - Numeric fields
totalCostandtotalQty.
You can drop them into a report, group by item, sum the cost and quantity, and you're done. No raw SQL anywhere in the report. The same Virtual Entity is also pickable from the Dashboard Widget Wizard — same auto-join behavior, same translations, same drill-down support.
Where to Find It
The Virtual Entity screen lives in the Basic module's menu. Open it like you would any other master file: click New, fill in the header, and you're in business.
The header has just a handful of fields:
- Code — short identifier, letters/digits/underscore only, must start with a letter. The system uses this to name the underlying view (
vw_<code>) and as the entity's type name. Once you save, the code can't be changed (renaming would mean dropping and re-creating the view, which Phase 1 doesn't do — see What's Coming Later). - Arabic Name / English Name — what users will see in the wizard's table picker, in field labels, and in screen titles.
- SQL Query — the SELECT statement that defines the entity. Multi-line; use
UNION,JOIN, expressions, aggregates — whatever you need. - Materialization — for now, only
Viewis allowed. TheTableoption is reserved for a future phase that will materialize the data into a refreshable physical table.
After filling these in, click Save. Then, to map the SELECT columns to entity properties, click the Edit Mappings button at the top of the screen. That opens the editor where the real magic happens.
The Edit Mappings Dialog
The dialog has two halves stacked vertically.
Top — The SQL Editor
A Monaco editor (the same one that powers VS Code) with SQL syntax highlighting, autocomplete, and full-screen height. Edit your query freely; the system parses it as you type so it can spot which columns each property is mapped to.
Above the editor, the Bootstrap button does the heavy lifting: it parses the entire SELECT, walks every column in the result set, figures out which physical table and column each one came from, looks up the matching property on that table's entity definition, and proposes a complete property list. Click it and the bottom half fills up with suggested mappings.
Tips
Bootstrap is the recommended starting point. You almost never want to type out twenty property mappings by hand when the parser can derive them all from your SQL. Think of it like the "Select Fields" button in the Report Wizard — same idea, applied to virtual-entity columns.
If your SQL doesn't parse, the editor shows a parse error in red just above it. The Bootstrap button still works in that case, but the proposals will mostly come back as Unresolved — you'll need to fill in the property details by hand.
Bottom — The Property List
This is the table that defines what fields the wizard will see. Each row is one property, with these columns:
| Column | What it does |
|---|---|
| Column Name | The name of the column in the SELECT list (or its alias). This is the system's link between the property and the underlying view column. |
| Full Name | The property's logical name — e.g., item, legalEntity, valueDate. This is what shows up in field IDs (InTransitMovements.item.code, etc.). |
| Field Type | Reference, Decimal, Text, Date, Integer, Boolean, etc. Reference types unlock auto-joins in the wizard. |
| Reference To | Only relevant when the field type is Reference — the entity this column points to (e.g., InvItem for an item column). |
| Arabic Name / English Name | The translations users will see in the wizard's field picker. |
Two ways to add rows:
- Click "Add Column". An empty row appears. The Column Name control on that row is a picker over the columns the parser found in your SQL — unmapped ones first (highlighted), then already-mapped ones marked "pick to overwrite". Picking a column runs single-column bootstrap and fills the rest of the row.
- Use Bootstrap. As described above — replaces the whole list with the parser's proposal.
If you bootstrap and rows already exist, the system asks for confirmation first. Same for picking a column on a row that already has a bootstrap-derived mapping — it asks before clobbering your work.
Orphan Flagging
If you edit your SQL after mapping properties, and a property's Column Name no longer matches any column in the new SELECT list, the row gets badged with a warning: "This column is no longer in the current SQL query." The system doesn't auto-remove anything — you decide whether to re-bootstrap, pick a replacement column, or delete the row.
The re-parse runs ~2.5 seconds after you stop typing, so the badge updates as you work.
How Bootstrap Resolves Each Column
Understanding what the parser does makes it much easier to predict its output.
For a plain column reference like l.item_id from CostInTransLine l:
- The parser sees
item_idbelongs to aliasl, which isCostInTransLine. - It looks up
CostInTransLinein the data model and finds the property whose underlying column isitem_id— that'sitem. - It copies that property's full name (
item), its type (Reference), its target entity (InvItem), and its Arabic/English labels into the proposal. - It overrides the
Column Nameon the proposal to whatever alias you used in the SELECT list, so the view's column name lines up.
Result: a fully-resolved row that needs no manual editing.
For an aggregate like SUM(l.totalCost):
The parser tags this as Aggregate Numeric. The proposal gets Decimal as the field type, no reference target, and uses the SELECT alias as the column name. You may want to fill in a friendlier label.
For an expression like l.totalCost * -1:
If the expression wraps a single underlying column, the parser still resolves to that column's property metadata — so l.totalCost * -1 resolves the same way l.totalCost would. If the expression mixes multiple columns or uses a CASE statement, the parser falls back to a Text type with no reference, marking the proposal as Expression Partial. Fill in the rest by hand.
For a UNION ALL (or any set operation):
The parser walks each branch and resolves columns position-by-position. For position N, it walks the branches in order and takes the first branch that produces a fully-resolved proposal. That's why this works:
select 0 as x from a
union all
select x from b
Branch 1 has a constant at position 0 (no source column). Branch 2 has b.x. The merger picks branch 2's resolution.
What Happens When You Save
Saving runs three things:
- Validation — Code shape, no name collision with an existing entity, materialization is
View, SQL is non-empty. - Data-model XML rebuild — The mapping JSON is converted into a
DMEntitydefinition (the same shape every other entity has) and stored on the record. - View DDL — The system runs
DROP VIEW IF EXISTS dbo.vw_<code>followed byCREATE VIEW dbo.vw_<code> AS <your SELECT>inside the same database transaction as the entity save. If the view DDL fails — bad syntax, missing column, illegalORDER BYwithoutTOP, permission issue — the whole transaction rolls back and you see the SQL Server error inline. Nothing partial gets persisted.
After the save commits, the data-model cache is rebuilt so the next call to the Report Wizard or the Dashboard Widget Wizard sees the new entity in their main-table picker. No restart, no manual cache clear.
Using Virtual Entities in the Report Wizard
Open the Report Wizard, click Main Table, and your virtual entity appears alongside the real ones. The picker shows them under the table type Virtual Entity (parallel to Entity, Detail Line, and System Table).
From there, everything works as if you were on a real table:
- Field selection — Click Select Fields and you'll see the property list you defined:
item,legalEntity,valueDate,totalCost,totalQty. Reference fields auto-expand to give youitem.code,item.name1,item.name2, etc. - Parameters — Add
valueDateas a parameter withBetweenfilter type and you get the standard date-range prompt. - Aggregations — Drop
totalCostandtotalQtyin as measures, group byitem, get a totaled report. - Translations — The Arabic/English names you set on each property show up as the column headers.
See the Report Wizard Guide for the rest of the wizard mechanics — they all apply unchanged.
Using Virtual Entities in the Dashboard Widget Wizard
In the Dashboard module, when you create a widget backed by a wizard data source, the same main-table picker appears with your virtual entity listed. Once you pick it, every BI feature that wizard-backed widgets get — dimension drill-by, auto-inferred cross-filter columns, field-ID pickers — works against the virtual entity exactly as it would against a real one.
This is where Virtual Entities pay off the most: you build one once, and from then on you can spin up new dashboard widgets visually without touching SQL again.
For details on how wizard-backed widgets behave, see the BI Module Guide and the Wizard Mode reference.
Worked Example — In-Transit Cost Movements Report
Let's walk the user-facing flow end to end with the SQL from the opening section.
Step 1 — Create the Virtual Entity
Open Virtual Entity from the Basic module menu, click New, and fill in:
- Code:
InTransitMovements - Arabic Name:
حركات البضاعة في الطريق - English Name:
In-Transit Movements - Materialization:
View - SQL Query:
select l.totalCost, l.totalQty, l.item_id, l.legalEntity_id, l.valueDate
from CostInTransLine l
union all
select l.totalCost * -1, l.totalQty * -1, l.item_id, l.legalEntity_id, l.valueDate
from CostOutTransLine l
Save the header.
Step 2 — Map the Columns
Click Edit Mappings. The dialog opens with the SQL already loaded. Click Bootstrap. The property list fills in:
| Column Name | Full Name | Field Type | Reference To |
|---|---|---|---|
totalCost | totalCost | Decimal | — |
totalQty | totalQty | Decimal | — |
item_id | item | Reference | InvItem |
legalEntity_id | legalEntity | Reference | LegalEntity |
valueDate | valueDate | Date | — |
Click OK. The dialog closes; on the entity screen, click Save. The view vw_InTransitMovements is created and the entity registers itself in the data model.
Step 3 — Build a Report
Open the Report Wizard, create a new wizard, and pick In-Transit Movements as the Main Table. Add fields:
this— the entity reference (so you can drill down)item— auto-joined toInvItembecause the property is a referencevalueDatetotalQty(withSumaggregation)totalCost(withSumaggregation)
Add valueDate as a parameter with filter type Between. Save and run. You get a per-item summary of net in-transit movement over your selected date range, with no raw SQL anywhere in the wizard.
Edge Cases & Common Pitfalls
| Situation | What happens |
|---|---|
| Your SQL has a syntax error | The editor shows the parser's complaint inline. You can still save, but the view DDL will then fail and surface the SQL Server error. |
| You reference a column that no longer exists in the source table | The view DDL fails on save. Fix your SELECT and try again. |
Your SQL has ORDER BY without TOP | SQL Server rejects this in views. The save fails with the SQL Server error. Move the sort to the consuming report instead. |
| You build a virtual entity that references another virtual entity | Allowed. The DB enforces non-cyclic dependencies — if you accidentally create a cycle (A → B → A), the second CREATE VIEW fails. |
| Two users save the same Virtual Entity at the same time | Standard optimistic-locking conflict; one save wins, the other sees a version mismatch error. |
| You save with zero properties mapped | Allowed. The view is created but the entity has no usable fields in the wizard. Add columns later. |
Your SQL contains a :placeholder parameter | Rejected at validation. Virtual Entity SQL must be self-contained. |
| You change the Code after first save | Rejected at validation. Create a new Virtual Entity with the new code if you need to rename. |
Trust Model — Who Should Have Access
A Virtual Entity definition runs SQL inside the application's database connection. There's no sandbox: whatever the application user can do, the Virtual Entity's SELECT can do. That means:
- Restrict the Virtual Entity menu and entity-edit permissions to power users / admins.
- Don't expose the entity-creation screen to roles you wouldn't trust to write arbitrary read-only SQL against the production database.
- The view created from the SELECT is a normal SQL view — anyone who can read its rows can query it freely once it exists, but creating/editing the definition is what needs gating.
What's Coming Later
Phase 1 ships Virtual Entities as views only. The JSON config already carries forward-compatible fields for the next phase, so anything you save now will round-trip cleanly when these features land:
- Materialization → Table — physical pre-aggregated tables for queries that are too expensive to run on every dashboard load.
- Refresh policies — manual, scheduled (cron), or on-write refresh of materialized tables.
- Index management — define indexes on the materialized table for query performance.
- Code rename — drop the old view and create a new one in a single transaction.
- Dependency tracking — see which reports, dashboards, and other virtual entities depend on a given virtual entity before you change or delete it.
For now, treat materialization as a roadmap signal: the dropdown shows Table disabled with a "Coming later" tooltip.